From 52e8b5f2741eaac3d86a2618ad8981fa1de74f66 Mon Sep 17 00:00:00 2001 From: kutluhaneth46 Date: Mon, 21 Sep 2026 21:55:28 +0000 Subject: [PATCH 1/4] feat(ack-id): bind signed A2A messages with optional aud recipient Allow createSignedA2AMessage to take a recipient DID and embed it as the JWT aud claim, matching the handshake payload shape. --- packages/ack-id/src/a2a/sign-message.test.ts | 14 ++++++++++++++ packages/ack-id/src/a2a/sign-message.ts | 14 ++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/ack-id/src/a2a/sign-message.test.ts b/packages/ack-id/src/a2a/sign-message.test.ts index fed6068d..b10fa05f 100644 --- a/packages/ack-id/src/a2a/sign-message.test.ts +++ b/packages/ack-id/src/a2a/sign-message.test.ts @@ -115,6 +115,20 @@ describe("createSignedA2AMessage", () => { expect(result.message.metadata?.sig).toBe(result.sig) expect(result.message.metadata?.traceId).toBe("abc") }) + + it("embeds aud when a recipient is provided", async () => { + const result = await createSignedA2AMessage(makeTextMessage(), { + did: agentDid, + jwtSigner, + recipient: userDid, + }) + + const payloadPart = result.sig.split(".")[1] + const payload = JSON.parse( + Buffer.from(payloadPart, "base64url").toString("utf8"), + ) as { aud?: string } + expect(payload.aud).toBe(userDid) + }) }) describe("createA2AHandshakeMessage", () => { diff --git a/packages/ack-id/src/a2a/sign-message.ts b/packages/ack-id/src/a2a/sign-message.ts index a4a041bc..1eeec40f 100644 --- a/packages/ack-id/src/a2a/sign-message.ts +++ b/packages/ack-id/src/a2a/sign-message.ts @@ -17,6 +17,11 @@ type SignMessageOptions = { jwtSigner: JwtSigner alg?: JwtAlgorithm expiresIn?: number + /** + * Intended recipient DID. When set, the signed JWT includes an `aud` claim + * so `verifyA2ASignedMessage` can bind the message to that recipient. + */ + recipient?: DidUri } type SignedA2AMessage = { @@ -33,8 +38,13 @@ export async function createSignedA2AMessage( { metadata, ...message }: Message, options: SignMessageOptions, ): Promise { - // Sign everything in the message, excluding the metadata - const { jwt: sig, jti } = await createMessageSignature({ message }, options) + // Sign everything in the message, excluding the metadata. Bind `aud` when a + // recipient is provided so verification can require the intended audience. + const payload = + options.recipient !== undefined + ? { message, aud: options.recipient } + : { message } + const { jwt: sig, jti } = await createMessageSignature(payload, options) const metadataWithSig = { ...metadata, From 878d487e2991cab6eb87cd16d66f2bd7971b2c20 Mon Sep 17 00:00:00 2001 From: kutluhaneth46 Date: Mon, 21 Sep 2026 21:55:28 +0000 Subject: [PATCH 2/4] fix(ack-id): honor did audience in verifyA2ASignedMessage Pass audience into verifyJwt so a signed message bound to another recipient can no longer verify against an unrelated caller DID. --- packages/ack-id/src/a2a/verify.test.ts | 18 ++++++++++++++++-- packages/ack-id/src/a2a/verify.ts | 13 +++++-------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/ack-id/src/a2a/verify.test.ts b/packages/ack-id/src/a2a/verify.test.ts index 3616321c..041b749e 100644 --- a/packages/ack-id/src/a2a/verify.test.ts +++ b/packages/ack-id/src/a2a/verify.test.ts @@ -182,9 +182,8 @@ describe("verifyA2ASignedMessage", () => { counterparty: userDid, }) - // Signed messages carry no aud claim today, so no audience is expected; - // the handshake flow embeds and verifies aud. expect(verifyJwt).toHaveBeenCalledWith("the.sig", { + audience: agentDid, issuer: userDid, resolver: expect.anything(), }) @@ -234,6 +233,21 @@ describe("verifyA2ASignedMessage", () => { ).rejects.toThrow("Signature invalid") }) + + it("passes audience=did so recipient-bound messages are enforced", async () => { + mockValidSignature() + + await verifyA2ASignedMessage(signedMessage(), { + did: agentDid, + counterparty: userDid, + }) + + expect(verifyJwt).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ audience: agentDid, issuer: userDid }), + ) + }) + 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 diff --git a/packages/ack-id/src/a2a/verify.ts b/packages/ack-id/src/a2a/verify.ts index d19c3269..07b882cd 100644 --- a/packages/ack-id/src/a2a/verify.ts +++ b/packages/ack-id/src/a2a/verify.ts @@ -62,9 +62,7 @@ export async function verifyA2AHandshakeMessage( export async function verifyA2ASignedMessage( message: Message, - // `did` stays in the options type for callers, but signed messages carry - // no `aud` claim today, so there is nothing to verify it against. - { counterparty, resolver = getDidResolver() }: VerifyA2AHandshakeOptions, + { did, counterparty, resolver = getDidResolver() }: VerifyA2AHandshakeOptions, ): Promise { // Ensure the message is a valid A2A signed message // We need to remove the auto-generated contextId from the message @@ -75,12 +73,11 @@ export async function verifyA2ASignedMessage( ...parsedMessage } = v.parse(messageWithSignatureSchema, message) - // Parse the signature from the message metadata, ensuring it is - // signed by the counterparty. Signed messages do not carry an `aud` - // claim today (`createSignedA2AMessage` has no recipient parameter), so - // no audience is expected here; the handshake path above does embed and - // verify `aud`. + // Parse the signature from the message metadata, ensuring it is signed by + // the counterparty. When `did` is provided, require `aud` to match (same as + // the handshake path) so a message bound to another recipient is rejected. const verified = await verifyJwt(metadata.sig, { + audience: did, issuer: counterparty, resolver, }) From f1e2a65f13e486ea3c2c17342092059237a0b828 Mon Sep 17 00:00:00 2001 From: kutluhaneth46 Date: Mon, 21 Sep 2026 21:56:20 +0000 Subject: [PATCH 3/4] docs(ack-id): document signed A2A audience binding and changeset Update the README example to pass recipient and add a minor changeset for the fail-closed audience check. --- .changeset/a2a-signed-message-aud.md | 5 +++++ packages/ack-id/README.md | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 .changeset/a2a-signed-message-aud.md diff --git a/.changeset/a2a-signed-message-aud.md b/.changeset/a2a-signed-message-aud.md new file mode 100644 index 00000000..3b950ad7 --- /dev/null +++ b/.changeset/a2a-signed-message-aud.md @@ -0,0 +1,5 @@ +--- +"@agentcommercekit/ack-id": minor +--- + +Bind signed A2A messages to an intended recipient: `createSignedA2AMessage` accepts optional `recipient` and embeds it as JWT `aud`, and `verifyA2ASignedMessage` now passes `audience: did` to `verifyJwt` (matching the handshake path). Callers that verify with `did` should sign with `recipient` set; tokens without `aud` fail closed. diff --git a/packages/ack-id/README.md b/packages/ack-id/README.md index 395fa1b3..87092703 100644 --- a/packages/ack-id/README.md +++ b/packages/ack-id/README.md @@ -159,7 +159,7 @@ import { verifyA2ASignedMessage, } from "@agentcommercekit/ack-id/a2a" -// To send a signed message: +// To send a signed message bound to the recipient: const signed = await createSignedA2AMessage( { role: "user", @@ -170,12 +170,13 @@ const signed = await createSignedA2AMessage( }, { did: "did:web:customer.example.com", + recipient: "did:web:bank.example.com", // ... }, ) // Send signed.message -// To verify a signed message: +// To verify a signed message (requires matching aud when recipient was set): const verified = await verifyA2ASignedMessage(signed.message, { did: "did:web:bank.example.com", counterparty: "did:web:customer.example.com", From f76dca509ea463406799fba10362ba16bdbc89ea Mon Sep 17 00:00:00 2001 From: kutluhaneth46 Date: Tue, 22 Sep 2026 07:55:18 +0000 Subject: [PATCH 4/4] docs(ack-id): clarify signed message verification always requires aud verifyA2ASignedMessage always passes audience=did, so tokens without aud fail closed even when the sender omitted recipient. --- packages/ack-id/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ack-id/README.md b/packages/ack-id/README.md index 87092703..33bdb8c3 100644 --- a/packages/ack-id/README.md +++ b/packages/ack-id/README.md @@ -176,7 +176,7 @@ const signed = await createSignedA2AMessage( ) // Send signed.message -// To verify a signed message (requires matching aud when recipient was set): +// To verify a signed message (always requires aud matching did): const verified = await verifyA2ASignedMessage(signed.message, { did: "did:web:bank.example.com", counterparty: "did:web:customer.example.com",