Skip to content

Fuzz the card proof and stop a malformed key set from demoting it - #352

Merged
jasonodoom merged 4 commits into
mainfrom
card-signature-fuzzing
Sep 5, 2026
Merged

Fuzz the card proof and stop a malformed key set from demoting it#352
jasonodoom merged 4 commits into
mainfrom
card-signature-fuzzing

Conversation

@jasonodoom

@jasonodoom jasonodoom commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

The new surface

Every differential surface so far can be fuzzed with bytes alone. A composite
verifier cannot: it checks a signature before it checks anything else, so a
generator that cannot sign never reaches the second branch and every case
rejects for the same reason. differential/README.md listed the composite
verifiers under "not covered" for exactly that reason.

agent-card-signature is the first surface whose generator holds a key. It
derives an Ed25519 key from the run's rng so a seed still replays exactly,
builds a card, signs it, and decides per case whether to re-sign after mutating.
Breaking the signature exercises the proof; keeping it valid over a mutated card
exercises everything past it.

Two design points worth stating:

  • The signature base is built from the spec text rather than imported from
    either implementation, so the generator is not one side's opinion of what it
    signs. A base built wrong costs signal, because both sides then reject, and it
    cannot manufacture a disagreement.
  • The generated cards stay inside the region where the spec pins the decision.
    Two corpus cases mark a decision the spec leaves open, one a cold chain
    extension and one a did:web resolver that is unavailable, and both need a
    cached card or a did:web resolution to reach. The generator emits neither and
    the seed mapper drops any corpus case carrying them, because a fuzzer that
    wandered in would report a disagreement the spec permits.

Findings

The first run, 3000 cases at seed 7, produced ten findings that all reduce to
one shape: keys.signing present but not an array.

Go read that member through a type assertion, so a malformed key set was read as
no key set at all and the card fell through to the legacy single-key path, where
the verifying key is the top-level publicKeyMultibase. The consequence is not
a reason mismatch. A card carrying such a member and signed with the
bootstrap keyId is accepted by Go as signed_authenticated and rejected by
the reference as invalid_card, so the key set stops being consulted at the
moment it is malformed, including entries it retires or revokes.

The reference reaches the safe answer by entering the key-set branch and failing
closed. Go now does the same: a key set that is present but unusable is
invalid_card, while an absent one still selects the legacy path. There is a Go
regression test named for the demotion.

Verification

  • 8000 cases at seed 7 on the fixed tree: no divergence.
  • go test ./... green, TypeScript suite green, typecheck, lint and
    check:facts green.

A conformance vector pinning this decision for future implementations is the
natural follow-up and is not in this change.

A composite verifier checks a signature first, so a generator that only mutates
bytes never reaches the second branch. This surface holds a key, signs the card
it builds, and re-signs after mutating when the case calls for it.

It found a bug on its first run. Go read keys.signing through a type assertion,
so a member present but not an array read as no key set and the card fell
through to the legacy single-key path, authenticated against the top-level
publicKeyMultibase even when the set revoked it. It is now an invalid card.

Signed-off-by: Jason Odoom <jason@adastracomputing.com>
The reference used a truthiness test, so a falsy keys.signing took the legacy
path there while Go now rejected it, and rotationChain had the same shape on
both sides: present but not an array read as absent, which roots the card at
genesis and skips the chain it declared. Both now reject.

The corpus case for this shape passed while the demotion was live, because it
carried a placeholder signature and the key-set keyId. Three vectors replace it
with the cases that distinguish failing closed from demoting.

Signed-off-by: Jason Odoom <jason@adastracomputing.com>
Two more members read as absent when present but malformed. A keys member that
is not an object took the legacy single-key path on both sides, and a
cardSignature that is present but not an object took the unsigned path, which
spec 3.4 reserves for a card carrying no such member at all. The unsigned path
is the permissive one on a cold first contact.

Signed-off-by: Jason Odoom <jason@adastracomputing.com>
extractCandidateKeys guarded a malformed signing array but not a malformed
keys member. Optional chaining made it undefined, so the card fell to the
legacy branch and the top-level key came back as active, ignoring whatever
the set said about rotation or revocation. Both implementations now return
an authoritative empty set.

Signed-off-by: Jason Odoom <jason@adastracomputing.com>
@jasonodoom

Copy link
Copy Markdown
Contributor Author

Fourth review round found nothing that blocks the merge and every check is green. Merging.

@jasonodoom
jasonodoom merged commit 116fce1 into main Sep 5, 2026
17 checks passed
@jasonodoom
jasonodoom deleted the card-signature-fuzzing branch September 5, 2026 13:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant