Skip to content

Engineering blog

When the golden vector is the attack: what a conformance audit of five verifiers found

Five verifiers in five languages, all passing the same pinned test vectors on every commit. An audit against the published specs still found three defects the vectors could not see — in two cases because the vectors themselves were wrong. What shared vectors actually prove, and what we changed.

INTYGA's receipt verifier exists as five independent implementations — TypeScript, Go, Rust, Python and Java — and every one of them is checked against the same pinned golden vectors on every change. We describe that on our security page as the reason a spec ambiguity cannot quietly drift between languages. It is true, and it is also narrower than it sounds. In August 2026 we audited all five verifiers line by line against the published DIV and DEWP specifications rather than against each other. The implementations held up; the vectors did not. Three findings are worth writing down, because each is a different way a shared test vector can pass while the thing it pins is wrong.

Finding 1: the vector pinned the test helper, not the wire

DIV §4.4.2 says a WebAuthn witness carries its authenticatorData, clientDataJSON and signature as unpadded base64url — the form the browser's assertion API hands back, and exactly what the gateway emits. The Go verifier decoded those fields with base64.StdEncoding. Rust used the STANDARD alphabet. Java used Base64.getDecoder(). The two alphabets differ only in characters 62 and 63, so any field containing a '-' or '_' fails to decode under the standard alphabet. Go and Rust refused every production passkey receipt; Java refused all but the rare ones whose bytes happened to avoid both characters. TypeScript and Python were lenient decoders and never noticed.

All five ports passed the shared WebAuthn vector. They passed it because the script that generated it serialized those three fields with Buffer.toString("base64") — standard, padded — which is not what the spec says and not what the gateway does. The vector was a faithful recording of a test helper's output, and the test helper had never been held to the wire form. A verifier that agreed with the vector was, in this one respect, guaranteed to disagree with production.

The fix is three dual-alphabet decoders, a vector regenerated in the production wire form, and a sentence in §4.4.2 that names the failure so the next implementer does not repeat it. The lesson generalizes: a vector produced by the reference implementation's test path pins that path. If the vector is supposed to stand in for what a verifier will meet in the field, it has to be generated by — or captured from — the code that actually produces field output.

Finding 2: the canonical valid fixture was the attack shape

DIV §5a defines offline approval for the case where the gateway cannot be reached: the relying party builds the challenge itself, approvers sign it on a disconnected device, and the proof carries a challengedAt and an expiresAt whose distance is capped at 60 minutes (72 hours for a delegation of the authority to approve). The whole point of the design is that nothing at rest authorizes anything — it replaced a pre-signed break-glass scheme precisely because possession of a pre-signed file was sufficient to act.

Every verifier enforced the width of that window. None of them enforced its position. Nothing compared challengedAt to the clock. So a proof dated ten years out, with a perfectly compliant 60-minute window, verified today and would keep verifying until that date — a pre-signed bearer capability, the exact thing §5a exists to forbid, signed by a legitimate approver and accepted by all five ports.

Why did no test catch it? Because every offline-approval vector is dated 2998 to 2999, so that it never expires and the suite stays green forever. The canonical "valid offline proof" fixture was forward-dated. It was the attack shape. A verifier implementing §5a correctly would have failed the conformance suite, and a verifier passing the suite was thereby certified to have the bug.

A fixture cannot be both valid and forward-dated unless evaluation time is part of the fixture. So now it is. Every offline and delegation case carries an explicit asOf that sits between its challengedAt and its expiresAt; every consumer passes it to its verifier's evaluation-time override (asOf, AsOf, as_of, as_of_unix_secs — each port already had one for expiry testing); and a new case carries bytes identical to its accepted sibling, evaluated at an asOf before the signed challengedAt, which must be refused.

{
  "name": "offline-forward-dated-refused",
  "receipt": { "canonicalPayload": "{...\"challengedAt\":\"2998-12-31T23:30:00.000Z\"...}", "...": "..." },
  "asOf": "2998-12-31T20:00:00.000Z",
  "expectOkWithOptIn": false
}

Two details of the rule, now normative in DIV §5a.3 rule 3. It is positional, not a ban on future dates: the same proof evaluated inside its own window is accepted. And it is not waived by the audit override that lets an auditor re-examine an expired proof — that override exists to look at a proof that was valid and has since lapsed, which says nothing about one dated in the future. A verifier that let allowExpired switch off the forward-dating check would have reopened the hole from the other side.

Finding 3: four rewrites were right and the reference was wrong

DEWP's Merkle inclusion proofs have a padding check. In a tree with an odd number of nodes at some level, the last node is hashed against itself to fill the pair. That means a prover who claims an index just past the end of the tree can produce a path that recomputes the genuine root — the duplicate-last-node root is byte-identical. The defence is observational: a step whose sibling equals the running node is legitimate only at the unpaired end of an odd level. Anywhere else, it is the signature of an index pointing into padding, and the proof is refused.

