Skip to content

fix(vc): bind credential verification to the signed proof (#105, #108)#110

Closed
EfeDurmaz16 wants to merge 2 commits into
agentcommercekit:mainfrom
EfeDurmaz16:fix/vc-bind-parsed-credential-to-signed-proof
Closed

fix(vc): bind credential verification to the signed proof (#105, #108)#110
EfeDurmaz16 wants to merge 2 commits into
agentcommercekit:mainfrom
EfeDurmaz16:fix/vc-bind-parsed-credential-to-signed-proof

Conversation

@EfeDurmaz16

@EfeDurmaz16 EfeDurmaz16 commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

TL;DR

On the parsed-credential input path, verifyParsedCredential() verified proof.jwt but then ran expiry, revocation, trustedIssuers, and claim-verifier checks against the caller-supplied outer object. A caller could present a validly-signed proof.jwt while mutating the outer credentialSubject / issuer / type, and those mutated fields were trusted. This binds all verification to the signed payload. Fixes #105 and #108.

What changed (@agentcommercekit/vc)

  • verifyProof() now returns the credential decoded from proof.jwt (previously void).
  • verifyParsedCredential() runs every downstream check against that verified credential, and returns it, so consumers can read signed fields from the return value instead of the object they passed in.

Where to look

  • packages/vc/src/verification/verify-proof.tsverifyJwtProof returns the decoded credential.
  • packages/vc/src/verification/verify-parsed-credential.ts — expiry / revocation / trusted-issuer / claim-verifier checks and the return value now use the verified credential.

Risk / compatibility

  • The return types of verifyProof and verifyParsedCredential change from void to the verified credential. This is backward-compatible: callers that ignore the return value are unaffected. Patch changeset included.
  • The behavior change is intentional and scoped to the parsed-credential path: mutated outer fields no longer affect verification.

Tests

  • Rewrote verify-parsed-credential.test.ts onto real signed credentials (no mocked proof).
  • Regression tests: a spoofed outer issuer (pointed at a trusted DID) is rejected; claim verifiers receive the signed subject rather than a mutated one; claim-verifier selection uses the signed type; and the returned credential is the signed one.
  • verify-proof.test.ts asserts the decoded credential is returned.
  • @agentcommercekit/vc: 51 tests passing. Repo-wide: check:types, test, lint (0 errors), and oxfmt --check all pass.

Relationship to #88

#88 hardens the ACK-Pay receipt path specifically and adds the paymentOptionId ↔ Payment Request binding, which lives at the ACK-Pay layer and is not covered here. This PR is the general fix at the @agentcommercekit/vc layer and is complementary. With verifyParsedCredential now returning the canonical credential, the receipt path's local re-parse in #88 can later be simplified to consume the return value.


Reviewed with ChatGPT 5.5 (xhigh reasoning) in addition to the author. Areas that may still warrant human review: (1) consumer adoptionverifyParsedCredential now returns the canonical credential, and call sites that read fields after verification should switch to the return value (the receipt path is separately hardened in #88); (2) expiry / revocation binding is covered structurally because those checks now receive the verified credential, but the unit tests mock isExpired / isRevoked, so a maintainer may want an end-to-end test with a genuinely expired or revoked signed credential.

Summary by CodeRabbit

  • Bug Fixes
    • Credential and proof verification is now bound to the signed proof payload, so spoofed or mutated outer fields are ignored.
    • verifyProof and verifyParsedCredential now return the verified/decoded credential for safer downstream handling.
    • Payment receipt verification now uses the verified receipt to prevent mismatched paymentRequestToken values.
  • Tests
    • Strengthened verification test coverage with real signed JWT credentials and regression cases for spoofed issuer and altered claim fields.

verifyProof() now returns the credential decoded from proof.jwt, and
verifyParsedCredential() runs expiry, revocation, trusted-issuer, and
claim-verifier checks against that verified credential instead of the
caller-supplied object. It also returns the verified credential so
consumers can read signed fields from the return value rather than 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 /
type; those fields were trusted directly. They are now ignored in favor
of the signed payload.

Adds regression tests proving a spoofed outer issuer, a mutated outer
subject, and a mutated outer type cannot bypass verification, and that
the returned credential is the signed one.

Fixes agentcommercekit#105
Fixes agentcommercekit#108

Signed-off-by: EfeDurmaz16 <efebarandurmaz05@gmail.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

This PR updates the @agentcommercekit/vc package to bind credential verification to the signed proof payload. The proof verification functions now return the decoded credential from the JWT, and parsed-credential verification performs all downstream checks against that verified credential instead of the caller-supplied input. Consumer code and payment receipt verification are updated to use the verified credentials returned by these functions.

Changes

Proof Binding and Verification

Layer / File(s) Summary
Proof verification return value and validation
packages/vc/src/verification/verify-proof.ts, packages/vc/src/verification/verify-proof.test.ts
verifyJwtProof and verifyProof return type changes from Promise<void> to Promise<Verifiable<W3CCredential>>. Internal type predicates validate the decoded credential shape before returning. Test infrastructure constructs mocked VerifiedCredential objects and asserts the decoded credential is returned, including a test for malformed decoded credentials.
Parsed credential verification binding and test refactor
packages/vc/src/verification/verify-parsed-credential.ts, packages/vc/src/verification/verify-parsed-credential.test.ts
verifyParsedCredential captures the verified credential from verifyProof and runs expiry, revocation, trusted-issuer, and claim-verifier checks against it instead of the caller-supplied credential, then returns it. Test infrastructure is refactored to build real signed JWT credentials. Regression tests verify spoofed outer fields are ignored in favor of the signed proof payload.
Release notes
.changeset/bind-parsed-credential-to-proof.md
Documents the behavior change: verification is driven by the credential decoded from proof.jwt, all downstream checks use the verified credential, caller-supplied outer fields are ignored on the parsed-credential path, verified credentials are returned, and verifyPaymentReceipt uses the verified receipt.

Consumer Code Updates

Layer / File(s) Summary
Demo and example verifier updates
demos/e2e/src/credential-verifier.ts, examples/verifier/src/routes/verify.ts
CredentialVerifier.verifyCredential captures the verified credential from verifyParsedCredential, re-validates it as a controller credential, and returns it. The example verifier route updates its local credential variable to the verified credential returned from verifyParsedCredential.
Payment receipt verification binding
packages/ack-pay/src/verify-payment-receipt.ts, packages/ack-pay/src/verify-payment-receipt.test.ts
verifyPaymentReceipt captures the verified receipt returned by verifyParsedCredential, validates it against the payment-receipt schema via Valibot, and extracts paymentRequestToken from the verified receipt. Test coverage expands to verify spoofed tokens in the caller-supplied credential are rejected in favor of the signed payload, and the verifyPaymentRequestTokenJwt option works correctly with parsed-credential input.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related issues

  • #108: Implements the proposed fix by changing verifyProof to return the decoded credential and updating verifyParsedCredential to run checks against the verified credential, directly binding parsed-credential validation to the signed proof.
  • #105: Makes the same code-level changes to bind parsed-credential verification to the signed JWT payload through return values and validation rewiring, with corresponding test updates.

Possibly related PRs

  • agentcommercekit/ack#95: The retrieved PR's new verify-payment-receipt tests asserting credentialSubject.metadata is preserved during receipt verification aligns with the main PR's change to make verifyPaymentReceipt operate on the verifyParsedCredential-verified credential (so the verified receipt—including metadata—drives the returned result).

Suggested reviewers

  • venables
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main security fix: binding credential verification to the signed proof instead of allowing unverified outer fields to influence checks.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/vc/src/verification/verify-proof.test.ts (1)

16-18: ⚠️ Potential issue | 🟡 Minor

Remove dead ./verify-credential-jwt mock in verify-proof.test.ts.

packages/vc/src/verification/verify-proof.ts doesn’t import ./verify-credential-jwt, and verifyCredentialJwt only appears in the mock (lines 16-18) and in the test name—so the mock is unused. Delete it to keep the test focused.

🤖 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/vc/src/verification/verify-proof.test.ts` around lines 16 - 18,
Delete the unused mock declaration vi.mock("./verify-credential-jwt", () => ({
verifyCredentialJwt: vi.fn(), })) from the test file (it’s dead code because
verify-proof.ts does not import that module); also update the test name if it
still references verifyCredentialJwt so the test name accurately reflects the
behavior being tested (remove or rename that phrase).
🧹 Nitpick comments (1)
packages/vc/src/verification/verify-parsed-credential.ts (1)

64-73: 🏗️ Heavy lift

Downstream caller may still read spoofed fields from parsedCredential.

Context snippet shows verifyPaymentReceipt calls verifyParsedCredential but continues using parsedCredential.credentialSubject.paymentRequestToken afterward without capturing the returned verified credential. This undermines the binding guarantee for that call site.

Consider updating downstream callers to use the returned verified credential, or document clearly in the JSDoc that callers must use the return value.

🤖 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/vc/src/verification/verify-parsed-credential.ts` around lines 64 -
73, The caller is still reading unverified fields from parsedCredential after
calling verifyParsedCredential; update downstream callers (e.g.,
verifyPaymentReceipt) to use the verified credential returned by
verifyParsedCredential (assign the function's return to a variable like
verifiedCredential and read paymentRequestToken from
verifiedCredential.credentialSubject) rather than parsedCredential, or
alternatively update the JSDoc on verifyParsedCredential to explicitly state
that callers must use the returned verified credential and then change each call
site to follow that contract (ensure any place referencing
parsedCredential.credentialSubject.* is changed to reference the returned
verified credential or marked as requiring no change if covered by the new JSDoc
policy).
🤖 Prompt for all review comments with 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.

Outside diff comments:
In `@packages/vc/src/verification/verify-proof.test.ts`:
- Around line 16-18: Delete the unused mock declaration
vi.mock("./verify-credential-jwt", () => ({ verifyCredentialJwt: vi.fn(), }))
from the test file (it’s dead code because verify-proof.ts does not import that
module); also update the test name if it still references verifyCredentialJwt so
the test name accurately reflects the behavior being tested (remove or rename
that phrase).

---

Nitpick comments:
In `@packages/vc/src/verification/verify-parsed-credential.ts`:
- Around line 64-73: The caller is still reading unverified fields from
parsedCredential after calling verifyParsedCredential; update downstream callers
(e.g., verifyPaymentReceipt) to use the verified credential returned by
verifyParsedCredential (assign the function's return to a variable like
verifiedCredential and read paymentRequestToken from
verifiedCredential.credentialSubject) rather than parsedCredential, or
alternatively update the JSDoc on verifyParsedCredential to explicitly state
that callers must use the returned verified credential and then change each call
site to follow that contract (ensure any place referencing
parsedCredential.credentialSubject.* is changed to reference the returned
verified credential or marked as requiring no change if covered by the new JSDoc
policy).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 72d9beac-de44-467f-bdfa-9ed19bf983af

📥 Commits

Reviewing files that changed from the base of the PR and between 60eec23 and 794e138.

📒 Files selected for processing (5)
  • .changeset/bind-parsed-credential-to-proof.md
  • packages/vc/src/verification/verify-parsed-credential.test.ts
  • packages/vc/src/verification/verify-parsed-credential.ts
  • packages/vc/src/verification/verify-proof.test.ts
  • packages/vc/src/verification/verify-proof.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 794e138e17

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

)
}
}

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 Consume the verified credential before reading receipt fields

