diff --git a/.changeset/validate-receipt-payment-option.md b/.changeset/validate-receipt-payment-option.md new file mode 100644 index 0000000..fe7586b --- /dev/null +++ b/.changeset/validate-receipt-payment-option.md @@ -0,0 +1,5 @@ +--- +"@agentcommercekit/ack-pay": patch +--- + +Validate that a PaymentReceipt paymentOptionId matches an option from the verified Payment Request token. When a parsed credential is passed, the receipt fields are now derived from the signed proof (`proof.jwt`) rather than the caller-supplied object, so the binding cannot be bypassed by mutating the outer credential. Exports `InvalidPaymentReceiptError` so callers can catch receipt validation failures explicitly. diff --git a/packages/ack-pay/src/errors.ts b/packages/ack-pay/src/errors.ts index 3765649..b030349 100644 --- a/packages/ack-pay/src/errors.ts +++ b/packages/ack-pay/src/errors.ts @@ -4,3 +4,10 @@ export class InvalidPaymentRequestTokenError extends Error { this.name = "InvalidPaymentRequestTokenError" } } + +export class InvalidPaymentReceiptError extends Error { + constructor(message = "Invalid payment receipt") { + super(message) + 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 4705b55..cda3915 100644 --- a/packages/ack-pay/src/verify-payment-receipt.test.ts +++ b/packages/ack-pay/src/verify-payment-receipt.test.ts @@ -22,7 +22,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 { verifyPaymentReceipt } from "./verify-payment-receipt" @@ -31,11 +34,12 @@ describe("verifyPaymentReceipt()", () => { let unsignedReceipt: W3CCredential let signedReceipt: Verifiable let signedReceiptJwt: JwtString + let receiptIssuerKeypair: Awaited> let receiptIssuerDid: DidUri let paymentRequestIssuerDid: DidUri beforeEach(async () => { - const receiptIssuerKeypair = await generateKeypair("secp256k1") + receiptIssuerKeypair = await generateKeypair("secp256k1") receiptIssuerDid = createDidKeyUri(receiptIssuerKeypair) const paymentRequestIssuerKeypair = await generateKeypair("secp256k1") paymentRequestIssuerDid = createDidKeyUri(paymentRequestIssuerKeypair) @@ -144,6 +148,61 @@ describe("verifyPaymentReceipt()", () => { ).rejects.toThrow(InvalidPaymentRequestTokenError) }) + it("throws when the receipt payment option is not in the payment request", async () => { + const mismatchedReceipt = { + ...unsignedReceipt, + credentialSubject: { + ...unsignedReceipt.credentialSubject, + paymentOptionId: "missing-payment-option-id", + }, + } + + const mismatchedReceiptJwt = await signCredential(mismatchedReceipt, { + did: receiptIssuerDid, + signer: createJwtSigner(receiptIssuerKeypair), + }) + + await expect( + verifyPaymentReceipt(mismatchedReceiptJwt, { resolver }), + ).rejects.toThrow(InvalidPaymentReceiptError) + }) + + it("derives the receipt option from the signed proof, not the outer object", async () => { + // Sign a receipt whose signed payload names an option that is NOT in the + // payment request, then present it as a parsed credential whose outer + // credentialSubject is mutated to a valid option id. The check must read + // the signed payload (via proof.jwt), so the mutation cannot smuggle the + // receipt past the payment-option binding. + const mismatchedReceipt = { + ...unsignedReceipt, + credentialSubject: { + ...unsignedReceipt.credentialSubject, + paymentOptionId: "missing-payment-option-id", + }, + } + + const mismatchedReceiptJwt = await signCredential(mismatchedReceipt, { + did: receiptIssuerDid, + signer: createJwtSigner(receiptIssuerKeypair), + }) + const mismatchedParsed = await parseJwtCredential( + mismatchedReceiptJwt, + resolver, + ) + + const spoofedReceipt = { + ...mismatchedParsed, + credentialSubject: { + ...mismatchedParsed.credentialSubject, + paymentOptionId: "test-payment-option-id", + }, + } + + await expect( + verifyPaymentReceipt(spoofedReceipt, { resolver }), + ).rejects.toThrow(InvalidPaymentReceiptError) + }) + it("validates trusted receipt issuers", async () => { const result = await verifyPaymentReceipt(signedReceiptJwt, { resolver, diff --git a/packages/ack-pay/src/verify-payment-receipt.ts b/packages/ack-pay/src/verify-payment-receipt.ts index 3359ed8..cfed7d5 100644 --- a/packages/ack-pay/src/verify-payment-receipt.ts +++ b/packages/ack-pay/src/verify-payment-receipt.ts @@ -4,12 +4,14 @@ import { InvalidCredentialError, InvalidCredentialSubjectError, isCredential, + isJwtProof, parseJwtCredential, verifyParsedCredential, type Verifiable, type W3CCredential, } from "@agentcommercekit/vc" +import { InvalidPaymentReceiptError } from "./errors" import type { PaymentRequest } from "./payment-request" import { getReceiptClaimVerifier, @@ -68,7 +70,15 @@ export async function verifyPaymentReceipt( if (isJwtString(receipt)) { parsedCredential = await parseJwtCredential(receipt, resolver) } else if (isCredential(receipt)) { - parsedCredential = receipt + // A parsed credential's top-level fields are caller-supplied and are not + // bound to the signed proof: verifyParsedCredential() verifies `proof.jwt` + // but does not reconcile the outer object against the decoded payload. When + // the proof is a JWT proof, re-derive the credential from `proof.jwt` so the + // paymentOptionId / paymentRequestToken reads below come from the signed + // payload rather than a (potentially mutated) caller-supplied object. + parsedCredential = isJwtProof(receipt.proof) + ? await parseJwtCredential(receipt.proof.jwt, resolver) + : receipt } else { throw new InvalidCredentialError("Receipt is not a JWT or Credential") } @@ -116,6 +126,18 @@ export async function verifyPaymentReceipt( }, ) + const receiptPaymentOptionId = + parsedCredential.credentialSubject.paymentOptionId + const paymentOptionExists = paymentRequest.paymentOptions.some( + (paymentOption) => paymentOption.id === receiptPaymentOptionId, + ) + + if (!paymentOptionExists) { + throw new InvalidPaymentReceiptError( + "Receipt paymentOptionId does not match any payment option in the Payment Request token", + ) + } + return { receipt: parsedCredential, paymentRequestToken,