a2aproject / a2aproject/a2a-js

[Bug]: canonicalizeAgentCard is input-form dependent — AgentCard instance input silently drops securitySchemes (oneof), breaking self- and cross-SDK verification

Abierto
#663 1 comentario 0 reacciones 0 asignados Ver en GitHub
Lenguaje dominante
TypeScript
Estrellas
613
Forks
169
Merge medio
1 d 6 h
PR fusionados (30 d)
21

Descripción

### 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)

Guía de contribución

Abrir la guía de contribución

Línea de trabajo

The bug is in src/signature.ts in the canonicalizeAgentCard function. Start by reading the function to understand the round-trip via AgentCard.fromJSON and AgentCard.toJSON. Look at how oneof fields like securitySchemes are represented internally. Write a test that reproduces the mismatch between plain JSON and AgentCard instance inputs. The fix likely involves checking if the input is already an instance and avoiding the re-parse, or making fromJSON handle the internal form. Verify by running existing signature tests and adding a regression test for cross-form signing and verification.

Escrito por el modelo de indexación a partir del texto del issue.

Evaluación

Stack tecnológico
javascript, node.js, typescript
Área
backend-api-design, security
Tipo de issue
Error
Dificultad
3/5
Tiempo estimado
1-2 días
Estado de actividad
Activo
Claridad
Bien especificado
Aptitud para principiantes
55/100

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.