The TypeScript reference compared sibling to node as strings. Its hashing step decoded them with Buffer.from(s, "hex"), which accepts uppercase and silently stops at the first non-hex character. Uppercase one sibling and the string comparison says "not self-paired" while the bytes are identical: the padding check passes, the root recomputes, and a path to a leaf slot the ledger never committed verifies against the real root. Reproduced against a real three-leaf tree.

// Honest proof for leaf 1 of a 3-leaf tree, with one sibling uppercased.
const forged = honest.map((s, i) =>
  i === 0 ? { ...s, siblingHash: s.siblingHash.toUpperCase() } : s,
)
// Before: selfPaired === false (string compare), hashPair() decodes the same
// 32 bytes, root recomputes → true. After: refused on DEWP §4.4's lowercase rule.
verifyMerkleProof(leaf, forged, root, bounds) // → false

The Go, Rust, Python and Java ports all already refused this. Each of them had been written with a gate requiring exactly 64 lowercase hex characters before any digest reaches the hash function, because DEWP §4.4 says digests are lowercase hex and their authors read the spec. The reference implementation — the one the vectors are generated from — was the only port accepting the forgery. Mirror drift usually means a port fell behind the reference. Here the reference had fallen behind the spec, and four independent rewrites had each quietly corrected it.

The vectors could not show this, for the same structural reason as finding 1: they are generated by the reference. A file of proofs that must verify will never contain the one proof the reference wrongly accepts. The fix adds the gate to the reference and two new ledger vectors — the uppercased-sibling forgery, which must be refused, and an uppercased root on an otherwise honest path, which must also be refused. The second one exists to stop the obvious wrong fix: a verifier that case-folds its inputs to be lenient passes the first case and reopens the forgery.

What shared vectors prove, and what they do not

After this audit we would state the guarantee more carefully than we did before. A set of pinned vectors that every implementation passes proves that the implementations agree with each other, and with whatever produced the vectors. It does not prove that any of them agrees with the specification, or with production, or with the clock. Those are three separate claims, and each of the three findings above is one of them failing while the vectors stayed green.

FindingWhy the vectors were greenWhat now pins it
Standard-alphabet WebAuthn decoders in Go, Rust and JavaThe vector was generated with the same non-spec encodingVector regenerated in the production wire form; DIV §4.4.2 names the drift
Forward-dated offline proofs accepted everywhereThe valid fixtures were themselves forward-dated, to never expireExplicit asOf on every time-bound case; a refusal case evaluated before challengedAt
Hex case-folding revived the Merkle padding forgery in the referenceVectors are generated by the reference, so they never contain what it wrongly acceptsLowercase-hex gate in the reference; two refusal cases, one of which catches the lenient fix

Four changes to how we test followed from that, and they are the transferable part of this post.

  1. Generate vectors from the production encoder, or capture them from real wire output. A test helper that serializes its own way pins the helper.
  2. Make evaluation time an explicit field of every time-bound vector. A fixture that "never expires" is a fixture that hides every rule about time.
  3. Ship refusal vectors, not only acceptance vectors. A suite that contains only things that must verify cannot catch a verifier that accepts too much — and for each refusal, consider adding the case that catches the obvious lenient fix.
  4. When a port disagrees with the reference, the reference is a suspect, not a judge. Four of our five ports were correct where the reference was not, and the process treated them as the deviation.

The smaller items

The same audit closed several narrower gaps, listed here so the changelog is complete. The Python verifier had three fail-open corners relative to the reference — a missing expected target bound the receipt to the empty string, a literal null nonce passed when the caller omitted its own, and a bundle with no kind skipped the DEWP §6.5 gate — all now refused, with the first WebAuthn test coverage in that package. The Go canonicalizer fell through to encoding/json for value types it did not handle, which sorts keys and escapes differently; it now refuses them. The TypeScript anchor-quorum count included WEBHOOK and unrecognized anchor kinds, which DEWP §5.2.1 excludes. Three shared refusal vectors were added for the rules a third-party verifier is most likely to get wrong: a delegation fed to the approval verifier, an agent-authority seal fed to the approval verifier, and an unrecognized signerClass. And two things the specs said were wrong: DEWP §4.3.1 required verifiers to refuse a non-portable leaf while also blessing a best-effort path that could not satisfy that requirement, and neither spec disclosed that integers between 2^53 and 10^16 canonicalize differently on arbitrary-precision runtimes than on double-based ones. Both are corrected in the text, with normative-change notes, rather than papered over in code.

The conformance test suite also turned out not to be running everywhere it was described as running: the Rust and Go client packages' tests were executed by no job on the default pull-request path. They are now. A verifier you are asked to trust should be able to show you not only that its tests pass, but that they run.