Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@ here. Pre-1.0 releases follow `0.Y.Z` semantics, see

### Changes

- The Go card verifier no longer demotes a card with a malformed key set to the
legacy single-key path. `keys.signing` present but not an array failed a type
assertion and was read as "no key set", so the card was verified against the
top-level `publicKeyMultibase`: a card whose set retires or revokes that key
stopped being consulted, and one signed with the `bootstrap` keyId was
authenticated where the reference rejects it as an invalid card. A present
but unusable key set is now `invalid_card`, which is the decision the
reference reaches by entering the key-set branch and failing closed. Found by
the new card-signature differential surface on its first run.
- The differential fuzzer covers its first composite verifier. The
`agent-card-signature` surface generates cards from a key it holds, so a
mutation can be re-signed and the checks past the signature are reachable;
every other surface can only mutate bytes, which is why the composite
verifiers were out of reach before.

- The signing and hashing entry points take `SignableBody` instead of
`Record<string, unknown>`, so a value of a declared interface type, including
the package's own message types, can be passed straight in. A declared
Expand Down
35 changes: 35 additions & 0 deletions conformance/v1/generate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2808,6 +2808,38 @@ vectorFile("agent-card-fetch", [
// by internal path, so only the decision is pinned). ──
const nonArraySigning = { ...acsBaseCard(keyDerivedId, G.mb), keys: { signing: { bad: true }, encryption: [] }, currentSigningKeyId: "g1", keySetVersion: 1, cardSignature: { keyId: "g1", signature: "A".repeat(86) } };

// The same malformed member, signed for real and with the legacy keyId, which
// is the case that distinguishes failing closed from demoting to the legacy
// single-key path. The card above carries a placeholder signature and the
// key-set keyId, so a verifier that demoted still rejected it and the vector
// passed while the demotion was live.
const malformedSigningBootstrapCard = (() => {
const c = acsBaseCard(keyDerivedId, G.mb);
c.keys = { signing: { bad: true }, encryption: [] };
c.keySetVersion = 1;
c.updatedAt = ACS_UPDATED_AT;
return c;
})();
const malformedSigningBootstrapSigned = await attach(malformedSigningBootstrapCard, "bootstrap", G.priv);
const nullSigningBootstrapCard = (() => {
const c = acsBaseCard(keyDerivedId, G.mb);
c.keys = { signing: null, encryption: [] };
c.keySetVersion = 1;
c.updatedAt = ACS_UPDATED_AT;
return c;
})();
const nullSigningBootstrapSigned = await attach(nullSigningBootstrapCard, "bootstrap", G.priv);
const malformedChainCard = (() => {
const c = acsBaseCard(keyDerivedId, G.mb);
c.keys = { signing: [signingEntry("g1", G, "active")], encryption: [] };
c.currentSigningKeyId = "g1";
c.keySetVersion = 1;
c.updatedAt = ACS_UPDATED_AT;
c.rotationChain = { bad: true };
return c;
})();
const malformedChainSigned = await attach(malformedChainCard, "g1", G.priv);

// ── base64url non-canonical trailing bit. The final base64url character of an
// 86-char Ed25519 signature carries 4 padding bits that MUST be zero for a
// canonical encoding. Both implementations decode the low bits leniently, so a
Expand Down Expand Up @@ -2946,6 +2978,9 @@ vectorFile("agent-card-fetch", [

// ── structural reject ──
acsReject("schema-invalid-non-array-signing-reject", "A card whose keys.signing is not an array fails closed rather than crashing or diverging; the verifier assumes schema validation already ran.", { card: nonArraySigning, agentId: keyDerivedId, options: { profile: "pre-1.0" } }),
acsReject("malformed-signing-bootstrap-signed-reject", "A validly signed card whose keys.signing is not an array, carrying the legacy bootstrap keyId. Reading the member as absent would demote the card to the legacy single key and authenticate it against the top-level publicKeyMultibase, so the key set that retires or revokes that key would stop being consulted.", { card: malformedSigningBootstrapSigned, agentId: keyDerivedId, options: { profile: "1.0" } }, { reason: "invalid_card" }),
acsReject("null-signing-bootstrap-signed-reject", "The same shape with a falsy member. A truthiness test would read null as absent and take the legacy path.", { card: nullSigningBootstrapSigned, agentId: keyDerivedId, options: { profile: "1.0" } }, { reason: "invalid_card" }),
acsReject("malformed-rotation-chain-reject", "A validly signed card whose rotationChain is present but not an array. Reading it as absent roots the card at genesis and skips the chain it declared.", { card: malformedChainSigned, agentId: keyDerivedId, options: { profile: "1.0" } }, { reason: "invalid_card" }),
]);

// ── agent-card-signature-phase-c (STAGED) ──────────────────────────────────
Expand Down
4 changes: 2 additions & 2 deletions conformance/v1/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@
"profile": "base",
"spec": "specs/ink-agent-card-signature.md",
"summary": "Self-authenticating Agent Card proof: the cardSignature proof, rotation-chain rooting by principal kind, head binding, the unsigned-card ratchet, and the continuity and rollback rules.",
"caseCount": 50,
"sha256": "621971ee7012b98b2f3ae64f929b122a3bb79c3b83fa13c9876ae033563f79c2"
"caseCount": 53,
"sha256": "a6b06fae90e31c0dd943b242fc872a6e90c2a21a10ff4273860a9206c193af55"
},
{
"id": "agent-card-signature-phase-c",
Expand Down
131 changes: 131 additions & 0 deletions conformance/v1/vectors/agent-card-signature.json
Original file line number Diff line number Diff line change
Expand Up @@ -3443,6 +3443,137 @@
"expect": {
"result": "reject"
}
},
{
"caseId": "malformed-signing-bootstrap-signed-reject",
"description": "A validly signed card whose keys.signing is not an array, carrying the legacy bootstrap keyId. Reading the member as absent would demote the card to the legacy single key and authenticate it against the top-level publicKeyMultibase, so the key set that retires or revokes that key would stop being consulted.",
"input": {
"card": {
"protocol": "ink/0.1",
"agentId": "tulpa:z6Mkon3Necd6NkkyfoGoHxid2znGc59LU3K7mubaRcFbLfLX",
"handle": "agent",
"displayName": "Agent",
"endpoint": "https://example.com/ink",
"publicKeyMultibase": "z6Mkon3Necd6NkkyfoGoHxid2znGc59LU3K7mubaRcFbLfLX",
"capabilities": {
"intentsAccepted": [],
"intentsSent": []
},
"availability": {
"timezone": "UTC"
},
"keys": {
"signing": {
"bad": true
},
"encryption": []
},
"keySetVersion": 1,
"updatedAt": "2026-07-20T00:00:00Z",
"cardSignature": {
"keyId": "bootstrap",
"signature": "QpeKBh_DuEMC3ipnQkeBo7RXiD79op2tip3nw2YV7__wMSGPXUPF5GRJpauObwWq6I5UGjspUegvYaa2NIMHBA"
}
},
"agentId": "tulpa:z6Mkon3Necd6NkkyfoGoHxid2znGc59LU3K7mubaRcFbLfLX",
"options": {
"profile": "1.0"
}
},
"expect": {
"result": "reject",
"reason": "invalid_card"
}
},
{
"caseId": "null-signing-bootstrap-signed-reject",
"description": "The same shape with a falsy member. A truthiness test would read null as absent and take the legacy path.",
"input": {
"card": {
"protocol": "ink/0.1",
"agentId": "tulpa:z6Mkon3Necd6NkkyfoGoHxid2znGc59LU3K7mubaRcFbLfLX",
"handle": "agent",
"displayName": "Agent",
"endpoint": "https://example.com/ink",
"publicKeyMultibase": "z6Mkon3Necd6NkkyfoGoHxid2znGc59LU3K7mubaRcFbLfLX",
"capabilities": {
"intentsAccepted": [],
"intentsSent": []
},
"availability": {
"timezone": "UTC"
},
"keys": {
"signing": null,
"encryption": []
},
"keySetVersion": 1,
"updatedAt": "2026-07-20T00:00:00Z",
"cardSignature": {
"keyId": "bootstrap",
"signature": "wOk7X255s_8fJ0Z5IZdNtMcYVsmbdm3ekTQMkhcrEDv5lgSa25ZnAMM3u9XcHOCSaNCuVvFpt7XFEMouP4iXAA"
}
},
"agentId": "tulpa:z6Mkon3Necd6NkkyfoGoHxid2znGc59LU3K7mubaRcFbLfLX",
"options": {
"profile": "1.0"
}
},
"expect": {
"result": "reject",
"reason": "invalid_card"
}
},
{
"caseId": "malformed-rotation-chain-reject",
"description": "A validly signed card whose rotationChain is present but not an array. Reading it as absent roots the card at genesis and skips the chain it declared.",
"input": {
"card": {
"protocol": "ink/0.1",
"agentId": "tulpa:z6Mkon3Necd6NkkyfoGoHxid2znGc59LU3K7mubaRcFbLfLX",
"handle": "agent",
"displayName": "Agent",
"endpoint": "https://example.com/ink",
"publicKeyMultibase": "z6Mkon3Necd6NkkyfoGoHxid2znGc59LU3K7mubaRcFbLfLX",
"capabilities": {
"intentsAccepted": [],
"intentsSent": []
},
"availability": {
"timezone": "UTC"
},
"keys": {
"signing": [
{
"keyId": "g1",
"algorithm": "Ed25519",
"publicKeyMultibase": "z6Mkon3Necd6NkkyfoGoHxid2znGc59LU3K7mubaRcFbLfLX",
"status": "active",
"validFrom": "2026-01-01T00:00:00Z"
}
],
"encryption": []
},
"currentSigningKeyId": "g1",
"keySetVersion": 1,
"updatedAt": "2026-07-20T00:00:00Z",
"rotationChain": {
"bad": true
},
"cardSignature": {
"keyId": "g1",
"signature": "mBMTgwZnYFoyXy8w_QlFRdQuSw9saWrvamA7XMhN6HOvtar4pv2iqmEGReP_J19qtsQc1k-bQyYemoEyveUnAQ"
}
},
"agentId": "tulpa:z6Mkon3Necd6NkkyfoGoHxid2znGc59LU3K7mubaRcFbLfLX",
"options": {
"profile": "1.0"
}
},
"expect": {
"result": "reject",
"reason": "invalid_card"
}
}
]
}
29 changes: 22 additions & 7 deletions differential/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ a finding promotes into the corpus with no translation step.
| 3 | `merkle-inclusion` | RFC 6962 inclusion walk |
| 3 | `merkle-consistency` | RFC 6962 consistency walk |
| 3 | `discovery-query-envelope` | schema, signature, audience, freshness, replay, in that order |
| 3 | `agent-card-signature` | the card proof: admission, signer resolution, signature, identity binding |

