Interactive Walkthrough — Applied Cryptography
There is a capture-the-flag trail sitting in front of my résumé, running the same AES-GCM as the real gate. One of them falls in seconds. You can break it yourself, below, in this tab.
My résumé lives at /resume as AES-GCM ciphertext. The site is static (GitHub Pages, no backend), so there’s nobody in the request path to check who’s asking. Encryption is the only gate there is. A crawler that fetches the file gets bytes.
Behind that gate I put a second door. It’s a three-stage trail at /ctfthat ends in the same passphrase, built for the reader who’d rather take the word than ask for it. Stage two is cryptographically identical to the real payload. Same AES-256-GCM, same PBKDF2-SHA256, opened by the same client code, byte for byte the same path.
One of them falls in seconds. The other doesn’t. Nothing about that difference is cryptographic. It comes down to a single property that has nothing to do with the algorithm, and it’s the property most client-side encryption gets wrong.
Rather than ask you to take that on faith, the next panel builds a working replica of stage two in your browser and hands you the loop.
Everything below is real. The payload is generated in this tab with WebCrypto: random salt, random IV, and a 256-bit AES-GCM key derived by PBKDF2-SHA256 from a passphrase picked at random out of the candidate list. Nothing is faked or pre-computed, and the timings are your machine’s.
The only thing I’ve handed the attacker is what my real trail gives away too. The list the key came from.
No cryptanalysis, no side channel. A loop over a list, and an authenticated cipher that says “yes” the moment you are right.
Push the skills slider to 20 and the same sweep takes about nineteen times as long. That’s the real trail’s keyspace, 380 ordered pairs. Now try the iteration slider instead. The total climbs, slowly, and every legitimate reader of my résumé pays that cost on every unlock. Section III draws the trade.
Three stages, all served from one origin as static files. No scanning, no fuzzing, no request loops pointed at my host. Everything a solver needs is already public when they arrive. The filename gets computed rather than guessed, which is the line between a puzzle and a directory brute-forcer.
There are two ways in, both deliberate. My robots.txt disallows /ctf/, which keeps compliant crawlers from turning “solve this” into “search for the writeup,” and doubles as the breadcrumb for anyone who reads robots.txt to see what wasn’t meant for an index. The other door is the devtools console on a locked /resume, where the page prints the algorithm, the iteration count and the trailhead.
Both are places engineers already poke around, and neither is anywhere near a recruiter who just wants to read a résumé. The console banner is deliberately inert: it logs and nothing else. No fetch, no storage, no globals. The tests assert that a locked /resume touches the network zero times, and a bit of showing off was not going to be what broke that.
Total attacker work is candidates × cost-per-guess. Put both terms in bits and the argument stops being rhetorical. Doubling either one adds exactly one bit, so on paper they look symmetric.
They aren’t symmetric, and the difference isn’t in the math. It’s in who pays. Drag both sliders.
The keyspace bar is free. Widening the real trail from 20 candidates to 380 took one line of code, flatMap in place of map. It cost me nothing and cost a solver 4.25 more bits. The KDF bar is rented. Every bit on it gets paid again by every reader who legitimately unlocks the page, forever, on the slowest phone they own.
That ceiling is why a KDF can never be the answer on its own. It buys a constant factor bounded by your users’ patience. Entropy is the only term here that scales without a bill attached.
Stretch a guessable secret and all you get is a slow break.
An offline break like this needs four things true at once. My trail hands over all four, and so does most client-side encryption I’ve seen shipped. Switch any one off and watch the verdict change.
Static file, public host. Fetch once, and every guess after that runs on their hardware. Nothing rate-limits, locks out, or even logs the attempt.
AES-GCM is authenticated: a wrong key fails the tag and throws, a right one returns plaintext. Every candidate is a clean boolean.
The key is not random. It comes from a finite set printed on the page it protects. Nobody guesses the secret; they enumerate its source.
The toll per guess is identical on attempt 1 and attempt 10,000. No lockout, no backoff, no second factor.
All four hold. The sweep in Exhibit A runs start to finish, offline, unobserved, which is exactly the situation my trail is in.
Look at what’s not on that list. AES-256 isn’t weakened. PBKDF2 isn’t misconfigured. The salt is random, the IV unique, the tag verified. Every primitive does its job correctly and the payload opens anyway, because correct primitives were never what protected it.
When the original stage two fell in 0.47 seconds, my first instinct was to crank the iteration count until the sweep hurt. I didn’t, and the reasoning matters more than the puzzle does.
The résumé payload runs 600,000 iterations, OWASP’s current floor for PBKDF2-HMAC-SHA256, about 0.6s in a browser. It ran at 310,000 until recently, which was the floor when I built it. That upgrade bought 0.95 bits, and every legitimate reader pays for those 0.95 bits on every unlock. The one-line keyspace change bought 4.25.
Raising the trail’s count would have been worse than useless. Both its README and its prize argue that stage two falls because its key is enumerable, pointing at the real payload’s higher cost as the thing stage two lacks. Push stage two past the real payload and the exhibit starts claiming a slow KDF is what saves you, which is the exact misconception it exists to kill. There’s now a build-time test that fails if the trail’s iteration count ever creeps past the real one. Mostly that’s a guard against future me, quietly inverting the lesson some evening.
| Property | Trail stage 2 | Real résumé payload |
|---|---|---|
| Cipher | AES-256-GCM | AES-256-GCM — identical |
| KDF | PBKDF2-SHA256 | PBKDF2-SHA256 — identical |
| Client code | the résumé decryptor | the same file, unmodified |
| Iterations | 100,000 | 600,000 |
| Key source | A list printed on the page it protects | A word published nowhere |
| Keyspace | 380 — fully enumerable | Not enumerable from anything shipped |
| Result | ~13s average in a browser | There is no sweep to run |
Six of seven rows are identical or favor the puzzle. The two that differ are the only two that decided anything.
The trail is contrived. The pattern underneath it isn’t. This is one of the more common ways a real system ends up with an encryption feature that protects nothing — OWASP files the family under A02: Cryptographic Failures — and every item below is those same four preconditions in different clothes.
11600 for 7-Zip, 17210–17230 for PKZIP, 9400–9600 for modern Office).16500) re-signs the token’s own header and payload with each candidate secret and stops when a signature matches. If your signing key is a word, it’s already cracked. Watch the algorithm-confusion variants (alg:none, RS256→HS256) while you’re in there.22000). The protocol is sound. The entropy usually isn’t.Every case above answers yes to one question. Could I write down the complete set of possible keys using only information I publish?
If yes, you don’t have a gate. You have a delay, and its length is set by how badly the reader wants in and what hardware they own. A 10⁶-member set doesn’t save you. Neither does a third of a second per guess.
Ranked by what they buy. Only the first two change the shape of the problem; the rest is hygiene that starts mattering once those two are handled.
Point these at anything in your codebase that decrypts in a client. One answer landing the wrong way is enough.
I’d rather write this down myself than have someone point it out to me. Anyone willing to spend half an hour can now reach my gated résumé. Metrics, contact block, PDF, all of it. That’s the deal, and I made it knowingly.
Scrapers and crawlers still get ciphertext, which was always the actual goal. The trail filters for effort. It was never a lock. One flag on the encrypt script drops it entirely if I ever want the gate invite-only again.
Everything regenerates on every re-encrypt: new salt, new stage-two key, new flag. A published solution goes stale on the next run. That’s less about spoiler discipline than about a property I wanted from the start. A trail that’s only solvable on average isn’t solvable. So the test suite solves the whole chain end to end across several rotations, and fails the build if any one of them turns out to be a dead end.
What I want a solver to leave with is the only part that generalizes, and it’s the same thing Exhibit A just did on your machine. They recovered a 256-bit AES key and never touched AES. The cipher was never the weak part. In my experience it almost never is. The weak part sits upstream of the algorithm, in where the key came from, and that’s the line to audit first.
Primary sources behind the walkthrough. If you read two, make them the OWASP storage sheet and NIST 800-63B — between them they cover most of what teams actually get wrong here.
Current parameters: Argon2id m=19 MiB/t=2/p=1, scrypt N=2¹⁷, bcrypt work factor ≥10, and PBKDF2-HMAC-SHA256 at 600,000 iterations — what my payload uses.
Rev 4: 15-char minimum for single-factor, composition rules prohibited, blocklist checks required, a 100-attempt rate-limit cap, salted and hashed storage against offline attack.
The GCM spec, including the IV-uniqueness requirement and exactly what an attacker recovers when a nonce repeats under one key.
The function this whole thing leans on, defined in a couple of pages, with the salt and iteration-count reasoning stated plainly.
The recommendation for new work, and the clearest explanation of why memory-hardness blunts a GPU where more iterations do not.
Everything Exhibit A is built on — deriveKey, encrypt, getRandomValues, and the secure-context rules that govern them.
The formal name for what stage two demonstrates, cross-linked to the hard-coded and guessable-credential entries.
Opinionated and readable — the fastest way to check whether the primitive you reached for is the one a specialist would have picked.