Skip to content

Repository files navigation

Frozen Heart

Weak Fiat-Shamir · transcript binding in NIZKs

A zero-knowledge proof forged for a statement its author cannot prove — because the challenge hash omitted one value it should have covered. The proof system is correct; the hash input was one field short. Built as a live, hand-rolled Schnorr proof over ristretto255 that you can break yourself against the real verifier.

Not production cryptography — a teaching demo. Everything runs in your browser; keys and nonces live only in memory for the session and are never persisted or transmitted.


What It Is

A Schnorr proof of knowledge of a discrete logarithm, made non-interactive with the Fiat-Shamir transform, over the ristretto255 prime-order group (RFC 9496). The prover convinces a verifier it knows the secret key x behind a public key pk = [x]G, without revealing x.

Fiat-Shamir replaces the verifier's random challenge with a hash of the transcript. It is sound only if that hash covers the whole transcript — every public input and, crucially, the prover's commitment R. Leave R out and the prover, not the hash, effectively chooses the challenge, which enables a genuine algebraic forgery.

  • The primitives are real. Point arithmetic and canonical encoding come from the audited @noble/curves library; the Schnorr prover, verifier, Fiat-Shamir challenge, and the forgeries are hand-rolled here so the internals are inspectable (src/schnorr/).
  • The forgery is real. It is a byte-exact algebraic construction — pick the response s, then solve R = [s]G − [c]pk — run against the same verifier an honest proof faces. No simulation, no warning banner standing in for a broken check.
  • Security model. A forged proof is an authentication forgery: the verifier is made to believe you hold a key you do not. It does not recover the secret key, does not break the discrete-log problem, and decrypts nothing.

Exhibits

  1. The transform, stepped — a real Schnorr proof walked through Commit → Challenge → Respond → Verify, with a toggle between the interactive protocol (a live verifier throws a random challenge) and the non-interactive Fiat-Shamir version (a hash plays the verifier). Shows the exact substitution and why the ordering — commit R before the challenge — is what keeps it honest.
  2. What is "the transcript"? — toggle which of {G, pk, R, m} the challenge hash covers; the formula c = H(...) updates live.
  3. Break it yourself — forge a proof for a target public key whose secret you do not have (the forge code is only ever handed the public key). Two independent indicators are rendered side by side: the raw cryptographic result (did the equation balance?) and the security verdict (SOUND / HELD / ALARM). Under weak Fiat-Shamir you see "Equation HOLDS ✓" next to "ALARM ⚠" — a forged proof that verifies is never green.
  4. The omission ladder — drop each field in turn and see the verdict the real verifier returns: only dropping the commitment R is fatal; dropping the message enables replay; dropping the public key leaves proofs unbound to any identity; dropping the generator is harmless in one fixed group. Each rung's severity is derived from three measurements taken live against the real verifier — a fixed-target forgery attempt, a witness-free key-and-proof minting attempt, and a replay of one honest proof under a second message — and all three readings are shown next to the chip. The severity is never switched on the rung's name, so if the algebra underneath changed the chip would change with it. The italic line under each rung is commentary, and is labelled as such.
  5. Frozen Heart (2022) — the Trail of Bits disclosures that hit independent implementations of Girault's proof of knowledge, Bulletproofs, and PlonK with the same root cause, plus a one-panel note on the random-oracle model.

When to Use It

  • Use it to build intuition for why "hash the transcript" must mean the entire transcript, and to see the difference between a fatal omission and a merely sloppy one.
  • Use it to explain the Frozen Heart bug class to engineers reviewing a Fiat-Shamir implementation.
  • Do NOT use it as a cryptographic library. This is a teaching build: it favours transparency over constant-time discipline and side-channel resistance, and it is not audited for production. For real Schnorr signatures use a vetted library and a specified scheme (e.g. Ed25519 / BIP-340).

Live Demo

https://systemslibrarian.github.io/crypto-lab-frozen-heart/

Step the transform, toggle the transcript fields, and forge a proof the real verifier accepts — then watch the security verdict alarm even though the equation balances.

What Can Go Wrong

