Skip to content

Ap2/verify cnf and reference binding - #616

Open
mike-parkhill wants to merge 10 commits into
masterfrom
ap2/verify-cnf-and-reference-binding
Open

Ap2/verify cnf and reference binding#616
mike-parkhill wants to merge 10 commits into
masterfrom
ap2/verify-cnf-and-reference-binding

Conversation

@mike-parkhill

Copy link
Copy Markdown
Contributor

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).

mike-parkhill and others added 8 commits July 31, 2026 15:24
…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>

Copilot AI 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.

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 / verifyClosedPaymentMandate derive the verification key strictly from the referenced Open Mandate’s cnf.jwk, and require openMandatePresentation, userPublicKey, and expectedNonce.
  • Add Open Checkout resolver (resolveOpenCheckoutMandateContent) and extend Closed Payment verification with optional checkout-reference binding (openCheckoutMandatePresentationreferenceVerified).
  • Update README, add DID-based verification example, expand tests, and bump @docknetwork/ap2 to 0.5.0 with 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;
}
mike-parkhill and others added 2 commits July 31, 2026 17:24
…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>

Copilot AI 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.

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

  • requireUserPublicKey only checks for undefined, so null (or other obviously-invalid sentinels) will pass the precondition and then fail later inside verifyJWT with a less actionable error. Since userPublicKey is now required for security-critical issuer verification, it should reject null explicitly (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',
    );
  }

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.

2 participants