From 82e4ddeed0a2456deec58e2244daa2270c4e4a5b Mon Sep 17 00:00:00 2001 From: Horizon Date: Thu, 1 Oct 2026 08:30:24 +0900 Subject: [PATCH] test(signing): add cross-SDK signature vectors Pins what a change to _canonicalize_agent_card (#1278) has to keep: a card signed by another SDK still verifies here. signing_interop_vectors.json carries the five s0-control vectors of the language-neutral a2a-card-sign-v01 corpus, each lifted verbatim: one card with every REQUIRED field and no field at its default value, signed by this SDK, @a2a-js/sdk 1.3.0, a2a-go (main 534a60fc) and a reference signer, plus the card edited after signing. These hold however a2aproject/A2A#2122 is settled. The frozen JS-signed production card from a2aproject/a2a-go#445 is checked against the anchors used in #1278. It carries a nested empty value, so that test pins today's bytes and needs updating if #2122 settles the other way. Test only; no library code changes. Signed-off-by: Horizon --- tests/utils/signing_gate_card_20260928.json | 243 ++++++++++++++++ tests/utils/signing_gate_jwks_20260928.json | 13 + tests/utils/signing_interop_vectors.json | 303 ++++++++++++++++++++ tests/utils/test_signing_interop.py | 124 ++++++++ 4 files changed, 683 insertions(+) create mode 100644 tests/utils/signing_gate_card_20260928.json create mode 100644 tests/utils/signing_gate_jwks_20260928.json create mode 100644 tests/utils/signing_interop_vectors.json create mode 100644 tests/utils/test_signing_interop.py diff --git a/tests/utils/signing_gate_card_20260928.json b/tests/utils/signing_gate_card_20260928.json new file mode 100644 index 000000000..17e6f2c28 --- /dev/null +++ b/tests/utils/signing_gate_card_20260928.json @@ -0,0 +1,243 @@ +{ + "name": "MCP Verification Gate", + "description": "Checks whether an MCP server exists, publishes an agent card, discloses who pays it, and returns identical output for identical input. Free. Conformance and disclosure only; this gate does not verify that any price returned by a checked server is correct.", + "supportedInterfaces": [ + { + "url": "https://gate.horizonshield.dev/a2a", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0" + }, + { + "url": "https://gate.horizonshield.dev/a2a", + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3" + } + ], + "url": "https://gate.horizonshield.dev/a2a", + "provider": { + "organization": "The HORIZ音s Co., Ltd.", + "url": "https://shield.the-horizons-innovation.com", + "legalEntity": { + "registry": "JP", + "scheme": "houjin-bango", + "id": "7021001075279", + "name": "The HORIZ音s Co., Ltd." + } + }, + "version": "0.4.17", + "protocolVersion": "1.0", + "capabilities": { + "streaming": false, + "pushNotifications": false, + "extensions": [ + { + "uri": "https://gate.horizonshield.dev/ext/conduct/v1", + "description": "Who pays this agent, where its measured conduct record lives, and where to file a witness walk. The specification is served at the URI.", + "required": false, + "params": { + "compensation": { + "paid_by": "buyer", + "referral_fee": false, + "listing_fee": false, + "success_fee_pct": 0, + "disclosure_url": "https://shield.the-horizons-innovation.com/yakumo/plans/" + }, + "measured_endpoints": [ + "https://gate.horizonshield.dev/mcp" + ], + "conduct_record": "https://gate.horizonshield.dev/history?endpoint=https%3A%2F%2Fgate.horizonshield.dev%2Fmcp", + "verdict_recipe": "https://gate.horizonshield.dev/spec", + "witness_intake": "https://ledger.horizonshield.dev/witness", + "register": "https://gate.horizonshield.dev/register", + "identity": { + "kind": "did", + "ref": "did:web:gate.horizonshield.dev" + }, + "witness_policy": { + "reciprocal": true + }, + "witness_reply": { + "transport": "a2a", + "answers": "https://gate.horizonshield.dev/a2a", + "request_schema": "nenrin-witness-request-v1", + "reply_schema": "nenrin-witness-observation-v1", + "key_url": "https://gate.horizonshield.dev/keys/witness.json", + "surfaces": [ + "health.gate_commit", + "agent-card.signature", + "well-known.jwks", + "well-known.openai-apps-challenge", + "ext.conduct-v1.spec", + "keys.agreement", + "keys.witness", + "keys.operator", + "well-known.did" + ], + "note": "When drawn, this gate measures the listed public surfaces of the requested endpoint from its own vantage and returns one Ed25519-signed nenrin-witness-observation-v1 (the key served at key_url, conduct-v1.1 section 11.4). It refuses to witness itself (self_witness) and refuses non-public targets. reciprocal here means it answers a draw; it does not initiate an unprompted caller_card walk-back." + }, + "rings": { + "spec": "https://github.com/ogasurfproject-jpg/horizon-shield/blob/main/workers/hs-ledger/nenrin/NENRIN_SPEC_v1.md", + "spec_sha256": "9ccba2e325fd2a555fcdb2dec519b8c6bf7a669064674846aea98ecfff824e3d", + "base": "https://raw.githubusercontent.com/ogasurfproject-jpg/mcp-conduct-register/main/rings/", + "path": "/.json", + "slug": "endpoint URL without https://, lower case, every run of characters outside [a-z0-9] replaced by one hyphen, hyphens trimmed at both ends", + "ledger": "https://ledger.horizonshield.dev/ledger" + } + } + }, + { + "uri": "https://gate.horizonshield.dev/ext/legal-entity/v1", + "description": "The legal entity that answers for this agent, declared inside the signed bytes. The specification is served at the URI.", + "required": false, + "params": { + "registry": "JP", + "scheme": "houjin-bango", + "id": "7021001075279", + "name": "The HORIZ音s Co., Ltd.", + "lookup_url": "https://www.houjin-bangou.nta.go.jp/henkorireki-johoto.html?selHouzinNo=7021001075279" + } + } + ] + }, + "defaultInputModes": [ + "text/plain" + ], + "defaultOutputModes": [ + "application/json", + "text/plain" + ], + "securitySchemes": { + "operator": { + "apiKeySecurityScheme": { + "location": "header", + "name": "x-sweep-token", + "description": "Operator-only routes (POST /sweep, POST /register/quarantine, POST /mould, DELETE /watch, the paid tier of POST /watch). No skill on this card requires it: the A2A interface at /a2a and the MCP interface at /mcp are public and unauthenticated." + } + } + }, + "securityRequirements": [ + {} + ], + "compensation": { + "paid_by": "buyer", + "referral_fee": false, + "listing_fee": false, + "success_fee_pct": 0, + "disclosure_url": "https://shield.the-horizons-innovation.com/yakumo/plans/" + }, + "preferredTransport": "JSONRPC", + "skills": [ + { + "id": "check", + "name": "Conformance check", + "description": "POST /check with an MCP endpoint URL, or call the check_conformance tool over MCP at /mcp. Returns a verdict with a recomputable SHA-256. Over A2A (SendMessage at /a2a), a text part carrying an MCP endpoint URL returns this gate's current register reading for it (the same bytes as GET /is-verified): verified true only on a full pass, null otherwise, never false.", + "tags": [ + "mcp", + "verification", + "conformance", + "disclosure" + ], + "examples": [ + "Check https://example.com/mcp for conformance", + "Does this server exist, publish an agent card, and return identical output for identical input?" + ], + "inputModes": [ + "text/plain", + "application/json" + ], + "outputModes": [ + "application/json" + ] + }, + { + "id": "verify", + "name": "Verdict verification", + "description": "Recompute the SHA-256 of a verdict this gate issued, so you do not have to trust the issuer. Available as the verify_verdict tool over MCP.", + "tags": [ + "verification", + "tamper-evident", + "recomputable" + ], + "examples": [ + "Verify this verdict hashes to its own record_sha256", + "Recompute the digest of a verdict returned by check" + ], + "inputModes": [ + "application/json" + ], + "outputModes": [ + "application/json" + ] + }, + { + "id": "conditions", + "name": "Verification conditions", + "description": "Return the exact conditions this gate measures (MCP reachability, agent card, compensation disclosure, deterministic output) and how each verdict is recomputed. The get_conditions tool over MCP and GET /spec serve the same methodology.", + "tags": [ + "methodology", + "transparency", + "spec" + ], + "examples": [ + "What does this gate actually check?", + "How is a verdict recomputed independently?" + ], + "inputModes": [ + "text/plain" + ], + "outputModes": [ + "application/json" + ] + }, + { + "id": "is-verified", + "name": "Verification status", + "description": "Report whether an endpoint's latest measurement passed all measured conditions. Returns true or null (state: verified, pending, held, watched, absent), never false. Available as the is_verified tool over MCP.", + "tags": [ + "verification", + "status", + "honest-null" + ], + "examples": [ + "Is https://example.com/mcp currently verified?", + "What is this endpoint's latest state on the register?" + ], + "inputModes": [ + "text/plain" + ], + "outputModes": [ + "application/json" + ] + }, + { + "id": "lookup", + "name": "Register lookup", + "description": "Look up an endpoint's record on the public register: its latest verdict, state, and record SHA-256, with a recompute URL. Available as the lookup_server tool over MCP.", + "tags": [ + "register", + "lookup", + "recomputable" + ], + "examples": [ + "Look up the record for https://example.com/mcp", + "Find the latest verdict hash for this endpoint" + ], + "inputModes": [ + "text/plain" + ], + "outputModes": [ + "application/json" + ] + } + ], + "signatures": [ + { + "protected": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJocy0yMDI2LTA5Iiwiamt1IjoiaHR0cHM6Ly9nYXRlLmhvcml6b25zaGllbGQuZGV2Ly53ZWxsLWtub3duL2p3a3MuanNvbiJ9", + "signature": "XxE-am8AERjp58y72N4jx6mk3wbxyoSv635yeFALY7dkbOs26OE2neHBCA5rAP1K9xuWMyulYCPIuhON4-0QwA" + }, + { + "protected": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJocy0yMDI2LTA5Iiwiamt1IjoiaHR0cHM6Ly9nYXRlLmhvcml6b25zaGllbGQuZGV2Ly53ZWxsLWtub3duL2p3a3MuanNvbiJ9", + "signature": "Fw5bTIYhj9EXUMyq8viKoi2qnA3S3IsrSwsi-uw3J7BrwcMIVqu0tOnyDqrqR-yv2zzm3-_eAFh3zCa03FV-xA" + } + ] +} \ No newline at end of file diff --git a/tests/utils/signing_gate_jwks_20260928.json b/tests/utils/signing_gate_jwks_20260928.json new file mode 100644 index 000000000..ecab2565e --- /dev/null +++ b/tests/utils/signing_gate_jwks_20260928.json @@ -0,0 +1,13 @@ +{ + "keys": [ + { + "kty": "EC", + "x": "CytwnuXFtXi7PFCcF-TCbvW5OgOg4KuWRLeRvdfHWLs", + "y": "Zha3FI2QplMaGveXjrIg8PxrZ6dTjHmESoGs88uAIiA", + "crv": "P-256", + "kid": "hs-2026-09", + "alg": "ES256", + "use": "sig" + } + ] +} \ No newline at end of file diff --git a/tests/utils/signing_interop_vectors.json b/tests/utils/signing_interop_vectors.json new file mode 100644 index 000000000..71227ef66 --- /dev/null +++ b/tests/utils/signing_interop_vectors.json @@ -0,0 +1,303 @@ +{ + "_comment": "Cross-SDK Agent Card signature vectors: the five vectors of the s0-control group of the language-neutral a2a-card-sign-v01 corpus, each lifted verbatim (Layer C of the corpus whose Layers A and B are a2a-tck#228 and #245). Every card carries every REQUIRED field and no field at its default value, so the canonical readings in use today agree on its bytes and these expectations hold however a2aproject/A2A#2122 is settled. The key is a published test key (derivation in `test_key`); it signs test vectors only. The jku in each protected header is a placeholder that is never fetched.", + "corpus": "a2a-card-sign-v01", + "specRef": "https://a2a-protocol.org/latest/specification/#841-canonicalization-requirements", + "oracles": [ + "rfc8785 (PyPI, 0.1.4)", + "gowebpki/jcs (Go, v1.0.1)" + ], + "generator": "https://github.com/ogasurfproject-jpg/horizon-shield/blob/main/workers/a2a-card-sign/interop-matrix/vectors.py", + "test_key": { + "kid": "hs-interop-test-v1", + "alg": "ES256", + "jwk": { + "kty": "EC", + "crv": "P-256", + "x": "khLaGq9LmIvsjFv8YF0N4IJwRGvldb36SwqntLYct04", + "y": "XCMP49DgLRfdGJKDr5MacrOCNaDXIwSvoFjID8ORkmQ", + "kid": "hs-interop-test-v1", + "alg": "ES256", + "use": "sig" + }, + "derivation": "d = int.from_bytes(sha256(phrase), 'big') mod (n - 1) + 1 on P-256", + "phrase": "HORIZON SHIELD A2A card-signing interop test key v1 (public, test vectors only)", + "note": "Published test key, so anyone can re-create the private key and re-sign every reference vector byte for byte (RFC 6979). It signs test vectors only and is nobody's production key." + }, + "counts": { + "accept": 4, + "reject": 1, + "total": 5 + }, + "vectors": [ + { + "id": "S0-001", + "clause": "a2a-spec-8.4.1", + "spec_ref": "https://a2a-protocol.org/latest/specification/#841-canonicalization-requirements", + "layer": "signature", + "disposition": "MUST-ACCEPT", + "accept_under": [ + "rule-1-as-written", + "prune-empty", + "served-as-is" + ], + "reject_under": [], + "signer": "reference (ES256, RFC 6979)", + "rationale": "Control: every REQUIRED field present and non-default, no empty value anywhere, so all three readings give the same bytes. Any verifier MUST accept, whichever way a2aproject/A2A#2122 is settled.", + "canonical_utf8_hex": "7b226361706162696c6974696573223a7b22707573684e6f74696669636174696f6e73223a66616c73652c2273747265616d696e67223a747275657d2c2264656661756c74496e7075744d6f646573223a5b22746578742f706c61696e225d2c2264656661756c744f75747075744d6f646573223a5b22746578742f706c61696e225d2c226465736372697074696f6e223a2250726f6265206361726420666f722063726f73732d53444b207369676e617475726520636865636b73222c226e616d65223a22496e7465726f702050726f6265222c22736b696c6c73223a5b7b226465736372697074696f6e223a22416e737765727320612070726f6265222c226964223a2270726f6265222c226e616d65223a2250726f6265222c2274616773223a5b2270726f6265225d7d5d2c22737570706f72746564496e7465726661636573223a5b7b2270726f746f636f6c42696e64696e67223a224a534f4e525043222c2270726f746f636f6c56657273696f6e223a22312e30222c2275726c223a2268747470733a2f2f6578616d706c652e636f6d2f6132612f7631227d5d2c2276657273696f6e223a22312e302e30227d", + "served_card": { + "name": "Interop Probe", + "description": "Probe card for cross-SDK signature checks", + "version": "1.0.0", + "supportedInterfaces": [ + { + "url": "https://example.com/a2a/v1", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0" + } + ], + "capabilities": { + "streaming": true, + "pushNotifications": false + }, + "defaultInputModes": [ + "text/plain" + ], + "defaultOutputModes": [ + "text/plain" + ], + "skills": [ + { + "id": "probe", + "name": "Probe", + "description": "Answers a probe", + "tags": [ + "probe" + ] + } + ], + "signatures": [ + { + "protected": "eyJhbGciOiJFUzI1NiIsImprdSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYTJhLWNhcmQtc2lnbi12MDEvdGVzdGtleV9qd2tzLmpzb24iLCJraWQiOiJocy1pbnRlcm9wLXRlc3QtdjEiLCJ0eXAiOiJKT1NFIn0", + "signature": "jkAX98oJc4IUm6400xBQWnk5_dN33PH6KSz3KYVh6mA6gvdaroVnSVxSkzT5NoNLVIN3IoAaxvHK4etXsDMelQ" + } + ] + } + }, + { + "id": "S0-002", + "clause": "a2a-spec-8.4.1", + "spec_ref": "https://a2a-protocol.org/latest/specification/#841-canonicalization-requirements", + "layer": "signature", + "disposition": "MUST-ACCEPT", + "accept_under": [ + "rule-1-as-written", + "prune-empty", + "served-as-is" + ], + "reject_under": [], + "signer": "a2a-sdk 1.2.1", + "rationale": "The control card signed by a2a-sdk 1.2.1 itself. Recorded as produced; its ECDSA nonce is the SDK's, so re-signing gives other bytes that verify the same way.", + "canonical_utf8_hex": "7b226361706162696c6974696573223a7b22707573684e6f74696669636174696f6e73223a66616c73652c2273747265616d696e67223a747275657d2c2264656661756c74496e7075744d6f646573223a5b22746578742f706c61696e225d2c2264656661756c744f75747075744d6f646573223a5b22746578742f706c61696e225d2c226465736372697074696f6e223a2250726f6265206361726420666f722063726f73732d53444b207369676e617475726520636865636b73222c226e616d65223a22496e7465726f702050726f6265222c22736b696c6c73223a5b7b226465736372697074696f6e223a22416e737765727320612070726f6265222c226964223a2270726f6265222c226e616d65223a2250726f6265222c2274616773223a5b2270726f6265225d7d5d2c22737570706f72746564496e7465726661636573223a5b7b2270726f746f636f6c42696e64696e67223a224a534f4e525043222c2270726f746f636f6c56657273696f6e223a22312e30222c2275726c223a2268747470733a2f2f6578616d706c652e636f6d2f6132612f7631227d5d2c2276657273696f6e223a22312e302e30227d", + "served_card": { + "name": "Interop Probe", + "description": "Probe card for cross-SDK signature checks", + "version": "1.0.0", + "supportedInterfaces": [ + { + "url": "https://example.com/a2a/v1", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0" + } + ], + "capabilities": { + "streaming": true, + "pushNotifications": false + }, + "defaultInputModes": [ + "text/plain" + ], + "defaultOutputModes": [ + "text/plain" + ], + "skills": [ + { + "id": "probe", + "name": "Probe", + "description": "Answers a probe", + "tags": [ + "probe" + ] + } + ], + "signatures": [ + { + "protected": "eyJhbGciOiJFUzI1NiIsImprdSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYTJhLWNhcmQtc2lnbi12MDEvdGVzdGtleV9qd2tzLmpzb24iLCJraWQiOiJocy1pbnRlcm9wLXRlc3QtdjEiLCJ0eXAiOiJKT1NFIn0", + "signature": "nw-32J6gLi6LsENaVDE6Qjz1oiagGEGtY6hYEc-LLXD3hmMLSYZ6cIZH7U9YHja3Hkqk-Uh4CUq2EtZY_TnjDg" + } + ] + } + }, + { + "id": "S0-003", + "clause": "a2a-spec-8.4.1", + "spec_ref": "https://a2a-protocol.org/latest/specification/#841-canonicalization-requirements", + "layer": "signature", + "disposition": "MUST-ACCEPT", + "accept_under": [ + "rule-1-as-written", + "prune-empty", + "served-as-is" + ], + "reject_under": [], + "signer": "@a2a-js/sdk 1.3.0", + "rationale": "The control card signed by @a2a-js/sdk 1.3.0 itself. Recorded as produced; its ECDSA nonce is the SDK's, so re-signing gives other bytes that verify the same way.", + "canonical_utf8_hex": "7b226361706162696c6974696573223a7b22707573684e6f74696669636174696f6e73223a66616c73652c2273747265616d696e67223a747275657d2c2264656661756c74496e7075744d6f646573223a5b22746578742f706c61696e225d2c2264656661756c744f75747075744d6f646573223a5b22746578742f706c61696e225d2c226465736372697074696f6e223a2250726f6265206361726420666f722063726f73732d53444b207369676e617475726520636865636b73222c226e616d65223a22496e7465726f702050726f6265222c22736b696c6c73223a5b7b226465736372697074696f6e223a22416e737765727320612070726f6265222c226964223a2270726f6265222c226e616d65223a2250726f6265222c2274616773223a5b2270726f6265225d7d5d2c22737570706f72746564496e7465726661636573223a5b7b2270726f746f636f6c42696e64696e67223a224a534f4e525043222c2270726f746f636f6c56657273696f6e223a22312e30222c2275726c223a2268747470733a2f2f6578616d706c652e636f6d2f6132612f7631227d5d2c2276657273696f6e223a22312e302e30227d", + "served_card": { + "name": "Interop Probe", + "description": "Probe card for cross-SDK signature checks", + "version": "1.0.0", + "supportedInterfaces": [ + { + "url": "https://example.com/a2a/v1", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0" + } + ], + "capabilities": { + "streaming": true, + "pushNotifications": false + }, + "defaultInputModes": [ + "text/plain" + ], + "defaultOutputModes": [ + "text/plain" + ], + "skills": [ + { + "id": "probe", + "name": "Probe", + "description": "Answers a probe", + "tags": [ + "probe" + ] + } + ], + "signatures": [ + { + "protected": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJocy1pbnRlcm9wLXRlc3QtdjEiLCJqa3UiOiJodHRwczovL2V4YW1wbGUuY29tL2EyYS1jYXJkLXNpZ24tdjAxL3Rlc3RrZXlfandrcy5qc29uIn0", + "signature": "aJbacfQKkE_YmVBGBCqwVtLZR9HOIPLlbzhVkF27T6ISnYiSJPeXX9xcVl9EhhRu-bk8P2iRQGtBmMd_eWR1oQ" + } + ] + } + }, + { + "id": "S0-004", + "clause": "a2a-spec-8.4.1", + "spec_ref": "https://a2a-protocol.org/latest/specification/#841-canonicalization-requirements", + "layer": "signature", + "disposition": "MUST-ACCEPT", + "accept_under": [ + "rule-1-as-written", + "prune-empty", + "served-as-is" + ], + "reject_under": [], + "signer": "a2a-go main 534a60fc", + "rationale": "The control card signed by a2a-go main 534a60fc itself. Recorded as produced; its ECDSA nonce is the SDK's, so re-signing gives other bytes that verify the same way.", + "canonical_utf8_hex": "7b226361706162696c6974696573223a7b22707573684e6f74696669636174696f6e73223a66616c73652c2273747265616d696e67223a747275657d2c2264656661756c74496e7075744d6f646573223a5b22746578742f706c61696e225d2c2264656661756c744f75747075744d6f646573223a5b22746578742f706c61696e225d2c226465736372697074696f6e223a2250726f6265206361726420666f722063726f73732d53444b207369676e617475726520636865636b73222c226e616d65223a22496e7465726f702050726f6265222c22736b696c6c73223a5b7b226465736372697074696f6e223a22416e737765727320612070726f6265222c226964223a2270726f6265222c226e616d65223a2250726f6265222c2274616773223a5b2270726f6265225d7d5d2c22737570706f72746564496e7465726661636573223a5b7b2270726f746f636f6c42696e64696e67223a224a534f4e525043222c2270726f746f636f6c56657273696f6e223a22312e30222c2275726c223a2268747470733a2f2f6578616d706c652e636f6d2f6132612f7631227d5d2c2276657273696f6e223a22312e302e30227d", + "served_card": { + "name": "Interop Probe", + "description": "Probe card for cross-SDK signature checks", + "version": "1.0.0", + "supportedInterfaces": [ + { + "url": "https://example.com/a2a/v1", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0" + } + ], + "capabilities": { + "streaming": true, + "pushNotifications": false + }, + "defaultInputModes": [ + "text/plain" + ], + "defaultOutputModes": [ + "text/plain" + ], + "skills": [ + { + "id": "probe", + "name": "Probe", + "description": "Answers a probe", + "tags": [ + "probe" + ] + } + ], + "signatures": [ + { + "protected": "eyJhbGciOiJFUzI1NiIsImprdSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYTJhLWNhcmQtc2lnbi12MDEvdGVzdGtleV9qd2tzLmpzb24iLCJraWQiOiJocy1pbnRlcm9wLXRlc3QtdjEiLCJ0eXAiOiJKT1NFIn0", + "signature": "Sm8gkamh8mcy5OsA5q84-GHO_hL5hyGrkyB8k4qJ1gm_4M64eMB6Av_m3sZSVNzgvygKXTTpeWM_wL22COXTww" + } + ] + } + }, + { + "id": "S0-REJECT-005", + "clause": "a2a-spec-8.4.1", + "spec_ref": "https://a2a-protocol.org/latest/specification/#841-canonicalization-requirements", + "layer": "signature", + "disposition": "MUST-REJECT", + "accept_under": [], + "reject_under": [ + "rule-1-as-written", + "prune-empty", + "served-as-is" + ], + "signer": "reference (ES256, RFC 6979)", + "rationale": "S0-001's signature on a card whose description gained one character after signing. Every reading changes, so every verifier MUST reject.", + "canonical_utf8_hex": "7b226361706162696c6974696573223a7b22707573684e6f74696669636174696f6e73223a66616c73652c2273747265616d696e67223a747275657d2c2264656661756c74496e7075744d6f646573223a5b22746578742f706c61696e225d2c2264656661756c744f75747075744d6f646573223a5b22746578742f706c61696e225d2c226465736372697074696f6e223a2250726f6265206361726420666f722063726f73732d53444b207369676e617475726520636865636b732e222c226e616d65223a22496e7465726f702050726f6265222c22736b696c6c73223a5b7b226465736372697074696f6e223a22416e737765727320612070726f6265222c226964223a2270726f6265222c226e616d65223a2250726f6265222c2274616773223a5b2270726f6265225d7d5d2c22737570706f72746564496e7465726661636573223a5b7b2270726f746f636f6c42696e64696e67223a224a534f4e525043222c2270726f746f636f6c56657273696f6e223a22312e30222c2275726c223a2268747470733a2f2f6578616d706c652e636f6d2f6132612f7631227d5d2c2276657273696f6e223a22312e302e30227d", + "served_card": { + "name": "Interop Probe", + "description": "Probe card for cross-SDK signature checks.", + "version": "1.0.0", + "supportedInterfaces": [ + { + "url": "https://example.com/a2a/v1", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0" + } + ], + "capabilities": { + "streaming": true, + "pushNotifications": false + }, + "defaultInputModes": [ + "text/plain" + ], + "defaultOutputModes": [ + "text/plain" + ], + "skills": [ + { + "id": "probe", + "name": "Probe", + "description": "Answers a probe", + "tags": [ + "probe" + ] + } + ], + "signatures": [ + { + "protected": "eyJhbGciOiJFUzI1NiIsImprdSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYTJhLWNhcmQtc2lnbi12MDEvdGVzdGtleV9qd2tzLmpzb24iLCJraWQiOiJocy1pbnRlcm9wLXRlc3QtdjEiLCJ0eXAiOiJKT1NFIn0", + "signature": "jkAX98oJc4IUm6400xBQWnk5_dN33PH6KSz3KYVh6mA6gvdaroVnSVxSkzT5NoNLVIN3IoAaxvHK4etXsDMelQ" + } + ] + } + } + ] +} diff --git a/tests/utils/test_signing_interop.py b/tests/utils/test_signing_interop.py new file mode 100644 index 000000000..a239ebd88 --- /dev/null +++ b/tests/utils/test_signing_interop.py @@ -0,0 +1,124 @@ +"""Cross-SDK Agent Card signature tests. + +`signing_interop_vectors.json` holds one card signed four ways: by this SDK, +by @a2a-js/sdk, by a2a-go and by an independent reference signer. The card +carries every REQUIRED field and no field at its default value, so the +canonical readings in use today agree on its bytes, and those expectations +hold however a2aproject/A2A#2122 is settled: a card another SDK signed still +verifies here. + +The frozen production card below is different. It carries +`securityRequirements: [{}]`, a nested empty value whose canonical form is +part of the #2122 question, so its test pins today's bytes, the ones this SDK +and @a2a-js/sdk agree on, as agreed in #1278. It needs updating if #2122 +settles the other way. +""" + +import hashlib +import json + +from pathlib import Path + +import pytest + +from a2a.types import AgentCard +from a2a.utils import signing +from google.protobuf import json_format +from jwt import PyJWK + + +_HERE = Path(__file__).parent +_VECTORS = json.loads( + (_HERE / 'signing_interop_vectors.json').read_text(encoding='utf-8') +) +_ACCEPT = [v for v in _VECTORS['vectors'] if v['disposition'] == 'MUST-ACCEPT'] +_REJECT = [v for v in _VECTORS['vectors'] if v['disposition'] == 'MUST-REJECT'] + +# A card served in production by HORIZON SHIELD, signed by @a2a-js/sdk with two +# ES256 signatures, frozen on 2026-09-28 and published with its key set in +# a2aproject/a2a-go#445. It carries fields this SDK's proto does not have +# (url, protocolVersion, preferredTransport, compensation), so it is parsed +# leniently. The anchors are the ones used for the independent verification +# in #1278. +_GATE_CARD = _HERE / 'signing_gate_card_20260928.json' +_GATE_JWKS = _HERE / 'signing_gate_jwks_20260928.json' +_GATE_CARD_SHA256 = ( + '2df33ff120745a7f46b8afc1378d72b437b3f05b4db61c8d8b3655c581584455' +) +_GATE_JWKS_SHA256 = ( + '692fd49da681cc0d0bda9ad46963fa5f45957a59d10f84998e9b2e4061209d3c' +) +_GATE_CANONICAL_LEN = 6410 +_GATE_CANONICAL_SHA256 = ( + 'c5d5384a19f3a15c761ff93bf9fec892ec99615b6356d7675fda5b79286b59b1' +) + + +def _key_provider(jwks: dict): + def provide(kid: str | None, jku: str | None) -> PyJWK: + for key in jwks['keys']: + if key['kid'] == kid: + return PyJWK(key) + raise ValueError(f'kid not in key set: {kid}') + + return provide + + +def _card(served: dict, lenient: bool = False) -> AgentCard: + return json_format.ParseDict( + served, AgentCard(), ignore_unknown_fields=lenient + ) + + +_VERIFY = signing.create_signature_verifier( + _key_provider({'keys': [_VECTORS['test_key']['jwk']]}), ['ES256'] +) + + +def test_vector_corpus_is_complete(): + """The corpus must be whole; a partially loaded corpus passes vacuously.""" + assert len(_ACCEPT) == _VECTORS['counts']['accept'] == 4 + assert len(_REJECT) == _VECTORS['counts']['reject'] == 1 + assert {v['signer'] for v in _ACCEPT} >= { + 'a2a-sdk 1.2.1', + '@a2a-js/sdk 1.3.0', + 'a2a-go main 534a60fc', + } + + +@pytest.mark.parametrize('vector', _ACCEPT, ids=lambda v: v['id']) +def test_card_signed_by_any_sdk_verifies(vector): + """A card signed by any SDK verifies here.""" + _VERIFY(_card(vector['served_card'])) + + +@pytest.mark.parametrize('vector', _ACCEPT, ids=lambda v: v['id']) +def test_card_canonical_bytes_are_the_shared_form(vector): + """The bytes every SDK and every reading agree on, byte for byte.""" + canonical = signing._canonicalize_agent_card(_card(vector['served_card'])) + assert canonical.encode('utf-8').hex() == vector['canonical_utf8_hex'] + + +@pytest.mark.parametrize('vector', _REJECT, ids=lambda v: v['id']) +def test_card_edited_after_signing_is_rejected(vector): + """A signature over other bytes must not verify.""" + with pytest.raises(signing.InvalidSignaturesError): + _VERIFY(_card(vector['served_card'])) + + +def test_frozen_js_signed_production_card(): + """A card @a2a-js/sdk signed in production verifies, bytes unchanged.""" + card_bytes = _GATE_CARD.read_bytes() + jwks_bytes = _GATE_JWKS.read_bytes() + assert hashlib.sha256(card_bytes).hexdigest() == _GATE_CARD_SHA256 + assert hashlib.sha256(jwks_bytes).hexdigest() == _GATE_JWKS_SHA256 + + card = _card(json.loads(card_bytes), lenient=True) + canonical = signing._canonicalize_agent_card(card).encode('utf-8') + assert len(canonical) == _GATE_CANONICAL_LEN + assert hashlib.sha256(canonical).hexdigest() == _GATE_CANONICAL_SHA256 + + verify = signing.create_signature_verifier( + _key_provider(json.loads(jwks_bytes)), ['ES256'] + ) + verify(card)