From f9f2cbf85f775ff69d953bc3da57e62b5a1a04bc Mon Sep 17 00:00:00 2001 From: ak68a Date: Wed, 25 Mar 2026 07:26:53 -0500 Subject: [PATCH 1/2] test(ack-id): add comprehensive A2A module test suite Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/ack-id/src/a2a/random.test.ts | 33 ++ .../ack-id/src/a2a/service-endpoints.test.ts | 67 +++ packages/ack-id/src/a2a/sign-message.test.ts | 238 ++++++++++ packages/ack-id/src/a2a/verify.test.ts | 435 ++++++++++++++++++ 4 files changed, 773 insertions(+) create mode 100644 packages/ack-id/src/a2a/random.test.ts create mode 100644 packages/ack-id/src/a2a/service-endpoints.test.ts create mode 100644 packages/ack-id/src/a2a/sign-message.test.ts create mode 100644 packages/ack-id/src/a2a/verify.test.ts diff --git a/packages/ack-id/src/a2a/random.test.ts b/packages/ack-id/src/a2a/random.test.ts new file mode 100644 index 0000000..c8d9fa4 --- /dev/null +++ b/packages/ack-id/src/a2a/random.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest" + +import { generateRandomJti, generateRandomNonce } from "./random" + +describe("generateRandomJti", () => { + it("returns a valid UUID string", () => { + const jti = generateRandomJti() + expect(jti).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ) + }) + + it("returns a unique value on each call", () => { + const jti1 = generateRandomJti() + const jti2 = generateRandomJti() + expect(jti1).not.toBe(jti2) + }) +}) + +describe("generateRandomNonce", () => { + it("returns a valid UUID string", () => { + const nonce = generateRandomNonce() + expect(nonce).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ) + }) + + it("returns a unique value on each call", () => { + const nonce1 = generateRandomNonce() + const nonce2 = generateRandomNonce() + expect(nonce1).not.toBe(nonce2) + }) +}) diff --git a/packages/ack-id/src/a2a/service-endpoints.test.ts b/packages/ack-id/src/a2a/service-endpoints.test.ts new file mode 100644 index 0000000..e175b46 --- /dev/null +++ b/packages/ack-id/src/a2a/service-endpoints.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest" + +import { createAgentCardServiceEndpoint } from "./service-endpoints" + +describe("createAgentCardServiceEndpoint", () => { + it("creates a service endpoint with correct structure", () => { + const did = "did:web:example.com" + const agentCardUrl = "https://example.com/.well-known/agent.json" + + const endpoint = createAgentCardServiceEndpoint(did, agentCardUrl) + + expect(endpoint).toEqual({ + id: "did:web:example.com#agent-card", + type: "AgentCard", + serviceEndpoint: "https://example.com/.well-known/agent.json", + }) + }) + + it("appends #agent-card fragment to the DID", () => { + const did = "did:web:my-agent.example.com" + const agentCardUrl = "https://my-agent.example.com/agent-card" + + const endpoint = createAgentCardServiceEndpoint(did, agentCardUrl) + + expect(endpoint.id).toBe("did:web:my-agent.example.com#agent-card") + }) + + it("preserves the agent card URL as-is", () => { + const did = "did:web:example.com" + const agentCardUrl = + "https://example.com/agents/my-agent/.well-known/agent.json" + + const endpoint = createAgentCardServiceEndpoint(did, agentCardUrl) + + expect(endpoint.serviceEndpoint).toBe(agentCardUrl) + }) + + it("sets the type to AgentCard", () => { + const endpoint = createAgentCardServiceEndpoint( + "did:web:example.com", + "https://example.com/agent.json", + ) + + expect(endpoint.type).toBe("AgentCard") + }) + + it("handles DIDs with paths", () => { + const did = "did:web:example.com:agents:my-agent" + const agentCardUrl = "https://example.com/agents/my-agent/agent.json" + + const endpoint = createAgentCardServiceEndpoint(did, agentCardUrl) + + expect(endpoint.id).toBe( + "did:web:example.com:agents:my-agent#agent-card", + ) + }) + + it("handles empty strings", () => { + const endpoint = createAgentCardServiceEndpoint("", "") + + expect(endpoint).toEqual({ + id: "#agent-card", + type: "AgentCard", + serviceEndpoint: "", + }) + }) +}) diff --git a/packages/ack-id/src/a2a/sign-message.test.ts b/packages/ack-id/src/a2a/sign-message.test.ts new file mode 100644 index 0000000..5ad26b0 --- /dev/null +++ b/packages/ack-id/src/a2a/sign-message.test.ts @@ -0,0 +1,238 @@ +import type { DidUri } from "@agentcommercekit/did" +import { createJwt, createJwtSigner } from "@agentcommercekit/jwt" +import { generateKeypair } from "@agentcommercekit/keys" +import type { W3CCredential } from "@agentcommercekit/vc" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { + createA2AHandshakeMessage, + createA2AHandshakeMessageFromJwt, + createA2AHandshakePayload, + createSignedA2AMessage, +} from "./sign-message" + +// Mock uuid to return deterministic values +vi.mock("uuid", () => ({ + v4: vi.fn(() => "test-uuid-1234"), +})) + +// Mock random to return deterministic values +vi.mock("./random", async () => { + const actual = + await vi.importActual("./random") + return { + ...actual, + generateRandomJti: vi.fn(() => "test-jti-1234"), + generateRandomNonce: vi.fn(() => "test-nonce-1234"), + } +}) + +describe("createA2AHandshakePayload", () => { + const recipient = "did:web:recipient.example.com" as DidUri + const vc: W3CCredential = { + "@context": ["https://www.w3.org/2018/credentials/v1"], + type: ["VerifiableCredential", "ControllerCredential"], + issuer: { id: "did:web:issuer.example.com" }, + issuanceDate: new Date().toISOString(), + credentialSubject: { id: "did:web:subject.example.com" }, + } + + it("creates a payload with aud, nonce, and vc", () => { + const payload = createA2AHandshakePayload({ recipient, vc }) + + expect(payload.aud).toBe(recipient) + expect(payload.nonce).toBe("test-nonce-1234") + expect(payload.vc).toBe(vc) + }) + + it("creates a payload without replyNonce when no requestNonce is provided", () => { + const payload = createA2AHandshakePayload({ recipient, vc }) + + expect(payload).toHaveProperty("nonce") + expect(payload).not.toHaveProperty("replyNonce") + }) + + it("includes replyNonce when requestNonce is provided", () => { + const payload = createA2AHandshakePayload({ + recipient, + vc, + requestNonce: "original-nonce", + }) + + expect(payload.nonce).toBe("original-nonce") + expect(payload).toHaveProperty("replyNonce", "test-nonce-1234") + }) +}) + +describe("createA2AHandshakeMessageFromJwt", () => { + it("creates a message with agent role", () => { + const jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.test.sig" + const message = createA2AHandshakeMessageFromJwt("agent", jwt) + + expect(message).toEqual({ + kind: "message", + messageId: "test-uuid-1234", + role: "agent", + parts: [ + { + kind: "data", + data: { jwt }, + }, + ], + }) + }) + + it("creates a message with user role", () => { + const jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.test.sig" + const message = createA2AHandshakeMessageFromJwt("user", jwt) + + expect(message.role).toBe("user") + expect(message.kind).toBe("message") + }) + + it("places the JWT in a data part", () => { + const jwt = "some-jwt-token" + const message = createA2AHandshakeMessageFromJwt("agent", jwt) + + expect(message.parts).toHaveLength(1) + expect(message.parts[0]).toEqual({ + kind: "data", + data: { jwt }, + }) + }) +}) + +describe("createSignedA2AMessage", () => { + let signOptions: { + did: DidUri + jwtSigner: ReturnType + } + + beforeEach(async () => { + const keypair = await generateKeypair("secp256k1") + signOptions = { + did: "did:web:signer.example.com" as DidUri, + jwtSigner: createJwtSigner(keypair), + } + }) + + it("signs a message and returns sig, jti, and signed message", async () => { + const message = { + kind: "message" as const, + messageId: "msg-1", + role: "user" as const, + parts: [{ kind: "text" as const, text: "hello" }], + } + + const result = await createSignedA2AMessage(message, signOptions) + + expect(result.sig).toBeDefined() + expect(typeof result.sig).toBe("string") + expect(result.jti).toBe("test-jti-1234") + expect(result.message.metadata).toBeDefined() + expect(result.message.metadata?.sig).toBe(result.sig) + }) + + it("preserves original message parts in the signed message", async () => { + const message = { + kind: "message" as const, + messageId: "msg-1", + role: "agent" as const, + parts: [{ kind: "text" as const, text: "response" }], + } + + const result = await createSignedA2AMessage(message, signOptions) + + expect(result.message.kind).toBe("message") + expect(result.message.messageId).toBe("msg-1") + expect(result.message.role).toBe("agent") + expect(result.message.parts).toEqual([ + { kind: "text", text: "response" }, + ]) + }) + + it("merges sig into existing metadata", async () => { + const message = { + kind: "message" as const, + messageId: "msg-1", + role: "user" as const, + parts: [{ kind: "text" as const, text: "hello" }], + metadata: { customField: "value" }, + } + + const result = await createSignedA2AMessage(message, signOptions) + + expect(result.message.metadata?.sig).toBe(result.sig) + expect(result.message.metadata?.customField).toBe("value") + }) +}) + +describe("createA2AHandshakeMessage", () => { + let signOptions: { + did: DidUri + jwtSigner: ReturnType + } + + const vc: W3CCredential = { + "@context": ["https://www.w3.org/2018/credentials/v1"], + type: ["VerifiableCredential", "ControllerCredential"], + issuer: { id: "did:web:issuer.example.com" }, + issuanceDate: new Date().toISOString(), + credentialSubject: { id: "did:web:subject.example.com" }, + } + + beforeEach(async () => { + const keypair = await generateKeypair("secp256k1") + signOptions = { + did: "did:web:signer.example.com" as DidUri, + jwtSigner: createJwtSigner(keypair), + } + }) + + it("creates a signed handshake message with agent role", async () => { + const result = await createA2AHandshakeMessage( + "agent", + { + recipient: "did:web:recipient.example.com" as DidUri, + vc, + }, + signOptions, + ) + + expect(result.sig).toBeDefined() + expect(typeof result.sig).toBe("string") + expect(result.jti).toBe("test-jti-1234") + expect(result.nonce).toBe("test-nonce-1234") + expect(result.message.role).toBe("agent") + expect(result.message.kind).toBe("message") + expect(result.message.parts).toHaveLength(1) + expect(result.message.parts[0].kind).toBe("data") + }) + + it("creates a signed handshake message with user role", async () => { + const result = await createA2AHandshakeMessage( + "user", + { + recipient: "did:web:recipient.example.com" as DidUri, + vc, + }, + signOptions, + ) + + expect(result.message.role).toBe("user") + }) + + it("includes the JWT in the message data part", async () => { + const result = await createA2AHandshakeMessage( + "agent", + { + recipient: "did:web:recipient.example.com" as DidUri, + vc, + }, + signOptions, + ) + + const dataPart = result.message.parts[0] as { kind: string; data: { jwt: string } } + expect(dataPart.data.jwt).toBe(result.sig) + }) +}) diff --git a/packages/ack-id/src/a2a/verify.test.ts b/packages/ack-id/src/a2a/verify.test.ts new file mode 100644 index 0000000..99123a1 --- /dev/null +++ b/packages/ack-id/src/a2a/verify.test.ts @@ -0,0 +1,435 @@ +import type { DidUri } from "@agentcommercekit/did" +import type { JwtVerified } from "@agentcommercekit/jwt" +import { describe, expect, it, vi } from "vitest" + +import { verifyA2AHandshakeMessage, verifyA2ASignedMessage } from "./verify" + +// Mock verifyJwt +vi.mock("@agentcommercekit/jwt", async () => { + const actual = + await vi.importActual( + "@agentcommercekit/jwt", + ) + return { + ...actual, + verifyJwt: vi.fn(), + } +}) + +// Mock getDidResolver +vi.mock("@agentcommercekit/did", async () => { + const actual = + await vi.importActual( + "@agentcommercekit/did", + ) + return { + ...actual, + getDidResolver: vi.fn(() => ({})), + } +}) + +// Import mocked modules +const { verifyJwt } = await import("@agentcommercekit/jwt") + +function createMockJwtVerified( + payload: Record, +): JwtVerified { + return { + verified: true, + payload: { + iss: "did:web:issuer.example.com", + ...payload, + }, + didResolutionResult: { + didResolutionMetadata: {}, + didDocument: null, + didDocumentMetadata: {}, + }, + issuer: "did:web:issuer.example.com", + signer: { + id: "did:web:issuer.example.com#key-1", + type: "Multikey", + controller: "did:web:issuer.example.com", + publicKeyHex: "02...", + }, + jwt: "mock.jwt.token", + } +} + +describe("verifyA2AHandshakeMessage", () => { + const did = "did:web:agent.example.com" as DidUri + const counterparty = "did:web:user.example.com" as DidUri + + it("verifies a valid handshake message", async () => { + const vc = { + "@context": ["https://www.w3.org/2018/credentials/v1"], + type: ["VerifiableCredential", "ControllerCredential"], + issuer: { id: "did:web:issuer.example.com" }, + issuanceDate: "2025-01-01T00:00:00.000Z", + credentialSubject: { id: "did:web:subject.example.com" }, + } + + vi.mocked(verifyJwt).mockResolvedValueOnce( + createMockJwtVerified({ + iss: counterparty, + nonce: "test-nonce", + vc, + }), + ) + + const message = { + kind: "message" as const, + messageId: "msg-1", + role: "user" as const, + parts: [ + { + kind: "data" as const, + data: { jwt: "valid.jwt.token" }, + }, + ], + } + + const result = await verifyA2AHandshakeMessage(message, { + did, + counterparty, + }) + + expect(result.iss).toBe(counterparty) + expect(result.nonce).toBe("test-nonce") + expect(result.vc).toEqual(vc) + }) + + it("throws when message is null", async () => { + await expect( + verifyA2AHandshakeMessage(null, { did }), + ).rejects.toThrow() + }) + + it("throws when message has no parts", async () => { + const message = { + kind: "message" as const, + messageId: "msg-1", + role: "user" as const, + parts: [], + } + + await expect( + verifyA2AHandshakeMessage(message, { did }), + ).rejects.toThrow() + }) + + it("throws when message part is not a data part", async () => { + const message = { + kind: "message" as const, + messageId: "msg-1", + role: "user" as const, + parts: [{ kind: "text" as const, text: "not a data part" }], + } + + await expect( + verifyA2AHandshakeMessage(message, { did }), + ).rejects.toThrow() + }) + + it("throws when data part has no jwt field", async () => { + const message = { + kind: "message" as const, + messageId: "msg-1", + role: "user" as const, + parts: [ + { + kind: "data" as const, + data: { notJwt: "something" }, + }, + ], + } + + await expect( + verifyA2AHandshakeMessage(message, { did }), + ).rejects.toThrow() + }) + + it("throws when JWT verification fails", async () => { + vi.mocked(verifyJwt).mockRejectedValueOnce( + new Error("JWT verification failed"), + ) + + const message = { + kind: "message" as const, + messageId: "msg-1", + role: "user" as const, + parts: [ + { + kind: "data" as const, + data: { jwt: "invalid.jwt.token" }, + }, + ], + } + + await expect( + verifyA2AHandshakeMessage(message, { did }), + ).rejects.toThrow("JWT verification failed") + }) + + it("throws when payload is missing required fields", async () => { + vi.mocked(verifyJwt).mockResolvedValueOnce( + createMockJwtVerified({ + iss: counterparty, + // missing nonce and vc + }), + ) + + const message = { + kind: "message" as const, + messageId: "msg-1", + role: "user" as const, + parts: [ + { + kind: "data" as const, + data: { jwt: "valid.jwt.token" }, + }, + ], + } + + await expect( + verifyA2AHandshakeMessage(message, { did, counterparty }), + ).rejects.toThrow() + }) + + it("throws when iss is not a valid DID URI", async () => { + vi.mocked(verifyJwt).mockResolvedValueOnce( + createMockJwtVerified({ + iss: "not-a-did", + nonce: "test-nonce", + vc: { + "@context": ["https://www.w3.org/2018/credentials/v1"], + type: ["VerifiableCredential"], + issuer: { id: "did:web:issuer.example.com" }, + issuanceDate: "2025-01-01T00:00:00.000Z", + credentialSubject: { id: "did:web:subject.example.com" }, + }, + }), + ) + + const message = { + kind: "message" as const, + messageId: "msg-1", + role: "user" as const, + parts: [ + { + kind: "data" as const, + data: { jwt: "valid.jwt.token" }, + }, + ], + } + + await expect( + verifyA2AHandshakeMessage(message, { did }), + ).rejects.toThrow() + }) + + it("passes counterparty as issuer to verifyJwt", async () => { + const vc = { + "@context": ["https://www.w3.org/2018/credentials/v1"], + type: ["VerifiableCredential", "ControllerCredential"], + issuer: { id: "did:web:issuer.example.com" }, + issuanceDate: "2025-01-01T00:00:00.000Z", + credentialSubject: { id: "did:web:subject.example.com" }, + } + + vi.mocked(verifyJwt).mockResolvedValueOnce( + createMockJwtVerified({ + iss: counterparty, + nonce: "test-nonce", + vc, + }), + ) + + const message = { + kind: "message" as const, + messageId: "msg-1", + role: "user" as const, + parts: [ + { + kind: "data" as const, + data: { jwt: "valid.jwt.token" }, + }, + ], + } + + await verifyA2AHandshakeMessage(message, { did, counterparty }) + + expect(verifyJwt).toHaveBeenCalledWith("valid.jwt.token", { + audience: did, + issuer: counterparty, + resolver: expect.anything(), + }) + }) +}) + +describe("verifyA2ASignedMessage", () => { + const did = "did:web:agent.example.com" as DidUri + const counterparty = "did:web:user.example.com" as DidUri + + it("verifies a valid signed message", async () => { + const messageContent = { + kind: "message", + messageId: "msg-1", + role: "user", + parts: [{ kind: "text", text: "hello" }], + } + + vi.mocked(verifyJwt).mockResolvedValueOnce( + createMockJwtVerified({ + message: messageContent, + }), + ) + + const message = { + kind: "message" as const, + messageId: "msg-1", + role: "user" as const, + parts: [{ kind: "text" as const, text: "hello" }], + metadata: { sig: "valid.jwt.signature" }, + } + + const result = await verifyA2ASignedMessage(message, { + did, + counterparty, + }) + + expect(result.verified).toBe(true) + }) + + it("throws when message has no metadata", async () => { + const message = { + kind: "message" as const, + messageId: "msg-1", + role: "user" as const, + parts: [{ kind: "text" as const, text: "hello" }], + } + + await expect( + verifyA2ASignedMessage(message, { did }), + ).rejects.toThrow() + }) + + it("throws when metadata has no sig field", async () => { + const message = { + kind: "message" as const, + messageId: "msg-1", + role: "user" as const, + parts: [{ kind: "text" as const, text: "hello" }], + metadata: { other: "data" }, + } + + await expect( + verifyA2ASignedMessage(message, { did }), + ).rejects.toThrow() + }) + + it("throws when message parts do not match signature payload", async () => { + vi.mocked(verifyJwt).mockResolvedValueOnce( + createMockJwtVerified({ + message: { + kind: "message", + messageId: "msg-1", + role: "user", + parts: [{ kind: "text", text: "different content" }], + }, + }), + ) + + const message = { + kind: "message" as const, + messageId: "msg-1", + role: "user" as const, + parts: [{ kind: "text" as const, text: "hello" }], + metadata: { sig: "valid.jwt.signature" }, + } + + await expect( + verifyA2ASignedMessage(message, { did, counterparty }), + ).rejects.toThrow("Message parts do not match") + }) + + it("throws when JWT verification fails", async () => { + vi.mocked(verifyJwt).mockRejectedValueOnce( + new Error("Signature invalid"), + ) + + const message = { + kind: "message" as const, + messageId: "msg-1", + role: "user" as const, + parts: [{ kind: "text" as const, text: "hello" }], + metadata: { sig: "bad.jwt.signature" }, + } + + await expect( + verifyA2ASignedMessage(message, { did }), + ).rejects.toThrow("Signature invalid") + }) + + it("strips contextId before comparing message content", async () => { + const messageContent = { + kind: "message", + messageId: "msg-1", + role: "user", + parts: [{ kind: "text", text: "hello" }], + } + + vi.mocked(verifyJwt).mockResolvedValueOnce( + createMockJwtVerified({ + message: messageContent, + }), + ) + + const message = { + kind: "message" as const, + messageId: "msg-1", + role: "user" as const, + parts: [{ kind: "text" as const, text: "hello" }], + contextId: "ctx-auto-generated", + metadata: { sig: "valid.jwt.signature" }, + } + + const result = await verifyA2ASignedMessage(message, { + did, + counterparty, + }) + + expect(result.verified).toBe(true) + }) + + it("passes counterparty as issuer to verifyJwt", async () => { + const messageContent = { + kind: "message", + messageId: "msg-1", + role: "user", + parts: [{ kind: "text", text: "hello" }], + } + + vi.mocked(verifyJwt).mockResolvedValueOnce( + createMockJwtVerified({ + message: messageContent, + }), + ) + + const message = { + kind: "message" as const, + messageId: "msg-1", + role: "user" as const, + parts: [{ kind: "text" as const, text: "hello" }], + metadata: { sig: "valid.jwt.signature" }, + } + + await verifyA2ASignedMessage(message, { did, counterparty }) + + expect(verifyJwt).toHaveBeenCalledWith("valid.jwt.signature", { + audience: did, + issuer: counterparty, + resolver: expect.anything(), + }) + }) +}) From dd65b30efd12cc8af4e00c1f503d18ea91a95596 Mon Sep 17 00:00:00 2001 From: ak68a Date: Wed, 25 Mar 2026 08:17:49 -0500 Subject: [PATCH 2/2] refactor(ack-id): extract shared test fixtures and tighten A2A tests - Add test-fixtures.ts with shared identity constants, message builders, and mock factories - Replace inline message construction with typed builder functions - Use it.each for schema rejection cases - Derive expected signed payload from signedMessage() to prevent fixture drift - Add unsignedMessage() builder for explicit unsigned test cases - Add comments explaining why (contextId stripping, schema-first validation, nonce correlation) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/ack-id/src/a2a/random.test.ts | 29 +- .../ack-id/src/a2a/service-endpoints.test.ts | 58 +-- packages/ack-id/src/a2a/sign-message.test.ts | 208 +++----- packages/ack-id/src/a2a/test-fixtures.ts | 101 ++++ packages/ack-id/src/a2a/verify.test.ts | 466 ++++++------------ 5 files changed, 323 insertions(+), 539 deletions(-) create mode 100644 packages/ack-id/src/a2a/test-fixtures.ts diff --git a/packages/ack-id/src/a2a/random.test.ts b/packages/ack-id/src/a2a/random.test.ts index c8d9fa4..121058d 100644 --- a/packages/ack-id/src/a2a/random.test.ts +++ b/packages/ack-id/src/a2a/random.test.ts @@ -2,32 +2,25 @@ import { describe, expect, it } from "vitest" import { generateRandomJti, generateRandomNonce } from "./random" +const uuidPattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ + describe("generateRandomJti", () => { - it("returns a valid UUID string", () => { - const jti = generateRandomJti() - expect(jti).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, - ) + it("returns a UUID", () => { + expect(generateRandomJti()).toMatch(uuidPattern) }) - it("returns a unique value on each call", () => { - const jti1 = generateRandomJti() - const jti2 = generateRandomJti() - expect(jti1).not.toBe(jti2) + it("returns unique values", () => { + expect(generateRandomJti()).not.toBe(generateRandomJti()) }) }) describe("generateRandomNonce", () => { - it("returns a valid UUID string", () => { - const nonce = generateRandomNonce() - expect(nonce).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, - ) + it("returns a UUID", () => { + expect(generateRandomNonce()).toMatch(uuidPattern) }) - it("returns a unique value on each call", () => { - const nonce1 = generateRandomNonce() - const nonce2 = generateRandomNonce() - expect(nonce1).not.toBe(nonce2) + it("returns unique values", () => { + expect(generateRandomNonce()).not.toBe(generateRandomNonce()) }) }) diff --git a/packages/ack-id/src/a2a/service-endpoints.test.ts b/packages/ack-id/src/a2a/service-endpoints.test.ts index e175b46..0db68b4 100644 --- a/packages/ack-id/src/a2a/service-endpoints.test.ts +++ b/packages/ack-id/src/a2a/service-endpoints.test.ts @@ -3,11 +3,11 @@ import { describe, expect, it } from "vitest" import { createAgentCardServiceEndpoint } from "./service-endpoints" describe("createAgentCardServiceEndpoint", () => { - it("creates a service endpoint with correct structure", () => { - const did = "did:web:example.com" - const agentCardUrl = "https://example.com/.well-known/agent.json" - - const endpoint = createAgentCardServiceEndpoint(did, agentCardUrl) + it("creates a service endpoint linking a DID to its agent card URL", () => { + const endpoint = createAgentCardServiceEndpoint( + "did:web:example.com", + "https://example.com/.well-known/agent.json", + ) expect(endpoint).toEqual({ id: "did:web:example.com#agent-card", @@ -16,52 +16,12 @@ describe("createAgentCardServiceEndpoint", () => { }) }) - it("appends #agent-card fragment to the DID", () => { - const did = "did:web:my-agent.example.com" - const agentCardUrl = "https://my-agent.example.com/agent-card" - - const endpoint = createAgentCardServiceEndpoint(did, agentCardUrl) - - expect(endpoint.id).toBe("did:web:my-agent.example.com#agent-card") - }) - - it("preserves the agent card URL as-is", () => { - const did = "did:web:example.com" - const agentCardUrl = - "https://example.com/agents/my-agent/.well-known/agent.json" - - const endpoint = createAgentCardServiceEndpoint(did, agentCardUrl) - - expect(endpoint.serviceEndpoint).toBe(agentCardUrl) - }) - - it("sets the type to AgentCard", () => { + it("handles DIDs with colon-separated path components", () => { const endpoint = createAgentCardServiceEndpoint( - "did:web:example.com", - "https://example.com/agent.json", - ) - - expect(endpoint.type).toBe("AgentCard") - }) - - it("handles DIDs with paths", () => { - const did = "did:web:example.com:agents:my-agent" - const agentCardUrl = "https://example.com/agents/my-agent/agent.json" - - const endpoint = createAgentCardServiceEndpoint(did, agentCardUrl) - - expect(endpoint.id).toBe( - "did:web:example.com:agents:my-agent#agent-card", + "did:web:example.com:agents:my-agent", + "https://example.com/agents/my-agent/agent.json", ) - }) - - it("handles empty strings", () => { - const endpoint = createAgentCardServiceEndpoint("", "") - expect(endpoint).toEqual({ - id: "#agent-card", - type: "AgentCard", - serviceEndpoint: "", - }) + expect(endpoint.id).toBe("did:web:example.com:agents:my-agent#agent-card") }) }) diff --git a/packages/ack-id/src/a2a/sign-message.test.ts b/packages/ack-id/src/a2a/sign-message.test.ts index 5ad26b0..582eb65 100644 --- a/packages/ack-id/src/a2a/sign-message.test.ts +++ b/packages/ack-id/src/a2a/sign-message.test.ts @@ -1,7 +1,6 @@ -import type { DidUri } from "@agentcommercekit/did" -import { createJwt, createJwtSigner } from "@agentcommercekit/jwt" +import type { JwtSigner } from "@agentcommercekit/jwt" +import { createJwtSigner } from "@agentcommercekit/jwt" import { generateKeypair } from "@agentcommercekit/keys" -import type { W3CCredential } from "@agentcommercekit/vc" import { beforeEach, describe, expect, it, vi } from "vitest" import { @@ -10,16 +9,19 @@ import { createA2AHandshakePayload, createSignedA2AMessage, } from "./sign-message" +import { + agentDid, + makeTextMessage, + testCredential, + userDid, +} from "./test-fixtures" -// Mock uuid to return deterministic values vi.mock("uuid", () => ({ v4: vi.fn(() => "test-uuid-1234"), })) -// Mock random to return deterministic values vi.mock("./random", async () => { - const actual = - await vi.importActual("./random") + const actual = await vi.importActual("./random") return { ...actual, generateRandomJti: vi.fn(() => "test-jti-1234"), @@ -28,211 +30,123 @@ vi.mock("./random", async () => { }) describe("createA2AHandshakePayload", () => { - const recipient = "did:web:recipient.example.com" as DidUri - const vc: W3CCredential = { - "@context": ["https://www.w3.org/2018/credentials/v1"], - type: ["VerifiableCredential", "ControllerCredential"], - issuer: { id: "did:web:issuer.example.com" }, - issuanceDate: new Date().toISOString(), - credentialSubject: { id: "did:web:subject.example.com" }, - } - - it("creates a payload with aud, nonce, and vc", () => { - const payload = createA2AHandshakePayload({ recipient, vc }) + it("creates a payload addressed to the recipient with a fresh nonce", () => { + const payload = createA2AHandshakePayload({ + recipient: userDid, + vc: testCredential, + }) - expect(payload.aud).toBe(recipient) + expect(payload.aud).toBe(userDid) expect(payload.nonce).toBe("test-nonce-1234") - expect(payload.vc).toBe(vc) - }) - - it("creates a payload without replyNonce when no requestNonce is provided", () => { - const payload = createA2AHandshakePayload({ recipient, vc }) - - expect(payload).toHaveProperty("nonce") + expect(payload.vc).toBe(testCredential) expect(payload).not.toHaveProperty("replyNonce") }) - it("includes replyNonce when requestNonce is provided", () => { + it("echoes the request nonce and generates a new reply nonce for responses", () => { const payload = createA2AHandshakePayload({ - recipient, - vc, + recipient: userDid, + vc: testCredential, requestNonce: "original-nonce", }) + // The initiator's nonce becomes ours so they can correlate the reply expect(payload.nonce).toBe("original-nonce") - expect(payload).toHaveProperty("replyNonce", "test-nonce-1234") + // We generate a fresh nonce for the next leg of the handshake + expect(payload.replyNonce).toBe("test-nonce-1234") }) }) describe("createA2AHandshakeMessageFromJwt", () => { - it("creates a message with agent role", () => { + it("wraps a JWT in an A2A data-part message", () => { const jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.test.sig" - const message = createA2AHandshakeMessageFromJwt("agent", jwt) - expect(message).toEqual({ + expect(createA2AHandshakeMessageFromJwt("agent", jwt)).toEqual({ kind: "message", messageId: "test-uuid-1234", role: "agent", - parts: [ - { - kind: "data", - data: { jwt }, - }, - ], + parts: [{ kind: "data", data: { jwt } }], }) }) - it("creates a message with user role", () => { - const jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.test.sig" - const message = createA2AHandshakeMessageFromJwt("user", jwt) - + it("respects the role parameter", () => { + const message = createA2AHandshakeMessageFromJwt("user", "any.jwt") expect(message.role).toBe("user") - expect(message.kind).toBe("message") - }) - - it("places the JWT in a data part", () => { - const jwt = "some-jwt-token" - const message = createA2AHandshakeMessageFromJwt("agent", jwt) - - expect(message.parts).toHaveLength(1) - expect(message.parts[0]).toEqual({ - kind: "data", - data: { jwt }, - }) }) }) describe("createSignedA2AMessage", () => { - let signOptions: { - did: DidUri - jwtSigner: ReturnType - } + let jwtSigner: JwtSigner beforeEach(async () => { const keypair = await generateKeypair("secp256k1") - signOptions = { - did: "did:web:signer.example.com" as DidUri, - jwtSigner: createJwtSigner(keypair), - } + jwtSigner = createJwtSigner(keypair) }) - it("signs a message and returns sig, jti, and signed message", async () => { - const message = { - kind: "message" as const, - messageId: "msg-1", - role: "user" as const, - parts: [{ kind: "text" as const, text: "hello" }], - } - - const result = await createSignedA2AMessage(message, signOptions) + it("produces a JWT signature and attaches it to message metadata", async () => { + const result = await createSignedA2AMessage(makeTextMessage(), { + did: agentDid, + jwtSigner, + }) - expect(result.sig).toBeDefined() - expect(typeof result.sig).toBe("string") + expect(result.sig).toEqual(expect.any(String)) expect(result.jti).toBe("test-jti-1234") - expect(result.message.metadata).toBeDefined() expect(result.message.metadata?.sig).toBe(result.sig) }) - it("preserves original message parts in the signed message", async () => { - const message = { - kind: "message" as const, - messageId: "msg-1", - role: "agent" as const, - parts: [{ kind: "text" as const, text: "response" }], - } - - const result = await createSignedA2AMessage(message, signOptions) + it("preserves original message content alongside the signature", async () => { + const original = makeTextMessage("agent") + const result = await createSignedA2AMessage(original, { + did: agentDid, + jwtSigner, + }) expect(result.message.kind).toBe("message") - expect(result.message.messageId).toBe("msg-1") expect(result.message.role).toBe("agent") - expect(result.message.parts).toEqual([ - { kind: "text", text: "response" }, - ]) + expect(result.message.parts).toEqual(original.parts) }) - it("merges sig into existing metadata", async () => { - const message = { - kind: "message" as const, - messageId: "msg-1", - role: "user" as const, - parts: [{ kind: "text" as const, text: "hello" }], - metadata: { customField: "value" }, - } - - const result = await createSignedA2AMessage(message, signOptions) + it("merges the signature into existing metadata without clobbering", async () => { + const message = makeTextMessage("user", { traceId: "abc" }) + const result = await createSignedA2AMessage(message, { + did: agentDid, + jwtSigner, + }) expect(result.message.metadata?.sig).toBe(result.sig) - expect(result.message.metadata?.customField).toBe("value") + expect(result.message.metadata?.traceId).toBe("abc") }) }) describe("createA2AHandshakeMessage", () => { - let signOptions: { - did: DidUri - jwtSigner: ReturnType - } - - const vc: W3CCredential = { - "@context": ["https://www.w3.org/2018/credentials/v1"], - type: ["VerifiableCredential", "ControllerCredential"], - issuer: { id: "did:web:issuer.example.com" }, - issuanceDate: new Date().toISOString(), - credentialSubject: { id: "did:web:subject.example.com" }, - } + let jwtSigner: JwtSigner beforeEach(async () => { const keypair = await generateKeypair("secp256k1") - signOptions = { - did: "did:web:signer.example.com" as DidUri, - jwtSigner: createJwtSigner(keypair), - } + jwtSigner = createJwtSigner(keypair) }) - it("creates a signed handshake message with agent role", async () => { + it("signs a credential handshake and returns the nonce for correlation", async () => { const result = await createA2AHandshakeMessage( "agent", - { - recipient: "did:web:recipient.example.com" as DidUri, - vc, - }, - signOptions, + { recipient: userDid, vc: testCredential }, + { did: agentDid, jwtSigner }, ) - expect(result.sig).toBeDefined() - expect(typeof result.sig).toBe("string") + expect(result.sig).toEqual(expect.any(String)) expect(result.jti).toBe("test-jti-1234") expect(result.nonce).toBe("test-nonce-1234") expect(result.message.role).toBe("agent") - expect(result.message.kind).toBe("message") - expect(result.message.parts).toHaveLength(1) - expect(result.message.parts[0].kind).toBe("data") }) - it("creates a signed handshake message with user role", async () => { - const result = await createA2AHandshakeMessage( - "user", - { - recipient: "did:web:recipient.example.com" as DidUri, - vc, - }, - signOptions, - ) - - expect(result.message.role).toBe("user") - }) - - it("includes the JWT in the message data part", async () => { + it("embeds the signed JWT in the message data part", async () => { const result = await createA2AHandshakeMessage( "agent", - { - recipient: "did:web:recipient.example.com" as DidUri, - vc, - }, - signOptions, + { recipient: userDid, vc: testCredential }, + { did: agentDid, jwtSigner }, ) - const dataPart = result.message.parts[0] as { kind: string; data: { jwt: string } } - expect(dataPart.data.jwt).toBe(result.sig) + expect(result.message.parts[0]).toEqual( + expect.objectContaining({ kind: "data", data: { jwt: result.sig } }), + ) }) }) diff --git a/packages/ack-id/src/a2a/test-fixtures.ts b/packages/ack-id/src/a2a/test-fixtures.ts new file mode 100644 index 0000000..2b88bc3 --- /dev/null +++ b/packages/ack-id/src/a2a/test-fixtures.ts @@ -0,0 +1,101 @@ +/** + * Shared fixtures for A2A test suites. + * + * Provides identity constants, message builders, and mock factories so + * individual test files can focus on behavior rather than setup. + */ +import type { DidUri } from "@agentcommercekit/did" +import type { JwtVerified } from "@agentcommercekit/jwt" +import type { W3CCredential } from "@agentcommercekit/vc" + +// --- Identity constants --- + +export const agentDid = "did:web:agent.example.com" as DidUri +export const userDid = "did:web:user.example.com" as DidUri + +export const testCredential: W3CCredential = { + "@context": ["https://www.w3.org/2018/credentials/v1"], + type: ["VerifiableCredential", "ControllerCredential"], + issuer: { id: "did:web:issuer.example.com" }, + issuanceDate: "2025-01-01T00:00:00.000Z", + credentialSubject: { id: "did:web:subject.example.com" }, +} + +// --- Message builders --- + +/** A text message, optionally with a specific role or pre-existing metadata. */ +export function makeTextMessage( + role: "agent" | "user" = "user", + metadata?: Record, +) { + return { + kind: "message" as const, + messageId: "msg-1", + role, + parts: [{ kind: "text" as const, text: "hello" }], + ...(metadata && { metadata }), + } +} + +/** A handshake message carrying a JWT in its data part. */ +export function handshakeMessage(jwt = "valid.jwt.token") { + return { + kind: "message" as const, + messageId: "msg-1", + role: "user" as const, + parts: [{ kind: "data" as const, data: { jwt } }], + } +} + +/** A signed message with text content and a signature in metadata. */ +export function signedMessage(text = "hello", sig = "valid.jwt.signature") { + return { + kind: "message" as const, + messageId: "msg-1", + role: "user" as const, + parts: [{ kind: "text" as const, text }], + metadata: { sig }, + } +} + +/** A message with no signature — for testing rejection of unsigned input. */ +export function unsignedMessage(text = "hello") { + return { + kind: "message" as const, + messageId: "msg-1", + role: "user" as const, + parts: [{ kind: "text" as const, text }], + } +} + +/** + * The expected JWT payload for a signed message with the given text. + * Derives from the same shape as signedMessage() so they can't drift apart. + */ +export function expectedSignedPayload(text = "hello") { + const { metadata: _, ...content } = signedMessage(text) + return { message: content } +} + +// --- Mock factories --- + +/** Builds a JwtVerified result with sensible defaults, overriding only the payload. */ +export function mockVerifiedJwt(payload: Record): JwtVerified { + return { + verified: true, + payload: { iss: "did:web:issuer.example.com", ...payload }, + didResolutionResult: { + didResolutionMetadata: {}, + didDocument: null, + didDocumentMetadata: {}, + }, + issuer: "did:web:issuer.example.com", + signer: { + id: "did:web:issuer.example.com#key-1", + type: "Multikey", + controller: "did:web:issuer.example.com", + publicKeyHex: "02...", + }, + jwt: "mock.jwt.token", + } +} diff --git a/packages/ack-id/src/a2a/verify.test.ts b/packages/ack-id/src/a2a/verify.test.ts index 99123a1..0202d40 100644 --- a/packages/ack-id/src/a2a/verify.test.ts +++ b/packages/ack-id/src/a2a/verify.test.ts @@ -1,435 +1,251 @@ -import type { DidUri } from "@agentcommercekit/did" -import type { JwtVerified } from "@agentcommercekit/jwt" import { describe, expect, it, vi } from "vitest" +import { + agentDid, + expectedSignedPayload, + handshakeMessage, + mockVerifiedJwt, + signedMessage, + testCredential, + unsignedMessage, + userDid, +} from "./test-fixtures" import { verifyA2AHandshakeMessage, verifyA2ASignedMessage } from "./verify" -// Mock verifyJwt vi.mock("@agentcommercekit/jwt", async () => { - const actual = - await vi.importActual( - "@agentcommercekit/jwt", - ) + const actual = await vi.importActual( + "@agentcommercekit/jwt", + ) return { ...actual, verifyJwt: vi.fn(), } }) -// Mock getDidResolver vi.mock("@agentcommercekit/did", async () => { - const actual = - await vi.importActual( - "@agentcommercekit/did", - ) + const actual = await vi.importActual( + "@agentcommercekit/did", + ) return { ...actual, getDidResolver: vi.fn(() => ({})), } }) -// Import mocked modules const { verifyJwt } = await import("@agentcommercekit/jwt") -function createMockJwtVerified( - payload: Record, -): JwtVerified { - return { - verified: true, - payload: { - iss: "did:web:issuer.example.com", - ...payload, - }, - didResolutionResult: { - didResolutionMetadata: {}, - didDocument: null, - didDocumentMetadata: {}, - }, - issuer: "did:web:issuer.example.com", - signer: { - id: "did:web:issuer.example.com#key-1", - type: "Multikey", - controller: "did:web:issuer.example.com", - publicKeyHex: "02...", - }, - jwt: "mock.jwt.token", - } -} +// --- Handshake verification --- describe("verifyA2AHandshakeMessage", () => { - const did = "did:web:agent.example.com" as DidUri - const counterparty = "did:web:user.example.com" as DidUri - - it("verifies a valid handshake message", async () => { - const vc = { - "@context": ["https://www.w3.org/2018/credentials/v1"], - type: ["VerifiableCredential", "ControllerCredential"], - issuer: { id: "did:web:issuer.example.com" }, - issuanceDate: "2025-01-01T00:00:00.000Z", - credentialSubject: { id: "did:web:subject.example.com" }, - } - + it("extracts issuer, nonce, and credential from a valid handshake", async () => { vi.mocked(verifyJwt).mockResolvedValueOnce( - createMockJwtVerified({ - iss: counterparty, - nonce: "test-nonce", - vc, + mockVerifiedJwt({ + iss: userDid, + nonce: "challenge-nonce", + vc: testCredential, }), ) - const message = { - kind: "message" as const, - messageId: "msg-1", - role: "user" as const, - parts: [ - { - kind: "data" as const, - data: { jwt: "valid.jwt.token" }, - }, - ], - } - - const result = await verifyA2AHandshakeMessage(message, { - did, - counterparty, + const result = await verifyA2AHandshakeMessage(handshakeMessage(), { + did: agentDid, + counterparty: userDid, }) - expect(result.iss).toBe(counterparty) - expect(result.nonce).toBe("test-nonce") - expect(result.vc).toEqual(vc) + expect(result.iss).toBe(userDid) + expect(result.nonce).toBe("challenge-nonce") + expect(result.vc).toEqual(testCredential) }) - it("throws when message is null", async () => { - await expect( - verifyA2AHandshakeMessage(null, { did }), - ).rejects.toThrow() - }) - - it("throws when message has no parts", async () => { - const message = { - kind: "message" as const, - messageId: "msg-1", - role: "user" as const, - parts: [], - } - - await expect( - verifyA2AHandshakeMessage(message, { did }), - ).rejects.toThrow() - }) + it("verifies the JWT with audience=self and issuer=counterparty", async () => { + vi.mocked(verifyJwt).mockResolvedValueOnce( + mockVerifiedJwt({ + iss: userDid, + nonce: "n", + vc: testCredential, + }), + ) - it("throws when message part is not a data part", async () => { - const message = { - kind: "message" as const, - messageId: "msg-1", - role: "user" as const, - parts: [{ kind: "text" as const, text: "not a data part" }], - } + await verifyA2AHandshakeMessage(handshakeMessage("the.jwt"), { + did: agentDid, + counterparty: userDid, + }) - await expect( - verifyA2AHandshakeMessage(message, { did }), - ).rejects.toThrow() + expect(verifyJwt).toHaveBeenCalledWith("the.jwt", { + audience: agentDid, + issuer: userDid, + resolver: expect.anything(), + }) }) - it("throws when data part has no jwt field", async () => { - const message = { - kind: "message" as const, - messageId: "msg-1", - role: "user" as const, - parts: [ - { - kind: "data" as const, - data: { notJwt: "something" }, - }, - ], - } - + // The message schema uses valibot's v.parse(), which rejects structurally + // invalid input before any JWT verification happens. This matters because + // a malformed message should fail fast, not trigger a network call to + // resolve a DID. + it.each([ + { name: "null message", message: null }, + { + name: "empty parts array", + message: { kind: "message", messageId: "m", role: "user", parts: [] }, + }, + { + name: "text part instead of data part", + message: { + kind: "message", + messageId: "m", + role: "user", + parts: [{ kind: "text", text: "x" }], + }, + }, + { + name: "data part without jwt field", + message: { + kind: "message", + messageId: "m", + role: "user", + parts: [{ kind: "data", data: { notJwt: "x" } }], + }, + }, + ])("rejects $name", async ({ message }) => { await expect( - verifyA2AHandshakeMessage(message, { did }), + verifyA2AHandshakeMessage(message as never, { did: agentDid }), ).rejects.toThrow() }) - it("throws when JWT verification fails", async () => { + it("rejects when JWT verification fails", async () => { vi.mocked(verifyJwt).mockRejectedValueOnce( new Error("JWT verification failed"), ) - const message = { - kind: "message" as const, - messageId: "msg-1", - role: "user" as const, - parts: [ - { - kind: "data" as const, - data: { jwt: "invalid.jwt.token" }, - }, - ], - } - await expect( - verifyA2AHandshakeMessage(message, { did }), + verifyA2AHandshakeMessage(handshakeMessage(), { did: agentDid }), ).rejects.toThrow("JWT verification failed") }) - it("throws when payload is missing required fields", async () => { + it("rejects when the verified payload is missing required handshake fields", async () => { + // JWT is valid but payload lacks nonce and vc — not a proper handshake vi.mocked(verifyJwt).mockResolvedValueOnce( - createMockJwtVerified({ - iss: counterparty, - // missing nonce and vc - }), + mockVerifiedJwt({ iss: userDid }), ) - const message = { - kind: "message" as const, - messageId: "msg-1", - role: "user" as const, - parts: [ - { - kind: "data" as const, - data: { jwt: "valid.jwt.token" }, - }, - ], - } - await expect( - verifyA2AHandshakeMessage(message, { did, counterparty }), + verifyA2AHandshakeMessage(handshakeMessage(), { + did: agentDid, + counterparty: userDid, + }), ).rejects.toThrow() }) - it("throws when iss is not a valid DID URI", async () => { + it("rejects when issuer is not a valid DID URI", async () => { + // The handshake payload schema requires iss to be a did: URI. + // A compromised or misconfigured peer might send a plain string. vi.mocked(verifyJwt).mockResolvedValueOnce( - createMockJwtVerified({ + mockVerifiedJwt({ iss: "not-a-did", - nonce: "test-nonce", - vc: { - "@context": ["https://www.w3.org/2018/credentials/v1"], - type: ["VerifiableCredential"], - issuer: { id: "did:web:issuer.example.com" }, - issuanceDate: "2025-01-01T00:00:00.000Z", - credentialSubject: { id: "did:web:subject.example.com" }, - }, + nonce: "n", + vc: testCredential, }), ) - const message = { - kind: "message" as const, - messageId: "msg-1", - role: "user" as const, - parts: [ - { - kind: "data" as const, - data: { jwt: "valid.jwt.token" }, - }, - ], - } - await expect( - verifyA2AHandshakeMessage(message, { did }), + verifyA2AHandshakeMessage(handshakeMessage(), { did: agentDid }), ).rejects.toThrow() }) +}) - it("passes counterparty as issuer to verifyJwt", async () => { - const vc = { - "@context": ["https://www.w3.org/2018/credentials/v1"], - type: ["VerifiableCredential", "ControllerCredential"], - issuer: { id: "did:web:issuer.example.com" }, - issuanceDate: "2025-01-01T00:00:00.000Z", - credentialSubject: { id: "did:web:subject.example.com" }, - } +// --- Signed message verification --- +describe("verifyA2ASignedMessage", () => { + function mockValidSignature() { vi.mocked(verifyJwt).mockResolvedValueOnce( - createMockJwtVerified({ - iss: counterparty, - nonce: "test-nonce", - vc, - }), + mockVerifiedJwt(expectedSignedPayload()), ) + } - const message = { - kind: "message" as const, - messageId: "msg-1", - role: "user" as const, - parts: [ - { - kind: "data" as const, - data: { jwt: "valid.jwt.token" }, - }, - ], - } - - await verifyA2AHandshakeMessage(message, { did, counterparty }) + it("verifies that a message's content matches its JWT signature", async () => { + mockValidSignature() - expect(verifyJwt).toHaveBeenCalledWith("valid.jwt.token", { - audience: did, - issuer: counterparty, - resolver: expect.anything(), + const result = await verifyA2ASignedMessage(signedMessage(), { + did: agentDid, + counterparty: userDid, }) - }) -}) - -describe("verifyA2ASignedMessage", () => { - const did = "did:web:agent.example.com" as DidUri - const counterparty = "did:web:user.example.com" as DidUri - - it("verifies a valid signed message", async () => { - const messageContent = { - kind: "message", - messageId: "msg-1", - role: "user", - parts: [{ kind: "text", text: "hello" }], - } - vi.mocked(verifyJwt).mockResolvedValueOnce( - createMockJwtVerified({ - message: messageContent, - }), - ) + expect(result.verified).toBe(true) + }) - const message = { - kind: "message" as const, - messageId: "msg-1", - role: "user" as const, - parts: [{ kind: "text" as const, text: "hello" }], - metadata: { sig: "valid.jwt.signature" }, - } + it("verifies the signature JWT with audience=self and issuer=counterparty", async () => { + mockValidSignature() - const result = await verifyA2ASignedMessage(message, { - did, - counterparty, + await verifyA2ASignedMessage(signedMessage("hello", "the.sig"), { + did: agentDid, + counterparty: userDid, }) - expect(result.verified).toBe(true) + expect(verifyJwt).toHaveBeenCalledWith("the.sig", { + audience: agentDid, + issuer: userDid, + resolver: expect.anything(), + }) }) - it("throws when message has no metadata", async () => { - const message = { - kind: "message" as const, - messageId: "msg-1", - role: "user" as const, - parts: [{ kind: "text" as const, text: "hello" }], - } - + it("rejects unsigned messages (no metadata at all)", async () => { await expect( - verifyA2ASignedMessage(message, { did }), + verifyA2ASignedMessage(unsignedMessage(), { did: agentDid }), ).rejects.toThrow() }) - it("throws when metadata has no sig field", async () => { - const message = { - kind: "message" as const, - messageId: "msg-1", - role: "user" as const, - parts: [{ kind: "text" as const, text: "hello" }], - metadata: { other: "data" }, - } + it("rejects messages with metadata but no sig field", async () => { + const noSig = { ...unsignedMessage(), metadata: { traceId: "abc" } } await expect( - verifyA2ASignedMessage(message, { did }), + verifyA2ASignedMessage(noSig as never, { did: agentDid }), ).rejects.toThrow() }) - it("throws when message parts do not match signature payload", async () => { + it("detects tampering: rejects when message content diverges from signed payload", async () => { + // Signature covers "original content" but the message body says "tampered" vi.mocked(verifyJwt).mockResolvedValueOnce( - createMockJwtVerified({ + mockVerifiedJwt({ message: { kind: "message", messageId: "msg-1", role: "user", - parts: [{ kind: "text", text: "different content" }], + parts: [{ kind: "text", text: "original content" }], }, }), ) - const message = { - kind: "message" as const, - messageId: "msg-1", - role: "user" as const, - parts: [{ kind: "text" as const, text: "hello" }], - metadata: { sig: "valid.jwt.signature" }, - } - await expect( - verifyA2ASignedMessage(message, { did, counterparty }), + verifyA2ASignedMessage(signedMessage("tampered"), { + did: agentDid, + counterparty: userDid, + }), ).rejects.toThrow("Message parts do not match") }) - it("throws when JWT verification fails", async () => { - vi.mocked(verifyJwt).mockRejectedValueOnce( - new Error("Signature invalid"), - ) - - const message = { - kind: "message" as const, - messageId: "msg-1", - role: "user" as const, - parts: [{ kind: "text" as const, text: "hello" }], - metadata: { sig: "bad.jwt.signature" }, - } + it("rejects when the underlying JWT signature is invalid", async () => { + vi.mocked(verifyJwt).mockRejectedValueOnce(new Error("Signature invalid")) await expect( - verifyA2ASignedMessage(message, { did }), + verifyA2ASignedMessage(signedMessage(), { did: agentDid }), ).rejects.toThrow("Signature invalid") }) - it("strips contextId before comparing message content", async () => { - const messageContent = { - kind: "message", - messageId: "msg-1", - role: "user", - parts: [{ kind: "text", text: "hello" }], - } - - vi.mocked(verifyJwt).mockResolvedValueOnce( - createMockJwtVerified({ - message: messageContent, - }), - ) + it("ignores server-injected contextId when comparing content", async () => { + // A2A servers may auto-assign a contextId after the client signs the + // message. The verification must strip it before comparing, otherwise + // every message routed through a server would fail validation. + mockValidSignature() - const message = { - kind: "message" as const, - messageId: "msg-1", - role: "user" as const, - parts: [{ kind: "text" as const, text: "hello" }], - contextId: "ctx-auto-generated", - metadata: { sig: "valid.jwt.signature" }, + const messageWithContextId = { + ...signedMessage(), + contextId: "ctx-server-assigned", } - const result = await verifyA2ASignedMessage(message, { - did, - counterparty, + const result = await verifyA2ASignedMessage(messageWithContextId, { + did: agentDid, + counterparty: userDid, }) expect(result.verified).toBe(true) }) - - it("passes counterparty as issuer to verifyJwt", async () => { - const messageContent = { - kind: "message", - messageId: "msg-1", - role: "user", - parts: [{ kind: "text", text: "hello" }], - } - - vi.mocked(verifyJwt).mockResolvedValueOnce( - createMockJwtVerified({ - message: messageContent, - }), - ) - - const message = { - kind: "message" as const, - messageId: "msg-1", - role: "user" as const, - parts: [{ kind: "text" as const, text: "hello" }], - metadata: { sig: "valid.jwt.signature" }, - } - - await verifyA2ASignedMessage(message, { did, counterparty }) - - expect(verifyJwt).toHaveBeenCalledWith("valid.jwt.signature", { - audience: did, - issuer: counterparty, - resolver: expect.anything(), - }) - }) })