DEV Community

Cover image for How many guesses is a seed phrase? BIP39 entropy and PBKDF2 cost, measured in Node.js
NefiSwap
NefiSwap

Posted on

How many guesses is a seed phrase? BIP39 entropy and PBKDF2 cost, measured in Node.js

In July 2026, attackers rebuilt Bitcoin wallets created on Coldcard hardware wallets without touching the devices. A firmware configuration mistake dating back to 2021 meant the devices skipped their hardware random number generator when creating seeds, and Coinkite, the maker, estimates the affected seeds carried about 40 bits of entropy on the Mk2 and Mk3 and about 72 bits on the Mk4, Mk5 and Q. A BIP39 seed is designed for 128. Galaxy Research counted 1,778 BTC stolen by August 14.

On November 1, Trezor raised its maximum wallet passphrase length from 50 to 128 characters and cited those attacks. This post works through the numbers behind both events with one Node script that has no dependencies: what a seed is made of, what one guess costs, how big 40, 72 and 128 bits are at that cost, and what a passphrase adds. Everything here ran on Node 24 on a laptop.

What a BIP39 seed is made of

A 12-word mnemonic encodes 128 bits of entropy plus a 4-bit checksum, split into twelve 11-bit indexes into a 2,048-word list. A 24-word mnemonic encodes 256 bits. The words are only an encoding. Their strength is exactly the strength of the random number behind them, which is why a broken generator produces normal-looking words.

The wallet never uses the words directly. BIP39 turns them into a 64-byte seed with PBKDF2:

import { pbkdf2Sync } from 'node:crypto';

// salt = "mnemonic" + passphrase, 2048 rounds of HMAC-SHA512, 64 bytes out
const seedFrom = (mnemonic, passphrase = '') =>
  pbkdf2Sync(mnemonic.normalize('NFKD'), ('mnemonic' + passphrase).normalize('NFKD'), 2048, 64, 'sha512');
Enter fullscreen mode Exit fullscreen mode

Two details matter for the rest of the post. The 2,048 rounds make every guess deliberately slow. And the passphrase is part of the salt, so a different passphrase gives a completely different seed, and every passphrase an attacker tries costs a full derivation of its own.

Check it against the test vector first

Timing a function that computes the wrong thing tells you nothing, so the script starts with the first official test vector, the all-zero entropy mnemonic with the passphrase TREZOR:

const VECTOR = {
  mnemonic: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
  passphrase: 'TREZOR',
  seed: 'c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04',
};
const ok = seedFrom(VECTOR.mnemonic, VECTOR.passphrase).toString('hex') === VECTOR.seed;
console.log(`BIP39 test vector: ${ok ? 'match' : 'MISMATCH'}`);
if (!ok) process.exit(1);
Enter fullscreen mode Exit fullscreen mode
BIP39 test vector: match
Enter fullscreen mode Exit fullscreen mode

Timing one guess

Any attack on a weak seed has to run this derivation once per candidate. The script warms up, then takes the median of three runs of 300 derivations on a single thread:

const rate = (n) => {
  const start = process.hrtime.bigint();
  for (let i = 0; i < n; i++) seedFrom(VECTOR.mnemonic, String(i));
  return n / (Number(process.hrtime.bigint() - start) / 1e9);
};
rate(100);
const perSecond = [rate(300), rate(300), rate(300)].sort((a, b) => a - b)[1];
Enter fullscreen mode Exit fullscreen mode
PBKDF2 seeds per second, one core of Intel(R) Core(TM) i5-10210U CPU @ 1.60GHz: 375
Enter fullscreen mode Exit fullscreen mode

About 2.7 ms per guess. Your machine will give a different number, and a GPU will give a much bigger one. A real attack also has to derive keys from each seed and check the resulting addresses against the chain, which adds work per guess. So read the next table for its orders of magnitude, not its exact values.

What 40, 72 and 128 bits mean at that rate

