Oracle
An educational demo that reads an encrypted message without the key, using only yes-or-no answers from a server

- oracle calls to recover one block
- ~2,000
- cryptographic building blocks written from scratch
- 5
- network APIs in the built app
- 0
- runtime dependencies, React and its renderer
- 2
On this page
The problem
Many people, engineers included, believe that "we encrypt it with AES" means a message is safe. It doesn't. A well-documented attack called the padding oracle recovers a whole encrypted message without breaking the cipher. It only needs a server that says whether a message's padding was valid. That single yes or no, asked about 2,000 times per block, is enough to rebuild the text.
The attack is written up in dozens of places, and the write-ups don't change minds. A page of algebra doesn't make a reader feel that their AES is beside the point. Most existing animations of it also skip a false-positive check the attack needs. Many quietly hold the answer in a variable and play it back.
I built Oracle for engineers who need to understand why authenticated encryption exists, for security teachers who want a live demo, and for anyone judging whether I can implement cryptography correctly.
It is an educational demo and not an attack tool. It has no network code at all. A test searches the built bundle and fails the build if any network API appears in it, so its inability to attack a live server is a property of the build.
What I built
I sealed the secret where the screen can't reach it
One module creates the key, the message and the starting block, encrypts, and hands out only four things: the encrypted text, the starting block, a yes-or-no oracle, and a call counter. The key and the message never leave it. When a recovered letter appears on screen, the attack produced it. A test holds a known message in its own sealed scope, runs the real attack, and checks the recovered bytes match. The app proves it isn't cheating.
The oracle answers with one bit and nothing more
The oracle takes an encrypted message and returns true or false. There is no error detail, no timing and no hint about which byte failed. A richer answer would let the attack cheat and would misrepresent what an actual attacker sees. So the attack works from one bit, as the real one does.
I built the false-positive check and let readers break it
When recovering the last byte of a block, a naive attack can accept a lucky match and record the wrong byte. Every byte after it is then corrupted. The fix is to change an earlier byte and ask again. I implemented it and animated it as its own step. Readers can switch it off and watch the recovery break. A test confirms the naive version fails on a message built to trigger it.
I run the attack once and replay the recording
The attack engine has no interface code and no clock. It runs the whole attack in milliseconds and records every guess, every verdict, every double-check and the maths behind each recovered byte. The screen only shows a point in that recording. Scrubbing back and forth is free, the animation speed is independent of the maths, and any moment can be shared as a link.
I hand-wrote the cryptography
AES-128, SHA-256, HMAC, the CBC chaining mode and PKCS7 padding are all my own code, checked against the official test vector. The only runtime dependencies are React and its DOM renderer. The first plan used a precompiled AES module. I dropped it because hand-written code needs no binary to audit and no exception to the no-network rule.
Result
Oracle is live and public. Its main view is a four-row stack of bytes: what the attacker sent, what padding the oracle judged, the middle block, and the recovered text filling in right to left in gold. A toggle swaps real AES for a labelled toy cipher, and the attack recovers the same text at the same cost. Turning on encrypt-then-MAC makes every forged message fail before decryption, so no gold ever appears.
A second mode rewrites "user=guest" to "user=admin" in an encrypted message without the key. Recovery takes about 2,000 oracle calls per block, which matches the theoretical average. I built it in seven days in September 2026.
Under the hoodTechnical detail for engineers
src/engine/secret.tsreturns anAttackTarget(ciphertext, IV, oracle, call counter). It has nokeyorplaintextfield, and the engine'sindex.tsexports only the attack-facing surface.tests/view-blindness.test.tsruns the attack against a plaintext held in the test's closure.type Oracle = (ciphertext: Uint8Array) => boolean.- Disambiguation: if the plaintext ends in
0x02, a crafted… 02 02is also valid padding. The attack perturbs the second-to-last byte and re-queries.tests/disambiguation.test.tsasserts the naive path fails on a0x02-terminated plaintext. The toggle is?dis=0. - The engine is pure and synchronous and imports nothing, not even
Date. A seeded PRNG (src/engine/rng.ts) makes every session reproducible from the URL; stale positions are clamped to the built timeline. - AES-128 is ~230 lines (
src/engine/aes.ts), pinned by the FIPS-197 known-answer vector, about 7.5 KB of source. The PRD originally specified an audited AES compiled to WebAssembly. - CI runs typecheck, lint, test, build, then a grep that fails if
fetch,XMLHttpRequest,WebSocketorsendBeaconappear in the bundle, then deploy to GitHub Pages. - The stack shipped as three rows and gained a fourth (the padding the oracle actually judged) mid-build. A bar chart in the cipher panel was removed in favour of two numbers.
- Accessibility pass: the Space shortcut was blocking Space on every button (WCAG 2.1.1); a tablist with no tabpanels was replaced with buttons and
aria-pressed(WCAG 4.1.2); single-keyf/rshortcuts were removed (WCAG 2.1.4). The one SMIL animation, which ignored reduced motion, was removed. 4.5:1 contrast floor, hex/table export of every recovered byte. - State is React context plus a hand-rolled reducer; animation is one
requestAnimationFrameloop; plain CSS (~2,080 lines). - About 128 oracle calls per byte. Page payload ~73 KB gzipped against a 250 KB budget.
- 43 tests in 11 files. ~3,560 lines of TypeScript. 65 commits on
main(68 across refs), 5 to 11 September 2026.
Screenshots
Hiring, or building something similar?
I'm open to full-time roles and select freelance work.