Fuzz the card proof and stop a malformed key set from demoting it - #352
Merged
Conversation
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>
Contributor
Author
|
Fourth review round found nothing that blocks the merge and every check is green. Merging. |
This was referenced Sep 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdlisted the compositeverifiers under "not covered" for exactly that reason.
agent-card-signatureis the first surface whose generator holds a key. Itderives 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:
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.
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.signingpresent 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 nota reason mismatch. A card carrying such a member and signed with the
bootstrapkeyId is accepted by Go assigned_authenticatedand rejected bythe reference as
invalid_card, so the key set stops being consulted at themoment 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 Goregression test named for the demotion.
Verification
go test ./...green, TypeScript suite green, typecheck, lint andcheck:factsgreen.A conformance vector pinning this decision for future implementations is the
natural follow-up and is not in this change.