Proof
The hostile inputs noa-receipt rejects.
Six ways a forged signature or a malformed key can lookvalid to a careless Ed25519 verifier — and the deterministic test vectors that prove noa-receipt rejects every one of them, on every implementation we ship. Don't take our word for it — every card below links to its exact source, and the checked-in test suite runs these vectors yourself (pnpm test:parity).
Cofactor-malleated signatures
Ed25519 has a small mathematical blind spot — an 8-point torsion subgroup. Starting from one real signature, you can derive up to 7 more, byte-different signatures over the exact same message and key. Each one satisfies the loose "cofactored" verification equation that many libraries use by default.
Why it matters — If a verifier accepts any of those look-alikes, a receipt no longer has one unique signature — a different-looking signature would still be called valid. That undermines the one property a signed receipt is supposed to give you.
We reject it. We use the strict, cofactorless RFC 8032 §5.1.7 check everywhere — the canonical (node:crypto) verifier and the browser-swap verify module /playground's client bundle uses both reject all 7 non-identity variants, asserted equal by this repo's CI.
[8]S′B = [8]R′+[8]k′A holds → a cofactored verifier accepts it, ours rejects itSee the exact testSmall-order public keys
A "public key" doesn't have to come from any real private key — it can be one of only 8 points forming Ed25519's tiny torsion subgroup, each with almost no cryptographic strength.
Why it matters — A key drawn from that subgroup can make an enormous number of different signatures verify against it — effectively a skeleton key for a check the algorithm is supposed to make unforgeable.
We reject it. All 8 torsion-point encodings are checked explicitly and rejected as invalid public keys, before any signature math runs.
8/8 small-order encodings tested → 8/8 rejectedSee the exact testOut-of-range signature scalar (S ≥ L)
Every signature has two halves, R and S. The spec requires S to be strictly smaller than the curve's group order L. Add L to a genuine S and the underlying value "wraps" back to the same one — but the 64-byte string looks completely different.
Why it matters — A verifier that skips the S < L check accepts that second, wrapped signature as equally valid — one receipt ends up with two "valid" signatures, exactly the ambiguity a signature exists to rule out.
We reject it. We check S < L explicitly before any curve arithmetic runs, and reject S ≥ L outright — including the exact S+L wraparound case.
S′ = S + L (still < 2²⁵⁶) → rejected by an explicit pre-check, not curve mathSee the exact testNon-canonical public-key encoding (y ≥ q)
A public key is a 255-bit y-coordinate. The field prime q is a little smaller than 2²⁵⁵, so a handful of 32-byte strings "fit" the format without encoding a valid point below q.
Why it matters — Implementations handle these out-of-range bytes inconsistently — silent reduction, rejection, or outright errors. That disagreement is exactly what breaks "independently verifiable, same answer everywhere."
We reject it. We reject any y ≥ q outright, including the exact boundary y = q — one canonical reading of a key, always.
y = q (boundary) and y = 2²⁵⁵−1 (all-0xff) → both rejectedSee the exact testLoosely-decoded base64
Keys and signatures travel as base64 text. A decoder that quietly tolerates an embedded space, an extra '=' padding character, or a stray newline can decode to bytes the signer never produced.
Why it matters — That looseness reopens the exact ambiguity signatures exist to prevent — a permissive decoder is a second, silent path to signature and key malleability, not just a formatting nicety.
We reject it. Our decoder is strict: exact base64 alphabet, no embedded whitespace, no extra padding. Any deviation is rejected before it reaches the crypto layer.
embedded space · extra '=' padding · embedded newline · non-canonical padding → all rejectedSee the exact testTwo independent implementations, one verdict
The reference verifier is TypeScript. A second, from-scratch Python re-implementation runs against the same attack vectors above — plus dozens more — every time either one changes.
Why it matters — A crypto spec is only as trustworthy as its weakest implementation. If our own second implementation silently disagreed with the reference on even one of these vectors, that's a real security bug waiting for someone to build a third, incompatible one.
We reject it. 92 cross-implementation checks, 0 disagreements, regenerated and diff-checked by the noa-receipt library's own release gate before every npm publish — the matrix is public.
TypeScript (reference) ⇄ Python (impl-py/noa_verify.py) — 10 vector classes, 0 mismatchesSee the exact testBrowser ↔ canonical parity
Vectors 01–05 run against both the canonical Node verifier and the browser-swap verification module the /playground client bundle uses — asserted identical on every push to main and every pull request, in this site's own CI.
Cross-implementation conformance
92 checks, 0 disagreements between the TypeScript reference and an independent Python re-implementation — regenerated and diff-checked by the noa-receipt library's own release gate before every npm publish, not this site's CI.
Read the spec. Verify the receipts. Trust nobody's word for it — including ours.
Open-source, Apache-2.0. The rejection rules and the verifier are both public.