Cryptography Foundations
The five locks and seals behind every private message you send, the same ones behind the whisper-code in The whisper code (how a call goes private), and behind every certificate, login and signed commit that follows.
Before you start. The Security Mindset gave you three things worth protecting: keep a message secret, keep it unchanged, know who sent it. This lesson gives you the five tools that deliver those three.
1991: a padlock the government called a weapon
Phil Zimmermann was a software engineer in Boulder, Colorado, who spent the 1980s working against nuclear weapons as a policy analyst. He watched people organising over public bulletin boards with no way to keep a message private, while governments and phone companies could read anything they sent. In 1991 he gave away, for free, a program built to fix that: Pretty Good Privacy, PGP.
Inside it he needed two tricks. One: a way to scramble a letter so completely that only the intended reader could turn it back. Done properly, that trick was too slow for a whole message, so he used it once per letter, to hand over a single secret number both sides then used for the fast part. That number is what this lesson calls a key; scrambling something so only the matching key can unscramble it is encryption.
The US government did not see a mail program. Any code using a key long enough to be unbreakable was, by law, a munition - the same category as a missile - and giving it away without a licence was a crime. Zimmermann became its formal target in February 1993; the case ran three years, until MIT Press published PGP’s source as an ordinary hardback book in 1995 - a book, unlike a weapon, you can export - and in 1996 the government dropped it, no charges filed.
That fight was really about one question: who is allowed to lock a message so nobody else can open it. This lesson is the five ways engineers answer it.
Two ways to lock a message, and why PGP needed both
The first way is a shared key: the same secret number locks and unlocks, like one house key on your own front door. It’s fast - a chip encrypts a gigabyte in about a fifth of a second - so it carries every file, video and message you send. Its weak point is getting that same key to the other person without anyone seeing it cross the wire.
The second way is a key pair: two numbers made together, where one locks and only the OTHER unlocks. Whoever holds the “lock” half - handed out to anyone, so it’s called public - can close the padlock but never open it; only whoever holds the matching private half can. That solves the shared key’s delivery problem, at the cost of being tens of thousands of times slower per byte.
So PGP, and every secure website since, does both: a brand-new shared key each message, the slow padlock used once to lock just that key, then the fast key for the letter itself - the exact trick Zimmermann needed in 1991, still how your browser talks to your bank today.
A seal that isn’t a lock at all
Locking a message keeps it secret. It does nothing to prove nobody touched it - neither trick above catches a single flipped letter. For that you need a different tool: a hash.
A hash turns a message of any length into one fixed-size fingerprint. Change one letter of the input and the fingerprint looks completely unrelated: change “hello” to “hellp” and 131 of its 256 bits flip, 51%, almost exactly what two unrelated fingerprints share by chance. A hash has no key and nothing to unlock - run it forward to check a copy still matches, never backward to recover the message.
That is what proves a downloaded file, a signed commit, or the previous block of a blockchain hasn’t been touched: reprint the fingerprint and compare it.
The same seal, sold to only two people
A plain hash proves a message matches its fingerprint, not who made it - anyone could hash a forged message and hand you a matching pair. HMAC closes that gap by folding a shared secret key into the hash, so only someone holding that key can produce it. Change the message, or the key, and the fingerprint changes either way. Two people sharing one key can each check a message arrived unaltered and genuinely came from the other - though neither can prove which one produced it, since either could have.
A stamp only you can press
Proving it was specifically you, not just “someone who knows the shared secret,” is what a signature closes. It reuses the key-pair trick backwards: press your private half against the message, leaving a mark. Anyone holding your public half can check the mark matches, but nobody else could have pressed it - stronger than the shared stamp, since it proves authorship even you cannot later deny.
It is also how PGP solved Zimmermann’s problem from the other direction: a message signed with his private key proved, to anyone holding his public one, that it came from him.
Agreeing a secret while someone is listening
One question is still open: how do two strangers with no shared secret agree on a first key while someone records every message between them? The lock picture stops helping here - think instead of mixing paint. Each side picks a secret colour, mixes it into a shared base colour, and sends only the result, never the secret itself. Each then adds their own secret colour to the mix they received. Both end on the same final colour, because mixing order doesn’t matter. An eavesdropper saw only the two mixes, never a secret colour, and cannot unmix them.
This exact trick is what your browser performs before it shows a lock icon. The second read below gives it its real name and does the arithmetic.
You now know
- Encryption keeps a message secret with a key. A shared key is fast but hard to deliver safely; a key pair (public locks, private unlocks) solves delivery by being tens of thousands of times slower - so real systems use the pair once, to hand over a shared key, then that fast key for the message. That hybrid first reached ordinary email in 1991, and every secure website still uses it.
- A hash turns any message into a fixed-size fingerprint; change one letter and it changes almost completely, which is how you prove a file hasn’t been altered.
- HMAC is a hash only someone holding a shared secret key can produce, proving a message is unaltered and came from someone who knows that secret.
- A signature reuses a key pair backwards: mark a message with your private key, and anyone with your public key can confirm only you could have made that mark.
- Two strangers can agree on a shared key while someone listens to every message, by combining secret and public values so only the two of them land on the same result.
Stop here if you want. What follows is the same five tools, in numbers and running code, for when you want to watch a fingerprint change and a signature fail for real.
Second read: the five tools, in code and numbers
Each of the five is a primitive, assembled into protocols like TLS. The shared-key trick is symmetric encryption (AES); the key-pair trick is asymmetric encryption (RSA, ECC). Rule zero: never roll your own crypto - almost never broken by the math, almost always by misuse.
By hand: AES at ~5 GB/s encrypts a 1 MB file in 1,048,576 ÷ 5,000,000,000 ≈ 0.2 ms. RSA-2048 handles 256 bytes per ~1 ms, so the same file needs 1,048,576 ÷ 256 = 4,096 operations, ≈ 4 s - 20,000× slower, why RSA only ever buys the key. Never use plain ECB: identical plaintext blocks make identical ciphertext blocks, the pattern leaking straight through (the “ECB penguin”). AES-GCM - authenticated encryption - gives secrecy and tamper-detection together, as the run below shows.
Why 256 bits is enough: n digests give n×(n−1)÷2 pairs at odds 1÷2²⁵⁶ each, so a collision is expected once n≈2¹²⁸ (23 people give 253 pairs, ~even odds of sharing a birthday). All of Earth’s Bitcoin mining, ~10²⁰ SHA-256/s, still needs ~10¹¹ years for one - about eight times the universe’s age. MD5’s 128 bits give only 2⁶⁴: about 0.18 seconds. That is what “broken” means.
HMAC gives integrity and authenticity but not non-repudiation - either side could have made the tag, since both hold the same key. A signature reverses public-key encryption:
message → hash → sign(private key) = signature then verify(public key) → valid? ✓
and underpins TLS certificates (a CA signs a server’s key), code signing and JWTs. A password hash needs a per-user random salt (Authentication) so identical passwords don’t store identically; Hashing covers the other kind, built for speed rather than one-wayness.
The open question - agreeing a key while someone listens - has a name: Diffie-Hellman. A fresh, ephemeral pair per connection gives forward secrecy: a stolen long-term key still can’t decrypt past traffic, since no session key was ever stored to steal.
Run below with node’s crypto; every key is generated on the spot and thrown away, so only the two SHA-256 lines match yours exactly. Five lines carry the rest: two unrelated HMAC tags, verify(...) = false on a flipped bit, equal: true on the ECDH secrets, and a refusal instead of a decrypt on the last line.
// crypto-tour.mjs - node crypto-tour.mjs
// The primitives, run. Every key here is generated on the spot and thrown away.
import { createHash, createHmac, randomBytes, generateKeyPairSync, sign, verify, createECDH, createCipheriv, createDecipheriv } from "node:crypto";
// 1. hash: deterministic, one-way, avalanche
const h1 = createHash("sha256").update("hello").digest("hex");
const h2 = createHash("sha256").update("hellp").digest("hex");
const bits = [...Buffer.from(h1, "hex")].map((b, i) => (b ^ Buffer.from(h2, "hex")[i]).toString(2).split("1").length - 1).reduce((a, b) => a + b);
console.log("sha256('hello') =", h1);
console.log("sha256('hellp') =", h2);
console.log(`bits that differ: ${bits} of 256 (${(100 * bits / 256).toFixed(0)}%)`);
// 2. HMAC: the same hash, keyed. Change one character of the message and the tag is unrelated.
const key = randomBytes(32);
const tag = (m) => createHmac("sha256", key).update(m).digest("hex");
console.log("\nHMAC(key, 'transfer 100') =", tag("transfer 100"));
console.log("HMAC(key, 'transfer 900') =", tag("transfer 900"));
console.log("same message, a different key =", createHmac("sha256", randomBytes(32)).update("transfer 100").digest("hex"));
// 3. signature: private key signs, public key verifies, one flipped byte fails
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
const msg = Buffer.from("pay 100 to account 42");
const sig = sign(null, msg, privateKey);
console.log("\ned25519 signature:", sig.toString("hex").slice(0, 32) + "...", `(${sig.length} bytes)`);
console.log("verify(original) =", verify(null, msg, publicKey, sig));
const tampered = Buffer.from("pay 900 to account 42");
console.log("verify(tampered) =", verify(null, tampered, publicKey, sig));
const badSig = Buffer.from(sig); badSig[0] ^= 1;
console.log("verify(sig with one bit flipped) =", verify(null, msg, publicKey, badSig));
// 4. key exchange: two sides, two private keys, one shared secret, nothing secret sent
const alice = createECDH("prime256v1"), bob = createECDH("prime256v1");
const aPub = alice.generateKeys(), bPub = bob.generateKeys(); // these two values cross the wire
const aSecret = alice.computeSecret(bPub), bSecret = bob.computeSecret(aPub);
console.log("\nECDH: alice's secret =", aSecret.toString("hex").slice(0, 24) + "...");
console.log(" bob's secret =", bSecret.toString("hex").slice(0, 24) + "...", " equal:", aSecret.equals(bSecret));
// 5. authenticated encryption: AES-256-GCM, and what a flipped ciphertext byte does
const aesKey = createHash("sha256").update(aSecret).digest();
const nonce = randomBytes(12);
const c = createCipheriv("aes-256-gcm", aesKey, nonce);
const ct = Buffer.concat([c.update("the menu is chai and vada"), c.final()]);
const authTag = c.getAuthTag();
console.log("\nAES-GCM ciphertext:", ct.toString("hex"), " tag:", authTag.toString("hex").slice(0, 16) + "...");
const d = createDecipheriv("aes-256-gcm", aesKey, nonce); d.setAuthTag(authTag);
console.log("decrypt =", Buffer.concat([d.update(ct), d.final()]).toString());
try { const ct2 = Buffer.from(ct); ct2[3] ^= 1; const d2 = createDecipheriv("aes-256-gcm", aesKey, nonce); d2.setAuthTag(authTag); d2.update(ct2); d2.final(); }
catch (e) { console.log("decrypt with one byte flipped:", e.message); }
sha256('hello') = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
sha256('hellp') = fdd7585e08c4e2afd71dcabdb4636c89d557a3f42db9e2040c8bbd1708aa4ce7
bits that differ: 131 of 256 (51%)
HMAC(key, 'transfer 100') = 515141f5cb078b1ab4e2bfdc0d2e000c9b8055d85d706c281e6d0419e6fedc5a
HMAC(key, 'transfer 900') = 0cb7c16232209c820fd38a4cb17459bb6f8ff4f6d769b97d432bf93be04425cf
same message, a different key = 58ff03b150ebce0ce1134375bc92599e6bfe267524f387a168a4f289cb8d9e84
ed25519 signature: 31f5639857a45ef8a5b5f8cea43257b2... (64 bytes)
verify(original) = true
verify(tampered) = false
verify(sig with one bit flipped) = false
ECDH: alice's secret = 65b610ed7670edcfc5835c97...
bob's secret = 65b610ed7670edcfc5835c97... equal: true
AES-GCM ciphertext: d3dabda7091af65e63779643a4a6cbec6092aed06e5a7867c1 tag: 4f3bc7c873a0107a...
decrypt = the menu is chai and vada
decrypt with one byte flipped: Unsupported state or unable to authenticate data
Five things to read off it:
- Avalanche is exactly half. Two random 256-bit strings also differ in about half their bits - any lean away from 50% is a crack in the hash.
- HMAC answers to a key. A different key or a changed message both give an unrelated tag; without the key, an attacker can’t produce or tell tags apart.
- A signature fails closed. One changed word, or one flipped bit of the signature, and
verifysays false - checked with only the public key, so anyone can check a certificate or a JWT. - Two secrets, never sent, equal. Only the two public values crossed the wire; both sides land on the identical 32 bytes.
- Refuses, doesn’t garble. One flipped ciphertext byte and AES-GCM throws, rather than returning a mostly-right plaintext; ECB or CBC would decrypt it to silent garbage.
The AES key here is the SHA-256 of the ECDH secret: key-pair math handing a fast cipher its key, the hybrid skeleton of every TLS connection (Transport & Network Security walks it in full):
sequenceDiagram participant C as client participant S as server C->>S: hello, here is my Diffie-Hellman public value S->>C: hello, my Diffie-Hellman public value, and my certificate Note over C: SIGNATURE: check the CA's signature on the certificate Note over C,S: DIFFIE-HELLMAN: both compute the same shared secret, nothing secret was sent Note over C,S: HASH: derive the AES key from the shared secret C->>S: AES-GCM encrypted request S->>C: AES-GCM encrypted response Note over C,S: HMAC-style tag on every record: a flipped byte is rejected, not garbled
The slow asymmetric work happens once, in the first two arrows; every arrow after is the fast cipher.
Two columns: the goal you want, and the primitive that delivers it.
GOAL PRIMITIVE
keep it secret ENCRYPTION (AES symmetric / RSA-ECC asymmetric)
detect tampering HASH (SHA-256)
tamper-proof + from-this-key HMAC (shared secret)
prove author + non-repudiation SIGNATURE (private/public key pair)
agree a key over an open wire DIFFIE-HELLMAN key exchange
The rule that matters most: keys random, protected, rotated; authenticated encryption always, ECB never; retire MD5 and SHA-1. Everything above is broken by misuse far more than by breaking the math - take the boring, vetted library.
Try it
import hashlib, hmac, secrets
print(hashlib.sha256(b"hello").hexdigest()) # deterministic one-way digest
print(hashlib.sha256(b"hellp").hexdigest()) # avalanche: totally different
key = secrets.token_bytes(32)
tag = hmac.new(key, b"transfer $100", hashlib.sha256).hexdigest()
print("hmac", tag) # integrity + authenticity tag
print(hmac.compare_digest(tag, tag)) # constant-time compare (avoid timing leaks)
Then pip install cryptography for an AES-GCM round trip and an Ed25519 sign/verify - feel the difference between “shared key” and “key pair”.
Or with nothing to install, using the node file above:
node crypto-tour.mjs: the two SHA-256 digests match yours exactly (no key, deterministic); every other line differs (a fresh key each run).- Change
"hellp"to"Hello"and count the differing bits again. Then hash a 1 MB file and a copy with one byte changed. Same 50%. - Swap
"aes-256-gcm"for"aes-256-cbc"(16-bytenonce, drop the tag lines) and rerun the flipped-byte decrypt: it succeeds and prints garbage, the silent failure GCM exists to remove. openssl dgst -sha256 <file>andshasum -a 256 <file>give the same digest as node: three implementations, one number.
Go deeper
- Crypto 101 (free book)
- Serious Cryptography - Aumasson
- Cryptographic Right Answers (Latacora)
- How TLS/DH works - Cloudflare
- The ECB penguin.
Related
- The Security Mindset - the goals these tools serve.
- Authentication - the hash and HMAC at work: password hashing and the JWT.
- Transport & Network Security - the handshake above at full resolution.
- The whisper code (how a call goes private) - the same handshake told from the packet’s side.
- Hashing - the other kind of hash: speed and spread, not one-wayness.
- The full Security list is in CURRICULUM.
Retrieval practice
9 questions. Answer each one before you open it.
Why did Zimmermann need two different kinds of lock inside PGP, and how do a shared key and a key pair differ?
A shared key is one secret number both ends hold: fast, but hard to deliver safely. A key pair is two numbers made together - a public half anyone locks with, a private half that alone unlocks - solving delivery, at the cost of being tens of thousands of times slower. PGP uses the pair once to hand over a shared key, then that fast key locks the message - the hybrid every secure website still uses.
Why can’t a hash be reversed, and what does it prove instead of secrecy?
A hash squashes a message of any length into one fixed-size fingerprint, throwing away information on the way in - nothing to run backward. It proves a message hasn’t changed: reprint the fingerprint and compare it to the one you were given.
HMAC vs a signature - what does each prove, and the key difference?
HMAC (hash plus shared secret) proves a message is unaltered and came from someone holding that key - but since both sides hold it, neither can prove which one made it. A signature (private key marks, public key checks) proves it came from one person who can’t deny it, since only their private half could make that mark.
What problem does the paint-mixing trick solve, and what would an eavesdropper who saw everything still be missing?
It lets two strangers with no shared secret reach the same value over a watched channel. The eavesdropper sees both sides’ public results but never a private colour, and can’t work backward from a mix to its ingredient.
Why “never roll your own crypto”, and what usually breaks crypto in practice?
The math is sound; what breaks it is misuse - a reused nonce, the wrong mode, a predictable key, a timing leak - which vetted libraries have already found and fixed. Crypto is broken by misuse far more often than by breaking the cipher itself.
Map each of the three goals from the security mindset to the primitive that delivers it.
Secrecy → encryption (shared key for bulk data, a pair to exchange it). Proof nothing changed → a hash, or HMAC if you also need to know who produced it. Proof of who sent it, undeniably → a signature; only a key pair provides that.
Changing one letter of the input flipped 131 of 256 digest bits. Why is “about half” the right answer, and what would 90% mean?
Two random 256-bit strings also differ in about half their bits, so a good hash makes a one-letter change look like an unrelated input. 90% would be just as flawed as 10%: any lean from 50% is structure an attacker could exploit.
In the key-exchange run, which values crossed the wire, and why can’t an eavesdropper who has both still find the secret?
Only the two public values, one from each side. Each side combines its own private value with the other’s public one to reach the same secret; recovering a private value from its public one is the hard problem the scheme rests on.
Flipping one byte of the encrypted output threw an error instead of decrypting. Why is that the behaviour you want, and what would a plain mode like ECB have done?
Authenticated encryption checks a tag before decrypting; a mismatch means the data was altered, and refusing is the only safe response. A plain mode would decrypt the altered bytes to silent garbage that downstream code acts on.