Full search at the measured rate (one core, then 10,000 cores like it):
    40.0 bits  Coldcard Mk2/Mk3 seed, Coinkite estimate                       92.9 years         3.39 days
    72.0 bits  Coldcard Mk4/Mk5/Q seed, Coinkite estimate                 3.99e+11 years     3.99e+7 years
   128.0 bits  12-word BIP39 seed, as designed                            2.88e+28 years    2.88e+24 years
    65.8 bits  40-bit seed + two random words from a 7,776-word list       5.62e+9 years     5.62e+5 years
   117.5 bits  40-bit seed + six random words from a 7,776-word list      2.05e+25 years    2.05e+21 years
   129.2 bits  50 private die rolls on their own                          6.83e+28 years    6.83e+24 years
Enter fullscreen mode Exit fullscreen mode

The 40-bit row is the one that matters. About a trillion candidates is a long job for one laptop core and a short one for a rented cluster, and the July thefts show someone ran it. On average an attacker finds a given seed halfway through the space, and an attacker scanning for any weak wallet with funds collects every hit along the way.

The 72-bit row looks safe in this table, and Coinkite still tells owners of affected Mk4, Mk5 and Q seeds to migrate. Casa's write-up mentions an independent analysis that puts the worst case for a targeted attacker much lower. An entropy estimate for a broken generator depends on what the attacker knows about the device's state, and "about 72 bits" is a best case.

What a passphrase adds

Because the passphrase is in the salt, a passphrase with p bits of entropy multiplies the search by 2^p. The script's rows for a 40-bit seed show the effect. Two words drawn at random from the EFF large wordlist (7,776 words, about 12.9 bits each) take the search to about 66 bits. Six random words take it to about 118.

The key word is random. Coinkite's security page says a passphrase "does not repair a seed generated on affected firmware", while it "can add a barrier", and Casa mentions a reported loss from a Mk3 with a two-word passphrase. Two words a person picks have far less entropy than two words from dice, because attackers try common words and phrases first. The table's numbers assume words drawn uniformly at random.

The passphrase length limit decides how much of that randomness fits:

const PRINTABLE = 94; // ASCII 33-126
const AVG_WORD = 6.99 + 1; // EFF large wordlist: 6.99 letters on average, plus a space
for (const limit of [50, 128]) {
  const words = Math.floor((limit + 1) / AVG_WORD);
  console.log(`${limit}-character limit: ${(limit * Math.log2(PRINTABLE)).toFixed(0)} bits of random printable characters, or about ${words} random words (${(words * Math.log2(7776)).toFixed(0)} bits)`);
}
Enter fullscreen mode Exit fullscreen mode
50-character limit: 328 bits of random printable characters, or about 6 random words (78 bits)
128-character limit: 839 bits of random printable characters, or about 16 random words (207 bits)
Enter fullscreen mode Exit fullscreen mode

Fifty characters were always enough for random characters. They were tight for word-based passphrases, which are the kind people can actually remember and type on a small screen. That is the case Trezor's new 128-character limit helps.

Dice rolls

Coinkite's exception for affected seeds is a seed created with at least 50 fair, private dice rolls that were never recorded. A six-sided die gives log2(6), about 2.585 bits per roll, so 50 rolls give about 129.2 bits, the last row of the table, and 99 rolls give about 256. User-supplied entropy works because it doesn't depend on the device's generator being right. Coinkite's newer firmware now requires it for every new seed: at least 65 key presses, 50 die rolls or 128 coin flips, mixed with the device's own sources.

Limits of this estimate

  • One CPU core is a floor for attacker speed, not a forecast. GPUs and many machines change the per-second numbers by orders of magnitude, in the attacker's favor.
  • Key derivation and address lookups add per-guess cost that this script leaves out.
  • The bit counts for the Coldcard seeds are Coinkite's estimates, not measurements.
  • A passphrase's entropy is a property of how it was chosen. No script can measure that from the passphrase itself.

