Appearance
8.2.1 — Symmetric Encryption and Randomness
A team needs to encrypt a stored national identity number. Someone picks AES-256 — the strongest-sounding option — writes twenty lines, tests that decryption returns the original value, and ships.
The cipher was never the risk. The decisions that actually decide whether that data is safe are the mode of operation, the nonce, where the key lives, and whether the ciphertext can be tampered with undetected — and every one of them is easy to get wrong in a way that still passes the encrypt-then-decrypt test.
That is the defining property of cryptographic bugs: a broken implementation works perfectly. There is no failing test, no exception, no log line. It just is not secure.
1. Kerckhoffs's principle, and what a key is
Auguste Kerckhoffs wrote in 1883 that a cipher must remain secure even if everything about the system except the key is public knowledge. Claude Shannon restated it as "the enemy knows the system".
This is why "we wrote our own algorithm and it is secret" is not a defence: algorithms leak through disassembly, through employees, through papers. A key is a secret because it can be changed when it leaks; an algorithm cannot.
Symmetric encryption uses the same key to encrypt and decrypt. It is fast — hardware-accelerated AES runs at gigabytes per second — and it has one hard problem: both parties need the same key, and getting it to them is the key-distribution problem that Chapter 8.2.3 solves with asymmetric cryptography.
2. AES, and why the mode is the real decision
AES is a block cipher: it transforms exactly 128 bits at a time, using a key of 128, 192 or 256 bits. That is all it does. Encrypting a 10 KB document means applying it many times, and how you apply it repeatedly is the mode of operation — which is where security is won or lost.
ECB (electronic codebook) encrypts each block independently. Identical plaintext blocks produce identical ciphertext blocks, so the structure of the data survives encryption. The famous demonstration is an image of a penguin encrypted with ECB: the colours change and the penguin is still clearly visible, because repeated regions stay repeated. Never use ECB. If you see it in a codebase, that is a finding.
CBC (cipher block chaining) XORs each block with the previous ciphertext block before encrypting, so identical blocks encrypt differently. The first block is XORed with an initialisation vector (IV), which must be random and unpredictable per message.
CBC works and carries two sharp edges. It needs padding to reach a block boundary, and a system that reports padding errors differently from other errors leaks enough to decrypt the whole message — the padding oracle attack, which broke real systems including ASP.NET in 2010. And CBC on its own provides no integrity: an attacker can flip bits in the IV to flip corresponding bits in the first decrypted block.
CTR (counter mode) turns the block cipher into a stream cipher: encrypt a counter, XOR the result with the plaintext. No padding, parallelisable, random access. It still has no integrity, and it has the nonce rule of section 3.
GCM (Galois/counter mode) is CTR plus an authentication tag. This is what you should use, and the reason is the next section.
ChaCha20-Poly1305 is the other modern answer — a stream cipher plus an authenticator, designed to be fast and constant-time in software. On hardware without AES acceleration it is substantially faster than AES, which is why TLS on mobile devices frequently negotiates it. Either is a correct choice.
3. AEAD: why encryption without authentication is broken
Encryption hides content. It does not stop the content being changed.
With CTR or CBC, an attacker who cannot read your message can still alter it in predictable ways. In CTR mode the ciphertext is plaintext XOR keystream, so flipping a bit in the ciphertext flips exactly that bit in the decrypted plaintext. If the attacker knows byte 12 is the transfer amount, they can change it without knowing the key.
AEAD — authenticated encryption with associated data — fixes this by producing an authentication tag alongside the ciphertext. Decryption verifies the tag first and refuses to return anything if it does not match.
The rule, stated plainly: never use encryption without authentication. Historically people bolted a MAC on afterwards and got the composition order wrong — encrypt-then-MAC is the safe order, MAC-then-encrypt and encrypt-and-MAC have both produced real breaks. AEAD modes remove the decision by doing it correctly for you.
The "associated data" part is genuinely useful and widely ignored. You can bind extra context to the ciphertext without encrypting it: the record id, the user id, the key version. The tag covers it, so ciphertext lifted from one row and pasted into another fails to decrypt.
ts
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
function encryptField(key: Buffer, plaintext: string, recordId: string) {
const iv = randomBytes(12); // (1)
const cipher = createCipheriv('aes-256-gcm', key, iv);
cipher.setAAD(Buffer.from(recordId)); // (2)
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
return { iv, ct, tag: cipher.getAuthTag(), v: 1 }; // (3)
}
function decryptField(key: Buffer, blob: Encrypted, recordId: string) {
const d = createDecipheriv('aes-256-gcm', key, blob.iv);
d.setAAD(Buffer.from(recordId));
d.setAuthTag(blob.tag); // (4)
return Buffer.concat([d.update(blob.ct), d.final()]).toString('utf8'); // (5)
}(1) A fresh 96-bit random nonce per message. 96 bits is the size GCM is specified for; other lengths are hashed internally and are a needless deviation. (2) The record id is bound in but not encrypted, so this ciphertext only decrypts for this record. (3) The version field costs one byte and is what makes key rotation and algorithm changes possible later — without it, you cannot tell how an old value was encrypted. (4) The tag must be set before finalising. (5) final() throws if the tag does not verify, and that exception must be treated as an attack signal, not caught and ignored.
4. The nonce rule
Nonce means "number used once", and in GCM it means it literally.
Reuse a nonce with the same key and two things happen. The keystream repeats, so XORing the two ciphertexts gives you the XOR of the two plaintexts — enough to recover both when either has any structure. Worse, in GCM, nonce reuse allows an attacker to recover the authentication key, after which they can forge valid tags for arbitrary messages. One repeat destroys integrity for that key permanently.
Two safe ways to generate one:
Random 96-bit nonces. With 96 bits, the birthday bound says collisions become a concern after roughly 2^{32} messages under one key — about 4 billion. Fine for most uses, and a reason to rotate keys.
A counter, which never repeats by construction, and is what TLS does. It requires state that survives restarts, and it is unsafe if a machine can be cloned — a virtual machine snapshot restored twice replays the same counter values. That is a real failure mode in cloud environments.
A nonce is not a secret. Store it alongside the ciphertext, in the clear. It must be unique, not unpredictable — except in CBC, where the IV must be unpredictable as well, which is a distinction worth keeping straight.
5. Randomness: the quiet source of catastrophic bugs
Cryptography needs unpredictable numbers for keys, nonces, IVs, session tokens and password reset links. Getting this wrong is invisible and total.
A PRNG is fast, deterministic and predictable. Math.random(), Python's random, Java's Random. Observe a few outputs and you can compute the internal state and predict every future value. These must never be used for anything security-related. A password reset token from Math.random() is guessable, and that has caused real account takeovers.
A CSPRNG is seeded from operating-system entropy and designed so that observing output reveals nothing about the state.
js
crypto.randomBytes(32) // Node
crypto.getRandomValues(new Uint8Array(32)) // browsers
crypto.randomUUID() // both — a v4 UUID from a CSPRNG
secrets.token_urlsafe(32) // Python — not random.random()On Linux, use /dev/urandom. The old advice to prefer /dev/random for "real" entropy is obsolete: since kernel 5.6 the two are equivalent once the pool has been initialised, and blocking on entropy caused far more outages than it prevented attacks. The one genuine risk is a system reading randomness before the pool is seeded, which happens on freshly booted virtual machines and embedded devices — getrandom() handles it by blocking exactly that once.
Three practical rules:
- Any token a user should not be able to guess needs at least 128 bits of CSPRNG output, hex or base64url encoded.
- Do not build tokens from timestamps, counters or user ids, even hashed. If the input is guessable the output is enumerable.
- A cloned virtual machine can share randomness state. Cloud images should regenerate host keys and any long-lived secret on first boot; a fleet where every instance has the same SSH host key started as one snapshot.
6. Getting a key from a password
A password is not a key: it is short, low-entropy and human-chosen. Turning one into a key needs a key derivation function, and which kind depends on what you are starting from.
From a password — use a deliberately slow one. Argon2id, scrypt or PBKDF2, with a random salt and a high cost. Slowness is the feature: it makes brute force expensive. Chapter 8.4.1 covers the parameters and why Argon2id's memory hardness matters against GPUs.
From an existing high-entropy key — use a fast one. HKDF derives several independent keys from one master key. Use it whenever one secret must serve two purposes:
ts
const encKey = hkdf(masterKey, salt, 'field-encryption:v1', 32); // (1)
const macKey = hkdf(masterKey, salt, 'record-signing:v1', 32); // (2)(1) and (2) differ only in the info string, and that is enough to make the outputs independent. Never use one key for two purposes — reuse across algorithms has produced real breaks, and HKDF makes avoiding it a one-line habit.
7. What "encrypted at rest" actually protects against
This phrase appears in every compliance questionnaire and is frequently misunderstood, so it is worth being exact about what each layer defends.
Full-disk encryption protects against someone stealing the physical disk. The running system decrypts transparently, so a compromised application, a leaked database dump produced by the database itself, or a SQL injection all read plaintext. It is necessary, cheap, and it is not application security.
Database-level transparent data encryption protects the data files on disk. Same limitation: the database decrypts for anyone who can query it.
Application-level field encryption — encrypting before the value reaches the database — is the one that protects against a compromised database, a rogue database administrator, and a leaked backup. The cost is real: you cannot index, sort or search an encrypted field, so it is reserved for the few fields that genuinely need it. Store a separate blind index (an HMAC of the normalised value, Chapter 8.2.2) if you need exact-match lookup.
Envelope encryption is how key management is done at scale. Generate a fresh data key per record or per file, encrypt the data with it, then encrypt the data key with a master key held in a key management service, and store the wrapped key next to the ciphertext. Rotating the master key means re-wrapping small keys rather than re-encrypting terabytes. Chapter 8.6.1 covers the operations.
And say the quiet part: if a compromised application server can decrypt it, then encryption at rest does not stop the attack you are most likely to suffer. It is a real control against real threats — lost hardware, discarded drives, a leaked backup file — and claiming more than that is where security theatre begins.
8. Choosing, in one table
| Need | Use | Not |
|---|---|---|
| Encrypt a message or field | AES-256-GCM or ChaCha20-Poly1305 | ECB, plain CBC, CTR alone |
| Encrypt a large file | Same, chunked with per-chunk nonces | One giant nonce-less stream |
| Random token | 128+ bits from a CSPRNG | Math.random, timestamps, ids |
| Key from a password | Argon2id / scrypt / PBKDF2 | A plain hash |
| Several keys from one key | HKDF | Reusing one key everywhere |
| Encrypt in transit | TLS 1.3 (Chapter 5.7) | Anything hand-rolled |
And the meta-rule: use a high-level library. libsodium, Tink, or your platform's crypto module with a modern AEAD. Their appeal is not convenience — it is that they remove the decisions where the failures are invisible.
What the interviewer will push on
"How would you encrypt a field in a database?" AES-256-GCM with a random 96-bit nonce per value, the record id bound as associated data, a version byte stored alongside, and the key from a key management service via envelope encryption. Then the honest limit: an encrypted field cannot be indexed or sorted, so this is for the few fields that need it.
"Why does the mode matter more than the cipher?" Because AES is fine and the mode decides whether identical plaintexts look identical (ECB), whether tampering is detectable (any non-AEAD mode), and whether a nonce mistake is catastrophic. The penguin picture and bit-flipping in CTR are the two examples that show you understand it rather than remember it.
"What happens if a nonce is reused in GCM?" The keystream repeats, so XORing the ciphertexts reveals the XOR of the plaintexts — and the authentication key becomes recoverable, so the attacker can forge valid tags for that key from then on. That second consequence is the one that separates a real answer.
"Why can't you use Math.random() for a password reset token?" It is a deterministic PRNG — a few observed outputs reveal the internal state and every future value. Use a CSPRNG with at least 128 bits. Then volunteer that tokens must not be derived from timestamps or ids either, because a guessable input makes an enumerable output.
"What does encryption at rest protect you from?" Stolen disks, discarded drives and leaked backup files. Not a compromised application, SQL injection or a database administrator, because the running system decrypts transparently. Only application-level field encryption addresses those, at the cost of indexing.
"Why is AEAD required rather than nice to have?" Because encryption alone leaves ciphertext malleable — flipping a ciphertext bit in CTR flips the corresponding plaintext bit — so an attacker can change a transfer amount without reading it. Then mention that hand-composing encryption with a MAC has produced real breaks through ordering mistakes, which is why AEAD removes the choice.
One thing to volunteer: mention binding a record id as associated data, so ciphertext copied from one row into another fails to decrypt. It costs one line, defends against an attack most designs never consider, and it demonstrates that you understand what the tag actually covers.
Recall
- Cryptographic bugs pass their tests. A broken implementation encrypts and decrypts correctly. Kerckhoffs: security must survive everything but the key being public.
- AES is a block cipher; the mode of operation is the real decision. ECB leaks structure — never use it. CBC needs an unpredictable IV, padding, and leaks via padding oracles. CTR has no integrity.
- Use an AEAD: AES-256-GCM or ChaCha20-Poly1305. Encryption without authentication is malleable — a flipped ciphertext bit flips the plaintext bit. Bind context as associated data so ciphertext moved between records fails to decrypt.
- Never reuse a nonce with the same key. In GCM it reveals the XOR of the plaintexts and the authentication key, permanently allowing forgery. 96-bit random nonces or a counter; a counter is unsafe when a machine can be cloned.
- Use a CSPRNG for anything unguessable —
randomBytes,getRandomValues,secrets— neverMath.random, timestamps or ids./dev/urandomis correct; the only real risk is reading before the pool is seeded on a fresh boot. - From a password use a slow KDF (Argon2id, scrypt, PBKDF2 with a salt). From a key use HKDF with a distinct
infostring per purpose — never one key for two jobs. - Encryption at rest protects against stolen disks and leaked backups, not against a compromised application. Only application-level field encryption does that, and it costs you indexing and sorting. Envelope encryption wraps a per-record data key with a master key so rotation is cheap.
- Store a version byte with every ciphertext, or you cannot ever change key or algorithm.
Self-test: Why is the penguin still visible under ECB? · What two things does a GCM nonce reuse give an attacker? · How does associated data stop a ciphertext being moved between rows? · Which KDF for a password, which for a key, and why? · What attack does full-disk encryption not stop? · What is the version byte for?
Next: 8.2.2 covers the other half of symmetric cryptography — hashes, what "one-way" really means, HMAC, and why comparing two secrets with === is a vulnerability.