Tier 1 is the signature path. A disagreement there means a body one side refuses
is accepted by the other, or that a message signed by one is unverifiable by the
Expand All @@ -76,6 +77,22 @@ identity and freshness, where a divergence is attribution confusion or a widened
replay window. Tier 3 is admission, where a divergence is an interop break and
sometimes an SSRF or a forged-inclusion gap.

`agent-card-signature` is the first surface whose generator holds a key. A
composite verifier checks a signature before it checks anything else, so a
generator that can only mutate bytes never reaches the second branch and every
case rejects for the same reason. This one 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. The
signature base is built from the spec rather than imported from either side, so
a base built wrong costs signal instead of manufacturing 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.

The Merkle surfaces earn tier 3 rather than lower because they are the sharpest
JavaScript-versus-Go numeric boundary in the protocol: a tree size past the
safe-integer range is an exact int64 in Go and a lossy double in JavaScript.
Expand All @@ -91,13 +108,11 @@ safe-integer range is an exact int64 in Go and a lossy double in JavaScript.
not belong in the first pass. Today the AAD binding is pinned case by case in
the `payload-encryption` conformance category and exercised live, with real
keys on both sides, in `interop-lab/`.
- **The composite verifiers**: agent-card signature, authorization grant,
authorization chain, inclusion receipt, audit-query response, first-contact
transcript. Same reason: their inputs are multi-key signed contexts, so a
generator that reaches past the first signature check has to become a signer.
Every primitive they are built from (canonicalization, the signature base,
timestamps, principals, the Merkle walks) is covered here, which is where a
divergence in them would originate.
- **The remaining composite verifiers**: authorization grant, authorization
chain, inclusion receipt, audit-query response, first-contact transcript.
Their inputs are multi-key signed contexts, so a generator that reaches past
the first signature check has to become a signer. The card-signature surface
below is the first one that does, and the others follow the same shape.
- **`replay-freshness` and `key-rotation`** are compositions of timestamp
parsing and set membership over the covered primitives.
- **The request-side SSRF gate and card-content host checks** are out of scope
Expand Down
37 changes: 37 additions & 0 deletions differential/deciders/go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,43 @@ func decide(surface string, in map[string]json.RawMessage) decision {
}
return reject()

