a2aproject / a2aproject/a2a-js
[Bug]: canonicalizeAgentCard is input-form dependent — AgentCard instance input silently drops securitySchemes (oneof), breaking self- and cross-SDK verification
- Vorherrschende Sprache
- TypeScript
- Sterne
- 613
- Forks
- 169
- Ø Merge
- 1 T. 6 Std.
- Gemergte PRs (30 T.)
- 21
Beschreibung
### What happened?
`canonicalizeAgentCard()` produces **different canonical payloads for the same card depending on the input form**. Passing a plain JSON object (external/ProtoJSON form) and passing the SDK's own `AgentCard` instance (e.g. the result of `AgentCard.fromJSON()`) differ in that `securitySchemes` — a oneof field — is **silently dropped** from the instance-form payload. No error is raised.
Both `generateAgentCardSignature()` and `verifyAgentCardSignature()` canonicalize their input as supplied, so the canonical payload depends on the form the caller happens to pass. Consequences:
1. **The SDK rejects its own signature** when signer and verifier pass different input forms for the same card. This is a variant of #605 that survived the #606 fix: #605/#606 covered default-valued fields, this one is about oneof fields.
2. **Cross-SDK verification fails for ASCII-only cards** that declare `securitySchemes` (the standard way to advertise OIDC auth in §8). This is a structural field drop, orthogonal to the normalization byte divergences tracked in a2aproject/A2A#2122 — note that #2122's C1/J1 ASCII controls pass, yet an ASCII-only card still fails cross-verification through this path.
### Minimal reproduction (all output verified)
`@a2a-js/sdk@1.0.1` (latest release), Node.js 22. The card is ASCII-only and contains `securitySchemes`:
```js
import {
AgentCard,
canonicalizeAgentCard,
generateAgentCardSignature,
verifyAgentCardSignature,
} from "@a2a-js/sdk";
import { generateKeyPairSync } from "node:crypto";
const plain = {
name: "demo-agent",
description: "A2A signing verification agent",
supportedInterfaces: [{ url: "http://agent:8123", protocolBinding: "JSONRPC" }],
provider: { url: "http://agent:8123", organization: "a2a-verification" },
version: "1.0.0",
capabilities: { streaming: false },
securitySchemes: {
oidc: {
openIdConnectSecurityScheme: {
openIdConnectUrl: "http://keycloak:8080/realms/a2a-test/.well-known/openid-configuration",
},
},
},
securityRequirements: [{ schemes: { oidc: { list: ["openid", "profile"] } } }],
defaultInputModes: ["text/plain"],
defaultOutputModes: ["text/plain"],
skills: [{ id: "echo", name: "Echo", description: "Verification echo skill" }],
};
// 1) Same card, two input forms -> different canonical payloads
const fromPlain = canonicalizeAgentCard(plain);
const fromInstance = canonicalizeAgentCard(AgentCard.fromJSON(plain));
console.log(fromPlain === fromInstance); // false <- bug
console.log(fromPlain.includes("securitySchemes")); // true
console.log(fromInstance.includes("securitySchemes")); // false <- silently dropped
// 2) The SDK rejects its own signature when the form differs between sign and verify
const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
const sign = generateAgentCardSignature(privateKey, {
alg: "ES256",
typ: "JOSE",
kid: "repro-0001",
jku: "http://example.test/jwks.json",
});
const verifier = verifyAgentCardSignature(async () => publicKey);
const signedFromInstance = await sign(AgentCard.fromJSON(plain)); // common pattern: fromJSON -> sign
const signedFromPlain = await sign(plain);
// Same form on both sides -> OK
await verifier(signedFromInstance); // PASS
await verifier(signedFromPlain); // PASS
// Form mismatch -> the SDK rejects a signature it just generated
await verifier(AgentCard.fromJSON(signedFromPlain)); // FAIL: No valid signatures found on agent card.
await verifier(AgentCard.toJSON(signedFromInstance)); // FAIL: No valid signatures found on agent card.
```
### Cross-SDK consequence (a2a-sdk 1.1.2, Python)
Same key, same ASCII-only card, two cards differing only in which JS input form was used to sign. The Python SDK parses the served card into its protobuf type and canonicalizes that message, so its payload **includes** `securitySchemes`:
```
card signed via JS instance path (fromJSON -> sign): FAIL — No valid signature found
card signed via JS plain-JSON path: PASS
```
```python
from google.protobuf.json_format import ParseDict
from a2a.types import AgentCard
from a2a.utils.signing import create_signature_verifier
from jwt.api_jwk import PyJWK
verifier = create_signature_verifier(lambda kid, jku: PyJWK(pub_jwk), ["ES256"])
verifier(ParseDict(served_card_json, AgentCard()))
```
So a JS agent that signs via the natural `AgentCard.fromJSON(json)` → `sign(card)` pattern produces a card that **no Python client can verify**, and a Python-signed card fails in a JS client that verifies the `fromJSON`'d instance — both directions, ASCII-only.
### Root cause
`canonicalizeAgentCard()` (src/signature.ts, v1.0.1 and current `main`):
```ts
const normalized = AgentCard.toJSON(AgentCard.fromJSON(agentCard));
delete normalized.signatures;
// ... cleanEmpty + JCS
```
`AgentCard.fromJSON` expects the external ProtoJSON form. When the input is already an `AgentCard` instance, its oneof fields (e.g. `securitySchemes`) are in the internal `{$case, value}` representation, which the `fromJSON` re-parse cannot handle — the field is dropped instead of round-tripping (or erroring). Hence:
- `canonicalizeAgentCard(plainJSON)` → payload **includes** `securitySchemes`
- `canonicalizeAgentCard(AgentCard.fromJSON(plainJSON))` → payload **excludes** it
The instance form is not an exotic input: `AgentCard.fromJSON()` is the SDK's own way of constructing an `AgentCard`, and `generateAgentCardSignature()` returns `{ ...card, signatures }` — a spread of whatever form was passed in, so the SDK itself propagates the internal form. (Related observation: `JSON.stringify`-ing that return value — e.g. via `agentCardHandler` — serves the internal `{$case, value}` form to clients, which is not the v1.0 external form.)
### Suggested fix
- Make the canonicalization form-agnostic: if the input is already an `AgentCard` instance, normalize via `toJSON` only (do not re-run `fromJSON` on the internal form), or make `fromJSON` accept the internal oneof form.
- Add a regression test: sign with instance input / verify with plain-JSON input (and vice versa) for a card that declares `securitySchemes`.
### Environment
- `@a2a-js/sdk` 1.0.1 (latest release; same round-trip present on `main`, src/signature.ts)
- Node.js v22
- Cross-check: `a2a-sdk` (Python) 1.1.2
### References
- a2aproject/A2A#2122 — §8.4.1 canonicalization under-determined (cross-SDK byte divergence; this issue adds the ASCII + oneof field-drop case that its ASCII controls do not cover)
- #605 / #606 — sign/verify asymmetry for default-valued fields (fixed in v1.0.1; this issue is the surviving oneof-field variant)
- #627 — `canonicalizeAgentCard` drops REQUIRED fields with default values (related `cleanEmpty` behavior)
Beitragsleitfaden
Bewertung
Dieses Issue wurde noch nicht bewertet.