Because this now only returns the credential decoded from proof.jwt and leaves the caller-supplied object unchanged, internal call sites that ignore the return value can still consume unsigned outer fields. For example, verifyPaymentReceipt calls verifyParsedCredential(...) and then reads parsedCredential.credentialSubject.paymentRequestToken at packages/ack-pay/src/verify-payment-receipt.ts:82-90; with parsed-object input, a caller can keep a valid signed receipt proof while swapping the outer paymentRequestToken, so the signed subject is verified but the swapped token is the one returned/verified. Please update those consumers to use this returned credential, or otherwise replace/mutate the input before returning from verification.

Useful? React with 👍 / 👎.

try {
await verifyCredential(proof.jwt, resolver)
const { verifiableCredential } = await verifyCredential(proof.jwt, resolver)
return verifiableCredential as Verifiable<W3CCredential>

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.

Small / Optional polish: verifiableCredential as Verifiable<W3CCredential> is an unchecked type assertion. If did-jwt-vc ever returns a shape that diverges from Verifiable<W3CCredential>, the cast masks the mismatch.

Possible Solution: Validate the shape with a type guard or narrow the return type of verifyCredential.

@venables

Copy link
Copy Markdown
Contributor

Location: demos/e2e/src/credential-verifier.ts:82-89

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

After verifyParsedCredential, the demo returns the caller-supplied parsedCredential (whose outer fields are still unbound to the proof) instead of the now-returned verified credential, so downstream demo consumers read unsigned fields.

Possible Solution: Capture and return the value from verifyParsedCredential (const verified = await verifyParsedCredential(...); return verified).

@venables

Copy link
Copy Markdown
Contributor

Location: examples/verifier/src/routes/verify.ts:44-50

await verifyParsedCredential(credential, {
trustedIssuers,
resolver,
verifiers: [getControllerClaimVerifier(), getReceiptClaimVerifier()],
})
return c.json(apiSuccessResponse(null))

Small / Optional polish: verifyParsedCredential return value is ignored. This endpoint only returns a success response (no credential fields leak), but the pattern demonstrates incorrect usage that downstream consumers may copy.

Possible Solution: Capture and use the return value, or add a comment documenting why the return value is intentionally discarded here.

@venables venables left a comment

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.

Thanks for putting this together — binding the downstream checks to the credential decoded from proof.jwt is the right fix and the new regression tests cover the tampering vectors well.

One thing to resolve before this lands: please check out the existing comment from the Codex reviewer on packages/vc/src/verification/verify-parsed-credential.ts. The core fix is correct, but the primary in-repo consumer — verifyPaymentReceipt in packages/ack-pay/src/verify-payment-receipt.ts:82-90 — still ignores the new return value and reads paymentRequestToken from the caller-supplied object, so the #105/#108 tampering vector remains exploitable through the payment-receipt path. Capturing the returned verified credential and reading the receipt fields from it closes that gap.

I've also left a couple of smaller notes on the demo/example call sites and the unchecked cast.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with 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.

Inline comments:
In `@packages/ack-pay/src/verify-payment-receipt.test.ts`:
- 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.

In `@packages/vc/src/verification/verify-proof.test.ts`:
- Line 72: The test "handles verification errors from verifyCredential" uses a
naming pattern that does not match the required assertive test naming
conventions. Rename this test to use one of the approved patterns: starts with
"creates...", "throws...", "requires...", or "returns..." depending on what the
test verifies. Choose the pattern that best describes what the test asserts
about the verification behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8a25eb07-4095-4304-b07e-caf1c37165a6

📥 Commits

Reviewing files that changed from the base of the PR and between 794e138 and d6d7337.

📒 Files selected for processing (7)
  • .changeset/bind-parsed-credential-to-proof.md
  • demos/e2e/src/credential-verifier.ts
  • examples/verifier/src/routes/verify.ts
  • packages/ack-pay/src/verify-payment-receipt.test.ts
  • packages/ack-pay/src/verify-payment-receipt.ts
  • packages/vc/src/verification/verify-proof.test.ts
  • packages/vc/src/verification/verify-proof.ts
✅ Files skipped from review due to trivial changes (1)
  • .changeset/bind-parsed-credential-to-proof.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/vc/src/verification/verify-proof.ts

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

})

