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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions src/samples/agents/verify-signing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,11 @@ async function main() {

// Step 3: Verify canonicalization produces deterministic output
console.log('--- Step 3: Verify canonicalization ---');
const { signatures: _, ...cardWithoutSignatures } = rawCard;
void _;
const canonical = canonicalizeAgentCard(cardWithoutSignatures);
// `signatures` are excluded by canonicalizeAgentCard itself.
const canonical = canonicalizeAgentCard(rawCard);
console.log(` Canonical form (first 200 chars): ${canonical.substring(0, 200)}...`);
// Run it twice to confirm determinism
const canonical2 = canonicalizeAgentCard(cardWithoutSignatures);
const canonical2 = canonicalizeAgentCard(rawCard);
if (canonical === canonical2) {
console.log(' PASS: Canonicalization is deterministic.\n');
} else {
Expand Down
49 changes: 30 additions & 19 deletions src/signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ export type AgentCardSignatureGenerator = (agentCard: AgentCard) => Promise<Agen
/**
* Creates an {@link AgentCardSignatureGenerator} that signs an agent card
* using JWS (Flattened JSON Serialization) with the provided private key.
* The `signatures` field is excluded from the payload before
* canonicalization. The `protectedHeader` MUST include `alg`, `kid`, `typ`.
* The payload is produced by {@link canonicalizeAgentCard}, which excludes
* the `signatures` field. The `protectedHeader` MUST include `alg`, `kid`,
* `typ`.
*
* @example
* ```ts
Expand All @@ -31,8 +32,7 @@ export function generateAgentCardSignature(
header?: jose.JWSHeaderParameters
): AgentCardSignatureGenerator {
return async (agentCard: AgentCard): Promise<AgentCard> => {
const { signatures: existingSignatures, ...cardWithoutSignatures } = agentCard;
const canonicalPayload = canonicalizeAgentCard(cardWithoutSignatures);
const canonicalPayload = canonicalizeAgentCard(agentCard);

const signBuilder = new jose.FlattenedSign(
new TextEncoder().encode(canonicalPayload)
Expand All @@ -52,7 +52,7 @@ export function generateAgentCardSignature(

return {
...agentCard,
signatures: [...(existingSignatures ?? []), agentCardSignature],
signatures: [...(agentCard.signatures ?? []), agentCardSignature],
};
};
}
Expand All @@ -65,6 +65,10 @@ export type AgentCardSignatureVerifier = (agentCard: AgentCard) => Promise<void>
* one signature on the card verifies against a key returned by
* `retrievePublicKey(kid, jku)`.
*
* Note that the payload is normalized by {@link canonicalizeAgentCard}, so
* fields outside the v1.0 schema are not covered by the signature and a
* successful verification says nothing about them.
*
* @example
* ```ts
* const verifier = verifyAgentCardSignature(async (kid, jku) => {
Expand All @@ -84,15 +88,7 @@ export function verifyAgentCardSignature(
throw new Error('No signatures found on agent card to verify.');
}

// Round-trip through AgentCard.fromJSON/toJSON to normalize the card:
// strip non-schema fields and omit fields with default values. Mirrors
// the Python SDK's MessageToDict behaviour.
const normalizedCard = AgentCard.toJSON(AgentCard.fromJSON(agentCard)) as Record<
string,
unknown
>;
delete normalizedCard.signatures;
const canonicalPayload = canonicalizeAgentCard(normalizedCard as Omit<AgentCard, 'signatures'>);
const canonicalPayload = canonicalizeAgentCard(agentCard);
const payloadBytes = new TextEncoder().encode(canonicalPayload);
const encodedPayload = jose.base64url.encode(payloadBytes);

Expand Down Expand Up @@ -172,12 +168,27 @@ function jcsStringify(value: unknown): string {
}

/**
* Canonicalizes an agent card using JCS (RFC 8785) for signing /
* verification. The `signatures` field MUST be excluded from `agentCard`
* before calling this.
* Canonicalizes an agent card for signing / verification, per spec §8.4.1.
*
* The card is first round-tripped through `AgentCard.fromJSON` /
* `AgentCard.toJSON`, which drops fields outside the v1.0 schema and omits
* fields whose value equals the protobuf default (the JS equivalent of the
* Python SDK's `MessageToDict`). The `signatures` field is then excluded to
* avoid a circular dependency, and the result is serialized with JCS
* (RFC 8785).
*
* Both the signing and the verification path MUST go through this function.
*
* Passing a card that still carries `signatures` is fine; they are stripped
* here.
*/
export function canonicalizeAgentCard(agentCard: Omit<AgentCard, 'signatures'>): string {
const cleaned = cleanEmpty(agentCard);
export function canonicalizeAgentCard(
agentCard: AgentCard | Omit<AgentCard, 'signatures'>
): string {
const normalized = AgentCard.toJSON(AgentCard.fromJSON(agentCard)) as Record<string, unknown>;
delete normalized.signatures;

const cleaned = cleanEmpty(normalized);
if (!cleaned) {
return '{}';
}
Expand Down
92 changes: 92 additions & 0 deletions test/signature.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,50 @@ describe('Agent Card Signature', () => {

expect(canonicalizeAgentCard(card1 as any)).toBe(canonicalizeAgentCard(card2 as any));
});

it('should omit fields set to their protobuf default value', () => {
mockAgentCard.capabilities!.extensions = [
{
uri: 'urn:example:extension',
description: 'Example',
required: false,
params: undefined,
},
];

const parsed = JSON.parse(canonicalizeAgentCard(mockAgentCard));

// `required` is a plain proto3 bool, so `false` is the default and must
// not appear in the canonical form (spec 8.4.1).
expect(parsed.capabilities.extensions[0]).toEqual({
uri: 'urn:example:extension',
description: 'Example',
});
});

it('should exclude signatures whether or not the caller stripped them', () => {
const { signatures: _, ...cardWithoutSignatures } = mockAgentCard;
void _;

const withSignatures = canonicalizeAgentCard({
...mockAgentCard,
signatures: [{ protected: 'p', signature: 's', header: undefined }],
});

expect(JSON.parse(withSignatures).signatures).toBeUndefined();
expect(withSignatures).toBe(canonicalizeAgentCard(cardWithoutSignatures));
});

it('should ignore fields outside the v1.0 schema', () => {
const cardWithExtraFields = {
...mockAgentCard,
url: 'http://localhost:8080',
preferredTransport: 'JSONRPC',
protocolVersion: '0.3',
} as AgentCard;

expect(canonicalizeAgentCard(cardWithExtraFields)).toBe(canonicalizeAgentCard(mockAgentCard));
});
});

describe('generateAgentCardSignature', () => {
Expand Down Expand Up @@ -221,6 +265,54 @@ describe('Agent Card Signature', () => {
await expect(verifier(cardWithExtraFields as AgentCard)).resolves.not.toThrow();
});

// Regression test: the signer canonicalized the card as supplied while the
// verifier canonicalized the proto-normalized card, so a card carrying an
// explicit protobuf default was rejected by its own verifier.
it('should verify a card carrying explicit protobuf default values', async () => {
mockAgentCard.capabilities!.extensions = [
{
uri: 'urn:example:extension',
description: 'Example',
required: false,
params: undefined,
},
];

const signer = generateAgentCardSignature(privateKey, {
alg: ALG,
kid: 'test-key-1',
typ: 'JOSE',
});
const signedCard = await signer(mockAgentCard);

const verifier = verifyAgentCardSignature(mockRetrieveKey);
await expect(verifier(signedCard)).resolves.not.toThrow();
});

it('should verify a card signed with non-schema fields present', async () => {
const cardWithExtraFields = {
...mockAgentCard,
url: 'http://localhost:8080',
preferredTransport: 'JSONRPC',
protocolVersion: '0.3',
} as AgentCard;

const signer = generateAgentCardSignature(privateKey, {
alg: ALG,
kid: 'test-key-1',
typ: 'JOSE',
});
const signedCard = await signer(cardWithExtraFields);

const verifier = verifyAgentCardSignature(mockRetrieveKey);
// Valid with the extra fields still attached...
await expect(verifier(signedCard)).resolves.not.toThrow();
// ...and still valid once a peer drops them.
await expect(
verifier({ ...mockAgentCard, signatures: signedCard.signatures })
).resolves.not.toThrow();
});

it('should pass jku to retrievePublicKey when present in header', async () => {
const signer = generateAgentCardSignature(privateKey, {
alg: ALG,
Expand Down
Loading