case "agent-card-signature":
// signerSecretHex is harness state and is deliberately not read here.
var card map[string]interface{}
if err := json.Unmarshal(in["card"], &card); err != nil {
return reject()
}
agentID, ok := str(in, "agentId")
if !ok {
return reject()
}
var opts struct {
CachedCard map[string]interface{} `json:"cachedCard"`
DidVerificationKeys *struct {
Status string `json:"status"`
VerificationKeys []string `json:"verificationKeys"`
} `json:"didVerificationKeys"`
Profile string `json:"profile"`
EnforcePhaseC *bool `json:"enforcePhaseC"`
}
if raw, has := in["options"]; has {
if err := json.Unmarshal(raw, &opts); err != nil {
return reject()
}
}
cardOpts := ink.CardVerifyOptions{CachedCard: opts.CachedCard, Profile: opts.Profile, EnforcePhaseC: opts.EnforcePhaseC}
if opts.DidVerificationKeys != nil {
cardOpts.DidVerificationKeys = &ink.DidResolution{
Status: opts.DidVerificationKeys.Status,
VerificationKeys: opts.DidVerificationKeys.VerificationKeys,
}
}
res := ink.VerifyAgentCardSignature(card, agentID, cardOpts)
if res.Rejected {
return decision{Result: "reject", Reason: string(res.Reason)}
}
return decision{Result: "accept", Reason: string(res.Reason)}

case "agent-card-fetch":
status, ok := asInt(in, "status")
if !ok {
Expand Down
10 changes: 10 additions & 0 deletions differential/deciders/ts-decide.mts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
verifyInkSignature,
AgentCardSchema,
evaluateAgentCardFetch,
verifyAgentCardSignature,
isPrivateHostname,
parseCheckpoint,
formatCheckpoint,
Expand Down Expand Up @@ -113,6 +114,15 @@ async function decide(surface: string, input: Record<string, unknown>): Promise<
case "agent-card": {
return { result: AgentCardSchema.safeParse(input.card).success ? "accept" : "reject" };
}
case "agent-card-signature": {
// signerSecretHex is harness state and is deliberately not read here.
const r = await verifyAgentCardSignature(
input.card as Parameters<typeof verifyAgentCardSignature>[0],
input.agentId as string,
(input.options ?? {}) as Parameters<typeof verifyAgentCardSignature>[2],
);
return { result: r.rejected ? "reject" : "accept", reason: r.reason };
}
case "agent-card-fetch": {
const fetchInput: AgentCardFetchInput = {
status: input.status as number,
Expand Down
Loading