it("handles verification errors from verifyCredentialJwt", async () => {
it("handles verification errors from verifyCredential", 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

Rename this test to the required assertive naming pattern.

The current title starts with handles..., which does not match the required patterns.

Suggested change
-  it("handles verification errors from verifyCredential", async () => {
+  it("throws InvalidProofError when verifyCredential fails", 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("handles verification errors from verifyCredential", async () => {
it("throws InvalidProofError when verifyCredential fails", 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/vc/src/verification/verify-proof.test.ts` at line 72, The test
"handles verification errors from verifyCredential" uses a naming pattern that
does not match the required assertive test naming conventions. Rename this test
to use one of the approved patterns: starts with "creates...", "throws...",
"requires...", or "returns..." depending on what the test verifies. Choose the
pattern that best describes what the test asserts about the verification
behavior.

Source: Coding guidelines

@venables

Copy link
Copy Markdown
Contributor

Thanks for this @EfeDurmaz16 — solid work pinning down the root cause and tying it to #105/#108.

We're going to land this fix via #113, which takes the same approach (bind all trust decisions + returned values to the proof-decoded credential) and folds in the review feedback from here: it reuses parseJwtCredential instead of re-casting in verify-proof.ts (addressing the unchecked-cast comment), handles the receipt-path P1, and uses assertive test names. #113 also references #105/#108 so they'll close on merge.

Closing this in favor of #113 — appreciate you surfacing it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tighten parsed credential binding to signed JWT payload

2 participants