Learn · Technologies we use
AES-OFB in Web Crypto: how we decrypt 2016 messages
Web Crypto has no OFB, but CBC applied to zero blocks gives exactly the OFB keystream. The proof, the numbers, the test, and how weak the old format was.
The browser's Web Crypto API has AES in three modes, CBC, CTR and GCM, and no OFB. Every message cipher.ink sealed between 2016 and 2026 used AES-256 in OFB mode, and we still had to open them in a browser. So we used a trick: encrypting a run of zero blocks with CBC produces exactly the OFB keystream. Below is why that's true, with real numbers from both the old library and the new code, how the new page uses it to open the 81 old messages that still exist, and how weak the old format was, stated plainly.
How OFB works
OFB (output feedback) makes a block cipher act like a stream cipher. Start with the IV, a 16-byte block. Encrypt it with AES and you get the first block of keystream, O₁ = E(IV). Encrypt that for the second, O₂ = E(O₁), then O₃ = E(O₂), and so on. The plaintext never touches AES. You XOR it with the keystream, C = P ⊕ O, and decrypt by XORing the ciphertext with the same keystream. The keystream depends only on the key and the IV, never on the message, so encryption and decryption are the same operation.
The 2016 cipher.ink page did this in the browser with a JavaScript library, aes-js 2.x. The key came from the passphrase, the IV from the message's own 16-character id, and the server kept the resulting bytes as a JSON array.
The trick: CBC applied to zeros
CBC (cipher block chaining) also works in blocks, but each plaintext block is XORed with the previous ciphertext block before it goes through AES: C₁ = E(P₁ ⊕ IV), C₂ = E(P₂ ⊕ C₁), C₃ = E(P₃ ⊕ C₂). Now make every plaintext block zero. XORing with zero changes nothing, so
which is the definition of the OFB keystream: O₁ = E(IV), O₂ = E(O₁), O₃ = E(O₂). The two sequences are one sequence. To decrypt an OFB message, ask Web Crypto to CBC-encrypt as many zero blocks as the message has, under the same key and IV, and XOR the result with the old ciphertext. Here's the proof by numbers. scripts/examples/ofb-through-cbc.mjs uses the key and IV rules of the 2016 page (the passphrase lighthouse padded with x to 32 bytes, and the id Ab3dEf6hIj9lMn0p as the IV):
| Block | OFB keystream, from the definition | CBC encryption of zeros, same key and IV |
|---|---|---|
| 1 | e21b75df70d67746b087dd5aec7198ba | e21b75df70d67746b087dd5aec7198ba |
| 2 | 630950c3b2e32032e7a9fc7707dbc7f9 | 630950c3b2e32032e7a9fc7707dbc7f9 |
| 3 | 40f8e54a9c18f9bce72d22c7786205f5 | 40f8e54a9c18f9bce72d22c7786205f5 |
Then the real 2016 library encrypts <p>A message from 2016.</p> to de6b4b9e50bb1235c3e6ba3fcc17ead50e2962f383d50e0ec8d9c27d. XOR those 28 bytes with the first two keystream blocks from the table (28 bytes need two blocks) and the plaintext comes back. The trick isn't clever by itself. It falls straight out of the definitions. What makes it useful is that Web Crypto's CBC runs in native code and exists in every browser.
The code
This is the function exactly as served. It's also on the annotated source page, with prose around it:
async function legacyTry(bytes, ivt, pass) {
var kb = te.encode(('x'.repeat(32) + pass).slice(-32));
if (kb.length !== 32) return null;
var key = await S.importKey('raw', kb, { name: 'AES-CBC' }, false, ['encrypt']);
var blocks = Math.ceil(bytes.length / 16);
var ks = new Uint8Array(await S.encrypt({ name: 'AES-CBC', iv: te.encode(ivt) }, key, new Uint8Array(blocks * 16)));
var out = new Uint8Array(bytes.length);
for (var i = 0; i < bytes.length; i++) out[i] = bytes[i] ^ ks[i];
var s;
try { s = new TextDecoder('utf-8', { fatal: true }).decode(out); } catch (e) { return null; }
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(s)) return null;
return s.replace(/\s+$/, '');
}Three details to notice. First, the key is rebuilt the way the 2016 page built it, the passphrase left-padded with x to 32 characters. Second, the zero blocks go through AES-CBC with the message id as the IV. Web Crypto adds PKCS#7 padding, so it returns one block more than we asked for, and the loop uses only as many bytes as the ciphertext has. Third, the last lines decide whether the passphrase was right, which gets its own section below.
Checked byte for byte against the real thing
A mode like this is easy to get subtly wrong, with an off-by-one in the chaining or a stray padding byte, so the test suite doesn't test it against itself. tests/crypto.test.mjs bundles the real aes-js 2.x library from 2016 and encrypts messages with exactly the code the old page ran, quirks included: the key rule, the space padding of short messages, and the id as IV. Then it opens each one with the new legacyOpen and demands an exact match. The cases are a short message, a long one with accents and emoji, and a passphrase longer than 32 characters. The same test checks that a wrong passphrase gets refused.
No tag, no proof of a wrong passphrase
OFB has no authentication. A wrong passphrase makes a different keystream, and the "decryption" is noise, with no failed tag to say so, unlike the GCM messages of today. So the page has to decide for itself whether what came out is a message. It leans on the fact that random bytes are almost never clean text: the output must be valid UTF-8 and contain no control characters other than tab and newline. How good is that test? The example script took the 28-byte message above and tried 20,000 random keystreams against it, and none of the 20,000 outputs passed as clean text. Longer messages do better still, since every added byte shrinks the chance of an accident. In practice the check is strong, but it's a heuristic, and a very short ciphertext could in principle fool it. That's one more reason the old format is read-only.
The page also tries the passphrase lowercased first and then as typed. The 2016 page lowercased every passphrase before using it, the sort of "helpful" normalization that quietly shrank the space of possibilities. Old messages need the lowercase key, and a reader who types capitals shouldn't be turned away.
How weak the old format was
- No key derivation. The passphrase was the key, with no salt and no repeated hashing. Testing a guessed passphrase cost one AES operation, where the 600,000 rounds of PBKDF2 now make it cost a hundred thousand times more.
- Lowercased and truncated. The passphrase was forced to lower case, and only its first 32 characters counted.
- A predictable IV. The IV was the message id, 16 characters made with
Math.random(), which isn't a cryptographic generator. (Random numbers in the browser explains why that matters.) - No integrity check. Anyone who could modify the stored ciphertext could flip chosen bits of the plaintext, and the reader would see the changed text with no warning.
- Short messages padded with spaces to 63 characters, which hides the length of tiny messages and nothing else.
- Weak passphrases. The old page accepted any passphrase and many were short, which is the real danger when guesses are nearly free.
That's why the format is read-only and why the new page never writes it. The messages stay readable for people with the original passphrase, but treat them as weakly protected. If you have one that matters, copy its content and re-seal it with the current version. How these messages ended up unreadable for eleven weeks, and what a dependency bump had to do with it, is in the history of cipher.ink.
Where else this comes up
The same move works for any mode Web Crypto lacks that's built from the block cipher run forwards. CFB can be derived from encryption of the previous ciphertext block, and a CTR keystream from ECB of counter blocks, which Web Crypto does offer as AES-CTR. The general lesson: a block cipher's encrypt direction is a pseudo-random function, and most modes are just ways of feeding it inputs and XORing what comes out. Given the primitive and the definition of the mode, you can usually build the mode.