The lab is the "what can go wrong," reproduced honestly:

  • Drop the commitment R from the hash → universal forgery. The challenge is fixed before R exists, so a forger picks s and solves R = [s]G − [c]pk, producing a proof that verifies for any target key. This is the Frozen Heart pattern.
  • Drop the message m → replay. The proof of knowledge is still sound, but nothing binds it to a context; an honest proof replays verbatim under a different message.
  • Drop the public key pk → unbound statements. No fixed-target forgery, but a proof is not tied to any particular key: a witness-free (key, proof) pair can be minted, so a verifying proof says nothing about a pre-existing identity.
  • Precision matters. A forgery here is authentication-only. Claiming it recovers the secret key or breaks discrete log would be false — and the lab is careful never to.

Real-World Usage

The Frozen Heart vulnerabilities (Trail of Bits, 2022) were exactly this bug, found independently across unrelated codebases: Girault's Schnorr-style proof of knowledge (in threshold-signature libraries), Bulletproofs range proofs, and PlonK zk-SNARKs. Each spec said "hash the transcript"; each implementer decided for themselves what that included, and some left a value out. The fix in every case was to fold every public input and every commitment into the Fiat-Shamir challenge. Impact varied by construction, and every issue was responsibly disclosed and patched — this lab reproduces the class of bug on the minimal Schnorr vehicle, not any specific product's exploit.

How to Run Locally

npm install
npm run dev        # http://localhost:5173/crypto-lab-frozen-heart/
npm test           # unit tests: KATs, round-trips, forgeries, the omission ladder
npm run build      # type-check + production build to dist/
npm run test:e2e   # browser gate: functional claims + axe-core WCAG 2.1 A/AA, both themes
npm run test:a11y  # just the accessibility half of that gate

Related Demos

Every one of these Fiat-Shamirs something, and so rests on the transcript-binding assumption this lab pokes at:

Build & Verify

  • 49 unit tests (Vitest), all passing, including 18 known-answer tests:
    • 16 RFC 9496 ristretto255 vectors — the canonical encodings of [0]B[15]B, verifying the group layer is the real ristretto255 bit-for-bit (src/schnorr/group.test.ts).
    • 2 NIST SHA-512 vectorsSHA-512("") and SHA-512("abc").
  • Correctness — honest proofs verify (100 randomized round-trips); the verifier rejects every tampered response, commitment, message, and wrong key, and rejects out-of-range responses fail-closed (src/schnorr/schnorr.test.ts).
  • Attack tests — the weak-Fiat-Shamir forgery is accepted by the real verifier across 50 random targets; the strong transform rejects all 50; the omission ladder confirms exactly one rung (drop R) yields a verifying fixed-target forgery (src/schnorr/forge.test.ts).
  • 22 browser claims tests (Playwright, e2e/claims.spec.ts) — the rendered page is driven for real and its verdicts are asserted, not assumed: the forged proof is accepted under drop R and rejected under every other preset (each with the reason it gives); the forgery's own printed s, c, R are fed back through the repo's group code to recover the target key and re-derive the challenge, proving the derivation shown is the arithmetic performed; every omission-ladder severity chip is recomputed from the three measurements printed beside it; and the stepped transform's verdict is checked against the two hex sides the page itself rendered.
  • Accessibility@axe-core/playwright scans the production build for zero WCAG 2.1 A/AA violations in both themes; the GitHub Pages deploy is blocked on any failure.

Run everything: npm test && npm run build && npm run test:e2e.

Performance

All operations are single ristretto255 scalar multiplications and one SHA-512 — sub- millisecond each; the UI recomputes proofs and the full omission ladder synchronously on every interaction with no perceptible delay.


Part of the Crypto Lab suite.

"So whether you eat or drink or whatever you do, do it all for the glory of God." — 1 Corinthians 10:31

About

Browser-based weak Fiat-Shamir demo — Frozen Heart, Trail of Bits 2022. Fiat-Shamir replaces the verifier's random challenge with a hash — sound only if it covers everything. Omit the public key and forge a Schnorr proof of a false statement that the real verifier accepts. No backends. No simulated math.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages