From 7344792f0e79eb92bd91a47d748c384fbfec9877 Mon Sep 17 00:00:00 2001 From: Jason Odoom Date: Sat, 5 Sep 2026 05:26:52 +0000 Subject: [PATCH 1/4] Fuzz the card proof and stop a malformed key set from demoting it 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 --- CHANGELOG.md | 15 ++++ differential/README.md | 29 ++++++-- differential/deciders/go/main.go | 37 ++++++++++ differential/deciders/ts-decide.mts | 10 +++ differential/lib/card-signer.mjs | 107 ++++++++++++++++++++++++++++ differential/lib/surfaces.mjs | 74 +++++++++++++++++++ go/ink/agentcardsignature.go | 26 ++++--- go/ink/agentcardsignature_test.go | 15 ++++ 8 files changed, 298 insertions(+), 15 deletions(-) create mode 100644 differential/lib/card-signer.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b9877b3..5987274f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`, so a value of a declared interface type, including the package's own message types, can be passed straight in. A declared diff --git a/differential/README.md b/differential/README.md index bb3dfd44..644680e6 100644 --- a/differential/README.md +++ b/differential/README.md @@ -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 @@ -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. @@ -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 diff --git a/differential/deciders/go/main.go b/differential/deciders/go/main.go index b0d7569a..207252f7 100644 --- a/differential/deciders/go/main.go +++ b/differential/deciders/go/main.go @@ -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 { diff --git a/differential/deciders/ts-decide.mts b/differential/deciders/ts-decide.mts index 7cee986c..26c195fd 100644 --- a/differential/deciders/ts-decide.mts +++ b/differential/deciders/ts-decide.mts @@ -24,6 +24,7 @@ import { verifyInkSignature, AgentCardSchema, evaluateAgentCardFetch, + verifyAgentCardSignature, isPrivateHostname, parseCheckpoint, formatCheckpoint, @@ -113,6 +114,15 @@ async function decide(surface: string, input: Record): 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[0], + input.agentId as string, + (input.options ?? {}) as Parameters[2], + ); + return { result: r.rejected ? "reject" : "accept", reason: r.reason }; + } case "agent-card-fetch": { const fetchInput: AgentCardFetchInput = { status: input.status as number, diff --git a/differential/lib/card-signer.mjs b/differential/lib/card-signer.mjs new file mode 100644 index 00000000..a7149b23 --- /dev/null +++ b/differential/lib/card-signer.mjs @@ -0,0 +1,107 @@ +// A key-holding generator for the card-signature surface, so the accept side of +// a composite verifier is reachable. See differential/README.md. + +import * as ed from "@noble/ed25519"; +import { sha512 } from "@noble/hashes/sha2.js"; +import canonicalize from "canonicalize"; + +ed.hashes.sha512 = sha512; + +const ED25519_MULTICODEC = [0xed, 0x01]; +const BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +function encodeBase58(bytes) { + const digits = [0]; + for (const byte of bytes) { + let carry = byte; + for (let i = 0; i < digits.length; i++) { + carry += digits[i] << 8; + digits[i] = carry % 58; + carry = (carry / 58) | 0; + } + while (carry > 0) { + digits.push(carry % 58); + carry = (carry / 58) | 0; + } + } + let out = ""; + for (const byte of bytes) { + if (byte !== 0) break; + out += BASE58[0]; + } + for (let i = digits.length - 1; i >= 0; i--) out += BASE58[digits[i]]; + return out; +} + +function base64url(bytes) { + return Buffer.from(bytes).toString("base64url"); +} + +/** A deterministic key, so a seed replays the same card. */ +export function keyFromRng(rng) { + const secret = new Uint8Array(32); + for (let i = 0; i < 32; i++) secret[i] = rng.between(0, 255); + const publicKey = ed.getPublicKey(secret); + const prefixed = new Uint8Array(ED25519_MULTICODEC.length + publicKey.length); + prefixed.set(ED25519_MULTICODEC); + prefixed.set(publicKey, ED25519_MULTICODEC.length); + return { secret, publicKey, multibase: "z" + encodeBase58(prefixed) }; +} + +/** `ink/agent-card\n` + JCS(card without cardSignature), per the card spec. */ +export function cardSignatureBase(card) { + const { cardSignature: _omit, ...rest } = card; + return `ink/agent-card\n${canonicalize(rest)}`; +} + +export function signCard(card, secret) { + const bytes = new TextEncoder().encode(cardSignatureBase(card)); + return base64url(ed.sign(bytes, secret)); +} + +/** + * A card whose agent id is derived from the signing key, with no cached card + * and no did:web resolution. Those are the two constructs the corpus marks as + * spec-optional, and a fuzzer that wandered into them would report a + * disagreement the spec permits. + */ +export function buildSignedCard(rng, key = keyFromRng(rng)) { + const validFrom = new Date(Date.UTC(2026, 0, 1 + rng.between(0, 200))).toISOString(); + const card = { + protocol: "ink/0.1", + agentId: `tulpa:${key.multibase}`, + handle: `agent${rng.between(0, 999)}`, + displayName: "Agent", + endpoint: `https://example${rng.between(0, 9)}.com/ink`, + publicKeyMultibase: key.multibase, + capabilities: { intentsAccepted: [], intentsSent: [] }, + availability: { timezone: "UTC" }, + keys: { + signing: [ + { + keyId: "g1", + algorithm: "Ed25519", + publicKeyMultibase: key.multibase, + status: "active", + validFrom, + }, + ], + encryption: [], + }, + currentSigningKeyId: "g1", + keySetVersion: 1, + updatedAt: new Date(Date.UTC(2026, 6, 1 + rng.between(0, 100))).toISOString(), + }; + return { card: { ...card, cardSignature: buildSignature(card, key) }, key }; +} + +function buildSignature(card, key) { + return { keyId: "g1", signature: signCard(card, key.secret) }; +} + +/** Re-sign a card after a mutation, so the mutation is what is under test. */ +export function resign(card, key) { + const { cardSignature, ...rest } = card; + if (!cardSignature || typeof cardSignature !== "object") return card; + return { ...card, cardSignature: { ...cardSignature, signature: signCard(rest, key.secret) } }; +} diff --git a/differential/lib/surfaces.mjs b/differential/lib/surfaces.mjs index e518d83a..e093e090 100644 --- a/differential/lib/surfaces.mjs +++ b/differential/lib/surfaces.mjs @@ -30,6 +30,7 @@ import { randomJsonText, randomHex, randomString, orderingObjectText, } from "./mutators.mjs"; import { jsonTextShrinkCandidates } from "./shrink.mjs"; +import { buildSignedCard, keyFromRng, resign } from "./card-signer.mjs"; const isStr = (v) => typeof v === "string"; const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v); @@ -64,6 +65,33 @@ function stringSurface({ id, tier, why, field, bank, seedFrom, randomize }) { }; } + +const hex = (bytes) => Buffer.from(bytes).toString("hex"); +const unhex = (text) => Uint8Array.from(Buffer.from(text, "hex")); + +/** + * Mutate the card, then decide whether the signature still covers it. Breaking + * the signature is one case; keeping it valid over a mutated card is the other, + * and only the second reaches the checks past the signature. + */ +function mutateCardInput(input, rng) { + let text = JSON.stringify(input.card); + for (let i = 0, n = rng.between(1, 3); i < n; i++) text = mutateJsonText(text, rng); + let card; + try { + card = JSON.parse(text); + } catch { + return { ...input, card: { broken: text } }; + } + const next = { ...input, card, agentId: rng.bool(0.8) ? input.agentId : String(card.agentId ?? "") }; + if (input.signerSecretHex && isObj(card) && rng.bool(0.5)) { + const key = keyFromRng({ between: () => 0 }); + key.secret = unhex(input.signerSecretHex); + next.card = resign(card, key); + } + return next; +} + export const SURFACES = [ // ── tier 1: the signature path ── { @@ -533,6 +561,52 @@ export const SURFACES = [ }; }, }, + { + id: "agent-card-signature", + tier: 3, + why: + "The first composite verifier the harness can reach: a card is admitted, a " + + "signature is checked against a key the card itself declares, and the agent " + + "id is bound to that key. A disagreement is a card one side trusts and the " + + "other does not, which is an identity split rather than a parse difference.", + // The generator holds the key, so a mutation can be re-signed and the accept + // side stays reachable. `signerSecretHex` is harness state and neither + // decider reads it; a decision that depended on it would be a finding. + wellFormed: (input) => isObj(input) && isObj(input.card) && isStr(input.agentId), + valueFields: ["reason"], + seedFrom: [ + { + category: "agent-card-signature", + map: (i) => { + // Two corpus cases pin a decision the spec leaves open, and both need + // a cached card or a did:web resolution to reach. Seeding from them + // would fuzz toward a disagreement the spec permits. + const options = i.options ?? {}; + if (options.cachedCard || options.didVerificationKeys) return undefined; + return { card: i.card, agentId: i.agentId, options }; + }, + }, + ], + random(rng) { + const { card, key } = buildSignedCard(rng); + const input = { card, agentId: card.agentId, options: { profile: "1.0" }, signerSecretHex: hex(key.secret) }; + return rng.bool(0.5) ? input : mutateCardInput(input, rng); + }, + mutate(input, rng) { + return mutateCardInput(input, rng); + }, + shrink(input) { + return jsonTextShrinkCandidates(JSON.stringify(input.card)) + .map((text) => { + try { + return { ...input, card: JSON.parse(text) }; + } catch { + return undefined; + } + }) + .filter((candidate) => candidate !== undefined); + }, + }, ]; export const SURFACE_BY_ID = new Map(SURFACES.map((s) => [s.id, s])); diff --git a/go/ink/agentcardsignature.go b/go/ink/agentcardsignature.go index ec9c909d..5fa19d84 100644 --- a/go/ink/agentcardsignature.go +++ b/go/ink/agentcardsignature.go @@ -156,7 +156,10 @@ type cardProofResult struct { } func verifyCardProof(card map[string]interface{}, keyID, signature string) cardProofResult { - signing, hasSigning := cardSigningEntries(card) + signing, hasSigning, usable := cardSigningEntries(card) + if hasSigning && !usable { + return cardProofResult{reason: ReasonInvalidCard} + } var signerKey []byte if hasSigning { @@ -394,7 +397,7 @@ func rootChainedCard(card map[string]interface{}, chain []interface{}, rootCandi } // (b) head signing set CORRESPONDS EXACTLY to the card's keys.signing, keyed // by keyId, with byte-equal decoded keys (§3.5) and equal status. - cardSigning, _ := cardSigningEntries(card) + cardSigning, _, _ := cardSigningEntries(card) if len(prevSet) != len(cardSigning) { return rootCardReject(ReasonHeadSetMismatch, nil) } @@ -430,7 +433,7 @@ func checkCardContinuity(card, cachedCard map[string]interface{}, cardSignerKey // Reject a new card whose signing key is not reachable from the cached card's // non-revoked signing set, directly OR through the rotation-chain links that // connect the cached set to the new head (§6). - cachedSigning, _ := cardSigningEntries(cachedCard) + cachedSigning, _, _ := cardSigningEntries(cachedCard) cachedNonRevoked := [][]byte{} for _, e := range cachedSigning { em, ok := e.(map[string]interface{}) @@ -525,16 +528,23 @@ func stripCardKey(card map[string]interface{}, key string) map[string]interface{ // cardSigningEntries returns card.keys.signing as a raw slice, matching the // reference `card.keys?.signing`: a missing keys object or a non-array signing // member yields the legacy (no-signing-set) path. -func cardSigningEntries(card map[string]interface{}) ([]interface{}, bool) { +// cardSigningEntries reports the key set, whether one is present, and whether it +// is usable. Present but unusable must not read as absent: that demotes the card +// to the legacy single-key path and stops consulting the set that revoked a key. +func cardSigningEntries(card map[string]interface{}) (entries []interface{}, present bool, usable bool) { keys, ok := card["keys"].(map[string]interface{}) if !ok { - return nil, false + return nil, false, true + } + signingVal, ok := keys["signing"] + if !ok { + return nil, false, true } - signing, ok := keys["signing"].([]interface{}) + signing, ok := signingVal.([]interface{}) if !ok { - return nil, false + return nil, true, false } - return signing, true + return signing, true, true } func rotationChainLinks(card map[string]interface{}) ([]interface{}, bool) { diff --git a/go/ink/agentcardsignature_test.go b/go/ink/agentcardsignature_test.go index e9868f3f..8f99d350 100644 --- a/go/ink/agentcardsignature_test.go +++ b/go/ink/agentcardsignature_test.go @@ -373,6 +373,21 @@ func TestCardVerify_LegacyBootstrapMismatchReject(t *testing.T) { expectReject(t, res, ReasonLegacyBootstrapMismatch) } +// A malformed key set must not demote the card to the legacy single-key path. +// It would authenticate the card against the top-level publicKeyMultibase, so +// a set that retires or revokes that key would stop being consulted at all. +func TestCardVerify_MalformedKeySetDoesNotDemoteToLegacy(t *testing.T) { + g := fixedKeypair(t, 1) + agentID := deriveAgentID(g) + card := baseCard(agentID, g.multibase) + card["keySetVersion"] = 1 + card["keys"] = map[string]interface{}{"signing": map[string]interface{}{"bad": true}, "encryption": []interface{}{}} + signed := attachCardSignature(t, card, legacyBootstrapKeyID, g.priv) + + res := VerifyAgentCardSignature(mustWire(t, signed), agentID, CardVerifyOptions{Profile: ProfilePre10}) + expectReject(t, res, ReasonInvalidCard) +} + func TestCardVerify_WrongDomainReject(t *testing.T) { g := fixedKeypair(t, 1) agentID := deriveAgentID(g) From ae1a103145a4b9eebcc8b7ac870349da9001ebfe Mon Sep 17 00:00:00 2001 From: Jason Odoom Date: Sat, 5 Sep 2026 08:12:27 +0000 Subject: [PATCH 2/4] Fail closed on a malformed member in both implementations 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 --- conformance/v1/generate.mjs | 35 +++++ conformance/v1/manifest.json | 4 +- .../v1/vectors/agent-card-signature.json | 131 ++++++++++++++++++ go/ink/agentcardsignature.go | 14 ++ go/ink/agentcardsignature_test.go | 40 ++++-- governance/releases/1.0-readiness-evidence.md | 12 +- specs/ink-compliance-checklist.md | 4 +- src/crypto/agent-card-signature.ts | 14 ++ test/agent-card-signature.test.ts | 37 +++++ 9 files changed, 273 insertions(+), 18 deletions(-) diff --git a/conformance/v1/generate.mjs b/conformance/v1/generate.mjs index 43a26f39..a6b12c4b 100644 --- a/conformance/v1/generate.mjs +++ b/conformance/v1/generate.mjs @@ -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 @@ -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) ────────────────────────────────── diff --git a/conformance/v1/manifest.json b/conformance/v1/manifest.json index ec740dfa..0a9622b5 100644 --- a/conformance/v1/manifest.json +++ b/conformance/v1/manifest.json @@ -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", diff --git a/conformance/v1/vectors/agent-card-signature.json b/conformance/v1/vectors/agent-card-signature.json index 28355041..e52d4fa4 100644 --- a/conformance/v1/vectors/agent-card-signature.json +++ b/conformance/v1/vectors/agent-card-signature.json @@ -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" + } } ] } diff --git a/go/ink/agentcardsignature.go b/go/ink/agentcardsignature.go index 5fa19d84..f1ac0f35 100644 --- a/go/ink/agentcardsignature.go +++ b/go/ink/agentcardsignature.go @@ -68,6 +68,20 @@ func VerifyAgentCardSignature(card map[string]interface{}, agentID string, optio if card == nil || agentID == "" { return rejectCard(ReasonInvalidCard) } + // A member present but not the shape the schema declares is not absent. + // Reading it as absent selects a weaker path, so fail closed here. + if keys, ok := card["keys"].(map[string]interface{}); ok { + if signing, present := keys["signing"]; present { + if _, isArray := signing.([]interface{}); !isArray { + return rejectCard(ReasonInvalidCard) + } + } + } + if chain, present := card["rotationChain"]; present { + if _, isArray := chain.([]interface{}); !isArray { + return rejectCard(ReasonInvalidCard) + } + } // §5 step 1 backstop: identity binding. if cid, ok := card["agentId"].(string); !ok || cid != agentID { return rejectCard(ReasonIdentityMismatch) diff --git a/go/ink/agentcardsignature_test.go b/go/ink/agentcardsignature_test.go index 8f99d350..74ac454a 100644 --- a/go/ink/agentcardsignature_test.go +++ b/go/ink/agentcardsignature_test.go @@ -377,15 +377,39 @@ func TestCardVerify_LegacyBootstrapMismatchReject(t *testing.T) { // It would authenticate the card against the top-level publicKeyMultibase, so // a set that retires or revokes that key would stop being consulted at all. func TestCardVerify_MalformedKeySetDoesNotDemoteToLegacy(t *testing.T) { - g := fixedKeypair(t, 1) - agentID := deriveAgentID(g) - card := baseCard(agentID, g.multibase) - card["keySetVersion"] = 1 - card["keys"] = map[string]interface{}{"signing": map[string]interface{}{"bad": true}, "encryption": []interface{}{}} - signed := attachCardSignature(t, card, legacyBootstrapKeyID, g.priv) + for _, signing := range []interface{}{ + map[string]interface{}{"bad": true}, nil, false, float64(0), "", "x", float64(7), + } { + g := fixedKeypair(t, 1) + agentID := deriveAgentID(g) + card := baseCard(agentID, g.multibase) + card["keySetVersion"] = 1 + card["keys"] = map[string]interface{}{"signing": signing, "encryption": []interface{}{}} + signed := attachCardSignature(t, card, legacyBootstrapKeyID, g.priv) - res := VerifyAgentCardSignature(mustWire(t, signed), agentID, CardVerifyOptions{Profile: ProfilePre10}) - expectReject(t, res, ReasonInvalidCard) + res := VerifyAgentCardSignature(mustWire(t, signed), agentID, CardVerifyOptions{Profile: ProfilePre10}) + expectReject(t, res, ReasonInvalidCard) + } +} + +// A malformed rotationChain must not read as absent either: that roots the card +// at genesis and skips the chain it declared. +func TestCardVerify_MalformedRotationChainDoesNotRootAtGenesis(t *testing.T) { + for _, chain := range []interface{}{ + map[string]interface{}{"bad": true}, nil, false, float64(0), "", "x", + } { + g := fixedKeypair(t, 1) + agentID := deriveAgentID(g) + card := baseCard(agentID, g.multibase) + card["keys"] = map[string]interface{}{"signing": []interface{}{signingEntry("g1", g, "active")}, "encryption": []interface{}{}} + card["currentSigningKeyId"] = "g1" + card["keySetVersion"] = 1 + card["rotationChain"] = chain + signed := attachCardSignature(t, card, "g1", g.priv) + + res := VerifyAgentCardSignature(mustWire(t, signed), agentID, CardVerifyOptions{Profile: ProfilePre10}) + expectReject(t, res, ReasonInvalidCard) + } } func TestCardVerify_WrongDomainReject(t *testing.T) { diff --git a/governance/releases/1.0-readiness-evidence.md b/governance/releases/1.0-readiness-evidence.md index 03285698..ffa02934 100644 --- a/governance/releases/1.0-readiness-evidence.md +++ b/governance/releases/1.0-readiness-evidence.md @@ -211,7 +211,7 @@ against the release commit. Nothing here is a template. The 1.0 commitment is a freeze of the **mandatory base profile**: the **16**[^ck] `base`-profile categories in the `ink.conformance.v1` corpus, carrying -**417**[^ck] vectors. They are: +**420**[^ck] vectors. They are: `agent-card`, `agent-card-fetch`, `agent-card-signature`, `authorization-header`, `connection-payload`, `first-contact-transcript`, @@ -261,9 +261,9 @@ only category added since the `v0.15.0` freeze. - Corpus id: `ink.conformance.v1` - Manifest format: `ink.conformance.manifest.v1` -- Coverage: **32 categories, 894 vectors**[^ck] +- Coverage: **32 categories, 897 vectors**[^ck] - Manifest integrity anchor (SHA-256 of `conformance/v1/manifest.json`): - `0de66d895e022bcd834d611a158303a74f44533413dcec5fbb21e1508b6574bf`[^ck] + `247d445fbd5a14572bf4597b06c57494e32c2e16f6904ca8085f72c41e165d2a`[^ck] Per profile: @@ -272,7 +272,7 @@ Per profile: | Profile | Categories | Vectors | |---------|-----------:|--------:| -| `base` | 16 | 417 | +| `base` | 16 | 420 | | `authorization` | 2 | 123 | | `audit` | 3 | 81 | | `evidence` | 3 | 66 | @@ -292,8 +292,8 @@ cannot silently drift from the corpus without the anchor changing. Reference results: - TypeScript reference: the conformance and manifest-integrity suites pass, - **1083 test cases**[^ck] across the two suites, covering the full 894-vector[^ck] corpus - plus the integrity cross-checks. + **1086 test cases**[^ck] across the two suites, + covering the full 897-vector[^ck] corpus plus the integrity cross-checks. - Independent Go verifier: runs the same corpus and the same manifest-integrity cross-check in the `go-conformance` CI job, green on `main`. Both implementations agree on the category set and on every vector's decision. diff --git a/specs/ink-compliance-checklist.md b/specs/ink-compliance-checklist.md index 81ffdea5..936a91d5 100644 --- a/specs/ink-compliance-checklist.md +++ b/specs/ink-compliance-checklist.md @@ -311,7 +311,7 @@ The Vectors column of every row above names the `conformance/v1` categories whos | `agent-card` | `base` | 53 | D2, D3, D4, D6, K2 | | `agent-card-evidence` | `evidence` | 19 | none | | `agent-card-fetch` | `base` | 34 | none | -| `agent-card-signature` | `base` | 50 | none | +| `agent-card-signature` | `base` | 53 | none | | `agent-card-signature-phase-c` | `staged` | 10 | none | | `attestation` | `evidence` | 34 | none | | `audit-query-response` | `audit` | 27 | W9, W11, W15, W16 | @@ -340,7 +340,7 @@ The Vectors column of every row above names the `conformance/v1` categories whos | `signed-body-utf8` | `base` | 21 | none | | `timestamp-validity` | `base` | 17 | none | -45 of 124 requirement rows cite at least one category; 16 of 32 categories are cited by at least one row; the corpus holds 894 cases. +45 of 124 requirement rows cite at least one category; 16 of 32 categories are cited by at least one row; the corpus holds 897 cases. --- diff --git a/src/crypto/agent-card-signature.ts b/src/crypto/agent-card-signature.ts index 774655d8..e0c13d09 100644 --- a/src/crypto/agent-card-signature.ts +++ b/src/crypto/agent-card-signature.ts @@ -217,6 +217,20 @@ export async function verifyAgentCardSignature( if (typeof agentId !== "string" || agentId.length === 0) { return reject("invalid_card"); } + // A member that is present but not the shape the schema declares is not + // absent. Reading it as absent selects a weaker path: no key set means the + // legacy single key, no rotation chain means the genesis root, and either + // one authenticates a card the set or the chain would have rejected. The + // verifier is exported, so it fails closed on its own rather than trusting + // that admission ran. + const keysMember: unknown = (card as { keys?: unknown }).keys; + if (keysMember !== null && typeof keysMember === "object") { + const signing: unknown = (keysMember as { signing?: unknown }).signing; + if (signing !== undefined && !Array.isArray(signing)) return reject("invalid_card"); + } + const chainMember: unknown = (card as { rotationChain?: unknown }).rotationChain; + if (chainMember !== undefined && !Array.isArray(chainMember)) return reject("invalid_card"); + // §5 step 1 backstop: identity binding. if (card.agentId !== agentId) { return reject("identity_mismatch"); diff --git a/test/agent-card-signature.test.ts b/test/agent-card-signature.test.ts index 3b186688..67c7fda5 100644 --- a/test/agent-card-signature.test.ts +++ b/test/agent-card-signature.test.ts @@ -205,6 +205,43 @@ describe("verifyAgentCardSignature — accept paths", () => { }); }); +describe("verifyAgentCardSignature — a malformed member never reads as absent", () => { + // Reading it as absent selects a weaker path: no key set means the legacy + // single key, no chain means the genesis root. + for (const signing of [{ bad: true }, null, false, 0, "", "x", 7]) { + it(`refuses keys.signing ${JSON.stringify(signing)} rather than falling back to the legacy key`, async () => { + const agentId = deriveAgentId(G.pub); + const card = baseCard(agentId, G.multibase); + card.keys = { signing, encryption: [] } as unknown as typeof card.keys; + card.keySetVersion = 1; + card.updatedAt = UPDATED_AT; + const signed = await attachCardSignature(card, "bootstrap", G.priv); + + const result = await verifyAgentCardSignature(signed, agentId, PROFILE_10); + expect(result.authenticated).toBe(false); + expect(result.rejected).toBe(true); + expect(result.reason).toBe("invalid_card"); + }); + } + + for (const chain of [{ bad: true }, null, false, 0, "", "x"]) { + it(`refuses rotationChain ${JSON.stringify(chain)} rather than rooting at genesis`, async () => { + const agentId = deriveAgentId(G.pub); + const card = baseCard(agentId, G.multibase); + card.keys = { signing: [signingEntry("g1", G, "active")], encryption: [] }; + card.currentSigningKeyId = "g1"; + card.keySetVersion = 1; + card.updatedAt = UPDATED_AT; + card.rotationChain = chain as unknown as typeof card.rotationChain; + const signed = await attachCardSignature(card, "g1", G.priv); + + const result = await verifyAgentCardSignature(signed, agentId, PROFILE_10); + expect(result.rejected).toBe(true); + expect(result.reason).toBe("invalid_card"); + }); + } +}); + describe("verifyAgentCardSignature — proof rejects", () => { async function keyDerivedSigned(mutate: (c: AgentCard) => void, signerKeyId: string, signerPriv: Uint8Array) { const agentId = deriveAgentId(G.pub); From 543db3fcbe78869890d9ba814048ba6093250654 Mon Sep 17 00:00:00 2001 From: Jason Odoom Date: Sat, 5 Sep 2026 12:21:01 +0000 Subject: [PATCH 3/4] Refuse a malformed keys member and a malformed card signature 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 --- go/ink/agentcardsignature.go | 12 ++++++++--- go/ink/agentcardsignature_test.go | 31 ++++++++++++++++++++++++++++ src/crypto/agent-card-signature.ts | 12 ++++++++++- test/agent-card-signature.test.ts | 33 ++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/go/ink/agentcardsignature.go b/go/ink/agentcardsignature.go index f1ac0f35..8832bd67 100644 --- a/go/ink/agentcardsignature.go +++ b/go/ink/agentcardsignature.go @@ -70,8 +70,12 @@ func VerifyAgentCardSignature(card map[string]interface{}, agentID string, optio } // A member present but not the shape the schema declares is not absent. // Reading it as absent selects a weaker path, so fail closed here. - if keys, ok := card["keys"].(map[string]interface{}); ok { - if signing, present := keys["signing"]; present { + if keysVal, present := card["keys"]; present { + keys, ok := keysVal.(map[string]interface{}) + if !ok { + return rejectCard(ReasonInvalidCard) + } + if signing, has := keys["signing"]; has { if _, isArray := signing.([]interface{}); !isArray { return rejectCard(ReasonInvalidCard) } @@ -95,9 +99,11 @@ func VerifyAgentCardSignature(card map[string]interface{}, agentID string, optio // no `cardSignature` at all (§3.4). A present-but-null member is not a card // signature; a present-but-malformed member fails closed rather than demoting. rawSig, present := card["cardSignature"] - if !present || rawSig == nil { + if !present { return verifyUnsignedCard(kind, cachedCard, phaseC) } + // §3.4: the only unsigned card is one carrying no member at all, so a + // present null takes the reject path rather than the permissive one. sigMap, ok := rawSig.(map[string]interface{}) if !ok { return rejectCard(ReasonInvalidCard) diff --git a/go/ink/agentcardsignature_test.go b/go/ink/agentcardsignature_test.go index 74ac454a..a9ac18a7 100644 --- a/go/ink/agentcardsignature_test.go +++ b/go/ink/agentcardsignature_test.go @@ -392,6 +392,37 @@ func TestCardVerify_MalformedKeySetDoesNotDemoteToLegacy(t *testing.T) { } } +func TestCardVerify_MalformedKeysMemberDoesNotDemoteToLegacy(t *testing.T) { + for _, keys := range []interface{}{nil, "x", float64(7), []interface{}{}, false} { + g := fixedKeypair(t, 1) + agentID := deriveAgentID(g) + card := baseCard(agentID, g.multibase) + card["keySetVersion"] = 1 + card["keys"] = keys + signed := attachCardSignature(t, card, legacyBootstrapKeyID, g.priv) + + res := VerifyAgentCardSignature(mustWire(t, signed), agentID, CardVerifyOptions{Profile: ProfilePre10}) + expectReject(t, res, ReasonInvalidCard) + } +} + +// Spec 3.4: the only unsigned card carries no member at all, so a present +// member that is not a signature must not reach the permissive unsigned path. +func TestCardVerify_MalformedCardSignatureIsNotUnsigned(t *testing.T) { + for _, sig := range []interface{}{nil, false, float64(0), "", "x", []interface{}{}} { + g := fixedKeypair(t, 1) + agentID := deriveAgentID(g) + card := baseCard(agentID, g.multibase) + card["keys"] = map[string]interface{}{"signing": []interface{}{signingEntry("g1", g, "active")}, "encryption": []interface{}{}} + card["currentSigningKeyId"] = "g1" + card["keySetVersion"] = 1 + card["cardSignature"] = sig + + res := VerifyAgentCardSignature(mustWire(t, card), agentID, CardVerifyOptions{Profile: ProfilePre10}) + expectReject(t, res, ReasonInvalidCard) + } +} + // A malformed rotationChain must not read as absent either: that roots the card // at genesis and skips the chain it declared. func TestCardVerify_MalformedRotationChainDoesNotRootAtGenesis(t *testing.T) { diff --git a/src/crypto/agent-card-signature.ts b/src/crypto/agent-card-signature.ts index e0c13d09..be478d72 100644 --- a/src/crypto/agent-card-signature.ts +++ b/src/crypto/agent-card-signature.ts @@ -224,10 +224,20 @@ export async function verifyAgentCardSignature( // verifier is exported, so it fails closed on its own rather than trusting // that admission ran. const keysMember: unknown = (card as { keys?: unknown }).keys; - if (keysMember !== null && typeof keysMember === "object") { + if (keysMember !== undefined) { + if (keysMember === null || typeof keysMember !== "object" || Array.isArray(keysMember)) { + return reject("invalid_card"); + } const signing: unknown = (keysMember as { signing?: unknown }).signing; if (signing !== undefined && !Array.isArray(signing)) return reject("invalid_card"); } + // §3.4: the only unsigned card is one carrying no `cardSignature` at all. + // A present member that is not a signature object must not take the + // unsigned path, which is the more permissive one on a cold first contact. + if ("cardSignature" in card) { + const sig: unknown = (card as { cardSignature?: unknown }).cardSignature; + if (sig === null || typeof sig !== "object" || Array.isArray(sig)) return reject("invalid_card"); + } const chainMember: unknown = (card as { rotationChain?: unknown }).rotationChain; if (chainMember !== undefined && !Array.isArray(chainMember)) return reject("invalid_card"); diff --git a/test/agent-card-signature.test.ts b/test/agent-card-signature.test.ts index 67c7fda5..a4d0d0df 100644 --- a/test/agent-card-signature.test.ts +++ b/test/agent-card-signature.test.ts @@ -224,6 +224,39 @@ describe("verifyAgentCardSignature — a malformed member never reads as absent" }); } + for (const keys of [null, "x", 7, [], false]) { + it(`refuses keys ${JSON.stringify(keys)} rather than falling back to the legacy key`, async () => { + const agentId = deriveAgentId(G.pub); + const card = baseCard(agentId, G.multibase); + card.keys = keys as unknown as typeof card.keys; + card.keySetVersion = 1; + card.updatedAt = UPDATED_AT; + const signed = await attachCardSignature(card, "bootstrap", G.priv); + + const result = await verifyAgentCardSignature(signed, agentId, PROFILE_10); + expect(result.rejected).toBe(true); + expect(result.reason).toBe("invalid_card"); + }); + } + + // 3.4: the only unsigned card carries no member at all, so a present member + // that is not a signature must not reach the more permissive unsigned path. + for (const sig of [null, false, 0, "", "x", []]) { + it(`refuses cardSignature ${JSON.stringify(sig)} rather than treating it as unsigned`, async () => { + const agentId = deriveAgentId(G.pub); + const card = baseCard(agentId, G.multibase); + card.keys = { signing: [signingEntry("g1", G, "active")], encryption: [] }; + card.currentSigningKeyId = "g1"; + card.keySetVersion = 1; + card.updatedAt = UPDATED_AT; + const withSig = { ...card, cardSignature: sig } as unknown as Parameters[0]; + + const result = await verifyAgentCardSignature(withSig, agentId, PROFILE_10); + expect(result.rejected).toBe(true); + expect(result.reason).toBe("invalid_card"); + }); + } + for (const chain of [{ bad: true }, null, false, 0, "", "x"]) { it(`refuses rotationChain ${JSON.stringify(chain)} rather than rooting at genesis`, async () => { const agentId = deriveAgentId(G.pub); From 7cd14f84aafaef25dcc94bfe0f08f557f6f3a5bb Mon Sep 17 00:00:00 2001 From: Jason Odoom Date: Sat, 5 Sep 2026 12:32:03 +0000 Subject: [PATCH 4/4] Stop a malformed keys member from reading as a legacy card 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 --- go/ink/agentcard.go | 15 ++++++++++----- go/ink/extractcandidatekeys_test.go | 16 ++++++++++++++++ src/discovery/agent-card.ts | 7 +++++++ test/ink-key-rotation.test.ts | 15 +++++++++++++++ 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/go/ink/agentcard.go b/go/ink/agentcard.go index 74e114d1..e6fe1b09 100644 --- a/go/ink/agentcard.go +++ b/go/ink/agentcard.go @@ -602,15 +602,20 @@ func ExtractCandidateKeys(card map[string]interface{}) []CandidateKey { } // card.keys?.signing: a missing `keys` object, or a `keys` that is not - // an object, or an object with no `signing` member all read as "signing - // absent" (undefined), matching the reference's optional-chaining - // semantics, which never throws on a non-object intermediate. + // an object with no `signing` member reads as "signing absent", matching + // the reference. A `keys` member that is present but not an object is a + // malformed card, not a legacy one, and returns an authoritative empty set. var signingVal interface{} signingPresent := false if keysVal, ok := card["keys"]; ok { - if keysObj, ok := keysVal.(map[string]interface{}); ok { - signingVal, signingPresent = keysObj["signing"] + keysObj, isObj := keysVal.(map[string]interface{}) + if !isObj { + // Present but not an object is not absent. Falling through would + // treat the card as legacy and hand back the top-level key as + // active, ignoring what the set said about rotation or revocation. + return out } + signingVal, signingPresent = keysObj["signing"] } if signingPresent { diff --git a/go/ink/extractcandidatekeys_test.go b/go/ink/extractcandidatekeys_test.go index 8fa958ba..b1deb30d 100644 --- a/go/ink/extractcandidatekeys_test.go +++ b/go/ink/extractcandidatekeys_test.go @@ -8,6 +8,22 @@ import ( // entry it returns must satisfy the key-entry schema on its own. An entry // that omits a schema-required field is skipped rather than admitted with an // open validity window. +// A malformed keys member must not read as a legacy card: that hands back the +// top-level key as active and ignores what the set said about rotation. +func TestExtractCandidateKeysRefusesMalformedKeysMember(t *testing.T) { + k := fixedKeypair(t, 0x31) + for _, keys := range []interface{}{nil, "x", float64(7), []interface{}{}, false} { + card := map[string]interface{}{ + "agentId": "tulpa:z6Mk", + "publicKeyMultibase": k.multibase, + "keys": keys, + } + if got := ExtractCandidateKeys(card); len(got) != 0 { + t.Errorf("keys %v: got %d candidate keys, want 0", keys, len(got)) + } + } +} + func TestExtractCandidateKeysRequiresSchemaFields(t *testing.T) { k := fixedKeypair(t, 0x31) cases := []struct { diff --git a/src/discovery/agent-card.ts b/src/discovery/agent-card.ts index 7a6a3c31..4a7d00ec 100644 --- a/src/discovery/agent-card.ts +++ b/src/discovery/agent-card.ts @@ -426,6 +426,13 @@ export function extractCandidateKeys(card: AgentCard): CandidateKey[] { if (card === null || typeof card !== "object" || Array.isArray(card)) { return []; } + // A `keys` member that is present but not an object is not an absent one. + // Falling through would treat the card as legacy and hand back the top-level + // key as active, ignoring whatever the set said about rotation or revocation. + const keysMember = (card as { keys?: unknown }).keys; + if (keysMember !== undefined) { + if (keysMember === null || typeof keysMember !== "object" || Array.isArray(keysMember)) return []; + } const signing = card.keys?.signing as unknown; if (signing !== undefined) { // Runtime type guard: a malformed card where `signing` is an object/ diff --git a/test/ink-key-rotation.test.ts b/test/ink-key-rotation.test.ts index 9718441c..2cec9a79 100644 --- a/test/ink-key-rotation.test.ts +++ b/test/ink-key-rotation.test.ts @@ -127,6 +127,21 @@ describe("INK Key Rotation — end-to-end test vectors", () => { expect(parsed.keySetVersion).toBe(2); }); + // A malformed keys member must not read as a legacy card: that hands back + // the top-level key as active and ignores what the set said about rotation. + for (const keys of [null, "x", 7, [], false]) { + it(`extractCandidateKeys returns nothing for keys ${JSON.stringify(keys)}`, async () => { + const key = await makeKeypair(); + const card = { + agentId: "tulpa:z6Mk", + publicKeyMultibase: encodePublicKeyMultibase(key.publicKey), + keys, + } as unknown as Parameters[0]; + + expect(extractCandidateKeys(card)).toEqual([]); + }); + } + it("extractCandidateKeys builds correct set from card with keys block", async () => { const keyA = await makeKeypair(); const keyB = await makeKeypair();