The full script

// How expensive is one guess at a BIP39 wallet, and how many guesses do weak and strong setups allow?
// Node 18+, no dependencies: node seed-math.mjs
import { pbkdf2Sync } from 'node:crypto';
import { cpus } from 'node:os';

// BIP39: the seed is PBKDF2-HMAC-SHA512 over the mnemonic, salted with "mnemonic" + passphrase, 2048 rounds.
const seedFrom = (mnemonic, passphrase = '') =>
  pbkdf2Sync(mnemonic.normalize('NFKD'), ('mnemonic' + passphrase).normalize('NFKD'), 2048, 64, 'sha512');

// 1. Check the function against the first official BIP39 test vector before trusting any timing.
const VECTOR = {
  mnemonic: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
  passphrase: 'TREZOR',
  seed: 'c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04',
};
const ok = seedFrom(VECTOR.mnemonic, VECTOR.passphrase).toString('hex') === VECTOR.seed;
console.log(`BIP39 test vector: ${ok ? 'match' : 'MISMATCH'}`);
if (!ok) process.exit(1);

// 2. Time one guess on this machine, single thread: warm up, then take the median of three runs.
//    A real attack also derives keys and checks addresses, which adds work per guess, and usually runs
//    on GPUs, which does far more guesses per second. Read the results as orders of magnitude.
const rate = (n) => {
  const start = process.hrtime.bigint();
  for (let i = 0; i < n; i++) seedFrom(VECTOR.mnemonic, String(i));
  return n / (Number(process.hrtime.bigint() - start) / 1e9);
};
rate(100);
const perSecond = [rate(300), rate(300), rate(300)].sort((a, b) => a - b)[1];
console.log(`PBKDF2 seeds per second, one core of ${cpus()[0].model.trim()}: ${perSecond.toFixed(0)}`);

// 3. Search spaces in bits, and how long a full search takes.
const human = (s) => {
  const units = [['years', 31557600], ['days', 86400], ['hours', 3600], ['minutes', 60], ['seconds', 1]];
  for (const [name, size] of units) if (s >= size) return `${(s / size).toPrecision(3)} ${name}`;
  return `${s.toPrecision(3)} seconds`;
};
const bits = (n) => Math.log2(n);
const SPACES = [
  ['Coldcard Mk2/Mk3 seed, Coinkite estimate', 40],
  ['Coldcard Mk4/Mk5/Q seed, Coinkite estimate', 72],
  ['12-word BIP39 seed, as designed', 128],
  ['40-bit seed + two random words from a 7,776-word list', 40 + 2 * bits(7776)],
  ['40-bit seed + six random words from a 7,776-word list', 40 + 6 * bits(7776)],
  ['50 private die rolls on their own', 50 * bits(6)],
];
const CORES = 10000;
console.log(`\nFull search at the measured rate (one core, then ${CORES.toLocaleString('en-US')} cores like it):`);
for (const [name, b] of SPACES) {
  const total = 2 ** b / perSecond;
  console.log(`  ${b.toFixed(1).padStart(6)} bits  ${name.padEnd(56)} ${human(total).padStart(16)}  ${human(total / CORES).padStart(16)}`);
}

// 4. What fits under a passphrase length limit: random characters versus words.
const PRINTABLE = 94; // ASCII 33-126
const AVG_WORD = 6.99 + 1; // EFF large wordlist (7,776 words): 6.99 letters on average, plus a space
for (const limit of [50, 128]) {
  const words = Math.floor((limit + 1) / AVG_WORD);
  console.log(`\n${limit}-character limit: ${(limit * bits(PRINTABLE)).toFixed(0)} bits of random printable characters, or about ${words} random words (${(words * bits(7776)).toFixed(0)} bits)`);
}
Enter fullscreen mode Exit fullscreen mode

References

Top comments (0)