Learn · Technologies we use
PBKDF2, and why we run it 600,000 times
How a passphrase becomes a key, worked by hand, and the arithmetic behind the iteration count.
PBKDF2 (Password-Based Key Derivation Function 2) turns a passphrase into an encryption key by running a keyed hash over it many thousands of times. The repetition is deliberate, because it makes every guess at the passphrase expensive. cipher.ink runs PBKDF2-HMAC-SHA256 for 600,000 iterations on every passphrase, with a fresh random salt, to make the AES-256 key that seals your message. Here's what those words mean, the algorithm worked through by hand, where PBKDF2 loses to newer designs, and the arithmetic behind "why 600,000".
The problem it solves
A passphrase isn't a key. AES-256 needs 256 bits that look random, and a passphrase is a few words a person chose, with maybe 40 to 80 bits of real unpredictability in them. Bridging the gap takes two things. One is a deterministic way to stretch whatever the person typed into exactly 256 bits, the same on every device. The other is a way to make guessing slow, because anyone with a cipher.ink message's link can download its ciphertext and attack it offline at leisure. If deriving the key took a microsecond, an attacker with a graphics card could try billions of passphrases a second. A derivation that costs a tenth of a second per try cuts that to ten a second per core. That's the whole job, called key stretching.
How PBKDF2 works
RFC 8018 specifies it. That's the current form of RSA Laboratories' PKCS #5, first published as RFC 2898 in 2000. It takes a password P, a salt S, an iteration count c and a pseudo-random function, in practice HMAC with a hash such as SHA-256 (see the HMAC article). It produces output in blocks, each the size of the hash. For block number i:
In words: round 1 hashes the password, keyed with the salt plus the block number. Each later round hashes the previous round's output, keyed with the password again, so round 2 can't start until round 1 has finished. The block is all the rounds XORed together, and the derived key is the blocks joined and cut to the length you asked for. That dependency between rounds is the point. Nobody can compute round 600,000 without computing every round before it, so there's no shortcut and no way to split one guess across many processors.
One example, by hand
These values come from scripts/examples/pbkdf2-worked.mjs, which does the arithmetic with plain HMAC calls and checks it against Node's own PBKDF2. The inputs are the password password, the salt salt, 2 iterations, SHA-256 and 32 bytes out (one block, i = 1):
| Step | Value (hex) |
|---|---|
| U₁ = HMAC(password, "salt" ‖ 00000001) | 120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b |
| U₂ = HMAC(password, U₁) | bc42ba5a5393f5ff6eedfdab7e3495e782557747df0e6497dee74026224cad38 |
| T₁ = U₁ ⊕ U₂ | ae4d0c95af6b46d32d0adff928f06dd02a303f8ef3c251dfd6e2d85a95474c43 |
With one iteration the answer is just U₁, 120fb6cf…0be17b, and you can check it in the HMAC tool with key password and message salt followed by the four bytes 00 00 00 01. The browser does exactly this, 600,000 rounds deep, when you open a passphrase message. The PBKDF2 benchmark times it on your own device.
The salt
The salt is a random value stored in the open next to the ciphertext (16 bytes per message here). It's no secret, and it has two jobs. First, it gives the same passphrase different keys for different messages, so an attacker can't do the work once and reuse it against every message. A precomputed "passphrase to key" table would be 2128 times bigger, so nobody builds one. Second, two people who both pick "correct-horse-battery-staple" don't end up with the same key. In cipher.ink crypto.getRandomValues generates the salt every time a message is sealed (see the annotated source).
Why 600,000?
The iteration count is a dial between the time you wait and the time a guesser pays. The sum is simple. Each PBKDF2-HMAC-SHA256 iteration costs two SHA-256 compression-function calls, one for the inner hash and one for the outer, because the padded key blocks are precomputed. A plain SHA-256 guess costs one call, and a PBKDF2 guess at 600,000 rounds costs 1.2 million, about a million times more. Measured on real hardware the gap is about as big. The hashcat benchmark for one RTX 4090 gives 21.98 billion plain SHA-256 guesses a second and about 14,800 a second at 600,000 PBKDF2 rounds, a ratio of 1.5 million. (The crack-time estimator describes that benchmark and its scaling.)
As for where 600,000 comes from, it's OWASP's current recommendation for PBKDF2-HMAC-SHA256 in its Password Storage Cheat Sheet. The same sheet lists 220,000 for HMAC-SHA512 and a legacy 1,400,000 for HMAC-SHA1. Those numbers move as hardware gets faster, and older standards named much smaller figures. RFC 2898 in 2000 recommended at least 1,000 iterations, and NIST SP 800-132 (2010) repeats a minimum of 1,000 while noting that 10,000,000 may suit especially critical keys. That's why every cipher.ink payload records its iteration count (it). We can raise the count for new messages and old ones still open.
What you pay is one wait. The benchmark typically shows a fraction of a second at this setting on a laptop, once each time you open a passphrase message.
Where PBKDF2 falls short
PBKDF2 uses almost no memory, a few hundred bytes of state. Against attackers with graphics cards or custom chips, that's a weakness. They have thousands of small cores with little memory each, so they can run enormous numbers of PBKDF2 guesses in parallel. Newer designs make memory part of the cost. scrypt (Percival, 2009) needs a large table that every guess must fill, and Argon2 (winner of the Password Hashing Competition in 2015, standardized in RFC 9106) tunes memory, time and parallelism separately. OWASP now lists Argon2id first and PBKDF2 for situations that need FIPS-validated algorithms. At similar defender cost, a memory-hard function cuts an attacker's guess rate far more. The password hashing comparison sets out the numbers.
We chose PBKDF2 for specific reasons. It's built into every browser as crypto.subtle.deriveKey, so running it adds no code to trust and no download. Argon2 and scrypt would need a JavaScript or WebAssembly implementation. Our Content-Security-Policy switches WebAssembly off, because allowing it would take a weaker policy, and a pure-JavaScript Argon2 would run so much slower than native code that we'd have to pick a weaker setting. It's a trade-off: native speed and a small attack surface, in exchange for being less memory-hard than the best available.
A subtle trap: asking for more output than the hash gives
SHA-256 gives 32 bytes, so a 256-bit AES key is exactly one PBKDF2 block, T₁. Ask for 64 bytes and PBKDF2 computes a second, independent chain, T₂. That doubles your work, but an attacker who only needs to check a guess can stop after the first block. The script above shows it. 200,000 rounds took 82 ms for 32 bytes and 173 ms for 64, so the legitimate user pays twice for nothing. That's why cipher.ink derives exactly 32 bytes (length: 256 in passKey) and puts the whole cost in the iteration count.
How cipher.ink uses this
In passphrase mode, the passphrase is put in Unicode NFC form first, so "café" typed on two keyboards gives the same bytes (the Unicode article shows why that matters). Then PBKDF2-HMAC-SHA256 runs for 600,000 rounds with the 16-byte salt, and the result becomes a non-extractable AES-256-GCM key. The salt and iteration count go into the payload. A wrong passphrase gives a wrong key, which the GCM tag rejects (AES-GCM, explained). In secret-link mode there's no passphrase at all. The key is 32 random bytes carried in the link, so there's nothing to stretch. The generated passphrases are about 47.3 bits, which at 600,000 rounds is far beyond one card, and the passphrase generator lets you choose more. How it works follows the whole path.
Further reading
- RFC 8018: PKCS #5: Password-Based Cryptography Specification Version 2.1 (2017)
- RFC 2898: PKCS #5 v2.0 (2000)
- NIST SP 800-132: Recommendation for Password-Based Key Derivation (2010)
- OWASP Password Storage Cheat Sheet
- RFC 9106: Argon2, the Memory-Hard Function for Password Hashing
- hashcat 6.2.6 benchmark, one RTX 4090
- MDN: SubtleCrypto.deriveKey(), PBKDF2