diff --git a/package-lock.json b/package-lock.json index 8c58ddc7..4b243279 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.3.7", "license": "Apache-2.0", "dependencies": { + "jose": "^6.1.3", "uuid": "^11.1.0" }, "devDependencies": { @@ -5492,6 +5493,15 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", diff --git a/package.json b/package.json index d23bc7ad..0efd95b5 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "test-build": "esbuild ./dist/client/index.js ./dist/server/index.js ./dist/index.js --bundle --platform=neutral --outdir=dist/tmp-checks --outbase=./dist" }, "dependencies": { + "jose": "^6.1.3", "uuid": "^11.1.0" }, "peerDependencies": { diff --git a/src/client/multitransport-client.ts b/src/client/multitransport-client.ts index e3aeb9c5..ec5e0fef 100644 --- a/src/client/multitransport-client.ts +++ b/src/client/multitransport-client.ts @@ -1,4 +1,5 @@ import { PushNotificationNotSupportedError } from '../errors.js'; +import { AgentCardSignatureVerifier } from '../signature.js'; import { MessageSendParams, TaskPushNotificationConfig, @@ -75,7 +76,10 @@ export class Client { * If the current agent card supports the extended feature, it will try to fetch the extended agent card from the server, * Otherwise it will return the current agent card value. */ - async getAgentCard(options?: RequestOptions): Promise { + async getAgentCard( + options?: RequestOptions, + verifyAgentCardSignature?: AgentCardSignatureVerifier + ): Promise { if (this.agentCard.supportsAuthenticatedExtendedCard) { this.agentCard = await this.executeWithInterceptors( { method: 'getAgentCard' }, @@ -83,6 +87,9 @@ export class Client { (_, options) => this.transport.getExtendedAgentCard(options) ); } + if (verifyAgentCardSignature) { + await verifyAgentCardSignature(this.agentCard); + } return this.agentCard; } diff --git a/src/server/request_handler/default_request_handler.ts b/src/server/request_handler/default_request_handler.ts index 71299264..608cde83 100644 --- a/src/server/request_handler/default_request_handler.ts +++ b/src/server/request_handler/default_request_handler.ts @@ -34,6 +34,7 @@ import { import { PushNotificationSender } from '../push_notification/push_notification_sender.js'; import { DefaultPushNotificationSender } from '../push_notification/default_push_notification_sender.js'; import { ServerCallContext } from '../context.js'; +import { AgentCardSignatureGenerator } from '../../signature.js'; const terminalStates: TaskState[] = ['completed', 'failed', 'canceled', 'rejected']; @@ -45,6 +46,7 @@ export class DefaultRequestHandler implements A2ARequestHandler { private readonly pushNotificationStore?: PushNotificationStore; private readonly pushNotificationSender?: PushNotificationSender; private readonly extendedAgentCardProvider?: AgentCard | ExtendedAgentCardProvider; + private readonly agentCardSignatureGenerator?: AgentCardSignatureGenerator; constructor( agentCard: AgentCard, @@ -53,13 +55,15 @@ export class DefaultRequestHandler implements A2ARequestHandler { eventBusManager: ExecutionEventBusManager = new DefaultExecutionEventBusManager(), pushNotificationStore?: PushNotificationStore, pushNotificationSender?: PushNotificationSender, - extendedAgentCardProvider?: AgentCard | ExtendedAgentCardProvider + extendedAgentCardProvider?: AgentCard | ExtendedAgentCardProvider, + agentCardSignatureGenerator?: AgentCardSignatureGenerator ) { this.agentCard = agentCard; this.taskStore = taskStore; this.agentExecutor = agentExecutor; this.eventBusManager = eventBusManager; this.extendedAgentCardProvider = extendedAgentCardProvider; + this.agentCardSignatureGenerator = agentCardSignatureGenerator; // If push notifications are supported, use the provided store and sender. // Otherwise, use the default in-memory store and sender. @@ -71,6 +75,9 @@ export class DefaultRequestHandler implements A2ARequestHandler { } async getAgentCard(): Promise { + if (this.agentCardSignatureGenerator) { + return await this.agentCardSignatureGenerator(this.agentCard); + } return this.agentCard; } @@ -81,13 +88,16 @@ export class DefaultRequestHandler implements A2ARequestHandler { if (!this.extendedAgentCardProvider) { throw A2AError.authenticatedExtendedCardNotConfigured(); } + let agentCard = this.agentCard; if (typeof this.extendedAgentCardProvider === 'function') { - return this.extendedAgentCardProvider(context); + agentCard = await this.extendedAgentCardProvider(context); + } else if (context?.user?.isAuthenticated) { + agentCard = this.extendedAgentCardProvider; } - if (context?.user?.isAuthenticated) { - return this.extendedAgentCardProvider; + if (this.agentCardSignatureGenerator) { + return await this.agentCardSignatureGenerator(agentCard); } - return this.agentCard; + return agentCard; } private async _createRequestContext( diff --git a/src/signature.ts b/src/signature.ts new file mode 100644 index 00000000..d32ef33d --- /dev/null +++ b/src/signature.ts @@ -0,0 +1,129 @@ +import { AgentCard, AgentCardSignature } from './types.js'; +import * as jose from 'jose'; + +export type AgentCardSignatureGenerator = (agentCard: AgentCard) => Promise; + +export function generateAgentCardSignature( + privateKey: jose.CryptoKey | jose.KeyObject | jose.JWK, + protectedHeader: jose.JWSHeaderParameters, + header?: jose.JWSHeaderParameters +): AgentCardSignatureGenerator { + return async (agentCard: AgentCard): Promise => { + const cardCopy = JSON.parse(JSON.stringify(agentCard)); + delete cardCopy.signatures; + const canonicalPayload = canonicalizeAgentCard(cardCopy); + + const jws = await new jose.FlattenedSign(new TextEncoder().encode(canonicalPayload)) + .setProtectedHeader(protectedHeader) + .setUnprotectedHeader(header) + .sign(privateKey); + + const agentCardSignature: AgentCardSignature = { + protected: jws.protected, + signature: jws.signature, + header: jws.header, + }; + + if (!agentCard.signatures) agentCard.signatures = []; + agentCard.signatures.push(agentCardSignature); + + return agentCard; + }; +} + +export type AgentCardSignatureVerifier = (agentCard: AgentCard) => Promise; + +export function verifyAgentCardSignature( + retrievePublicKey: ( + kid: string, + jku?: string + ) => Promise +): AgentCardSignatureVerifier { + return async (agentCard: AgentCard): Promise => { + if (!agentCard.signatures?.length) { + throw new Error('No signatures found on agent card to verify.'); + } + const cardCopy = JSON.parse(JSON.stringify(agentCard)); + delete cardCopy.signatures; + const canonicalPayload = canonicalizeAgentCard(cardCopy); + const payloadBytes = new TextEncoder().encode(canonicalPayload); + const encodedPayload = jose.base64url.encode(payloadBytes); + + for (const signatureEntry of agentCard.signatures) { + try { + const protectedHeader = jose.decodeProtectedHeader(signatureEntry); + if (!protectedHeader.kid || !protectedHeader.typ || !protectedHeader.alg) { + throw new Error('Missing required header parameters (kid, typ, alg)'); + } + const publicKey = await retrievePublicKey(protectedHeader.kid, protectedHeader.jku); + const jws: jose.FlattenedJWS = { + payload: encodedPayload, + protected: signatureEntry.protected, + signature: signatureEntry.signature, + header: signatureEntry.header, + }; + await jose.flattenedVerify(jws, publicKey); + return; + } catch (error) { + console.warn('Signature verification failed:', error); + } + } + throw new Error('No valid signatures found on agent card.'); + }; +} + +function cleanEmpty(d: unknown): unknown { + if (d === '' || d === null || d === undefined) { + return null; + } + + if (Array.isArray(d)) { + const cleanedList = d.map((v) => cleanEmpty(v)).filter((v) => v !== null); + return cleanedList.length > 0 ? cleanedList : null; + } + + if (typeof d === 'object') { + if (d instanceof Date) return d; + const cleanedDict: { [key: string]: unknown } = {}; + for (const [key, v] of Object.entries(d)) { + const cleanedValue = cleanEmpty(v); + if (cleanedValue !== null) { + cleanedDict[key] = cleanedValue; + } + } + return Object.keys(cleanedDict).length > 0 ? cleanedDict : null; + } + + return d; +} + +/** + * JCS Canonicalization (RFC 8785) + * Sorts keys recursively and serializes to string. + */ +function jcsStringify(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + + if (Array.isArray(value)) { + return '[' + value.map((item) => jcsStringify(item)).join(',') + ']'; + } + + const record = value as Record; + const keys = Object.keys(record).sort(); + const parts = keys.map((key) => { + return `${JSON.stringify(key)}:${jcsStringify(record[key])}`; + }); + + return '{' + parts.join(',') + '}'; +} + +export function canonicalizeAgentCard(agentCard: Omit): string { + const cleaned = cleanEmpty(agentCard); + if (!cleaned) { + return '{}'; + } + + return jcsStringify(cleaned); +} diff --git a/test/signature.spec.ts b/test/signature.spec.ts new file mode 100644 index 00000000..087a44d2 --- /dev/null +++ b/test/signature.spec.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; +import * as jose from 'jose'; +import { + generateAgentCardSignature, + verifyAgentCardSignature, + canonicalizeAgentCard, +} from '../src/signature.js'; +import { AgentCard } from '../src/types.js'; + +let mockAgentCard: AgentCard; +let privateKey: jose.CryptoKey; +let publicKey: jose.CryptoKey; +const ALG = 'ES256'; + +describe('Agent Card Signature', () => { + beforeAll(async () => { + const keys = await jose.generateKeyPair(ALG, { extractable: true }); + privateKey = keys.privateKey; + publicKey = keys.publicKey; + }); + + beforeEach(() => { + mockAgentCard = { + protocolVersion: '0.3.0', + name: 'Test Agent', + description: 'An agent for testing purposes', + url: 'http://localhost:8080', + preferredTransport: 'JSONRPC', + version: '1.0.0', + capabilities: { + streaming: true, + pushNotifications: true, + }, + defaultInputModes: ['text/plain'], + defaultOutputModes: ['text/plain'], + skills: [], + }; + }); + + describe('canonicalizeAgentCard', () => { + it('should remove empty values and sort keys recursively (JCS)', () => { + const input: AgentCard = { + name: 'Example Agent', + description: '', + capabilities: { + streaming: false, + pushNotifications: false, + extensions: [], + }, + skills: [], + defaultInputModes: [], + defaultOutputModes: [], + protocolVersion: '', + url: '', + version: '1.0.0', + }; + + const expected = `{"capabilities":{"pushNotifications":false,"streaming":false},"name":"Example Agent","version":"1.0.0"}`; + const result = canonicalizeAgentCard(input); + expect(result).toBe(expected); + }); + }); + + describe('generateAgentCardSignature', () => { + it('should add a signature to the agent card', async () => { + const signer = generateAgentCardSignature(privateKey, { + alg: ALG, + kid: 'test-key-1', + typ: 'JOSE', + }); + + const signedCard = await signer(mockAgentCard); + + expect(signedCard.signatures).toBeDefined(); + expect(signedCard.signatures).toHaveLength(1); + + const sig = signedCard.signatures![0]; + expect(sig.protected).toBeDefined(); + expect(sig.signature).toBeDefined(); + + const decodedHeader = jose.decodeProtectedHeader(sig); + expect(decodedHeader.kid).toBe('test-key-1'); + expect(decodedHeader.alg).toBe(ALG); + }); + + it('should append signatures if one already exists', async () => { + const signer = generateAgentCardSignature(privateKey, { + alg: ALG, + kid: 'key-1', + typ: 'JOSE', + }); + + await signer(mockAgentCard); + await signer(mockAgentCard); + expect(mockAgentCard.signatures).toHaveLength(2); + }); + }); + + describe('verifyAgentCardSignature', () => { + const mockRetrieveKey = vi.fn(); + + beforeAll(() => { + mockRetrieveKey.mockImplementation(async (_kid: string) => { + return publicKey; + }); + }); + + it('should successfully verify a valid signature', async () => { + const signer = generateAgentCardSignature(privateKey, { + alg: ALG, + kid: 'test-key-1', + typ: 'JOSE', + }); + await signer(mockAgentCard); + + const verifier = verifyAgentCardSignature(mockRetrieveKey); + await expect(verifier(mockAgentCard)).resolves.not.toThrow(); + + expect(mockRetrieveKey).toHaveBeenCalledWith('test-key-1', undefined); + }); + + it('should fail if the payload has been tampered with', async () => { + const signer = generateAgentCardSignature(privateKey, { + alg: ALG, + kid: 'test-key-1', + typ: 'JOSE', + }); + await signer(mockAgentCard); + + mockAgentCard.name = 'Modified Agent Name'; + const verifier = verifyAgentCardSignature(mockRetrieveKey); + await expect(verifier(mockAgentCard)).rejects.toThrow(); + }); + + it('should fail if the signature is invalid/malformed', async () => { + const signer = generateAgentCardSignature(privateKey, { + alg: ALG, + kid: 'test-key-1', + typ: 'JOSE', + }); + await signer(mockAgentCard); + + mockAgentCard.signatures![0].signature = 'invalid_signature_string'; + const verifier = verifyAgentCardSignature(mockRetrieveKey); + await expect(verifier(mockAgentCard)).rejects.toThrow(); + }); + + it('should pass if at least one signature is valid (Multi-sig support)', async () => { + mockAgentCard.signatures = []; + mockAgentCard.signatures.push({ + protected: 'invalid value', + signature: 'invalid value', + }); + + const signer = generateAgentCardSignature(privateKey, { + alg: ALG, + kid: 'test-key-1', + typ: 'JOSE', + }); + await signer(mockAgentCard); + + const verifier = verifyAgentCardSignature(mockRetrieveKey); + await expect(verifier(mockAgentCard)).resolves.not.toThrow(); + }); + }); +});