Skip to content
AFM Studio
  • Pages

    AFM Studio
  • Work
  • Lab
  • About
  • CV
  • Contact
  • Projects

    AksaraA searchable library for Otorita IKN's knowledge, where the server checks every reader's access across three levels
  • Daily TaqwaTamper-resistant prayer attendance system for ~2,000 OIKN government employees at Masjid Negara IKN
  • Lantara v2A permit engine for Otorita IKN where administrators add new permit types from a screen, without a developer
  • eKiosk IKNTwo control rooms for Nusantara: kiosk content management and live bus tracking with arrival times that grade themselves
  • E-Monev IKNA monitoring platform for Nusantara's 23-year build, tracking every unit's quarterly progress against 24 national KPIs
  • JDIH OIKNOIKN's public legal document portal, with a controlled publishing workflow and AI summaries that never leave the server
  • Jejak PenanamanA check-in app for IKN tree-planting events that proves each participant was on site with a geofenced selfie
  • Dashboard SAKTIA nightly pipeline and dashboard that replaces OIKN's manual budget downloads for all six of its budget units
  • DoseRxA bedside dose calculator for 127 drugs that rounds each dose to tablets and syrups Indonesian pharmacies sell
  • CubiqA speedcubing trainer with competition-grade timing and a solver I wrote for each of the 8 major puzzles
  • ScimotionAn interactive science library of 86 articles, each explained next to animations the reader can control
  • WA Daily Scrum BotA WhatsApp bot that chases missing standup reports for a 14-person team and flags tasks stuck for days

↑ ↓ to move · Enter to open · Esc to close

Oracle

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

Screenshots
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.ts returns an AttackTarget (ciphertext, IV, oracle, call counter). It has no key or plaintext field, and the engine's index.ts exports only the attack-facing surface. tests/view-blindness.test.ts runs 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 02 is also valid padding. The attack perturbs the second-to-last byte and re-queries. tests/disambiguation.test.ts asserts the naive path fails on a 0x02-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, WebSocket or sendBeacon appear 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-key f/r shortcuts 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 requestAnimationFrame loop; 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

Beranda
The Interrogator
The Resolution
The Message
The Defences
The Record

Hiring, or building something similar?

I'm open to full-time roles and select freelance work.