Ap2/verify cnf and reference binding - #616
Open
mike-parkhill wants to merge 10 commits into
Open
Conversation
…d of trusting the caller verifyClosedCheckoutMandate/verifyClosedPaymentMandate previously verified the envelope signature against whatever publicKey/holderJwk the caller supplied, with no check that it was actually the key the Open Mandate endorsed via cnf.jwk. A Closed Mandate closed with an unauthorized key would still "verify" as long as the caller (wrongly or maliciously) vouched for that same key -- signature validity says nothing about *whose* key it was authorized to be. Both functions now derive the expected key from openMandatePresentation's own cnf.jwk when it's supplied (the recommended way to call them), falling back to the old caller-supplied-key behavior only when openMandatePresentation is omitted. Adds resolveOpenCheckoutMandateContent (mirroring the existing resolveOpenPaymentMandateContent) since deriving a Checkout Mandate's cnf needed a checkout-side content resolver that didn't exist yet. Also adds an openCheckoutMandatePresentation parameter to verifyClosedPaymentMandate, closing a second caller-left-open gap: the payment.reference constraint's conditional_transaction_id binding to a specific checkout, now verified against a fresh sd_hash and returned as referenceVerified. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…erification verifyClosedCheckoutMandate/verifyClosedPaymentMandate derived the verification key from the Open Mandate's cnf.jwk when openMandatePresentation was supplied, but fell back to trusting a directly-supplied publicKey/ holderJwk when it was omitted -- exactly the insecure behavior the cnf- derivation fix was meant to close. A caller that forgot (or chose not) to pass openMandatePresentation got silent, unauthenticated-key verification back, with no signal that the security guarantee had been dropped. The package's own test suite demonstrated this: a Closed Mandate closed by an attacker's key still verified as true under the fallback path. Since there's no production usage of this package yet, remove the fallback outright rather than carry it forward: openMandatePresentation is now a required parameter, publicKey/holderJwk are removed, and the sd_hash check (previously also conditional on openMandatePresentation) is now unconditional as a side effect. Updates the pending changeset in place to describe the corrected behavior, since it was never released. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ture verifyClosedCheckoutMandate/verifyClosedPaymentMandate derive their verification key from the referenced Open Mandate's cnf.jwk, but never checked that the Open Mandate itself was actually signed by a legitimate User -- resolveOpenCheckoutMandateContent/resolveOpenPaymentMandateContent only check internal digest/schema/expiry self-consistency, by design (the User's key isn't embedded anywhere in the protocol data). That left the entire trust chain unanchored: an attacker could fabricate a whole Open + Closed Mandate chain naming their own key in cnf.jwk, and every check that existed (digest consistency, cnf-derived signature, sd_hash) would pass, since they're all self-referential to the forged chain. Add a required `userPublicKey` parameter to both functions -- the User's key, resolved independently by the caller (DID resolution, a wallet registry, whatever identity system applies) -- and verify it against the Open Mandate's own issuer envelope before trusting its cnf.jwk delegation. Also verify it against openCheckoutMandatePresentation's issuer when supplied, since the reference-binding check was reading its sd_hash without ever confirming who signed it either. resolveOpenCheckoutMandateContent/resolveOpenPaymentMandateContent gain a matching optional userPublicKey param (returning issuerVerified: true) for direct callers; they still support decode-without-verify for legitimate preview use cases, but the two verify* entry points that assert `verified: true` can no longer skip this check. Updates the pending (unreleased) changeset in place rather than adding a new one, since it describes the same not-yet-shipped feature. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
verifyClosedCheckoutMandate/verifyClosedPaymentMandate never checked the envelope's typ claim, unlike verifyReceipt (which already requires typ === "JWT"). In practice a swapped-in Open Mandate presentation (typ: dc+sd-jwt) would usually be caught downstream anyway (wrong content shape for _sd/aud), but that's incidental, not enforced -- and a self-delegated Open Mandate (cnf.jwk equal to the signer's own key) shows the gap directly: its envelope signature verifies fine against the key verifyClosed*Mandate derives, with nothing else stopping it from being accepted as a Closed Mandate. Add an explicit typ === "kb+sd-jwt" check, run immediately after signature verification (before any content-shape checks) so it's a real gate rather than an implicit side effect of other validation. Tests use the self-delegation construction above to isolate the typ check as the only thing standing between a valid signature and a false "verified: true". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
signClosedCheckoutMandate/signClosedPaymentMandate accept a nonce and embed it in the envelope, but verifyClosedCheckoutMandate/verifyClosedPaymentMandate never checked it against anything. Closed Mandate envelopes also carry no exp of their own -- their only expiry is inherited from the referenced Open Mandate, which is typically valid for an entire shopping session. Together that meant a validly-signed Closed Mandate presentation could be replayed for a different transaction indefinitely; nonce was accepted at signing time but enforced nowhere. Add a required expectedNonce parameter to both functions, checked against the envelope's nonce claim -- the same pattern as expectedIssuer/ expectedReference on verifyReceipt. Callers pass the single-use value they themselves generated and track for this specific transaction. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
verifyClosedCheckoutMandate/verifyClosedPaymentMandate now require userPublicKey, and the package deliberately has no opinion on how a caller obtains it -- that's an external trust decision (DID resolution, a wallet registry, whatever identity system applies), not something derivable from the mandate presentation itself. Add an example showing the shape of that lookup for a DID-identified User: resolve to a DID Document, pull the publicKeyJwk out of a verification method, convert via jwkToSecp256r1PublicKey, and feed it into the existing verification flow. The DID resolution step itself is stubbed (a Map standing in for a resolver) rather than pulling in a real resolution library -- reimplementing did:key's multicodec parsing or pulling in credential-sdk's full DID resolution stack would be disproportionate for an illustrative example, and getting either wrong in example code would be worse than not showing it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
AP2 v0.2 (confirmed current -- no later version exists) defines no revocation mechanism for Mandates at all; per spec, managing/revoking a delegated Mandate is explicitly out of scope and left to the Shopping Agent/wallet UI layer, with no protocol-level status check a verifier can query. exp is the only verifier-enforceable lifecycle control, and it's merely RECOMMENDED (not required) and schema-optional even on Open Mandates -- a mandate issued with no exp stays valid, and closeable, indefinitely. Add a README section spelling this out (with spec citations) plus a note on why a proprietary revocation extension would be actively counterproductive (invisible to other AP2 verifiers, and the spec requires unknown constraints to fail evaluation, so a constraint-based scheme would break interop rather than add safety). Add matching JSDoc notes to buildOpenCheckoutMandate/buildOpenPaymentMandate pointing back to the README section. No behavior change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR hardens AP2 Closed Mandate verification by removing caller-trusted verification-key fallbacks and enforcing additional bindings/claims (issuer verification, typ, nonce, and reference binding), plus updating docs, examples, tests, and versioning to match the breaking API changes.
Changes:
- Make
verifyClosedCheckoutMandate/verifyClosedPaymentMandatederive the verification key strictly from the referenced Open Mandate’scnf.jwk, and requireopenMandatePresentation,userPublicKey, andexpectedNonce. - Add Open Checkout resolver (
resolveOpenCheckoutMandateContent) and extend Closed Payment verification with optional checkout-reference binding (openCheckoutMandatePresentation→referenceVerified). - Update README, add DID-based verification example, expand tests, and bump
@docknetwork/ap2to0.5.0with a corresponding changelog entry.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| packages/ap2/src/mandates.js | Enforces stricter Closed Mandate verification (no caller key fallback), adds nonce + typ checks, issuer verification, Open Checkout resolver, and payment reference binding. |
| packages/ap2/tests/mandates.test.js | Updates existing tests for the new required verification inputs and adds extensive new coverage for the hardened checks. |
| packages/ap2/README.md | Documents the new required verification parameters, adds lifecycle/revocation clarification, and references the new DID example. |
| packages/ap2/examples/verify-mandate-with-did-user-key.mjs | Adds an example showing how to resolve userPublicKey from a DID document and use it during verification. |
| packages/ap2/package.json | Bumps version to 0.5.0 and adds an npm script to run the new DID example. |
| packages/ap2/CHANGELOG.md | Documents the breaking verification API changes and the new behaviors in 0.5.0. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+702
to
+711
| let referenceVerified; | ||
| if (openCheckoutMandatePresentation !== undefined) { | ||
| const { issuerJwt: checkoutIssuerJwt, disclosures: checkoutDisclosures } = parseSdJwtPresentation( | ||
| openCheckoutMandatePresentation, | ||
| ); | ||
| verifyOpenMandateIssuer(checkoutIssuerJwt, userPublicKey, OPEN_CHECKOUT_MANDATE_LABEL); | ||
| const reference = (openPaymentContent.constraints ?? []).find((c) => c?.type === 'payment.reference'); | ||
| const expectedTransactionId = computeSdHash({ issuerJwt: checkoutIssuerJwt, disclosures: checkoutDisclosures }); | ||
| referenceVerified = reference?.conditional_transaction_id === expectedTransactionId; | ||
| } |
…ing referenceVerified Flagged by Copilot review on PR #616 (review comment r3693310654): verifyClosedPaymentMandate's reference-binding branch only verified openCheckoutMandatePresentation's raw envelope signature (verifyOpenMandateIssuer), never resolving it through resolveOpenCheckoutMandateContent -- so its disclosed content's schema and its own exp (both of which live on the content, not the envelope) were never checked. An expired or otherwise invalid Open Checkout Mandate presentation could still produce referenceVerified: true. Replace the raw parseSdJwtPresentation + verifyOpenMandateIssuer pair with resolveOpenCheckoutMandateContent (schema + exp + issuer, matching the pattern already used for openMandatePresentation itself), keeping a separate parseSdJwtPresentation call afterward only for the sd_hash computation, which operates on the raw issuerJwt/disclosures rather than the resolved content. Added a test confirming an expired openCheckoutMandatePresentation now fails the whole verifyClosedPaymentMandate call instead of silently computing referenceVerified from it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (1)
packages/ap2/src/mandates.js:493
requireUserPublicKeyonly checks forundefined, sonull(or other obviously-invalid sentinels) will pass the precondition and then fail later insideverifyJWTwith a less actionable error. SinceuserPublicKeyis now required for security-critical issuer verification, it should rejectnullexplicitly (and ideally any non-object-ish values) with the same clear "is required" message.
function requireUserPublicKey(userPublicKey) {
if (userPublicKey === undefined) {
throw new TypeError(
'"userPublicKey" is required: without it, the Open Mandate\'s own issuer signature -- '
+ 'and therefore its cnf.jwk delegation -- is never verified, and the whole mandate chain '
+ 'could be entirely self-forged',
);
}
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Harden AP2 Closed Mandate verification — close every gap where trust was silently deferred to the caller instead of enforced.
verifyClosedCheckoutMandate/verifyClosedPaymentMandate accumulated several places where a signature check passed but the thing that actually mattered wasn't verified. Since this package has no production usage yet, each gap is closed outright rather than kept as a fallback:
Caller-supplied-key fallback removed. publicKey/holderJwk options are gone; the verification key is always derived from the Open Mandate's own cnf.jwk. openMandatePresentation is now required.
Open Mandate issuer now verified. New required userPublicKey param — the caller's independently-resolved trusted key for the delegating User — checked against the Open Mandate's own envelope. Without this, an attacker could fabricate an entire Open + Closed Mandate chain naming their own key in cnf.jwk, and every other check would pass. resolveOpenCheckoutMandateContent/resolveOpenPaymentMandateContent gained a matching optional userPublicKey for direct callers.
Envelope typ now checked (kb+sd-jwt), rejecting an Open Mandate or other envelope type fed in where a Closed one is expected.
Replay protection: nonce is now enforced. New required expectedNonce param, checked against the envelope's nonce. Closed Mandate envelopes carry no exp of their own, so previously a validly-signed presentation could be replayed indefinitely — nonce was accepted at signing time but never checked at verification time.
Also:
verifyClosedPaymentMandate gained openCheckoutMandatePresentation (verifies the Open Payment Mandate's payment.reference binding, previously left to the caller) and resolveOpenCheckoutMandateContent was added (mirrors the payment-side resolver).
Docs: documented that AP2 v0.2 defines no revocation mechanism at all (confirmed against the spec — explicitly out of scope, exp is the only verifier-enforceable lifecycle control and is only RECOMMENDED, not required); added an example resolving userPublicKey from a DID.
Added .d.ts type declarations to this package and @docknetwork/crypto-utils (unrelated tooling fix, bundled in since both landed in this window).
Breaking: yes, but pre-1.0 and unreleased — no downstream consumers exist yet. Confirmed via repo-wide search across wallet-sdk (no usage), ap2-example-app (no direct usage, only via MCP calls to truvera-mcp-server), and truvera-mcp-server (3 call sites in wallet-server/truvera-api need updating — tracked separately, not part of this PR).