Skip to content
Closed
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
8 changes: 8 additions & 0 deletions .changeset/bind-parsed-credential-to-proof.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@agentcommercekit/vc": patch
"@agentcommercekit/ack-pay": patch
---

Bind credential verification to the signed proof. `verifyProof()` now returns the credential decoded from `proof.jwt`, and `verifyParsedCredential()` runs every downstream check (expiry, revocation, trusted issuer, and claim verifiers) against that verified credential rather than the caller-supplied object. It also now returns the verified credential, so consumers can read signed fields from the return value instead of the object they passed in. On the parsed-credential input path a caller could previously attach a valid `proof.jwt` while mutating the outer `credentialSubject`, `issuer`, etc.; those fields were trusted directly. Now they are ignored in favor of the signed payload. Fixes #105 and #108.

`verifyPaymentReceipt()` now reads the receipt returned by `verifyParsedCredential()` before using signed receipt fields, so parsed receipt callers cannot swap the outer `paymentRequestToken` away from the token in `proof.jwt`.
8 changes: 6 additions & 2 deletions demos/e2e/src/credential-verifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,16 @@ export class CredentialVerifier {
throw new InvalidCredentialSubjectError()
}

await verifyParsedCredential(parsedCredential, {
const verifiedCredential = await verifyParsedCredential(parsedCredential, {
resolver: this.resolver,
trustedIssuers: this.trustedIssuers,
verifiers: [getControllerClaimVerifier()],
})

return parsedCredential
if (!isControllerCredential(verifiedCredential)) {
throw new InvalidCredentialSubjectError()
}

return verifiedCredential as Verifiable<ControllerCredential>
}
}
2 changes: 1 addition & 1 deletion examples/verifier/src/routes/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ app.post(
credential = await parseJwtCredential(credential, resolver)
}

await verifyParsedCredential(credential, {
credential = await verifyParsedCredential(credential, {
trustedIssuers,
resolver,
verifiers: [getControllerClaimVerifier(), getReceiptClaimVerifier()],
Expand Down
43 changes: 43 additions & 0 deletions packages/ack-pay/src/verify-payment-receipt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,49 @@ describe("verifyPaymentReceipt()", () => {
expect(result.paymentRequest).toBeDefined()
})

it("uses the signed paymentRequestToken for parsed credentials", async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use an allowed assertive test title prefix here.

This test name starts with uses...; it should use one of the approved prefixes.

Suggested change
-  it("uses the signed paymentRequestToken for parsed credentials", async () => {
+  it("returns the signed paymentRequestToken for parsed credentials", async () => {

As per coding guidelines, "Use assertive test names with patterns: it(\"creates...\") , it(\"throws...\") , it(\"requires...\") , it(\"returns...\")".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it("uses the signed paymentRequestToken for parsed credentials", async () => {
it("returns the signed paymentRequestToken for parsed credentials", async () => {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ack-pay/src/verify-payment-receipt.test.ts` at line 105, The test
title starting with "uses the signed paymentRequestToken for parsed credentials"
does not follow the approved test naming conventions. Replace the "uses" prefix
with one of the allowed assertive prefixes: "creates", "throws", "requires", or
"returns". Based on the test's intent of verifying that the signed
paymentRequestToken is used for parsed credentials, consider using "returns" or
"creates" as the new prefix while keeping the descriptive part of the test name
intact.

Source: Coding guidelines

const spoofedReceipt = {
...signedReceipt,
credentialSubject: {
...signedReceipt.credentialSubject,
paymentRequestToken: signedReceiptJwt,
},
}

const result = await verifyPaymentReceipt(spoofedReceipt, {
resolver,
})

expect(result.receipt).not.toBe(spoofedReceipt)
expect(result.receipt.credentialSubject.paymentRequestToken).toBe(
paymentRequestToken,
)
expect(result.paymentRequestToken).toBe(paymentRequestToken)
expect(result.paymentRequest).toBeDefined()
})

it("returns the signed paymentRequestToken when token verification is disabled", async () => {
const spoofedReceipt = {
...signedReceipt,
credentialSubject: {
...signedReceipt.credentialSubject,
paymentRequestToken: signedReceiptJwt,
},
}

const result = await verifyPaymentReceipt(spoofedReceipt, {
resolver,
verifyPaymentRequestTokenJwt: false,
})

expect(result.receipt).not.toBe(spoofedReceipt)
expect(result.receipt.credentialSubject.paymentRequestToken).toBe(
paymentRequestToken,
)
expect(result.paymentRequestToken).toBe(paymentRequestToken)
expect(result.paymentRequest).toBeNull()
})

it("preserves receipt metadata through JWT verification", async () => {
const evidenceMetadata = {
policyRef: "policy://merchant-spend-v3",
Expand Down
23 changes: 19 additions & 4 deletions packages/ack-pay/src/verify-payment-receipt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ import {
type Verifiable,
type W3CCredential,
} from "@agentcommercekit/vc"
import * as v from "valibot"

import type { PaymentRequest } from "./payment-request"
import {
getReceiptClaimVerifier,
isPaymentReceiptCredential,
type PaymentReceiptCredential,
} from "./receipt-claim-verifier"
import { paymentReceiptClaimSchema } from "./schemas/valibot"
import { verifyPaymentRequestToken } from "./verify-payment-request-token"

interface VerifyPaymentReceiptOptions {
Expand All @@ -36,6 +39,12 @@ interface VerifyPaymentReceiptOptions {
paymentRequestIssuer?: string
}

function isVerifiedPaymentReceiptCredential(
credential: Verifiable<W3CCredential>,
): credential is Verifiable<PaymentReceiptCredential> {
return v.is(paymentReceiptClaimSchema, credential.credentialSubject)
}

/**
* Validates and verifies a PaymentReceipt, in either JWT or parsed format.
*
Expand Down Expand Up @@ -79,19 +88,25 @@ export async function verifyPaymentReceipt(
)
}

await verifyParsedCredential(parsedCredential, {
const verifiedReceipt = await verifyParsedCredential(parsedCredential, {
resolver,
trustedIssuers: trustedReceiptIssuers,
verifiers: [getReceiptClaimVerifier()],
})

if (!isVerifiedPaymentReceiptCredential(verifiedReceipt)) {
throw new InvalidCredentialError(
"Credential is not a PaymentReceiptCredential",
)
}

// Verify the paymentRequestToken is a valid JWT
const paymentRequestToken =
parsedCredential.credentialSubject.paymentRequestToken
verifiedReceipt.credentialSubject.paymentRequestToken

if (!verifyPaymentRequestTokenJwt) {
return {
receipt: parsedCredential,
receipt: verifiedReceipt,
paymentRequestToken,
paymentRequest: null,
}
Expand All @@ -117,7 +132,7 @@ export async function verifyPaymentReceipt(
)

return {
receipt: parsedCredential,
receipt: verifiedReceipt,
paymentRequestToken,
paymentRequest,
}
Expand Down
Loading