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..121058d --- /dev/null +++ b/packages/ack-id/src/a2a/random.test.ts @@ -0,0 +1,26 @@ +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 UUID", () => { + expect(generateRandomJti()).toMatch(uuidPattern) + }) + + it("returns unique values", () => { + expect(generateRandomJti()).not.toBe(generateRandomJti()) + }) +}) + +describe("generateRandomNonce", () => { + it("returns a UUID", () => { + expect(generateRandomNonce()).toMatch(uuidPattern) + }) + + 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 new file mode 100644 index 0000000..14db959 --- /dev/null +++ b/packages/ack-id/src/a2a/service-endpoints.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest" + +import { createAgentCardServiceEndpoint } from "./service-endpoints" + +describe("createAgentCardServiceEndpoint", () => { + 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", + type: "AgentCard", + serviceEndpoint: "https://example.com/.well-known/agent.json", + }) + }) + + it("creates correct id for DIDs with colon-separated path components", () => { + const endpoint = createAgentCardServiceEndpoint( + "did:web:example.com:agents:my-agent", + "https://example.com/agents/my-agent/agent.json", + ) + + 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 new file mode 100644 index 0000000..ba34562 --- /dev/null +++ b/packages/ack-id/src/a2a/sign-message.test.ts @@ -0,0 +1,152 @@ +import type { JwtSigner } from "@agentcommercekit/jwt" +import { createJwtSigner } from "@agentcommercekit/jwt" +import { generateKeypair } from "@agentcommercekit/keys" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { + createA2AHandshakeMessage, + createA2AHandshakeMessageFromJwt, + createA2AHandshakePayload, + createSignedA2AMessage, +} from "./sign-message" +import { + agentDid, + makeTextMessage, + testCredential, + userDid, +} from "./test-fixtures" + +vi.mock("uuid", () => ({ + v4: vi.fn(() => "test-uuid-1234"), +})) + +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", () => { + it("creates a payload addressed to the recipient with a fresh nonce", () => { + const payload = createA2AHandshakePayload({ + recipient: userDid, + vc: testCredential, + }) + + expect(payload.aud).toBe(userDid) + expect(payload.nonce).toBe("test-nonce-1234") + expect(payload.vc).toBe(testCredential) + expect(payload).not.toHaveProperty("replyNonce") + }) + + it("returns the request nonce and generates a new reply nonce for responses", () => { + const payload = createA2AHandshakePayload({ + 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") + // We generate a fresh nonce for the next leg of the handshake + expect(payload.replyNonce).toBe("test-nonce-1234") + }) +}) + +describe("createA2AHandshakeMessageFromJwt", () => { + it("creates an A2A data-part message from a JWT", () => { + const jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.test.sig" + + expect(createA2AHandshakeMessageFromJwt("agent", jwt)).toEqual({ + kind: "message", + messageId: "test-uuid-1234", + role: "agent", + parts: [{ kind: "data", data: { jwt } }], + }) + }) + + it("returns the correct role in the message", () => { + const message = createA2AHandshakeMessageFromJwt("user", "any.jwt") + expect(message.role).toBe("user") + }) +}) + +describe("createSignedA2AMessage", () => { + let jwtSigner: JwtSigner + + beforeEach(async () => { + const keypair = await generateKeypair("secp256k1") + jwtSigner = createJwtSigner(keypair) + }) + + it("creates a JWT signature and attaches it to message metadata", async () => { + const result = await createSignedA2AMessage(makeTextMessage(), { + did: agentDid, + jwtSigner, + }) + + expect(result.sig).toEqual(expect.any(String)) + expect(result.jti).toBe("test-jti-1234") + expect(result.message.metadata?.sig).toBe(result.sig) + }) + + it("returns 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.role).toBe("agent") + expect(result.message.parts).toEqual(original.parts) + }) + + it("creates metadata with signature merged into existing fields", 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?.traceId).toBe("abc") + }) +}) + +describe("createA2AHandshakeMessage", () => { + let jwtSigner: JwtSigner + + beforeEach(async () => { + const keypair = await generateKeypair("secp256k1") + jwtSigner = createJwtSigner(keypair) + }) + + it("creates a signed credential handshake and returns the nonce for correlation", async () => { + const result = await createA2AHandshakeMessage( + "agent", + { recipient: userDid, vc: testCredential }, + { did: agentDid, jwtSigner }, + ) + + 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") + }) + + it("returns the signed JWT in the message data part", async () => { + const result = await createA2AHandshakeMessage( + "agent", + { recipient: userDid, vc: testCredential }, + { did: agentDid, jwtSigner }, + ) + + 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 new file mode 100644 index 0000000..6f2179e --- /dev/null +++ b/packages/ack-id/src/a2a/verify.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it, vi } from "vitest" + +import { + agentDid, + expectedSignedPayload, + handshakeMessage, + mockVerifiedJwt, + signedMessage, + testCredential, + unsignedMessage, + userDid, +} from "./test-fixtures" +import { verifyA2AHandshakeMessage, verifyA2ASignedMessage } from "./verify" + +vi.mock("@agentcommercekit/jwt", async () => { + const actual = await vi.importActual( + "@agentcommercekit/jwt", + ) + return { + ...actual, + verifyJwt: vi.fn(), + } +}) + +vi.mock("@agentcommercekit/did", async () => { + const actual = await vi.importActual( + "@agentcommercekit/did", + ) + return { + ...actual, + getDidResolver: vi.fn(() => ({})), + } +}) + +const { verifyJwt } = await import("@agentcommercekit/jwt") + +// --- Handshake verification --- + +describe("verifyA2AHandshakeMessage", () => { + it("returns issuer, nonce, and credential from a valid handshake", async () => { + vi.mocked(verifyJwt).mockResolvedValueOnce( + mockVerifiedJwt({ + iss: userDid, + nonce: "challenge-nonce", + vc: testCredential, + }), + ) + + const result = await verifyA2AHandshakeMessage(handshakeMessage(), { + did: agentDid, + counterparty: userDid, + }) + + expect(result.iss).toBe(userDid) + expect(result.nonce).toBe("challenge-nonce") + expect(result.vc).toEqual(testCredential) + }) + + it("requires audience=self and issuer=counterparty for JWT verification", async () => { + vi.mocked(verifyJwt).mockResolvedValueOnce( + mockVerifiedJwt({ + iss: userDid, + nonce: "n", + vc: testCredential, + }), + ) + + await verifyA2AHandshakeMessage(handshakeMessage("the.jwt"), { + did: agentDid, + counterparty: userDid, + }) + + expect(verifyJwt).toHaveBeenCalledWith("the.jwt", { + audience: agentDid, + issuer: userDid, + resolver: expect.anything(), + }) + }) + + // 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 as never, { did: agentDid }), + ).rejects.toThrow() + }) + + it("throws when JWT verification fails", async () => { + vi.mocked(verifyJwt).mockRejectedValueOnce( + new Error("JWT verification failed"), + ) + + await expect( + verifyA2AHandshakeMessage(handshakeMessage(), { did: agentDid }), + ).rejects.toThrow("JWT verification failed") + }) + + it("throws 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( + mockVerifiedJwt({ iss: userDid }), + ) + + await expect( + verifyA2AHandshakeMessage(handshakeMessage(), { + did: agentDid, + counterparty: userDid, + }), + ).rejects.toThrow() + }) + + it("throws 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( + mockVerifiedJwt({ + iss: "not-a-did", + nonce: "n", + vc: testCredential, + }), + ) + + await expect( + verifyA2AHandshakeMessage(handshakeMessage(), { did: agentDid }), + ).rejects.toThrow() + }) +}) + +// --- Signed message verification --- + +describe("verifyA2ASignedMessage", () => { + function mockValidSignature() { + vi.mocked(verifyJwt).mockResolvedValueOnce( + mockVerifiedJwt(expectedSignedPayload()), + ) + } + + it("returns verified when message content matches its JWT signature", async () => { + mockValidSignature() + + const result = await verifyA2ASignedMessage(signedMessage(), { + did: agentDid, + counterparty: userDid, + }) + + expect(result.verified).toBe(true) + }) + + it("requires audience=self and issuer=counterparty for signature verification", async () => { + mockValidSignature() + + await verifyA2ASignedMessage(signedMessage("hello", "the.sig"), { + did: agentDid, + counterparty: userDid, + }) + + expect(verifyJwt).toHaveBeenCalledWith("the.sig", { + audience: agentDid, + issuer: userDid, + resolver: expect.anything(), + }) + }) + + it("throws for unsigned messages (no metadata at all)", async () => { + await expect( + verifyA2ASignedMessage(unsignedMessage(), { did: agentDid }), + ).rejects.toThrow() + }) + + it("throws for messages with metadata but no sig field", async () => { + const noSig = { ...unsignedMessage(), metadata: { traceId: "abc" } } + + await expect( + verifyA2ASignedMessage(noSig as never, { did: agentDid }), + ).rejects.toThrow() + }) + + it("throws when message content diverges from signed payload", async () => { + // Signature covers "original content" but the message body says "tampered" + vi.mocked(verifyJwt).mockResolvedValueOnce( + mockVerifiedJwt({ + message: { + kind: "message", + messageId: "msg-1", + role: "user", + parts: [{ kind: "text", text: "original content" }], + }, + }), + ) + + await expect( + verifyA2ASignedMessage(signedMessage("tampered"), { + did: agentDid, + counterparty: userDid, + }), + ).rejects.toThrow("Message parts do not match") + }) + + it("throws when the underlying JWT signature is invalid", async () => { + vi.mocked(verifyJwt).mockRejectedValueOnce(new Error("Signature invalid")) + + await expect( + verifyA2ASignedMessage(signedMessage(), { did: agentDid }), + ).rejects.toThrow("Signature invalid") + }) + + it("returns verified when server-injected contextId is present", 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 messageWithContextId = { + ...signedMessage(), + contextId: "ctx-server-assigned", + } + + const result = await verifyA2ASignedMessage(messageWithContextId, { + did: agentDid, + counterparty: userDid, + }) + + expect(result.verified).toBe(true) + }) +})