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/validate-receipt-payment-option.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agentcommercekit/ack-pay": patch
---

Validate that a PaymentReceipt paymentOptionId matches an option from the verified Payment Request token. Exports `InvalidPaymentReceiptError` so callers can catch receipt 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 @@ -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"
}
}
27 changes: 25 additions & 2 deletions packages/ack-pay/src/verify-payment-receipt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -31,11 +34,12 @@ describe("verifyPaymentReceipt()", () => {
let unsignedReceipt: W3CCredential
let signedReceipt: Verifiable<W3CCredential>
let signedReceiptJwt: JwtString
let receiptIssuerKeypair: Awaited<ReturnType<typeof generateKeypair>>
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)
Expand Down Expand Up @@ -144,6 +148,25 @@ 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("validates trusted receipt issuers", async () => {
const result = await verifyPaymentReceipt(signedReceiptJwt, {
resolver,
Expand Down
13 changes: 13 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 @@ -116,6 +117,18 @@ export async function verifyPaymentReceipt(
},
)

const receiptPaymentOptionId =
parsedCredential.credentialSubject.paymentOptionId
const paymentOptionExists = paymentRequest.paymentOptions.some(
(paymentOption) => paymentOption.id === receiptPaymentOptionId,
)
Comment on lines +120 to +124
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Derive receipt option from the signed proof

For parsed-credential inputs, this new binding check reads credentialSubject.paymentOptionId from the caller-supplied object, but verifyParsedCredential() only validates proof.jwt and then runs claim checks against the current object. If a service accepts a parsed VC, an attacker can supply a valid receipt proof whose signed payload names one option, mutate this field to an offered option before verification, and this check passes even though the signed receipt did not bind that option. Reparse or derive the receipt fields from proof.jwt (or otherwise reject mutated parsed credentials) before enforcing the payment-option match.

Useful? React with 👍 / 👎.


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

return {
receipt: parsedCredential,
paymentRequestToken,
Expand Down