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
24 changes: 24 additions & 0 deletions .changeset/receipt-issuer-service-binding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@agentcommercekit/ack-pay": patch
---

Fixed `verifyPaymentReceipt` not binding the verified receipt's issuer to
the selected `PaymentOption.receiptService`.

Previously, `verifyPaymentReceipt` only checked that the receipt's issuer was
somewhere in the caller's global `trustedReceiptIssuers` list, and that its
`paymentOptionId` existed in the verified Payment Request. It did not check
that the receipt issuer matched the *specific* `receiptService` the selected
payment option named.

In multi-rail deployments where a verifier trusts multiple receipt issuers
globally (e.g. one per payment rail: card, USDC, Solana), this meant a
receipt legitimately issued by one trusted service could be accepted for a
payment option that named a *different* trusted `receiptService`, weakening
per-option trust separation.

`verifyPaymentReceipt` now rejects a receipt whose verified issuer does not
match the selected payment option's `receiptService`, when that value is a
DID. URL-form `receiptService` values are left unenforced, since binding a
DID-based issuer to a URL isn't well-defined without an application-level
policy.
165 changes: 165 additions & 0 deletions packages/ack-pay/src/verify-payment-receipt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,3 +265,168 @@ describe("verifyPaymentReceipt()", () => {
).rejects.toThrow(UntrustedIssuerError)
})
})

