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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/a2a-signed-message-aud.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 3 additions & 2 deletions packages/ack-id/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 (always requires aud matching did):
const verified = await verifyA2ASignedMessage(signed.message, {
did: "did:web:bank.example.com",
counterparty: "did:web:customer.example.com",
Expand Down
14 changes: 14 additions & 0 deletions packages/ack-id/src/a2a/sign-message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
14 changes: 12 additions & 2 deletions packages/ack-id/src/a2a/sign-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -33,8 +38,13 @@ export async function createSignedA2AMessage(
{ metadata, ...message }: Message,
options: SignMessageOptions,
): Promise<SignedA2AMessage> {
// 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,
Expand Down
18 changes: 16 additions & 2 deletions packages/ack-id/src/a2a/verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
})
Expand Down Expand Up @@ -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
Expand Down
13 changes: 5 additions & 8 deletions packages/ack-id/src/a2a/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<JwtVerified> {
// Ensure the message is a valid A2A signed message
// We need to remove the auto-generated contextId from the message
Expand All @@ -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,
})
Expand Down