fix(vc,ack-pay): bind credential verification to the verified proof (credential forgery)#113
Conversation
Security fix (CRITICAL): `verifyParsedCredential` verified the JWT in `proof.jwt` but then made every trust decision — expiry, revocation, trusted-issuer, and claim verification — from the caller-supplied outer credential object, which is not bound to the proof. An attacker could take any legitimately signed credential, parse it to object form, mutate `issuer`/`credentialSubject`/`expirationDate` on the object while keeping the original valid `proof.jwt`, and pass verification. This is remotely reachable via the verifier example (POST /verify accepts a parsed credential object) and affects `verifyPaymentReceipt` on the object-input path. Fix: the JWT proof is the only authoritative source. `verifyProof` now returns the credential decoded from the verified proof (via `parseJwtCredential`), and `verifyParsedCredential` runs all checks against that verified credential instead of the caller-supplied object. The JWT-string input path was already safe (its object is derived from the verified JWT), so legitimate flows are unchanged. Verified with a real sign -> parse -> tamper -> verify PoC: legit credentials still verify; a tampered object's forged role/issuer are ignored (the verifier receives the verified payload). Added a regression test asserting trust decisions use the verified proof, not the outer object. verifyProof's return type widens from void to Verifiable<W3CCredential> (backward compatible for existing `await verifyProof(...)` statement callers). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t input Completes the proof-binding fix (26fc9b7). The final panel review found the guarantee was incomplete: `verifyParsedCredential` validated the proof-decoded credential internally but returned `void`, so callers kept reading fields from their own (tamperable) object. In particular `verifyPaymentReceipt`, on the object-input path, read `paymentRequestToken` and returned `receipt` from the caller-supplied object after verification — re-introducing the forgery on the exact path the original fix targeted. - `verifyParsedCredential` now returns the `Verifiable<W3CCredential>` decoded from the verified proof. - `verifyPaymentReceipt` uses that returned credential for the `paymentRequestToken` read and the returned `receipt`, and re-checks `isPaymentReceiptCredential` against the verified credential. Verified with a real sign -> parse -> tamper(object) -> verifyPaymentReceipt PoC: the returned token/metadata are the verified values, not the tampered ones. Strengthened verify-proof's happy-path test to assert it returns the decoded credential. Foregone from the panel: codex's "string issuer" concern is a non-issue — the verified credential comes from did-jwt-vc which always normalizes `iss` to `issuer.id` (the type guarantees it; prior code already relied on it). Also updated the issuer README to document ALLOW_UNSIGNED_PAYLOADS (flagged by codex). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address security-panel test-coverage findings: - ack-pay: add an adversarial test that passes a receipt object with a valid proof but a tampered `credentialSubject.paymentRequestToken`, asserting the returned `receipt`/`paymentRequestToken` come from the proof-decoded credential (not the caller object). - vc: assert `verifyParsedCredential` RETURNS the verified credential — the contract `verifyPaymentReceipt` now depends on. - ack-pay: comment the pre-verification `isPaymentReceiptCredential` check as a cheap fast-reject, not a trust boundary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Walkthrough
ChangesProof-bound credential verification
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related issues
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (2)
packages/ack-pay/src/verify-payment-receipt.test.ts (1)
105-105: ⚡ Quick winRename this test to an approved assertive prefix.
Please rename to start with
returns...(orthrows.../requires.../creates...) to satisfy the repository test naming rule.Suggested rename
- it("ignores tampered outer fields on the object path and returns verified values", async () => { + it("returns verified values when outer object fields are tampered on the object path", async () => {As per coding guidelines, "Use assertive test names with patterns:
it(\"creates...\"),it(\"throws...\"),it(\"requires...\"),it(\"returns...\")".🤖 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 function at the location uses "ignores" as the test name prefix, which does not follow the approved assertive test naming convention. Rename the test case starting with "ignores tampered outer fields on the object path and returns verified values" to use an approved assertive prefix such as "returns..." instead. Choose a prefix that accurately describes what the test verifies (in this case, the test appears to verify that the function returns verified values, so "returns..." is the most appropriate prefix).Source: Coding guidelines
packages/vc/src/verification/verify-parsed-credential.test.ts (1)
224-224: ⚡ Quick winRename this test to match enforced
*.test.tsnaming prefixes.Use an allowed prefix such as
returns...for this case name.Suggested rename
- it("uses the credential from the verified proof, not the caller-supplied object", async () => { + it("returns the credential from the verified proof, not the caller-supplied object", async () => {As per coding guidelines, "Use assertive test names with patterns:
it(\"creates...\"),it(\"throws...\"),it(\"requires...\"),it(\"returns...\")".🤖 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.test.ts` at line 224, The test case named "uses the credential from the verified proof, not the caller-supplied object" does not follow the enforced naming conventions for test cases. Rename this test in the verify-parsed-credential.test.ts file to use an allowed prefix pattern such as "returns..." to match the coding guidelines that require test names to follow patterns like "creates...", "throws...", "requires...", or "returns...". Choose a prefix that best describes what the test verifies.Source: Coding guidelines
🤖 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.
Nitpick comments:
In `@packages/ack-pay/src/verify-payment-receipt.test.ts`:
- Line 105: The test function at the location uses "ignores" as the test name
prefix, which does not follow the approved assertive test naming convention.
Rename the test case starting with "ignores tampered outer fields on the object
path and returns verified values" to use an approved assertive prefix such as
"returns..." instead. Choose a prefix that accurately describes what the test
verifies (in this case, the test appears to verify that the function returns
verified values, so "returns..." is the most appropriate prefix).
In `@packages/vc/src/verification/verify-parsed-credential.test.ts`:
- Line 224: The test case named "uses the credential from the verified proof,
not the caller-supplied object" does not follow the enforced naming conventions
for test cases. Rename this test in the verify-parsed-credential.test.ts file to
use an allowed prefix pattern such as "returns..." to match the coding
guidelines that require test names to follow patterns like "creates...",
"throws...", "requires...", or "returns...". Choose a prefix that best describes
what the test verifies.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: c3177be1-aba0-4726-9662-c5648bdc66fa
📒 Files selected for processing (7)
.changeset/vc-ackpay-proof-binding.mdpackages/ack-pay/src/verify-payment-receipt.test.tspackages/ack-pay/src/verify-payment-receipt.tspackages/vc/src/verification/verify-parsed-credential.test.tspackages/vc/src/verification/verify-parsed-credential.tspackages/vc/src/verification/verify-proof.test.tspackages/vc/src/verification/verify-proof.ts
Conform to the repo's assertive test-name convention (returns/throws/...) rather than uses/ignores prefixes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Fixes a credential-forgery vulnerability in
@agentcommercekit/vc(and its consumer@agentcommercekit/ack-pay).Fixes #105, #108. Supersedes #110 (same root-cause fix; this version reuses
parseJwtCredentialrather than re-casting inverify-proof.ts, and conforms to the assertive test-name convention).verifyParsedCredentialverified the JWT inproof.jwtbut then made every trust decision — expiry, revocation, trusted-issuer, and claim verification — from the caller-supplied credential object, which is not bound to the proof. An attacker could take any legitimately signed credential, parse it to object form, mutateissuer/credentialSubject/expirationDateon the object while keeping the original validproof.jwt, and pass verification.This is remotely reachable: the verifier example accepts a parsed credential object (
POST /verify), andverifyPaymentReceipthad the same gap on its object-input path — it returned the tamperedpaymentRequestTokenandreceipt.Fix
The JWT proof is the only authoritative source.
verifyProofnow returns the credential decoded from the verified proof (viaparseJwtCredential, which calls did-jwt-vc'sverifyCredential— signature verification is unchanged).verifyParsedCredentialruns all trust decisions against that verified credential and returns it.verifyPaymentReceiptuses the returned verified credential for thepaymentRequestTokenread and the returnedreceipt.The JWT-string input paths were already safe (their object is derived from the verified JWT), so legitimate flows are unchanged.
verifyProof/verifyParsedCredentialreturn types widen fromvoidtoVerifiable<W3CCredential>— backward compatible for existing statement callers.Verification
verifyParsedCredentialandverifyPaymentReceipt's object path).vc48/48 andack-pay34/34 pass; fullpnpm run checkis green.Notes
JwtStringinputs are already safe.parseJwtCredential(centralizes the one remaining unchecked cast).🤖 Generated with Claude Code