describe("verifyPaymentReceipt() - receiptService binding", () => {
let resolver: Resolvable
let legitReceiptIssuerDid: DidUri
let legitReceiptIssuerKeypair: Awaited<ReturnType<typeof generateKeypair>>
let otherTrustedReceiptIssuerDid: DidUri
let otherTrustedReceiptIssuerKeypair: Awaited<
ReturnType<typeof generateKeypair>
>
let paymentRequestToken: JwtString

beforeEach(async () => {
legitReceiptIssuerKeypair = await generateKeypair("secp256k1")
legitReceiptIssuerDid = createDidKeyUri(legitReceiptIssuerKeypair)

otherTrustedReceiptIssuerKeypair = await generateKeypair("secp256k1")
otherTrustedReceiptIssuerDid = createDidKeyUri(
otherTrustedReceiptIssuerKeypair,
)

const paymentRequestIssuerKeypair = await generateKeypair("secp256k1")
const paymentRequestIssuerDid = createDidKeyUri(paymentRequestIssuerKeypair)

resolver = getDidResolver()

// A payment option that names a specific receiptService (the legit
// issuer), per the issue's minimal reproduction.
const paymentRequestInit: PaymentRequestInit = {
id: "test-request-id",
paymentOptions: [
{
id: "card-option",
amount: 100,
decimals: 2,
currency: "USD",
network: "eip155:84532",
recipient: "0x592D4858DE40BC81A77E5B373238B70D7C79D3C79",
receiptService: legitReceiptIssuerDid,
},
],
}

const paymentRequiredBody = await createSignedPaymentRequest(
paymentRequestInit,
{
issuer: paymentRequestIssuerDid,
signer: createJwtSigner(paymentRequestIssuerKeypair),
algorithm: curveToJwtAlgorithm(paymentRequestIssuerKeypair.curve),
},
)
paymentRequestToken = paymentRequiredBody.paymentRequestToken
})

async function signReceiptAs(
issuerDid: DidUri,
issuerKeypair: Awaited<ReturnType<typeof generateKeypair>>,
): Promise<JwtString> {
const unsignedReceipt = createPaymentReceipt({
paymentRequestToken,
paymentOptionId: "card-option",
issuer: issuerDid,
payerDid: createDidPkhUri(
"eip155:84532",
"0x7B3D8F2E1C9A4B5D6E7F8A9B0C1D2E3F4A5B6C",
),
})

return signCredential(unsignedReceipt, {
did: issuerDid,
signer: createJwtSigner(issuerKeypair),
})
}

it("rejects a receipt from a different trusted issuer than the option's receiptService", async () => {
// The receipt is issued and signed by otherTrustedReceiptIssuerDid, not
// by legitReceiptIssuerDid (the DID the selected payment option names as
// its receiptService). Both are in trustedReceiptIssuers, so the old
// "is the issuer trusted at all, globally" check alone would accept
// this - the fix must bind to the option's own receiptService instead.
const receiptJwt = await signReceiptAs(
otherTrustedReceiptIssuerDid,
otherTrustedReceiptIssuerKeypair,
)

await expect(
verifyPaymentReceipt(receiptJwt, {
resolver,
trustedReceiptIssuers: [
legitReceiptIssuerDid,
otherTrustedReceiptIssuerDid,
],
}),
).rejects.toThrow(InvalidPaymentReceiptError)
})

it("accepts a receipt from the option's own receiptService", async () => {
const receiptJwt = await signReceiptAs(
legitReceiptIssuerDid,
legitReceiptIssuerKeypair,
)

const result = await verifyPaymentReceipt(receiptJwt, {
resolver,
trustedReceiptIssuers: [
legitReceiptIssuerDid,
otherTrustedReceiptIssuerDid,
],
})

expect(result.receipt).toBeDefined()
})

it("does not enforce receiptService binding when the option doesn't specify one", async () => {
// Re-run createSignedPaymentRequest without receiptService on the option,
// to confirm the binding check is a no-op (opt-in) rather than requiring
// every payment option to declare a receiptService.
const paymentRequestIssuerKeypair = await generateKeypair("secp256k1")
const paymentRequestIssuerDid = createDidKeyUri(paymentRequestIssuerKeypair)

const paymentRequestInit: PaymentRequestInit = {
id: "test-request-id-no-receipt-service",
paymentOptions: [
{
id: "card-option",
amount: 100,
decimals: 2,
currency: "USD",
network: "eip155:84532",
recipient: "0x592D4858DE40BC81A77E5B373238B70D7C79D3C79",
// no receiptService
},
],
}

const paymentRequiredBody = await createSignedPaymentRequest(
paymentRequestInit,
{
issuer: paymentRequestIssuerDid,
signer: createJwtSigner(paymentRequestIssuerKeypair),
algorithm: curveToJwtAlgorithm(paymentRequestIssuerKeypair.curve),
},
)

const unsignedReceipt = createPaymentReceipt({
paymentRequestToken: paymentRequiredBody.paymentRequestToken,
paymentOptionId: "card-option",
issuer: otherTrustedReceiptIssuerDid,
payerDid: createDidPkhUri(
"eip155:84532",
"0x7B3D8F2E1C9A4B5D6E7F8A9B0C1D2E3F4A5B6C",
),
})
const receiptJwt = await signCredential(unsignedReceipt, {
did: otherTrustedReceiptIssuerDid,
signer: createJwtSigner(otherTrustedReceiptIssuerKeypair),
})

const result = await verifyPaymentReceipt(receiptJwt, {
resolver,
trustedReceiptIssuers: [otherTrustedReceiptIssuerDid],
})

expect(result.receipt).toBeDefined()
})
})
28 changes: 26 additions & 2 deletions packages/ack-pay/src/verify-payment-receipt.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Resolvable } from "@agentcommercekit/did"
import { isDidUri } from "@agentcommercekit/did"
import { isJwtString, type JwtString } from "@agentcommercekit/jwt"
import {
InvalidCredentialError,
Expand Down Expand Up @@ -132,17 +133,40 @@ export async function verifyPaymentReceipt(
// Bind the receipt's selected option back to an option actually offered by
// the verified Payment Request. Reads from `verifiedReceipt` (proof-decoded),
// so a mutated outer credential cannot smuggle in an unoffered option.
const paymentOptionExists = paymentRequest.paymentOptions.some(
const selectedPaymentOption = paymentRequest.paymentOptions.find(
(paymentOption) =>
paymentOption.id === verifiedReceipt.credentialSubject.paymentOptionId,
)

if (!paymentOptionExists) {
if (!selectedPaymentOption) {
throw new InvalidPaymentReceiptError(
"Receipt paymentOptionId does not match any payment option in the Payment Request token",
)
}

// A payment option can name a specific receiptService (typically to
// support multi-rail deployments where different payment options delegate
// receipt issuance to different services). When it's a DID, the verified
// receipt's issuer must be that exact DID - otherwise a receipt issued by
// *any* trusted issuer (from a global `trustedReceiptIssuers` list) would
// be accepted for an option that named a different, specific issuer,
// defeating the per-option trust separation the deployment relies on.
//
// URL-form receiptService values are left unenforced here, since binding a
// DID-based receipt issuer to a URL isn't well-defined without an
// application-level policy for what "matches" means; callers that need
// this can compare `selectedPaymentOption.receiptService` against
// `verifiedReceipt.issuer.id` themselves.
if (
selectedPaymentOption.receiptService !== undefined &&
isDidUri(selectedPaymentOption.receiptService) &&
selectedPaymentOption.receiptService !== verifiedReceipt.issuer.id
) {
throw new InvalidPaymentReceiptError(
"Receipt issuer does not match the selected payment option's receiptService",
)
}

return {
receipt: verifiedReceipt,
paymentRequestToken,
Expand Down