diff --git a/.changeset/validate-receipt-payment-option.md b/.changeset/validate-receipt-payment-option.md new file mode 100644 index 0000000..0c7636c --- /dev/null +++ b/.changeset/validate-receipt-payment-option.md @@ -0,0 +1,16 @@ +--- +"@agentcommercekit/ack-pay": patch +--- + +Validate that a PaymentReceipt's `paymentOptionId` matches an option offered by +the verified Payment Request token. + +A receipt proves a `paymentRequestToken` and a selected `paymentOptionId`, but +verification did not bind that selection back to an option actually offered by +the request. `verifyPaymentReceipt` now rejects a receipt whose +`paymentOptionId` is not present in the verified request's `paymentOptions`. The +check reads from the proof-decoded credential, so it cannot be bypassed by +mutating the outer object on the parsed-credential input path. + +Introduces `InvalidPaymentReceiptError` so callers can catch receipt-level +validation failures explicitly. diff --git a/packages/ack-pay/src/errors.ts b/packages/ack-pay/src/errors.ts index 662cf29..a492835 100644 --- a/packages/ack-pay/src/errors.ts +++ b/packages/ack-pay/src/errors.ts @@ -7,3 +7,10 @@ export class InvalidPaymentRequestTokenError extends Error { this.name = "InvalidPaymentRequestTokenError" } } + +export class InvalidPaymentReceiptError extends Error { + constructor(message = "Invalid payment receipt", options?: ErrorOptions) { + super(message, options) + this.name = "InvalidPaymentReceiptError" + } +} diff --git a/packages/ack-pay/src/verify-payment-receipt.test.ts b/packages/ack-pay/src/verify-payment-receipt.test.ts index 1fe2b49..30fadb9 100644 --- a/packages/ack-pay/src/verify-payment-receipt.test.ts +++ b/packages/ack-pay/src/verify-payment-receipt.test.ts @@ -23,7 +23,10 @@ import { beforeEach, describe, expect, it } from "vitest" import { createPaymentReceipt } from "./create-payment-receipt" import { createSignedPaymentRequest } from "./create-signed-payment-request" -import { InvalidPaymentRequestTokenError } from "./errors" +import { + InvalidPaymentReceiptError, + InvalidPaymentRequestTokenError, +} from "./errors" import type { PaymentRequestInit } from "./payment-request" import { isPaymentReceiptCredential } from "./receipt-claim-verifier" import { verifyPaymentReceipt } from "./verify-payment-receipt" @@ -175,6 +178,27 @@ describe("verifyPaymentReceipt()", () => { }) }) + it("throws when the receipt payment option is not in the payment request", async () => { + const mismatchedReceipt = createPaymentReceipt({ + paymentRequestToken, + paymentOptionId: "missing-payment-option-id", + issuer: receiptIssuerDid, + payerDid: createDidPkhUri( + "eip155:84532", + "0x7B3D8F2E1C9A4B5D6E7F8A9B0C1D2E3F4A5B6C", + ), + }) + + const mismatchedReceiptJwt = await signCredential(mismatchedReceipt, { + did: receiptIssuerDid, + signer: createJwtSigner(receiptIssuerKeypair), + }) + + await expect( + verifyPaymentReceipt(mismatchedReceiptJwt, { resolver }), + ).rejects.toThrow(InvalidPaymentReceiptError) + }) + it("throws for an invalid JWT receipt", async () => { await expect( verifyPaymentReceipt("invalid-jwt", { resolver }), diff --git a/packages/ack-pay/src/verify-payment-receipt.ts b/packages/ack-pay/src/verify-payment-receipt.ts index 9351fd1..2b4eeab 100644 --- a/packages/ack-pay/src/verify-payment-receipt.ts +++ b/packages/ack-pay/src/verify-payment-receipt.ts @@ -10,6 +10,7 @@ import { type W3CCredential, } from "@agentcommercekit/vc" +import { InvalidPaymentReceiptError } from "./errors" import type { PaymentRequest } from "./payment-request" import { getReceiptClaimVerifier, @@ -128,6 +129,20 @@ 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( + (paymentOption) => + paymentOption.id === verifiedReceipt.credentialSubject.paymentOptionId, + ) + + if (!paymentOptionExists) { + throw new InvalidPaymentReceiptError( + "Receipt paymentOptionId does not match any payment option in the Payment Request token", + ) + } + return { receipt: verifiedReceipt, paymentRequestToken,