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. 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.
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"
}
}
63 changes: 61 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,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,
Expand Down
24 changes: 23 additions & 1 deletion packages/ack-pay/src/verify-payment-receipt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -116,6 +126,18 @@ export async function verifyPaymentReceipt(
},
)

const receiptPaymentOptionId =
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good. One note for future reference: this check runs against parsedCredential.credentialSubject, which is trustworthy when the input is a JWT string (decoded from the signed payload) but is the caller-supplied object when the input is a pre-parsed credential.

The proof verification validates the JWT inside proof, but doesn't currently enforce that the outer object matches the signed payload. Not a blocker for this PR tho.

parsedCredential.credentialSubject.paymentOptionId
const paymentOptionExists = paymentRequest.paymentOptions.some(
(paymentOption) => paymentOption.id === receiptPaymentOptionId,
)
Comment on lines +129 to +133
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
Loading