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
16 changes: 16 additions & 0 deletions .changeset/validate-receipt-payment-option.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions packages/ack-pay/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
26 changes: 25 additions & 1 deletion packages/ack-pay/src/verify-payment-receipt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 }),
Expand Down
15 changes: 15 additions & 0 deletions packages/ack-pay/src/verify-payment-receipt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type W3CCredential,
} from "@agentcommercekit/vc"

import { InvalidPaymentReceiptError } from "./errors"
import type { PaymentRequest } from "./payment-request"
import {
getReceiptClaimVerifier,
Expand Down Expand Up @@ -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,
Expand Down