From 29dea583eb1b2785a0d9d827bc4ad73de75f909b Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Fri, 31 Jul 2026 15:24:08 -0400 Subject: [PATCH 01/11] fix(ap2): verify holder key against the Open Mandate's cnf.jwk instead 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 --- .changeset/strong-otters-verify.md | 28 +++ packages/ap2/src/mandates.js | 156 ++++++++++++++--- packages/ap2/tests/mandates.test.js | 256 +++++++++++++++++++++++++++- 3 files changed, 414 insertions(+), 26 deletions(-) create mode 100644 .changeset/strong-otters-verify.md diff --git a/.changeset/strong-otters-verify.md b/.changeset/strong-otters-verify.md new file mode 100644 index 000000000..c5e4a2aea --- /dev/null +++ b/.changeset/strong-otters-verify.md @@ -0,0 +1,28 @@ +--- +"@docknetwork/ap2": minor +--- + +`verifyClosedCheckoutMandate` and `verifyClosedPaymentMandate` now derive the +envelope's expected signing key from the referenced Open Mandate's own +`cnf.jwk` when `openMandatePresentation` is supplied, instead of trusting a +separately passed `publicKey`/`holderJwk`. Previously, a caller that +verified a signature against a self-supplied key had no guarantee that key +was ever the one the Open Mandate actually endorsed as its holder — a +Closed Mandate signed by an unauthorized key would still "verify" as long +as the caller supplied that same (wrong) key. Passing `publicKey`/`holderJwk` +without `openMandatePresentation` continues to work exactly as before. + +Added `resolveOpenCheckoutMandateContent`, mirroring +`resolveOpenPaymentMandateContent` for Open Checkout Mandates, which the fix +above uses internally and which callers can also use directly (e.g. to +enforce checkout constraints or re-verify `cnf.jwk` before closing a mandate, +the way `issueClosedPaymentMandate`-style flows already do for payments). + +`verifyClosedPaymentMandate` also accepts a new optional +`openCheckoutMandatePresentation` parameter. When supplied alongside +`openMandatePresentation`, it verifies the Open Payment Mandate's +`payment.reference.conditional_transaction_id` constraint against a fresh +`sd_hash` of that Open Checkout Mandate presentation, returning +`referenceVerified` in the result. This was previously left entirely to the +caller (see the AP2 spec's Reference constraint) even though the package +already required both presentations for its other checks. diff --git a/packages/ap2/src/mandates.js b/packages/ap2/src/mandates.js index d7712e88e..015db05dd 100644 --- a/packages/ap2/src/mandates.js +++ b/packages/ap2/src/mandates.js @@ -26,6 +26,7 @@ export { } from './utils'; const OPEN_PAYMENT_MANDATE_LABEL = 'Open Payment Mandate'; +const OPEN_CHECKOUT_MANDATE_LABEL = 'Open Checkout Mandate'; const DEFAULT_SD_ALG = 'sha-256'; const SD_HASH_ALGORITHMS = { @@ -463,13 +464,20 @@ function resolveCheckoutJwtClaim(content, disclosures, sdAlg) { /** * Verifies a Closed Checkout Mandate presentation. * - * Verifies: the envelope signature against `publicKey` (or `holderJwk`, the - * agent's `cnf.jwk` from the referenced Open Checkout Mandate — pass either), - * the `checkout_jwt` disclosure against the content's `_sd` digest, - * `checkout_hash` against a fresh hash of the revealed `checkout_jwt`, - * `aud === "merchant"`, and — when `openMandatePresentation` is supplied — - * `sd_hash` against that presentation. See `signClosedCheckoutMandate` for - * the `sd_hash` caveat. + * Verifies: the envelope signature against the referenced Open Checkout + * Mandate's own `cnf.jwk` (derived from `openMandatePresentation`, which is + * therefore the recommended way to call this) — falling back to a directly + * supplied `publicKey`/`holderJwk` only when `openMandatePresentation` is + * omitted. Deriving the key from the Open Mandate itself, rather than + * trusting whatever key a caller separately claims is the holder, is what + * actually enforces that this presentation was closed by the party the Open + * Mandate delegated to — a caller-supplied key that happens to verify a + * signature proves nothing about *whose* key it was authorized to be. + * Also verifies the `checkout_jwt` disclosure against the content's `_sd` + * digest, `checkout_hash` against a fresh hash of the revealed + * `checkout_jwt`, `aud === "merchant"`, and — since `openMandatePresentation` + * is now also used for key derivation — `sd_hash` against that same + * presentation. See `signClosedCheckoutMandate` for the `sd_hash` caveat. * * @param {string} presentation * @param {{publicKey?: *, holderJwk?: object, openMandatePresentation?: string, @@ -485,7 +493,12 @@ export function verifyClosedCheckoutMandate( ) { try { const { issuerJwt, disclosures } = parseSdJwtPresentation(presentation); - const result = verifyJWT(issuerJwt, resolvePublicKey({ publicKey, holderJwk }), { algorithms: ['ES256'] }); + const verificationKey = openMandatePresentation !== undefined + ? jwkToSecp256r1PublicKey( + resolveOpenCheckoutMandateContent(openMandatePresentation, { currentDate, clockTolerance }).content.cnf.jwk, + ) + : resolvePublicKey({ publicKey, holderJwk }); + const result = verifyJWT(issuerJwt, verificationKey, { algorithms: ['ES256'] }); if (!result.verified) { return result; } @@ -526,35 +539,51 @@ export function verifyClosedCheckoutMandate( /** * Verifies a Closed Payment Mandate presentation. * - * Verifies: the envelope signature against `publicKey` (the agent's `cnf` - * key from the referenced Open Payment Mandate), `aud === "credential-provider"`, - * and — when the corresponding Closed Checkout Mandate's `checkout_jwt` is - * supplied — that `transaction_id` matches a fresh hash of it (the same - * `checkout_hash` computation). When `openMandatePresentation` is supplied, - * also verifies `sd_hash` against it. See `signClosedCheckoutMandate` for the - * `sd_hash` caveat. + * Verifies: the envelope signature against the referenced Open Payment + * Mandate's own `cnf.jwk` (derived from `openMandatePresentation`, which is + * therefore the recommended way to call this) — falling back to a directly + * supplied `publicKey`/`holderJwk` only when `openMandatePresentation` is + * omitted. See `verifyClosedCheckoutMandate`'s doc comment for why deriving + * the key from the mandate itself matters. Also verifies + * `aud === "credential-provider"`; when the corresponding Closed Checkout + * Mandate's `checkout_jwt` is supplied, that `transaction_id` matches a fresh + * hash of it; since `openMandatePresentation` is now also used for key + * derivation, `sd_hash` is verified against it too. See + * `signClosedCheckoutMandate` for the `sd_hash` caveat. * - * This does not verify the `conditional_transaction_id` binding (Open Payment - * Mandate's `payment.reference` constraint against the checkout's delegate - * chain) — that check requires the checkout's full delegate chain and is - * left to the caller, which is expected to already hold both mandates when - * assembling a payment authorization decision. + * When `openCheckoutMandatePresentation` is also supplied (alongside + * `openMandatePresentation`), also verifies the Open Payment Mandate's + * `payment.reference.conditional_transaction_id` constraint against a fresh + * `sd_hash` of that Open Checkout Mandate presentation — the binding that + * ties this payment authorization to one specific checkout. Previously this + * package left that check entirely to the caller (see the AP2 spec's + * Reference constraint); it's now handled here since the caller already has + * to supply both presentations for the other checks above. * * @param {string} presentation * @param {{publicKey?: *, holderJwk?: object, checkoutJwt?: string, openMandatePresentation?: string, - * currentDate?: Date, clockTolerance?: number}} options + * openCheckoutMandatePresentation?: string, currentDate?: Date, clockTolerance?: number}} options * @returns {{verified: boolean, content?: object, protectedHeader?: object, - * transactionIdVerified?: boolean, sdHashVerified?: boolean, error?: Error}} + * transactionIdVerified?: boolean, sdHashVerified?: boolean, referenceVerified?: boolean, error?: Error}} */ export function verifyClosedPaymentMandate( presentation, { - publicKey, holderJwk, checkoutJwt, openMandatePresentation, currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE, + publicKey, holderJwk, checkoutJwt, openMandatePresentation, openCheckoutMandatePresentation, + currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE, } = {}, ) { try { const { issuerJwt, disclosures } = parseSdJwtPresentation(presentation); - const result = verifyJWT(issuerJwt, resolvePublicKey({ publicKey, holderJwk }), { algorithms: ['ES256'] }); + + const openPaymentContent = openMandatePresentation !== undefined + ? resolveOpenPaymentMandateContent(openMandatePresentation, { currentDate, clockTolerance }).content + : undefined; + const verificationKey = openPaymentContent !== undefined + ? jwkToSecp256r1PublicKey(openPaymentContent.cnf.jwk) + : resolvePublicKey({ publicKey, holderJwk }); + + const result = verifyJWT(issuerJwt, verificationKey, { algorithms: ['ES256'] }); if (!result.verified) { return result; } @@ -581,11 +610,22 @@ export function verifyClosedPaymentMandate( OPEN_PAYMENT_MANDATE_LABEL, ); + let referenceVerified; + if (openPaymentContent !== undefined && openCheckoutMandatePresentation !== undefined) { + const reference = (openPaymentContent.constraints ?? []).find((c) => c?.type === 'payment.reference'); + const { issuerJwt: checkoutIssuerJwt, disclosures: checkoutDisclosures } = parseSdJwtPresentation( + openCheckoutMandatePresentation, + ); + const expectedTransactionId = computeSdHash({ issuerJwt: checkoutIssuerJwt, disclosures: checkoutDisclosures }); + referenceVerified = reference?.conditional_transaction_id === expectedTransactionId; + } + return { verified: true, content: validatedContent, protectedHeader, ...(transactionIdVerified === undefined ? {} : { transactionIdVerified }), + ...(referenceVerified === undefined ? {} : { referenceVerified }), ...(sdHashVerified === undefined ? {} : { sdHashVerified }), }; } catch (error) { @@ -662,3 +702,71 @@ export function resolveOpenPaymentMandateContent( return { content, protectedHeader }; } + +// Reveals the array-element disclosures inside an Open Checkout Mandate's +// resolved content -- the reverse of `redactCheckoutConstraints`. Entries +// that are already plain objects (not SD-redacted) are passed through +// unchanged. +function resolveCheckoutConstraintDisclosures(content, disclosures, sdAlg) { + const revealEntry = (entry) => { + const digest = entry?.['...']; + if (typeof digest !== 'string') { + return entry; + } + const disclosure = findDisclosureByDigest(disclosures, digest, sdAlg); + if (!disclosure) { + throw new Error('No disclosure found for a checkout constraint array-element digest'); + } + return decodeArrayElementDisclosure(disclosure); + }; + + const constraints = (content.constraints ?? []).map((constraint) => { + if (constraint?.type === 'checkout.line_items' && Array.isArray(constraint.items)) { + return { + ...constraint, + items: constraint.items.map((item) => ( + Array.isArray(item?.acceptable_items) + ? { ...item, acceptable_items: item.acceptable_items.map(revealEntry) } + : item + )), + }; + } + if (constraint?.type === 'checkout.allowed_merchants' && Array.isArray(constraint.allowed)) { + return { ...constraint, allowed: constraint.allowed.map(revealEntry) }; + } + return constraint; + }); + return { ...content, constraints }; +} + +/** + * Decodes and resolves an Open Checkout Mandate presentation's own content + * (`constraints`, `cnf`, `exp`, ...) -- WITHOUT verifying an issuer signature. + * See `resolveOpenPaymentMandateContent`'s doc comment for the same caveat: + * Open Checkout Mandates are signed by the User's key, which this package + * cannot check on its own without the caller separately supplying it. + * + * @param {string} presentation + * @param {{currentDate?: Date, clockTolerance?: number}} options + * @returns {{content: object, protectedHeader: object}} + */ +export function resolveOpenCheckoutMandateContent( + presentation, + { currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE } = {}, +) { + const { issuerJwt, disclosures } = parseSdJwtPresentation(presentation); + const payload = decodeJwtPayload(issuerJwt, OPEN_CHECKOUT_MANDATE_LABEL); + const protectedHeader = decodeJwtProtectedHeader(issuerJwt, OPEN_CHECKOUT_MANDATE_LABEL); + const sdAlg = getSdAlg(payload); + + const rawContent = resolveMandateContent(payload, disclosures, sdAlg); + const resolvedContent = resolveCheckoutConstraintDisclosures(rawContent, disclosures, sdAlg); + const content = buildOpenCheckoutMandate(resolvedContent); + + // Unlike Open Payment Mandates, an Open Checkout Mandate's `exp` (like its + // other properties) lives on the disclosed content, not the outer envelope + // payload -- same reasoning as `resolveOpenPaymentMandateContent`. + checkNotExpired(content, { currentDate, clockTolerance }, OPEN_CHECKOUT_MANDATE_LABEL); + + return { content, protectedHeader }; +} diff --git a/packages/ap2/tests/mandates.test.js b/packages/ap2/tests/mandates.test.js index 67d457fd5..800a4b1c2 100644 --- a/packages/ap2/tests/mandates.test.js +++ b/packages/ap2/tests/mandates.test.js @@ -20,6 +20,7 @@ import { verifyClosedCheckoutMandate, verifyClosedPaymentMandate, resolveOpenPaymentMandateContent, + resolveOpenCheckoutMandateContent, computeDisclosureDigest, computeCheckoutHash, } from '../src'; @@ -113,7 +114,7 @@ describe('AP2 mandates: round trip', () => { ], cnf, iat: 1000, - exp: 100000, + exp: 4102444800, // 2100-01-01 -- resolving the Open Mandate now enforces its own expiry too }); const openCheckoutPresentation = await signOpenCheckoutMandate(openCheckoutContent, { signer: userKeypair, @@ -151,7 +152,7 @@ describe('AP2 mandates: round trip', () => { ], cnf, iat: 1000, - exp: 100000, + exp: 4102444800, // 2100-01-01 -- resolving the Open Mandate now enforces its own expiry too }); const openPaymentPresentation = await signOpenPaymentMandate(openPaymentContent, { signer: userKeypair, @@ -359,3 +360,254 @@ describe('AP2 mandates: resolveOpenPaymentMandateContent', () => { expect(content.vct).toBe('mandate.payment.open.1'); }); }); + +describe('AP2 mandates: resolveOpenCheckoutMandateContent', () => { + test('resolves line_items and allowed_merchants constraints', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + const merchant = { id: 'merchant_1', name: 'Demo Merchant', website: 'https://demo-merchant.example' }; + + const presentation = await signOpenCheckoutMandate( + buildOpenCheckoutMandate({ + vct: VCT_CHECKOUT_OPEN, + constraints: [ + MINIMAL_LINE_ITEMS_CONSTRAINT, + { type: 'checkout.allowed_merchants', allowed: [merchant] }, + ], + cnf: { jwk: secp256r1PublicKeyToJwk(agentKeypair.publicKey()) }, + }), + { signer: userKeypair }, + ); + + const { content } = resolveOpenCheckoutMandateContent(presentation); + expect(content.constraints).toEqual([ + MINIMAL_LINE_ITEMS_CONSTRAINT, + { type: 'checkout.allowed_merchants', allowed: [merchant] }, + ]); + expect(content.cnf.jwk).toMatchObject({ crv: 'P-256', kty: 'EC' }); + }); + + test('rejects a hand-edited presentation whose content no longer matches its digest', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + + const presentation = await signOpenCheckoutMandate( + buildOpenCheckoutMandate({ + vct: VCT_CHECKOUT_OPEN, + constraints: [MINIMAL_LINE_ITEMS_CONSTRAINT], + cnf: { jwk: secp256r1PublicKeyToJwk(agentKeypair.publicKey()) }, + }), + { signer: userKeypair }, + ); + + const parts = presentation.split('~'); + const contentDisclosure = parts[parts.length - 2]; + const decoded = JSON.parse(Buffer.from(contentDisclosure, 'base64url').toString('utf8')); + decoded[1].constraints[0].items[0].quantity = 999; + const tamperedDisclosure = Buffer.from(JSON.stringify(decoded)).toString('base64url'); + const tamperedPresentation = [...parts.slice(0, -2), tamperedDisclosure, ''].join('~'); + + expect(() => resolveOpenCheckoutMandateContent(tamperedPresentation)).toThrow( + /No disclosure found/, + ); + }); + + test('rejects an expired Open Checkout Mandate', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + + const presentation = await signOpenCheckoutMandate( + buildOpenCheckoutMandate({ + vct: VCT_CHECKOUT_OPEN, + constraints: [MINIMAL_LINE_ITEMS_CONSTRAINT], + cnf: { jwk: secp256r1PublicKeyToJwk(agentKeypair.publicKey()) }, + iat: 1000, + exp: 2000, + }), + { signer: userKeypair }, + ); + + expect(() => resolveOpenCheckoutMandateContent(presentation, { + currentDate: new Date(3000 * 1000), + })).toThrow(/expired/); + }); +}); + +describe('AP2 mandates: cnf is derived from the Open Mandate, not trusted from the caller', () => { + test('verifyClosedCheckoutMandate rejects a mandate closed by an unauthorized key, even when the caller vouches for that same key', async () => { + const userKeypair = Secp256r1Keypair.random(); + const authorizedAgentKeypair = Secp256r1Keypair.random(); + const attackerKeypair = Secp256r1Keypair.random(); + + const openCheckoutPresentation = await signOpenCheckoutMandate( + buildOpenCheckoutMandate({ + vct: VCT_CHECKOUT_OPEN, + constraints: [MINIMAL_LINE_ITEMS_CONSTRAINT], + // Only authorizedAgentKeypair is endorsed to close this mandate. + cnf: { jwk: secp256r1PublicKeyToJwk(authorizedAgentKeypair.publicKey()) }, + }), + { signer: userKeypair }, + ); + + // The attacker signs their own Closed Checkout Mandate against the same + // Open Mandate, using their own key instead of the authorized one. + const closedCheckoutPresentation = await signClosedCheckoutMandate( + buildClosedCheckoutMandate({ + vct: VCT_CHECKOUT_CLOSED, + checkout_jwt: SAMPLE_CHECKOUT_JWT, + checkout_hash: computeCheckoutHash(SAMPLE_CHECKOUT_JWT), + }), + { + signer: attackerKeypair, + nonce: 'nonce-1', + openMandatePresentation: openCheckoutPresentation, + }, + ); + + // A caller naively trusting the attacker's own claim of "I'm the holder" + // (passing the attacker's key directly, with no openMandatePresentation) + // would incorrectly see this as verified -- signature-only verification + // can't tell an unauthorized key from an authorized one. + const signatureOnlyResult = verifyClosedCheckoutMandate(closedCheckoutPresentation, { + publicKey: attackerKeypair.publicKey(), + }); + expect(signatureOnlyResult.verified).toBe(true); + + // But deriving the key from the actual Open Mandate rejects it, because + // the attacker's key was never the one endorsed in cnf. + const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, { + publicKey: attackerKeypair.publicKey(), + openMandatePresentation: openCheckoutPresentation, + }); + expect(result.verified).toBe(false); + }); + + test('verifyClosedPaymentMandate rejects a mandate closed by an unauthorized key, even when the caller vouches for that same key', async () => { + const userKeypair = Secp256r1Keypair.random(); + const authorizedAgentKeypair = Secp256r1Keypair.random(); + const attackerKeypair = Secp256r1Keypair.random(); + + const openPaymentPresentation = await signOpenPaymentMandate( + buildOpenPaymentMandate({ + vct: 'mandate.payment.open.1', + constraints: [{ type: 'payment.reference', conditional_transaction_id: 'digest-1' }], + cnf: { jwk: secp256r1PublicKeyToJwk(authorizedAgentKeypair.publicKey()) }, + }), + { signer: userKeypair }, + ); + + const closedPaymentPresentation = await signClosedPaymentMandate( + buildClosedPaymentMandate({ + vct: 'mandate.payment.1', + transaction_id: computeCheckoutHash(SAMPLE_CHECKOUT_JWT), + payee: { id: 'merchant_1', name: 'Demo Merchant', website: 'https://demo-merchant.example' }, + payment_amount: { amount: 19900, currency: 'USD' }, + payment_instrument: { id: 'stub', type: 'card', description: 'Card ****4242' }, + }), + { + signer: attackerKeypair, + nonce: 'nonce-1', + openMandatePresentation: openPaymentPresentation, + }, + ); + + const signatureOnlyResult = verifyClosedPaymentMandate(closedPaymentPresentation, { + publicKey: attackerKeypair.publicKey(), + }); + expect(signatureOnlyResult.verified).toBe(true); + + const result = verifyClosedPaymentMandate(closedPaymentPresentation, { + publicKey: attackerKeypair.publicKey(), + openMandatePresentation: openPaymentPresentation, + }); + expect(result.verified).toBe(false); + }); +}); + +describe('AP2 mandates: payment.reference binding', () => { + const merchant = { id: 'merchant_1', name: 'Demo Merchant', website: 'https://demo-merchant.example' }; + + async function buildBoundClosedPaymentMandate(userKeypair, agentKeypair, conditionalTransactionId) { + const openPaymentPresentation = await signOpenPaymentMandate( + buildOpenPaymentMandate({ + vct: 'mandate.payment.open.1', + constraints: [{ type: 'payment.reference', conditional_transaction_id: conditionalTransactionId }], + cnf: { jwk: secp256r1PublicKeyToJwk(agentKeypair.publicKey()) }, + }), + { signer: userKeypair }, + ); + const closedPaymentPresentation = await signClosedPaymentMandate( + buildClosedPaymentMandate({ + vct: 'mandate.payment.1', + transaction_id: computeCheckoutHash(SAMPLE_CHECKOUT_JWT), + payee: merchant, + payment_amount: { amount: 19900, currency: 'USD' }, + payment_instrument: { id: 'stub', type: 'card', description: 'Card ****4242' }, + }), + { signer: agentKeypair, nonce: 'nonce-1', openMandatePresentation: openPaymentPresentation }, + ); + return { openPaymentPresentation, closedPaymentPresentation }; + } + + test('verifies payment.reference against a fresh sd_hash of the supplied Open Checkout Mandate', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + + const openCheckoutPresentation = await signOpenCheckoutMandate( + buildOpenCheckoutMandate({ + vct: VCT_CHECKOUT_OPEN, + constraints: [MINIMAL_LINE_ITEMS_CONSTRAINT], + cnf: { jwk: secp256r1PublicKeyToJwk(userKeypair.publicKey()) }, + }), + { signer: userKeypair }, + ); + const conditionalTransactionId = computeSdHash(parseSdJwtPresentation(openCheckoutPresentation)); + const { openPaymentPresentation, closedPaymentPresentation } = await buildBoundClosedPaymentMandate( + userKeypair, + agentKeypair, + conditionalTransactionId, + ); + + const result = verifyClosedPaymentMandate(closedPaymentPresentation, { + openMandatePresentation: openPaymentPresentation, + openCheckoutMandatePresentation: openCheckoutPresentation, + }); + expect(result.verified).toBe(true); + expect(result.referenceVerified).toBe(true); + }); + + test('fails referenceVerified when payment.reference points at a different checkout', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + + const openCheckoutPresentation = await signOpenCheckoutMandate( + buildOpenCheckoutMandate({ + vct: VCT_CHECKOUT_OPEN, + constraints: [MINIMAL_LINE_ITEMS_CONSTRAINT], + cnf: { jwk: secp256r1PublicKeyToJwk(userKeypair.publicKey()) }, + }), + { signer: userKeypair }, + ); + const unrelatedCheckoutPresentation = await signOpenCheckoutMandate( + buildOpenCheckoutMandate({ + vct: VCT_CHECKOUT_OPEN, + constraints: [MINIMAL_LINE_ITEMS_CONSTRAINT], + cnf: { jwk: secp256r1PublicKeyToJwk(userKeypair.publicKey()) }, + }), + { signer: userKeypair }, + ); + const wrongTransactionId = computeSdHash(parseSdJwtPresentation(unrelatedCheckoutPresentation)); + const { openPaymentPresentation, closedPaymentPresentation } = await buildBoundClosedPaymentMandate( + userKeypair, + agentKeypair, + wrongTransactionId, + ); + + const result = verifyClosedPaymentMandate(closedPaymentPresentation, { + openMandatePresentation: openPaymentPresentation, + openCheckoutMandatePresentation: openCheckoutPresentation, + }); + expect(result.verified).toBe(true); + expect(result.referenceVerified).toBe(false); + }); +}); From 36215ea4bdaa87b8385969380f47175a09ae3b66 Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Fri, 31 Jul 2026 15:40:15 -0400 Subject: [PATCH 02/11] fix(ap2): remove the caller-supplied-key fallback in Closed Mandate verification 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 --- .changeset/strong-otters-verify.md | 22 +++-- packages/ap2/README.md | 11 ++- packages/ap2/src/mandates.js | 130 ++++++++++++---------------- packages/ap2/tests/mandates.test.js | 92 ++++++++++++++------ 4 files changed, 140 insertions(+), 115 deletions(-) diff --git a/.changeset/strong-otters-verify.md b/.changeset/strong-otters-verify.md index c5e4a2aea..027cc93b9 100644 --- a/.changeset/strong-otters-verify.md +++ b/.changeset/strong-otters-verify.md @@ -2,15 +2,19 @@ "@docknetwork/ap2": minor --- -`verifyClosedCheckoutMandate` and `verifyClosedPaymentMandate` now derive the -envelope's expected signing key from the referenced Open Mandate's own -`cnf.jwk` when `openMandatePresentation` is supplied, instead of trusting a -separately passed `publicKey`/`holderJwk`. Previously, a caller that -verified a signature against a self-supplied key had no guarantee that key -was ever the one the Open Mandate actually endorsed as its holder — a -Closed Mandate signed by an unauthorized key would still "verify" as long -as the caller supplied that same (wrong) key. Passing `publicKey`/`holderJwk` -without `openMandatePresentation` continues to work exactly as before. +`verifyClosedCheckoutMandate` and `verifyClosedPaymentMandate` now require +`openMandatePresentation` and always derive the envelope's expected signing +key from the referenced Open Mandate's own `cnf.jwk`. The `publicKey`/ +`holderJwk` parameters are removed entirely — there is no longer a way to +verify a Closed Mandate against a caller-supplied key. Previously, a caller +that verified a signature against a self-supplied key had no guarantee that +key was ever the one the Open Mandate actually endorsed as its holder — a +Closed Mandate signed by an unauthorized key would still "verify" as long as +the caller supplied that same (wrong) key. Since this package has no +production usage yet, that insecure fallback is removed rather than kept +for backwards compatibility: calling either function without +`openMandatePresentation` now returns `{ verified: false, error }` instead +of silently trusting an unverified key. Added `resolveOpenCheckoutMandateContent`, mirroring `resolveOpenPaymentMandateContent` for Open Checkout Mandates, which the fix diff --git a/packages/ap2/README.md b/packages/ap2/README.md index a95f2b634..0c0cd6ea3 100644 --- a/packages/ap2/README.md +++ b/packages/ap2/README.md @@ -34,9 +34,12 @@ const jwt = await signReceipt(receipt, { type: 'payment', }); // Verify the mandate first (signature, cnf key-binding, checkout binding, aud, expiry). +// openMandatePresentation is required -- it's how the trusted verification +// key is derived from the Open Mandate's own cnf.jwk, rather than trusted +// from a caller-supplied key. const mandateVerification = verifyClosedPaymentMandate( closedMandatePresentation, - { /* publicKey or holderJwk, checkoutJwt, openMandatePresentation, ... */ }, + { openMandatePresentation, checkoutJwt, openCheckoutMandatePresentation }, ); const result = verifyPaymentReceipt(jwt, { @@ -145,9 +148,11 @@ const closedCheckoutPresentation = await signClosedCheckoutMandate(closedCheckou openMandatePresentation: openCheckoutPresentation, }); -// 3. The Merchant/Credential Provider verifies it. +// 3. The Merchant/Credential Provider verifies it. The verification key is +// always derived from the Open Mandate's own cnf.jwk -- there is no +// caller-supplied-key option, since a caller-supplied key that happens to +// verify a signature proves nothing about *whose* key it was authorized to be. const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, { - holderJwk: agentPublicJwk, // or publicKey openMandatePresentation: openCheckoutPresentation, }); if (!result.verified) throw result.error; diff --git a/packages/ap2/src/mandates.js b/packages/ap2/src/mandates.js index 015db05dd..750845ef0 100644 --- a/packages/ap2/src/mandates.js +++ b/packages/ap2/src/mandates.js @@ -379,16 +379,6 @@ export async function signClosedPaymentMandate( return `${issuerJwt}~${contentDisclosure}~`; } -function resolvePublicKey({ publicKey, holderJwk }) { - if (publicKey !== undefined) { - return publicKey; - } - if (holderJwk !== undefined) { - return jwkToSecp256r1PublicKey(holderJwk); - } - throw new TypeError('Either "publicKey" or "holderJwk" is required'); -} - function resolveMandateContent(payload, disclosures, sdAlg) { if (!Array.isArray(payload.delegate_payload) || payload.delegate_payload.length !== 1) { throw new Error('"delegate_payload" must contain exactly one entry'); @@ -425,12 +415,7 @@ function checkAudience(payload, expectedAud) { } } -// Returns `true` if `sd_hash` was checked, `undefined` if `openMandatePresentation` -// was not supplied (verification of that binding is then left to the caller). function checkSdHashAgainstOpenMandate(payload, openMandatePresentation, label) { - if (openMandatePresentation === undefined) { - return undefined; - } const { issuerJwt: openIssuerJwt, disclosures: openDisclosures } = parseSdJwtPresentation( openMandatePresentation, ); @@ -438,7 +423,15 @@ function checkSdHashAgainstOpenMandate(payload, openMandatePresentation, label) if (payload.sd_hash !== expectedSdHash) { throw new Error(`"sd_hash" does not match the referenced ${label} presentation`); } - return true; +} + +function requireOpenMandatePresentation(openMandatePresentation) { + if (typeof openMandatePresentation !== 'string' || openMandatePresentation.length === 0) { + throw new TypeError( + '"openMandatePresentation" is required: the verification key is always derived from ' + + 'its cnf.jwk, never trusted from a caller-supplied key', + ); + } } // Resolves the checkout_jwt claim disclosure referenced by a Closed Checkout @@ -464,23 +457,21 @@ function resolveCheckoutJwtClaim(content, disclosures, sdAlg) { /** * Verifies a Closed Checkout Mandate presentation. * - * Verifies: the envelope signature against the referenced Open Checkout - * Mandate's own `cnf.jwk` (derived from `openMandatePresentation`, which is - * therefore the recommended way to call this) — falling back to a directly - * supplied `publicKey`/`holderJwk` only when `openMandatePresentation` is - * omitted. Deriving the key from the Open Mandate itself, rather than - * trusting whatever key a caller separately claims is the holder, is what - * actually enforces that this presentation was closed by the party the Open - * Mandate delegated to — a caller-supplied key that happens to verify a - * signature proves nothing about *whose* key it was authorized to be. - * Also verifies the `checkout_jwt` disclosure against the content's `_sd` - * digest, `checkout_hash` against a fresh hash of the revealed - * `checkout_jwt`, `aud === "merchant"`, and — since `openMandatePresentation` - * is now also used for key derivation — `sd_hash` against that same - * presentation. See `signClosedCheckoutMandate` for the `sd_hash` caveat. + * The envelope signature is verified against the referenced Open Checkout + * Mandate's own `cnf.jwk`, derived from the required `openMandatePresentation` + * — never against a caller-supplied key. Deriving the key from the Open + * Mandate itself, rather than trusting whatever key a caller separately + * claims is the holder, is what actually enforces that this presentation was + * closed by the party the Open Mandate delegated to — a caller-supplied key + * that happens to verify a signature proves nothing about *whose* key it was + * authorized to be. Also verifies the `checkout_jwt` disclosure against the + * content's `_sd` digest, `checkout_hash` against a fresh hash of the + * revealed `checkout_jwt`, `aud === "merchant"`, expiry, and `sd_hash` + * against `openMandatePresentation`. See `signClosedCheckoutMandate` for the + * `sd_hash` caveat. * * @param {string} presentation - * @param {{publicKey?: *, holderJwk?: object, openMandatePresentation?: string, + * @param {{openMandatePresentation: string, * currentDate?: Date, clockTolerance?: number}} options * @returns {{verified: boolean, content?: object, checkoutJwt?: string, * protectedHeader?: object, sdHashVerified?: boolean, error?: Error}} @@ -488,16 +479,15 @@ function resolveCheckoutJwtClaim(content, disclosures, sdAlg) { export function verifyClosedCheckoutMandate( presentation, { - publicKey, holderJwk, openMandatePresentation, currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE, + openMandatePresentation, currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE, } = {}, ) { try { + requireOpenMandatePresentation(openMandatePresentation); const { issuerJwt, disclosures } = parseSdJwtPresentation(presentation); - const verificationKey = openMandatePresentation !== undefined - ? jwkToSecp256r1PublicKey( - resolveOpenCheckoutMandateContent(openMandatePresentation, { currentDate, clockTolerance }).content.cnf.jwk, - ) - : resolvePublicKey({ publicKey, holderJwk }); + const verificationKey = jwkToSecp256r1PublicKey( + resolveOpenCheckoutMandateContent(openMandatePresentation, { currentDate, clockTolerance }).content.cnf.jwk, + ); const result = verifyJWT(issuerJwt, verificationKey, { algorithms: ['ES256'] }); if (!result.verified) { return result; @@ -518,18 +508,14 @@ export function verifyClosedCheckoutMandate( checkAudience(payload, 'merchant'); checkNotExpired(payload, { currentDate, clockTolerance }, 'Closed Checkout Mandate'); - const sdHashVerified = checkSdHashAgainstOpenMandate( - payload, - openMandatePresentation, - 'Open Checkout Mandate', - ); + checkSdHashAgainstOpenMandate(payload, openMandatePresentation, 'Open Checkout Mandate'); return { verified: true, content: validatedContent, checkoutJwt, protectedHeader, - ...(sdHashVerified === undefined ? {} : { sdHashVerified }), + sdHashVerified: true, }; } catch (error) { return { verified: false, error: error instanceof Error ? error : new Error(String(error)) }; @@ -539,29 +525,23 @@ export function verifyClosedCheckoutMandate( /** * Verifies a Closed Payment Mandate presentation. * - * Verifies: the envelope signature against the referenced Open Payment - * Mandate's own `cnf.jwk` (derived from `openMandatePresentation`, which is - * therefore the recommended way to call this) — falling back to a directly - * supplied `publicKey`/`holderJwk` only when `openMandatePresentation` is - * omitted. See `verifyClosedCheckoutMandate`'s doc comment for why deriving - * the key from the mandate itself matters. Also verifies - * `aud === "credential-provider"`; when the corresponding Closed Checkout - * Mandate's `checkout_jwt` is supplied, that `transaction_id` matches a fresh - * hash of it; since `openMandatePresentation` is now also used for key - * derivation, `sd_hash` is verified against it too. See - * `signClosedCheckoutMandate` for the `sd_hash` caveat. + * The envelope signature is verified against the referenced Open Payment + * Mandate's own `cnf.jwk`, derived from the required `openMandatePresentation` + * — never against a caller-supplied key. See `verifyClosedCheckoutMandate`'s + * doc comment for why deriving the key from the mandate itself matters. Also + * verifies `aud === "credential-provider"`, expiry, and `sd_hash` against + * `openMandatePresentation`; when the corresponding Closed Checkout Mandate's + * `checkout_jwt` is supplied, that `transaction_id` matches a fresh hash of + * it. * - * When `openCheckoutMandatePresentation` is also supplied (alongside - * `openMandatePresentation`), also verifies the Open Payment Mandate's - * `payment.reference.conditional_transaction_id` constraint against a fresh - * `sd_hash` of that Open Checkout Mandate presentation — the binding that - * ties this payment authorization to one specific checkout. Previously this - * package left that check entirely to the caller (see the AP2 spec's - * Reference constraint); it's now handled here since the caller already has - * to supply both presentations for the other checks above. + * When `openCheckoutMandatePresentation` is also supplied, also verifies the + * Open Payment Mandate's `payment.reference.conditional_transaction_id` + * constraint against a fresh `sd_hash` of that Open Checkout Mandate + * presentation — the binding that ties this payment authorization to one + * specific checkout (see the AP2 spec's Reference constraint). * * @param {string} presentation - * @param {{publicKey?: *, holderJwk?: object, checkoutJwt?: string, openMandatePresentation?: string, + * @param {{openMandatePresentation: string, checkoutJwt?: string, * openCheckoutMandatePresentation?: string, currentDate?: Date, clockTolerance?: number}} options * @returns {{verified: boolean, content?: object, protectedHeader?: object, * transactionIdVerified?: boolean, sdHashVerified?: boolean, referenceVerified?: boolean, error?: Error}} @@ -569,19 +549,19 @@ export function verifyClosedCheckoutMandate( export function verifyClosedPaymentMandate( presentation, { - publicKey, holderJwk, checkoutJwt, openMandatePresentation, openCheckoutMandatePresentation, + checkoutJwt, openMandatePresentation, openCheckoutMandatePresentation, currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE, } = {}, ) { try { + requireOpenMandatePresentation(openMandatePresentation); const { issuerJwt, disclosures } = parseSdJwtPresentation(presentation); - const openPaymentContent = openMandatePresentation !== undefined - ? resolveOpenPaymentMandateContent(openMandatePresentation, { currentDate, clockTolerance }).content - : undefined; - const verificationKey = openPaymentContent !== undefined - ? jwkToSecp256r1PublicKey(openPaymentContent.cnf.jwk) - : resolvePublicKey({ publicKey, holderJwk }); + const openPaymentContent = resolveOpenPaymentMandateContent( + openMandatePresentation, + { currentDate, clockTolerance }, + ).content; + const verificationKey = jwkToSecp256r1PublicKey(openPaymentContent.cnf.jwk); const result = verifyJWT(issuerJwt, verificationKey, { algorithms: ['ES256'] }); if (!result.verified) { @@ -604,14 +584,10 @@ export function verifyClosedPaymentMandate( transactionIdVerified = true; } - const sdHashVerified = checkSdHashAgainstOpenMandate( - payload, - openMandatePresentation, - OPEN_PAYMENT_MANDATE_LABEL, - ); + checkSdHashAgainstOpenMandate(payload, openMandatePresentation, OPEN_PAYMENT_MANDATE_LABEL); let referenceVerified; - if (openPaymentContent !== undefined && openCheckoutMandatePresentation !== undefined) { + if (openCheckoutMandatePresentation !== undefined) { const reference = (openPaymentContent.constraints ?? []).find((c) => c?.type === 'payment.reference'); const { issuerJwt: checkoutIssuerJwt, disclosures: checkoutDisclosures } = parseSdJwtPresentation( openCheckoutMandatePresentation, @@ -626,7 +602,7 @@ export function verifyClosedPaymentMandate( protectedHeader, ...(transactionIdVerified === undefined ? {} : { transactionIdVerified }), ...(referenceVerified === undefined ? {} : { referenceVerified }), - ...(sdHashVerified === undefined ? {} : { sdHashVerified }), + sdHashVerified: true, }; } catch (error) { return { verified: false, error: error instanceof Error ? error : new Error(String(error)) }; diff --git a/packages/ap2/tests/mandates.test.js b/packages/ap2/tests/mandates.test.js index 800a4b1c2..e6e67f84a 100644 --- a/packages/ap2/tests/mandates.test.js +++ b/packages/ap2/tests/mandates.test.js @@ -133,7 +133,6 @@ describe('AP2 mandates: round trip', () => { }); const checkoutResult = verifyClosedCheckoutMandate(closedCheckoutPresentation, { - publicKey: agentKeypair.publicKey(), openMandatePresentation: openCheckoutPresentation, }); expect(checkoutResult.verified).toBe(true); @@ -172,7 +171,6 @@ describe('AP2 mandates: round trip', () => { }); const paymentResult = verifyClosedPaymentMandate(closedPaymentPresentation, { - publicKey: agentKeypair.publicKey(), checkoutJwt, openMandatePresentation: openPaymentPresentation, }); @@ -210,13 +208,13 @@ describe('AP2 mandates: round trip', () => { ); const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, { - publicKey: agentKeypair.publicKey(), + openMandatePresentation: openCheckoutPresentation, }); expect(result.verified).toBe(false); expect(result.error.message).toMatch(/checkout_hash/); }); - test('rejects a Closed Checkout Mandate signed by the wrong key', async () => { + test('rejects a Closed Checkout Mandate closed by an unauthorized key', async () => { const userKeypair = Secp256r1Keypair.random(); const agentKeypair = Secp256r1Keypair.random(); const otherKeypair = Secp256r1Keypair.random(); @@ -232,6 +230,7 @@ describe('AP2 mandates: round trip', () => { ); const checkoutJwt = SAMPLE_CHECKOUT_JWT; + // otherKeypair is not the key endorsed in the Open Mandate's cnf.jwk. const closedCheckoutPresentation = await signClosedCheckoutMandate( buildClosedCheckoutMandate({ vct: VCT_CHECKOUT_CLOSED, @@ -239,14 +238,14 @@ describe('AP2 mandates: round trip', () => { checkout_hash: computeCheckoutHash(checkoutJwt), }), { - signer: agentKeypair, + signer: otherKeypair, nonce: 'nonce-1', openMandatePresentation: openCheckoutPresentation, }, ); const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, { - publicKey: otherKeypair.publicKey(), + openMandatePresentation: openCheckoutPresentation, }); expect(result.verified).toBe(false); }); @@ -434,7 +433,7 @@ describe('AP2 mandates: resolveOpenCheckoutMandateContent', () => { }); describe('AP2 mandates: cnf is derived from the Open Mandate, not trusted from the caller', () => { - test('verifyClosedCheckoutMandate rejects a mandate closed by an unauthorized key, even when the caller vouches for that same key', async () => { + test('verifyClosedCheckoutMandate rejects a mandate closed by an unauthorized key', async () => { const userKeypair = Secp256r1Keypair.random(); const authorizedAgentKeypair = Secp256r1Keypair.random(); const attackerKeypair = Secp256r1Keypair.random(); @@ -464,25 +463,16 @@ describe('AP2 mandates: cnf is derived from the Open Mandate, not trusted from t }, ); - // A caller naively trusting the attacker's own claim of "I'm the holder" - // (passing the attacker's key directly, with no openMandatePresentation) - // would incorrectly see this as verified -- signature-only verification - // can't tell an unauthorized key from an authorized one. - const signatureOnlyResult = verifyClosedCheckoutMandate(closedCheckoutPresentation, { - publicKey: attackerKeypair.publicKey(), - }); - expect(signatureOnlyResult.verified).toBe(true); - - // But deriving the key from the actual Open Mandate rejects it, because - // the attacker's key was never the one endorsed in cnf. + // The verification key is always derived from the Open Mandate's own + // cnf.jwk -- verifyClosedCheckoutMandate has no caller-supplied-key + // option that could be used to bless the attacker's key instead. const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, { - publicKey: attackerKeypair.publicKey(), openMandatePresentation: openCheckoutPresentation, }); expect(result.verified).toBe(false); }); - test('verifyClosedPaymentMandate rejects a mandate closed by an unauthorized key, even when the caller vouches for that same key', async () => { + test('verifyClosedPaymentMandate rejects a mandate closed by an unauthorized key', async () => { const userKeypair = Secp256r1Keypair.random(); const authorizedAgentKeypair = Secp256r1Keypair.random(); const attackerKeypair = Secp256r1Keypair.random(); @@ -511,17 +501,67 @@ describe('AP2 mandates: cnf is derived from the Open Mandate, not trusted from t }, ); - const signatureOnlyResult = verifyClosedPaymentMandate(closedPaymentPresentation, { - publicKey: attackerKeypair.publicKey(), - }); - expect(signatureOnlyResult.verified).toBe(true); - const result = verifyClosedPaymentMandate(closedPaymentPresentation, { - publicKey: attackerKeypair.publicKey(), openMandatePresentation: openPaymentPresentation, }); expect(result.verified).toBe(false); }); + + test('verifyClosedCheckoutMandate requires openMandatePresentation -- there is no caller-supplied-key fallback', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + const cnf = { jwk: secp256r1PublicKeyToJwk(agentKeypair.publicKey()) }; + + const openCheckoutPresentation = await signOpenCheckoutMandate( + buildOpenCheckoutMandate({ + vct: VCT_CHECKOUT_OPEN, + constraints: [MINIMAL_LINE_ITEMS_CONSTRAINT], + cnf, + }), + { signer: userKeypair }, + ); + const closedCheckoutPresentation = await signClosedCheckoutMandate( + buildClosedCheckoutMandate({ + vct: VCT_CHECKOUT_CLOSED, + checkout_jwt: SAMPLE_CHECKOUT_JWT, + checkout_hash: computeCheckoutHash(SAMPLE_CHECKOUT_JWT), + }), + { signer: agentKeypair, nonce: 'nonce-1', openMandatePresentation: openCheckoutPresentation }, + ); + + const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, {}); + expect(result.verified).toBe(false); + expect(result.error.message).toMatch(/openMandatePresentation/); + }); + + test('verifyClosedPaymentMandate requires openMandatePresentation -- there is no caller-supplied-key fallback', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + const cnf = { jwk: secp256r1PublicKeyToJwk(agentKeypair.publicKey()) }; + + const openPaymentPresentation = await signOpenPaymentMandate( + buildOpenPaymentMandate({ + vct: 'mandate.payment.open.1', + constraints: [{ type: 'payment.reference', conditional_transaction_id: 'digest-1' }], + cnf, + }), + { signer: userKeypair }, + ); + const closedPaymentPresentation = await signClosedPaymentMandate( + buildClosedPaymentMandate({ + vct: 'mandate.payment.1', + transaction_id: computeCheckoutHash(SAMPLE_CHECKOUT_JWT), + payee: { id: 'merchant_1', name: 'Demo Merchant', website: 'https://demo-merchant.example' }, + payment_amount: { amount: 19900, currency: 'USD' }, + payment_instrument: { id: 'stub', type: 'card', description: 'Card ****4242' }, + }), + { signer: agentKeypair, nonce: 'nonce-1', openMandatePresentation: openPaymentPresentation }, + ); + + const result = verifyClosedPaymentMandate(closedPaymentPresentation, {}); + expect(result.verified).toBe(false); + expect(result.error.message).toMatch(/openMandatePresentation/); + }); }); describe('AP2 mandates: payment.reference binding', () => { From 419d88409592f54351703a6c4f195306a3ea605c Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Fri, 31 Jul 2026 15:59:34 -0400 Subject: [PATCH 03/11] fix(ap2): require verification of the Open Mandate's own issuer signature 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 --- .changeset/strong-otters-verify.md | 22 +++- packages/ap2/README.md | 16 ++- packages/ap2/src/mandates.js | 149 +++++++++++++++------ packages/ap2/tests/mandates.test.js | 192 ++++++++++++++++++++++++++++ 4 files changed, 332 insertions(+), 47 deletions(-) diff --git a/.changeset/strong-otters-verify.md b/.changeset/strong-otters-verify.md index 027cc93b9..1feb7dc97 100644 --- a/.changeset/strong-otters-verify.md +++ b/.changeset/strong-otters-verify.md @@ -16,6 +16,21 @@ for backwards compatibility: calling either function without `openMandatePresentation` now returns `{ verified: false, error }` instead of silently trusting an unverified key. +Both functions also now require `userPublicKey` — the User's own key, +resolved independently by the caller (e.g. via DID resolution or a wallet +registry) — and verify it against the referenced Open Mandate's own issuer +signature. Without this, deriving the verification key from `cnf.jwk` only +proves the Closed Mandate matches whatever the Open Mandate says, not that +the Open Mandate itself came from a real, authorized User: an attacker could +previously fabricate an entire Open + Closed Mandate chain naming their own +key in `cnf.jwk`, and every internal check (digest consistency, envelope +signature, `sd_hash`) would pass, since they're all self-referential to the +forged chain. `resolveOpenCheckoutMandateContent`/ +`resolveOpenPaymentMandateContent` gained a matching optional +`userPublicKey` parameter (returning `issuerVerified: true` when supplied and +valid) for callers using them directly; omitting it there still only checks +internal self-consistency, as before. + Added `resolveOpenCheckoutMandateContent`, mirroring `resolveOpenPaymentMandateContent` for Open Checkout Mandates, which the fix above uses internally and which callers can also use directly (e.g. to @@ -24,9 +39,10 @@ the way `issueClosedPaymentMandate`-style flows already do for payments). `verifyClosedPaymentMandate` also accepts a new optional `openCheckoutMandatePresentation` parameter. When supplied alongside -`openMandatePresentation`, it verifies the Open Payment Mandate's -`payment.reference.conditional_transaction_id` constraint against a fresh -`sd_hash` of that Open Checkout Mandate presentation, returning +`openMandatePresentation`, its issuer signature is verified against the same +`userPublicKey`, then the Open Payment Mandate's +`payment.reference.conditional_transaction_id` constraint is checked against +a fresh `sd_hash` of that Open Checkout Mandate presentation, returning `referenceVerified` in the result. This was previously left entirely to the caller (see the AP2 spec's Reference constraint) even though the package already required both presentations for its other checks. diff --git a/packages/ap2/README.md b/packages/ap2/README.md index 0c0cd6ea3..6622c0cf6 100644 --- a/packages/ap2/README.md +++ b/packages/ap2/README.md @@ -36,10 +36,16 @@ const jwt = await signReceipt(receipt, { // Verify the mandate first (signature, cnf key-binding, checkout binding, aud, expiry). // openMandatePresentation is required -- it's how the trusted verification // key is derived from the Open Mandate's own cnf.jwk, rather than trusted -// from a caller-supplied key. +// from a caller-supplied key. userPublicKey is also required: it's the +// User's own key (resolved independently, e.g. via DID resolution or a +// wallet registry), verified against the Open Mandate's issuer signature -- +// without it, the cnf.jwk delegation inside a self-consistent but entirely +// forged Open + Closed Mandate chain would go unnoticed. const mandateVerification = verifyClosedPaymentMandate( closedMandatePresentation, - { openMandatePresentation, checkoutJwt, openCheckoutMandatePresentation }, + { + openMandatePresentation, userPublicKey, checkoutJwt, openCheckoutMandatePresentation, + }, ); const result = verifyPaymentReceipt(jwt, { @@ -152,8 +158,14 @@ const closedCheckoutPresentation = await signClosedCheckoutMandate(closedCheckou // always derived from the Open Mandate's own cnf.jwk -- there is no // caller-supplied-key option, since a caller-supplied key that happens to // verify a signature proves nothing about *whose* key it was authorized to be. +// userPublicKey (the User's own key, resolved independently -- e.g. via DID +// resolution or a wallet registry) is required too, and is checked against +// the Open Mandate's own issuer signature: without it, cnf.jwk could name +// anyone, since nothing would confirm the Open Mandate actually came from +// the User it claims to. const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, { openMandatePresentation: openCheckoutPresentation, + userPublicKey, }); if (!result.verified) throw result.error; ``` diff --git a/packages/ap2/src/mandates.js b/packages/ap2/src/mandates.js index 750845ef0..d0f51f834 100644 --- a/packages/ap2/src/mandates.js +++ b/packages/ap2/src/mandates.js @@ -434,6 +434,28 @@ function requireOpenMandatePresentation(openMandatePresentation) { } } +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', + ); + } +} + +// Verifies an Open Mandate's own issuer envelope against the trusted User +// key the caller separately resolved (e.g. via DID resolution or a wallet +// registry) -- the only way to confirm this Open Mandate, and therefore the +// cnf.jwk delegation inside it, actually came from the User it claims to, +// rather than being entirely self-forged. +function verifyOpenMandateIssuer(issuerJwt, userPublicKey, label) { + const result = verifyJWT(issuerJwt, userPublicKey, { algorithms: ['ES256'] }); + if (!result.verified) { + throw new Error(`${label} issuer signature verification failed`); + } +} + // Resolves the checkout_jwt claim disclosure referenced by a Closed Checkout // Mandate content's "_sd" digest array, returning the revealed checkout_jwt // string. Throws if the digest, disclosure, or claim name don't line up. @@ -464,29 +486,43 @@ function resolveCheckoutJwtClaim(content, disclosures, sdAlg) { * claims is the holder, is what actually enforces that this presentation was * closed by the party the Open Mandate delegated to — a caller-supplied key * that happens to verify a signature proves nothing about *whose* key it was - * authorized to be. Also verifies the `checkout_jwt` disclosure against the - * content's `_sd` digest, `checkout_hash` against a fresh hash of the - * revealed `checkout_jwt`, `aud === "merchant"`, expiry, and `sd_hash` - * against `openMandatePresentation`. See `signClosedCheckoutMandate` for the + * authorized to be. + * + * That delegation is only meaningful if the Open Mandate itself was actually + * signed by the User it claims to be from -- otherwise an attacker can + * fabricate an entire Open + Closed Mandate chain naming their own key in + * `cnf.jwk`. The required `userPublicKey` is the caller's independently + * resolved trusted key for that User (e.g. via DID resolution or a wallet + * registry), verified here against the Open Mandate's envelope. + * + * Also verifies the `checkout_jwt` disclosure against the content's `_sd` + * digest, `checkout_hash` against a fresh hash of the revealed + * `checkout_jwt`, `aud === "merchant"`, expiry, and `sd_hash` against + * `openMandatePresentation`. See `signClosedCheckoutMandate` for the * `sd_hash` caveat. * * @param {string} presentation - * @param {{openMandatePresentation: string, + * @param {{openMandatePresentation: string, userPublicKey: *, * currentDate?: Date, clockTolerance?: number}} options * @returns {{verified: boolean, content?: object, checkoutJwt?: string, - * protectedHeader?: object, sdHashVerified?: boolean, error?: Error}} + * protectedHeader?: object, sdHashVerified?: boolean, + * openMandateIssuerVerified?: boolean, error?: Error}} */ export function verifyClosedCheckoutMandate( presentation, { - openMandatePresentation, currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE, + openMandatePresentation, userPublicKey, currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE, } = {}, ) { try { requireOpenMandatePresentation(openMandatePresentation); + requireUserPublicKey(userPublicKey); const { issuerJwt, disclosures } = parseSdJwtPresentation(presentation); const verificationKey = jwkToSecp256r1PublicKey( - resolveOpenCheckoutMandateContent(openMandatePresentation, { currentDate, clockTolerance }).content.cnf.jwk, + resolveOpenCheckoutMandateContent( + openMandatePresentation, + { userPublicKey, currentDate, clockTolerance }, + ).content.cnf.jwk, ); const result = verifyJWT(issuerJwt, verificationKey, { algorithms: ['ES256'] }); if (!result.verified) { @@ -516,6 +552,7 @@ export function verifyClosedCheckoutMandate( checkoutJwt, protectedHeader, sdHashVerified: true, + openMandateIssuerVerified: true, }; } catch (error) { return { verified: false, error: error instanceof Error ? error : new Error(String(error)) }; @@ -528,38 +565,46 @@ export function verifyClosedCheckoutMandate( * The envelope signature is verified against the referenced Open Payment * Mandate's own `cnf.jwk`, derived from the required `openMandatePresentation` * — never against a caller-supplied key. See `verifyClosedCheckoutMandate`'s - * doc comment for why deriving the key from the mandate itself matters. Also - * verifies `aud === "credential-provider"`, expiry, and `sd_hash` against + * doc comment for why deriving the key from the mandate itself matters, and + * for why the required `userPublicKey` (verified against the Open Payment + * Mandate's own envelope) is what makes that delegation trustworthy in the + * first place, rather than an entirely self-forgeable chain. Also verifies + * `aud === "credential-provider"`, expiry, and `sd_hash` against * `openMandatePresentation`; when the corresponding Closed Checkout Mandate's * `checkout_jwt` is supplied, that `transaction_id` matches a fresh hash of * it. * - * When `openCheckoutMandatePresentation` is also supplied, also verifies the - * Open Payment Mandate's `payment.reference.conditional_transaction_id` - * constraint against a fresh `sd_hash` of that Open Checkout Mandate - * presentation — the binding that ties this payment authorization to one - * specific checkout (see the AP2 spec's Reference constraint). + * When `openCheckoutMandatePresentation` is also supplied, also verifies its + * issuer envelope against the same `userPublicKey` (the AP2 flow's Open + * Checkout and Open Payment Mandates are both signed by the same User) and + * checks the Open Payment Mandate's + * `payment.reference.conditional_transaction_id` constraint against a fresh + * `sd_hash` of that Open Checkout Mandate presentation — the binding that + * ties this payment authorization to one specific checkout (see the AP2 + * spec's Reference constraint). * * @param {string} presentation - * @param {{openMandatePresentation: string, checkoutJwt?: string, + * @param {{openMandatePresentation: string, userPublicKey: *, checkoutJwt?: string, * openCheckoutMandatePresentation?: string, currentDate?: Date, clockTolerance?: number}} options * @returns {{verified: boolean, content?: object, protectedHeader?: object, - * transactionIdVerified?: boolean, sdHashVerified?: boolean, referenceVerified?: boolean, error?: Error}} + * transactionIdVerified?: boolean, sdHashVerified?: boolean, referenceVerified?: boolean, + * openMandateIssuerVerified?: boolean, error?: Error}} */ export function verifyClosedPaymentMandate( presentation, { - checkoutJwt, openMandatePresentation, openCheckoutMandatePresentation, + checkoutJwt, openMandatePresentation, userPublicKey, openCheckoutMandatePresentation, currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE, } = {}, ) { try { requireOpenMandatePresentation(openMandatePresentation); + requireUserPublicKey(userPublicKey); const { issuerJwt, disclosures } = parseSdJwtPresentation(presentation); const openPaymentContent = resolveOpenPaymentMandateContent( openMandatePresentation, - { currentDate, clockTolerance }, + { userPublicKey, currentDate, clockTolerance }, ).content; const verificationKey = jwkToSecp256r1PublicKey(openPaymentContent.cnf.jwk); @@ -588,10 +633,11 @@ export function verifyClosedPaymentMandate( let referenceVerified; if (openCheckoutMandatePresentation !== undefined) { - const reference = (openPaymentContent.constraints ?? []).find((c) => c?.type === 'payment.reference'); 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; } @@ -603,6 +649,7 @@ export function verifyClosedPaymentMandate( ...(transactionIdVerified === undefined ? {} : { transactionIdVerified }), ...(referenceVerified === undefined ? {} : { referenceVerified }), sdHashVerified: true, + openMandateIssuerVerified: true, }; } catch (error) { return { verified: false, error: error instanceof Error ? error : new Error(String(error)) }; @@ -637,32 +684,38 @@ function resolvePaymentConstraintDisclosures(content, disclosures, sdAlg) { /** * Decodes and resolves an Open Payment Mandate presentation's own content - * (`constraints`, `cnf`, `exp`, ...) -- WITHOUT verifying an issuer signature. + * (`constraints`, `cnf`, `exp`, ...). * * Open Payment Mandates are signed by the User's key (see * `signOpenPaymentMandate`), which is generally a *different* key from the * mandate's own `cnf.jwk` (the Agent's key, only endorsed to close it later) * -- so, unlike `verifyClosedCheckoutMandate`/`verifyClosedPaymentMandate`, - * there is no single key this package can check the envelope signature - * against on its own. A caller that independently knows the issuing User's - * public key can verify the envelope itself (`verifyJWT`, on the `issuerJwt` - * half of `parseSdJwtPresentation`'s result) before or after calling this. + * there is no single key this package can derive the envelope's expected + * signer from on its own. Pass `userPublicKey` (the User's key, resolved by + * the caller via whatever identity system applies -- DID resolution, a + * wallet registry, etc.) to have this function verify the issuer signature + * itself; **omitting it skips that verification and only checks internal + * self-consistency**, so a mandate's `cnf.jwk` delegation cannot be trusted + * from an unverified call. * - * What this DOES check: the SD-JWT's internal digest/disclosure consistency - * (a hand-edited constraints blob whose digest no longer matches - * `delegate_payload` is rejected), that the resolved content matches the - * Open Payment Mandate schema, and (when `exp` is present) that it hasn't - * passed. + * What this always checks, regardless of `userPublicKey`: the SD-JWT's + * internal digest/disclosure consistency (a hand-edited constraints blob + * whose digest no longer matches `delegate_payload` is rejected), that the + * resolved content matches the Open Payment Mandate schema, and (when `exp` + * is present) that it hasn't passed. * * @param {string} presentation - * @param {{currentDate?: Date, clockTolerance?: number}} options - * @returns {{content: object, protectedHeader: object}} + * @param {{userPublicKey?: *, currentDate?: Date, clockTolerance?: number}} options + * @returns {{content: object, protectedHeader: object, issuerVerified?: boolean}} */ export function resolveOpenPaymentMandateContent( presentation, - { currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE } = {}, + { userPublicKey, currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE } = {}, ) { const { issuerJwt, disclosures } = parseSdJwtPresentation(presentation); + if (userPublicKey !== undefined) { + verifyOpenMandateIssuer(issuerJwt, userPublicKey, OPEN_PAYMENT_MANDATE_LABEL); + } const payload = decodeJwtPayload(issuerJwt, OPEN_PAYMENT_MANDATE_LABEL); const protectedHeader = decodeJwtProtectedHeader(issuerJwt, OPEN_PAYMENT_MANDATE_LABEL); const sdAlg = getSdAlg(payload); @@ -676,7 +729,11 @@ export function resolveOpenPaymentMandateContent( // `{delegate_payload, _sd_alg}`) -- so it's checked against `content` here. checkNotExpired(content, { currentDate, clockTolerance }, OPEN_PAYMENT_MANDATE_LABEL); - return { content, protectedHeader }; + return { + content, + protectedHeader, + ...(userPublicKey === undefined ? {} : { issuerVerified: true }), + }; } // Reveals the array-element disclosures inside an Open Checkout Mandate's @@ -717,20 +774,24 @@ function resolveCheckoutConstraintDisclosures(content, disclosures, sdAlg) { /** * Decodes and resolves an Open Checkout Mandate presentation's own content - * (`constraints`, `cnf`, `exp`, ...) -- WITHOUT verifying an issuer signature. - * See `resolveOpenPaymentMandateContent`'s doc comment for the same caveat: - * Open Checkout Mandates are signed by the User's key, which this package - * cannot check on its own without the caller separately supplying it. + * (`constraints`, `cnf`, `exp`, ...). See `resolveOpenPaymentMandateContent`'s + * doc comment for the same caveat: Open Checkout Mandates are signed by the + * User's key, which this package cannot verify without the caller supplying + * `userPublicKey` -- omitting it skips issuer verification and only checks + * internal self-consistency. * * @param {string} presentation - * @param {{currentDate?: Date, clockTolerance?: number}} options - * @returns {{content: object, protectedHeader: object}} + * @param {{userPublicKey?: *, currentDate?: Date, clockTolerance?: number}} options + * @returns {{content: object, protectedHeader: object, issuerVerified?: boolean}} */ export function resolveOpenCheckoutMandateContent( presentation, - { currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE } = {}, + { userPublicKey, currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE } = {}, ) { const { issuerJwt, disclosures } = parseSdJwtPresentation(presentation); + if (userPublicKey !== undefined) { + verifyOpenMandateIssuer(issuerJwt, userPublicKey, OPEN_CHECKOUT_MANDATE_LABEL); + } const payload = decodeJwtPayload(issuerJwt, OPEN_CHECKOUT_MANDATE_LABEL); const protectedHeader = decodeJwtProtectedHeader(issuerJwt, OPEN_CHECKOUT_MANDATE_LABEL); const sdAlg = getSdAlg(payload); @@ -744,5 +805,9 @@ export function resolveOpenCheckoutMandateContent( // payload -- same reasoning as `resolveOpenPaymentMandateContent`. checkNotExpired(content, { currentDate, clockTolerance }, OPEN_CHECKOUT_MANDATE_LABEL); - return { content, protectedHeader }; + return { + content, + protectedHeader, + ...(userPublicKey === undefined ? {} : { issuerVerified: true }), + }; } diff --git a/packages/ap2/tests/mandates.test.js b/packages/ap2/tests/mandates.test.js index e6e67f84a..7d5b00173 100644 --- a/packages/ap2/tests/mandates.test.js +++ b/packages/ap2/tests/mandates.test.js @@ -134,10 +134,12 @@ describe('AP2 mandates: round trip', () => { const checkoutResult = verifyClosedCheckoutMandate(closedCheckoutPresentation, { openMandatePresentation: openCheckoutPresentation, + userPublicKey: userKeypair.publicKey(), }); expect(checkoutResult.verified).toBe(true); expect(checkoutResult.checkoutJwt).toBe(checkoutJwt); expect(checkoutResult.sdHashVerified).toBe(true); + expect(checkoutResult.openMandateIssuerVerified).toBe(true); const conditionalTransactionId = computeSdHash(parseSdJwtPresentation(openCheckoutPresentation)); const openPaymentContent = buildOpenPaymentMandate({ @@ -173,10 +175,12 @@ describe('AP2 mandates: round trip', () => { const paymentResult = verifyClosedPaymentMandate(closedPaymentPresentation, { checkoutJwt, openMandatePresentation: openPaymentPresentation, + userPublicKey: userKeypair.publicKey(), }); expect(paymentResult.verified).toBe(true); expect(paymentResult.transactionIdVerified).toBe(true); expect(paymentResult.sdHashVerified).toBe(true); + expect(paymentResult.openMandateIssuerVerified).toBe(true); }); test('rejects a Closed Checkout Mandate with a tampered checkout_hash', async () => { @@ -209,6 +213,7 @@ describe('AP2 mandates: round trip', () => { const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, { openMandatePresentation: openCheckoutPresentation, + userPublicKey: userKeypair.publicKey(), }); expect(result.verified).toBe(false); expect(result.error.message).toMatch(/checkout_hash/); @@ -246,6 +251,7 @@ describe('AP2 mandates: round trip', () => { const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, { openMandatePresentation: openCheckoutPresentation, + userPublicKey: userKeypair.publicKey(), }); expect(result.verified).toBe(false); }); @@ -287,6 +293,31 @@ describe('AP2 mandates: resolveOpenPaymentMandateContent', () => { ); }); + test('with userPublicKey, verifies the issuer signature and rejects the wrong key', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + const attackerKeypair = Secp256r1Keypair.random(); + const cnf = { jwk: secp256r1PublicKeyToJwk(agentKeypair.publicKey()) }; + + const presentation = await signOpenPaymentMandate( + buildOpenPaymentMandate({ + vct: 'mandate.payment.open.1', + constraints: [{ type: 'payment.reference', conditional_transaction_id: 'digest-1' }], + cnf, + }), + { signer: userKeypair }, + ); + + const { issuerVerified } = resolveOpenPaymentMandateContent(presentation, { + userPublicKey: userKeypair.publicKey(), + }); + expect(issuerVerified).toBe(true); + + expect(() => resolveOpenPaymentMandateContent(presentation, { + userPublicKey: attackerKeypair.publicKey(), + })).toThrow(/issuer signature verification failed/); + }); + test('rejects a hand-edited presentation whose content no longer matches its digest', async () => { const userKeypair = Secp256r1Keypair.random(); const agentKeypair = Secp256r1Keypair.random(); @@ -386,6 +417,30 @@ describe('AP2 mandates: resolveOpenCheckoutMandateContent', () => { expect(content.cnf.jwk).toMatchObject({ crv: 'P-256', kty: 'EC' }); }); + test('with userPublicKey, verifies the issuer signature and rejects the wrong key', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + const attackerKeypair = Secp256r1Keypair.random(); + + const presentation = await signOpenCheckoutMandate( + buildOpenCheckoutMandate({ + vct: VCT_CHECKOUT_OPEN, + constraints: [MINIMAL_LINE_ITEMS_CONSTRAINT], + cnf: { jwk: secp256r1PublicKeyToJwk(agentKeypair.publicKey()) }, + }), + { signer: userKeypair }, + ); + + const { issuerVerified } = resolveOpenCheckoutMandateContent(presentation, { + userPublicKey: userKeypair.publicKey(), + }); + expect(issuerVerified).toBe(true); + + expect(() => resolveOpenCheckoutMandateContent(presentation, { + userPublicKey: attackerKeypair.publicKey(), + })).toThrow(/issuer signature verification failed/); + }); + test('rejects a hand-edited presentation whose content no longer matches its digest', async () => { const userKeypair = Secp256r1Keypair.random(); const agentKeypair = Secp256r1Keypair.random(); @@ -468,6 +523,7 @@ describe('AP2 mandates: cnf is derived from the Open Mandate, not trusted from t // option that could be used to bless the attacker's key instead. const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, { openMandatePresentation: openCheckoutPresentation, + userPublicKey: userKeypair.publicKey(), }); expect(result.verified).toBe(false); }); @@ -503,10 +559,84 @@ describe('AP2 mandates: cnf is derived from the Open Mandate, not trusted from t const result = verifyClosedPaymentMandate(closedPaymentPresentation, { openMandatePresentation: openPaymentPresentation, + userPublicKey: userKeypair.publicKey(), }); expect(result.verified).toBe(false); }); + test('verifyClosedCheckoutMandate rejects an Open Mandate forged by an attacker naming their own cnf', async () => { + const realUserKeypair = Secp256r1Keypair.random(); + const attackerKeypair = Secp256r1Keypair.random(); + + // The attacker fabricates an entire chain: they sign the "Open" Mandate + // themselves, naming their own key as its cnf.jwk, then close it with + // that same key. Every internal check (digest consistency, cnf-derived + // signature, sd_hash) is self-consistent -- only comparing the Open + // Mandate's issuer against the real User's known public key catches this. + const forgedOpenPresentation = await signOpenCheckoutMandate( + buildOpenCheckoutMandate({ + vct: VCT_CHECKOUT_OPEN, + constraints: [MINIMAL_LINE_ITEMS_CONSTRAINT], + cnf: { jwk: secp256r1PublicKeyToJwk(attackerKeypair.publicKey()) }, + }), + { signer: attackerKeypair }, + ); + const forgedClosedPresentation = await signClosedCheckoutMandate( + buildClosedCheckoutMandate({ + vct: VCT_CHECKOUT_CLOSED, + checkout_jwt: SAMPLE_CHECKOUT_JWT, + checkout_hash: computeCheckoutHash(SAMPLE_CHECKOUT_JWT), + }), + { + signer: attackerKeypair, + nonce: 'nonce-1', + openMandatePresentation: forgedOpenPresentation, + }, + ); + + const result = verifyClosedCheckoutMandate(forgedClosedPresentation, { + openMandatePresentation: forgedOpenPresentation, + userPublicKey: realUserKeypair.publicKey(), + }); + expect(result.verified).toBe(false); + expect(result.error.message).toMatch(/issuer signature verification failed/); + }); + + test('verifyClosedPaymentMandate rejects an Open Mandate forged by an attacker naming their own cnf', async () => { + const realUserKeypair = Secp256r1Keypair.random(); + const attackerKeypair = Secp256r1Keypair.random(); + + const forgedOpenPresentation = await signOpenPaymentMandate( + buildOpenPaymentMandate({ + vct: 'mandate.payment.open.1', + constraints: [{ type: 'payment.reference', conditional_transaction_id: 'digest-1' }], + cnf: { jwk: secp256r1PublicKeyToJwk(attackerKeypair.publicKey()) }, + }), + { signer: attackerKeypair }, + ); + const forgedClosedPresentation = await signClosedPaymentMandate( + buildClosedPaymentMandate({ + vct: 'mandate.payment.1', + transaction_id: computeCheckoutHash(SAMPLE_CHECKOUT_JWT), + payee: { id: 'merchant_1', name: 'Demo Merchant', website: 'https://demo-merchant.example' }, + payment_amount: { amount: 19900, currency: 'USD' }, + payment_instrument: { id: 'stub', type: 'card', description: 'Card ****4242' }, + }), + { + signer: attackerKeypair, + nonce: 'nonce-1', + openMandatePresentation: forgedOpenPresentation, + }, + ); + + const result = verifyClosedPaymentMandate(forgedClosedPresentation, { + openMandatePresentation: forgedOpenPresentation, + userPublicKey: realUserKeypair.publicKey(), + }); + expect(result.verified).toBe(false); + expect(result.error.message).toMatch(/issuer signature verification failed/); + }); + test('verifyClosedCheckoutMandate requires openMandatePresentation -- there is no caller-supplied-key fallback', async () => { const userKeypair = Secp256r1Keypair.random(); const agentKeypair = Secp256r1Keypair.random(); @@ -534,6 +664,35 @@ describe('AP2 mandates: cnf is derived from the Open Mandate, not trusted from t expect(result.error.message).toMatch(/openMandatePresentation/); }); + test('verifyClosedCheckoutMandate requires userPublicKey -- there is no way to skip Open Mandate issuer verification', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + const cnf = { jwk: secp256r1PublicKeyToJwk(agentKeypair.publicKey()) }; + + const openCheckoutPresentation = await signOpenCheckoutMandate( + buildOpenCheckoutMandate({ + vct: VCT_CHECKOUT_OPEN, + constraints: [MINIMAL_LINE_ITEMS_CONSTRAINT], + cnf, + }), + { signer: userKeypair }, + ); + const closedCheckoutPresentation = await signClosedCheckoutMandate( + buildClosedCheckoutMandate({ + vct: VCT_CHECKOUT_CLOSED, + checkout_jwt: SAMPLE_CHECKOUT_JWT, + checkout_hash: computeCheckoutHash(SAMPLE_CHECKOUT_JWT), + }), + { signer: agentKeypair, nonce: 'nonce-1', openMandatePresentation: openCheckoutPresentation }, + ); + + const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, { + openMandatePresentation: openCheckoutPresentation, + }); + expect(result.verified).toBe(false); + expect(result.error.message).toMatch(/userPublicKey/); + }); + test('verifyClosedPaymentMandate requires openMandatePresentation -- there is no caller-supplied-key fallback', async () => { const userKeypair = Secp256r1Keypair.random(); const agentKeypair = Secp256r1Keypair.random(); @@ -562,6 +721,37 @@ describe('AP2 mandates: cnf is derived from the Open Mandate, not trusted from t expect(result.verified).toBe(false); expect(result.error.message).toMatch(/openMandatePresentation/); }); + + test('verifyClosedPaymentMandate requires userPublicKey -- there is no way to skip Open Mandate issuer verification', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + const cnf = { jwk: secp256r1PublicKeyToJwk(agentKeypair.publicKey()) }; + + const openPaymentPresentation = await signOpenPaymentMandate( + buildOpenPaymentMandate({ + vct: 'mandate.payment.open.1', + constraints: [{ type: 'payment.reference', conditional_transaction_id: 'digest-1' }], + cnf, + }), + { signer: userKeypair }, + ); + const closedPaymentPresentation = await signClosedPaymentMandate( + buildClosedPaymentMandate({ + vct: 'mandate.payment.1', + transaction_id: computeCheckoutHash(SAMPLE_CHECKOUT_JWT), + payee: { id: 'merchant_1', name: 'Demo Merchant', website: 'https://demo-merchant.example' }, + payment_amount: { amount: 19900, currency: 'USD' }, + payment_instrument: { id: 'stub', type: 'card', description: 'Card ****4242' }, + }), + { signer: agentKeypair, nonce: 'nonce-1', openMandatePresentation: openPaymentPresentation }, + ); + + const result = verifyClosedPaymentMandate(closedPaymentPresentation, { + openMandatePresentation: openPaymentPresentation, + }); + expect(result.verified).toBe(false); + expect(result.error.message).toMatch(/userPublicKey/); + }); }); describe('AP2 mandates: payment.reference binding', () => { @@ -611,6 +801,7 @@ describe('AP2 mandates: payment.reference binding', () => { const result = verifyClosedPaymentMandate(closedPaymentPresentation, { openMandatePresentation: openPaymentPresentation, openCheckoutMandatePresentation: openCheckoutPresentation, + userPublicKey: userKeypair.publicKey(), }); expect(result.verified).toBe(true); expect(result.referenceVerified).toBe(true); @@ -646,6 +837,7 @@ describe('AP2 mandates: payment.reference binding', () => { const result = verifyClosedPaymentMandate(closedPaymentPresentation, { openMandatePresentation: openPaymentPresentation, openCheckoutMandatePresentation: openCheckoutPresentation, + userPublicKey: userKeypair.publicKey(), }); expect(result.verified).toBe(true); expect(result.referenceVerified).toBe(false); From 6e252d506e7e051f58ff256ad8daf7eeba9c15db Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Fri, 31 Jul 2026 16:07:14 -0400 Subject: [PATCH 04/11] fix(ap2): reject envelope typ confusion in Closed Mandate verification 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 --- .changeset/strong-otters-verify.md | 7 +++++ packages/ap2/src/mandates.js | 29 +++++++++++++----- packages/ap2/tests/mandates.test.js | 46 +++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/.changeset/strong-otters-verify.md b/.changeset/strong-otters-verify.md index 1feb7dc97..5f5a6d50d 100644 --- a/.changeset/strong-otters-verify.md +++ b/.changeset/strong-otters-verify.md @@ -46,3 +46,10 @@ a fresh `sd_hash` of that Open Checkout Mandate presentation, returning `referenceVerified` in the result. This was previously left entirely to the caller (see the AP2 spec's Reference constraint) even though the package already required both presentations for its other checks. + +Both `verifyClosedCheckoutMandate` and `verifyClosedPaymentMandate` now also +check the envelope's `typ` claim (`"kb+sd-jwt"`), rejecting an Open Mandate +presentation (`typ: "dc+sd-jwt"`) or any other envelope type fed in where a +Closed Mandate is expected. Other checks (schema shape, `aud`, +`checkout_hash`) would likely catch a swapped-in envelope in practice, but +this closes the gap explicitly rather than relying on that. diff --git a/packages/ap2/src/mandates.js b/packages/ap2/src/mandates.js index d0f51f834..59d0c83ff 100644 --- a/packages/ap2/src/mandates.js +++ b/packages/ap2/src/mandates.js @@ -415,6 +415,17 @@ function checkAudience(payload, expectedAud) { } } +// Rejects an Open Mandate presentation (typ: dc+sd-jwt) fed in where a Closed +// Mandate (typ: kb+sd-jwt) is expected, or any other envelope type confusion. +// Other checks (aud, _sd, checkout_hash) would likely also catch a swapped-in +// envelope, but this rejects it immediately, with a clearer error, rather +// than relying on that being true in every case. +function checkTyp(protectedHeader) { + if (protectedHeader.typ !== 'kb+sd-jwt') { + throw new Error(`Expected envelope "typ" of "kb+sd-jwt", got "${protectedHeader.typ}"`); + } +} + function checkSdHashAgainstOpenMandate(payload, openMandatePresentation, label) { const { issuerJwt: openIssuerJwt, disclosures: openDisclosures } = parseSdJwtPresentation( openMandatePresentation, @@ -495,9 +506,11 @@ function resolveCheckoutJwtClaim(content, disclosures, sdAlg) { * resolved trusted key for that User (e.g. via DID resolution or a wallet * registry), verified here against the Open Mandate's envelope. * - * Also verifies the `checkout_jwt` disclosure against the content's `_sd` - * digest, `checkout_hash` against a fresh hash of the revealed - * `checkout_jwt`, `aud === "merchant"`, expiry, and `sd_hash` against + * Also verifies the envelope's `typ === "kb+sd-jwt"` (rejecting an Open + * Mandate or other envelope type fed in where a Closed Mandate is expected), + * the `checkout_jwt` disclosure against the content's `_sd` digest, + * `checkout_hash` against a fresh hash of the revealed `checkout_jwt`, + * `aud === "merchant"`, expiry, and `sd_hash` against * `openMandatePresentation`. See `signClosedCheckoutMandate` for the * `sd_hash` caveat. * @@ -529,6 +542,7 @@ export function verifyClosedCheckoutMandate( return result; } const { payload, protectedHeader } = result; + checkTyp(protectedHeader); const sdAlg = getSdAlg(payload); const content = resolveMandateContent(payload, disclosures, sdAlg); @@ -569,10 +583,10 @@ export function verifyClosedCheckoutMandate( * for why the required `userPublicKey` (verified against the Open Payment * Mandate's own envelope) is what makes that delegation trustworthy in the * first place, rather than an entirely self-forgeable chain. Also verifies - * `aud === "credential-provider"`, expiry, and `sd_hash` against - * `openMandatePresentation`; when the corresponding Closed Checkout Mandate's - * `checkout_jwt` is supplied, that `transaction_id` matches a fresh hash of - * it. + * the envelope's `typ === "kb+sd-jwt"`, `aud === "credential-provider"`, + * expiry, and `sd_hash` against `openMandatePresentation`; when the + * corresponding Closed Checkout Mandate's `checkout_jwt` is supplied, that + * `transaction_id` matches a fresh hash of it. * * When `openCheckoutMandatePresentation` is also supplied, also verifies its * issuer envelope against the same `userPublicKey` (the AP2 flow's Open @@ -613,6 +627,7 @@ export function verifyClosedPaymentMandate( return result; } const { payload, protectedHeader } = result; + checkTyp(protectedHeader); const sdAlg = getSdAlg(payload); const content = resolveMandateContent(payload, disclosures, sdAlg); diff --git a/packages/ap2/tests/mandates.test.js b/packages/ap2/tests/mandates.test.js index 7d5b00173..45fef3622 100644 --- a/packages/ap2/tests/mandates.test.js +++ b/packages/ap2/tests/mandates.test.js @@ -843,3 +843,49 @@ describe('AP2 mandates: payment.reference binding', () => { expect(result.referenceVerified).toBe(false); }); }); + +describe('AP2 mandates: envelope typ confusion', () => { + // Self-delegates cnf to the User's own key, so an Open Mandate's envelope + // signature verifies fine against the key verifyClosed*Mandate derives -- + // isolating the "typ" check as the only thing that can catch an Open + // Mandate presentation fed in where a Closed one is expected. + test('verifyClosedCheckoutMandate rejects an Open Checkout Mandate presentation', async () => { + const userKeypair = Secp256r1Keypair.random(); + + const openCheckoutPresentation = await signOpenCheckoutMandate( + buildOpenCheckoutMandate({ + vct: VCT_CHECKOUT_OPEN, + constraints: [MINIMAL_LINE_ITEMS_CONSTRAINT], + cnf: { jwk: secp256r1PublicKeyToJwk(userKeypair.publicKey()) }, + }), + { signer: userKeypair }, + ); + + const result = verifyClosedCheckoutMandate(openCheckoutPresentation, { + openMandatePresentation: openCheckoutPresentation, + userPublicKey: userKeypair.publicKey(), + }); + expect(result.verified).toBe(false); + expect(result.error.message).toMatch(/"typ"/); + }); + + test('verifyClosedPaymentMandate rejects an Open Payment Mandate presentation', async () => { + const userKeypair = Secp256r1Keypair.random(); + + const openPaymentPresentation = await signOpenPaymentMandate( + buildOpenPaymentMandate({ + vct: 'mandate.payment.open.1', + constraints: [{ type: 'payment.reference', conditional_transaction_id: 'digest-1' }], + cnf: { jwk: secp256r1PublicKeyToJwk(userKeypair.publicKey()) }, + }), + { signer: userKeypair }, + ); + + const result = verifyClosedPaymentMandate(openPaymentPresentation, { + openMandatePresentation: openPaymentPresentation, + userPublicKey: userKeypair.publicKey(), + }); + expect(result.verified).toBe(false); + expect(result.error.message).toMatch(/"typ"/); + }); +}); From b900793ac1020041e8ba410d0c984a372dedd629 Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Fri, 31 Jul 2026 16:32:56 -0400 Subject: [PATCH 05/11] fix(ap2): require and verify the Closed Mandate nonce 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 --- .changeset/strong-otters-verify.md | 10 ++ packages/ap2/README.md | 15 ++- packages/ap2/src/mandates.js | 60 +++++++++--- packages/ap2/tests/mandates.test.js | 142 ++++++++++++++++++++++++++++ 4 files changed, 212 insertions(+), 15 deletions(-) diff --git a/.changeset/strong-otters-verify.md b/.changeset/strong-otters-verify.md index 5f5a6d50d..4d4b7db34 100644 --- a/.changeset/strong-otters-verify.md +++ b/.changeset/strong-otters-verify.md @@ -53,3 +53,13 @@ presentation (`typ: "dc+sd-jwt"`) or any other envelope type fed in where a Closed Mandate is expected. Other checks (schema shape, `aud`, `checkout_hash`) would likely catch a swapped-in envelope in practice, but this closes the gap explicitly rather than relying on that. + +Both functions also now require `expectedNonce`, checked against the +envelope's `nonce` claim. Closed Mandate envelopes carry no `exp` of their +own -- their only expiry comes transitively from the referenced Open +Mandate, which is typically valid for an entire shopping session -- and +`nonce` was previously accepted at signing time but never checked at +verification time, so a validly-signed Closed Mandate presentation could be +replayed for a different transaction indefinitely. `expectedNonce` should be +the single-use value the merchant/credential-provider itself generated and +tracks for this specific transaction. diff --git a/packages/ap2/README.md b/packages/ap2/README.md index 6622c0cf6..75d3c38bb 100644 --- a/packages/ap2/README.md +++ b/packages/ap2/README.md @@ -33,18 +33,22 @@ const jwt = await signReceipt(receipt, { signer: keypair, type: 'payment', }); -// Verify the mandate first (signature, cnf key-binding, checkout binding, aud, expiry). +// Verify the mandate first (signature, cnf key-binding, checkout binding, aud, expiry, nonce). // openMandatePresentation is required -- it's how the trusted verification // key is derived from the Open Mandate's own cnf.jwk, rather than trusted // from a caller-supplied key. userPublicKey is also required: it's the // User's own key (resolved independently, e.g. via DID resolution or a // wallet registry), verified against the Open Mandate's issuer signature -- // without it, the cnf.jwk delegation inside a self-consistent but entirely -// forged Open + Closed Mandate chain would go unnoticed. +// forged Open + Closed Mandate chain would go unnoticed. expectedNonce is +// required too: Closed Mandate envelopes carry no exp of their own, so +// nonce -- checked against the single-use value the merchant generated for +// this transaction -- is what stops a validly-signed presentation from +// being replayed for a different one. const mandateVerification = verifyClosedPaymentMandate( closedMandatePresentation, { - openMandatePresentation, userPublicKey, checkoutJwt, openCheckoutMandatePresentation, + openMandatePresentation, userPublicKey, expectedNonce, checkoutJwt, openCheckoutMandatePresentation, }, ); @@ -162,10 +166,13 @@ const closedCheckoutPresentation = await signClosedCheckoutMandate(closedCheckou // resolution or a wallet registry) is required too, and is checked against // the Open Mandate's own issuer signature: without it, cnf.jwk could name // anyone, since nothing would confirm the Open Mandate actually came from -// the User it claims to. +// the User it claims to. expectedNonce -- the same single-use value the +// merchant generated in step 2 -- is required as well, since a Closed +// Mandate envelope has no exp of its own to bound how long it stays replayable. const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, { openMandatePresentation: openCheckoutPresentation, userPublicKey, + expectedNonce: 'merchant-supplied-nonce', }); if (!result.verified) throw result.error; ``` diff --git a/packages/ap2/src/mandates.js b/packages/ap2/src/mandates.js index 59d0c83ff..8ece9ca59 100644 --- a/packages/ap2/src/mandates.js +++ b/packages/ap2/src/mandates.js @@ -415,6 +415,28 @@ function checkAudience(payload, expectedAud) { } } +// Binds a Closed Mandate to the one transaction it was issued for. Closed +// Mandate envelopes carry no exp of their own (see requireExpectedNonce) -- +// their only expiry is inherited from the referenced Open Mandate, which is +// typically valid for an entire shopping session. Without comparing "nonce" +// against a value the verifier itself generated and tracks as single-use, +// a validly-signed Closed Mandate presentation can be replayed indefinitely. +function checkNonce(payload, expectedNonce) { + if (payload.nonce !== expectedNonce) { + throw new Error('"nonce" does not match the expected value'); + } +} + +function requireExpectedNonce(expectedNonce) { + if (typeof expectedNonce !== 'string' || expectedNonce.length === 0) { + throw new TypeError( + '"expectedNonce" is required: the merchant-generated, single-use nonce is what binds a ' + + 'Closed Mandate to one specific transaction -- without checking it, a validly-signed ' + + 'presentation could be replayed for a different transaction indefinitely', + ); + } +} + // Rejects an Open Mandate presentation (typ: dc+sd-jwt) fed in where a Closed // Mandate (typ: kb+sd-jwt) is expected, or any other envelope type confusion. // Other checks (aud, _sd, checkout_hash) would likely also catch a swapped-in @@ -510,12 +532,19 @@ function resolveCheckoutJwtClaim(content, disclosures, sdAlg) { * Mandate or other envelope type fed in where a Closed Mandate is expected), * the `checkout_jwt` disclosure against the content's `_sd` digest, * `checkout_hash` against a fresh hash of the revealed `checkout_jwt`, - * `aud === "merchant"`, expiry, and `sd_hash` against - * `openMandatePresentation`. See `signClosedCheckoutMandate` for the - * `sd_hash` caveat. + * `aud === "merchant"`, expiry, `nonce` against the required `expectedNonce`, + * and `sd_hash` against `openMandatePresentation`. See + * `signClosedCheckoutMandate` for the `sd_hash` caveat. + * + * `expectedNonce` matters because Closed Mandate envelopes 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. Without + * checking `nonce` against a value the merchant itself generated and tracks + * as single-use, a validly-signed Closed Mandate presentation could be + * replayed for a different transaction indefinitely. * * @param {string} presentation - * @param {{openMandatePresentation: string, userPublicKey: *, + * @param {{openMandatePresentation: string, userPublicKey: *, expectedNonce: string, * currentDate?: Date, clockTolerance?: number}} options * @returns {{verified: boolean, content?: object, checkoutJwt?: string, * protectedHeader?: object, sdHashVerified?: boolean, @@ -524,12 +553,14 @@ function resolveCheckoutJwtClaim(content, disclosures, sdAlg) { export function verifyClosedCheckoutMandate( presentation, { - openMandatePresentation, userPublicKey, currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE, + openMandatePresentation, userPublicKey, expectedNonce, + currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE, } = {}, ) { try { requireOpenMandatePresentation(openMandatePresentation); requireUserPublicKey(userPublicKey); + requireExpectedNonce(expectedNonce); const { issuerJwt, disclosures } = parseSdJwtPresentation(presentation); const verificationKey = jwkToSecp256r1PublicKey( resolveOpenCheckoutMandateContent( @@ -558,6 +589,7 @@ export function verifyClosedCheckoutMandate( checkAudience(payload, 'merchant'); checkNotExpired(payload, { currentDate, clockTolerance }, 'Closed Checkout Mandate'); + checkNonce(payload, expectedNonce); checkSdHashAgainstOpenMandate(payload, openMandatePresentation, 'Open Checkout Mandate'); return { @@ -584,9 +616,12 @@ export function verifyClosedCheckoutMandate( * Mandate's own envelope) is what makes that delegation trustworthy in the * first place, rather than an entirely self-forgeable chain. Also verifies * the envelope's `typ === "kb+sd-jwt"`, `aud === "credential-provider"`, - * expiry, and `sd_hash` against `openMandatePresentation`; when the - * corresponding Closed Checkout Mandate's `checkout_jwt` is supplied, that - * `transaction_id` matches a fresh hash of it. + * expiry, `nonce` against the required `expectedNonce` (see + * `verifyClosedCheckoutMandate` for why this matters -- Closed Mandate + * envelopes carry no `exp` of their own), and `sd_hash` against + * `openMandatePresentation`; when the corresponding Closed Checkout + * Mandate's `checkout_jwt` is supplied, that `transaction_id` matches a + * fresh hash of it. * * When `openCheckoutMandatePresentation` is also supplied, also verifies its * issuer envelope against the same `userPublicKey` (the AP2 flow's Open @@ -598,8 +633,9 @@ export function verifyClosedCheckoutMandate( * spec's Reference constraint). * * @param {string} presentation - * @param {{openMandatePresentation: string, userPublicKey: *, checkoutJwt?: string, - * openCheckoutMandatePresentation?: string, currentDate?: Date, clockTolerance?: number}} options + * @param {{openMandatePresentation: string, userPublicKey: *, expectedNonce: string, + * checkoutJwt?: string, openCheckoutMandatePresentation?: string, + * currentDate?: Date, clockTolerance?: number}} options * @returns {{verified: boolean, content?: object, protectedHeader?: object, * transactionIdVerified?: boolean, sdHashVerified?: boolean, referenceVerified?: boolean, * openMandateIssuerVerified?: boolean, error?: Error}} @@ -607,13 +643,14 @@ export function verifyClosedCheckoutMandate( export function verifyClosedPaymentMandate( presentation, { - checkoutJwt, openMandatePresentation, userPublicKey, openCheckoutMandatePresentation, + checkoutJwt, openMandatePresentation, userPublicKey, expectedNonce, openCheckoutMandatePresentation, currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE, } = {}, ) { try { requireOpenMandatePresentation(openMandatePresentation); requireUserPublicKey(userPublicKey); + requireExpectedNonce(expectedNonce); const { issuerJwt, disclosures } = parseSdJwtPresentation(presentation); const openPaymentContent = resolveOpenPaymentMandateContent( @@ -635,6 +672,7 @@ export function verifyClosedPaymentMandate( checkAudience(payload, 'credential-provider'); checkNotExpired(payload, { currentDate, clockTolerance }, 'Closed Payment Mandate'); + checkNonce(payload, expectedNonce); let transactionIdVerified; if (checkoutJwt !== undefined) { diff --git a/packages/ap2/tests/mandates.test.js b/packages/ap2/tests/mandates.test.js index 45fef3622..9e2758ae8 100644 --- a/packages/ap2/tests/mandates.test.js +++ b/packages/ap2/tests/mandates.test.js @@ -135,6 +135,7 @@ describe('AP2 mandates: round trip', () => { const checkoutResult = verifyClosedCheckoutMandate(closedCheckoutPresentation, { openMandatePresentation: openCheckoutPresentation, userPublicKey: userKeypair.publicKey(), + expectedNonce: 'nonce-1', }); expect(checkoutResult.verified).toBe(true); expect(checkoutResult.checkoutJwt).toBe(checkoutJwt); @@ -176,6 +177,7 @@ describe('AP2 mandates: round trip', () => { checkoutJwt, openMandatePresentation: openPaymentPresentation, userPublicKey: userKeypair.publicKey(), + expectedNonce: 'nonce-2', }); expect(paymentResult.verified).toBe(true); expect(paymentResult.transactionIdVerified).toBe(true); @@ -214,6 +216,7 @@ describe('AP2 mandates: round trip', () => { const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, { openMandatePresentation: openCheckoutPresentation, userPublicKey: userKeypair.publicKey(), + expectedNonce: 'nonce-1', }); expect(result.verified).toBe(false); expect(result.error.message).toMatch(/checkout_hash/); @@ -252,6 +255,7 @@ describe('AP2 mandates: round trip', () => { const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, { openMandatePresentation: openCheckoutPresentation, userPublicKey: userKeypair.publicKey(), + expectedNonce: 'nonce-1', }); expect(result.verified).toBe(false); }); @@ -524,6 +528,7 @@ describe('AP2 mandates: cnf is derived from the Open Mandate, not trusted from t const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, { openMandatePresentation: openCheckoutPresentation, userPublicKey: userKeypair.publicKey(), + expectedNonce: 'nonce-1', }); expect(result.verified).toBe(false); }); @@ -560,6 +565,7 @@ describe('AP2 mandates: cnf is derived from the Open Mandate, not trusted from t const result = verifyClosedPaymentMandate(closedPaymentPresentation, { openMandatePresentation: openPaymentPresentation, userPublicKey: userKeypair.publicKey(), + expectedNonce: 'nonce-1', }); expect(result.verified).toBe(false); }); @@ -597,6 +603,7 @@ describe('AP2 mandates: cnf is derived from the Open Mandate, not trusted from t const result = verifyClosedCheckoutMandate(forgedClosedPresentation, { openMandatePresentation: forgedOpenPresentation, userPublicKey: realUserKeypair.publicKey(), + expectedNonce: 'nonce-1', }); expect(result.verified).toBe(false); expect(result.error.message).toMatch(/issuer signature verification failed/); @@ -632,6 +639,7 @@ describe('AP2 mandates: cnf is derived from the Open Mandate, not trusted from t const result = verifyClosedPaymentMandate(forgedClosedPresentation, { openMandatePresentation: forgedOpenPresentation, userPublicKey: realUserKeypair.publicKey(), + expectedNonce: 'nonce-1', }); expect(result.verified).toBe(false); expect(result.error.message).toMatch(/issuer signature verification failed/); @@ -802,6 +810,7 @@ describe('AP2 mandates: payment.reference binding', () => { openMandatePresentation: openPaymentPresentation, openCheckoutMandatePresentation: openCheckoutPresentation, userPublicKey: userKeypair.publicKey(), + expectedNonce: 'nonce-1', }); expect(result.verified).toBe(true); expect(result.referenceVerified).toBe(true); @@ -838,6 +847,7 @@ describe('AP2 mandates: payment.reference binding', () => { openMandatePresentation: openPaymentPresentation, openCheckoutMandatePresentation: openCheckoutPresentation, userPublicKey: userKeypair.publicKey(), + expectedNonce: 'nonce-1', }); expect(result.verified).toBe(true); expect(result.referenceVerified).toBe(false); @@ -864,6 +874,9 @@ describe('AP2 mandates: envelope typ confusion', () => { const result = verifyClosedCheckoutMandate(openCheckoutPresentation, { openMandatePresentation: openCheckoutPresentation, userPublicKey: userKeypair.publicKey(), + // The Open Mandate envelope has no "nonce" of its own; any value here + // is enough to clear the precondition and reach the typ check below. + expectedNonce: 'irrelevant', }); expect(result.verified).toBe(false); expect(result.error.message).toMatch(/"typ"/); @@ -884,8 +897,137 @@ describe('AP2 mandates: envelope typ confusion', () => { const result = verifyClosedPaymentMandate(openPaymentPresentation, { openMandatePresentation: openPaymentPresentation, userPublicKey: userKeypair.publicKey(), + expectedNonce: 'irrelevant', }); expect(result.verified).toBe(false); expect(result.error.message).toMatch(/"typ"/); }); }); + +describe('AP2 mandates: nonce binding', () => { + test('verifyClosedCheckoutMandate rejects a nonce that does not match expectedNonce', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + const cnf = { jwk: secp256r1PublicKeyToJwk(agentKeypair.publicKey()) }; + + const openCheckoutPresentation = await signOpenCheckoutMandate( + buildOpenCheckoutMandate({ + vct: VCT_CHECKOUT_OPEN, + constraints: [MINIMAL_LINE_ITEMS_CONSTRAINT], + cnf, + }), + { signer: userKeypair }, + ); + const closedCheckoutPresentation = await signClosedCheckoutMandate( + buildClosedCheckoutMandate({ + vct: VCT_CHECKOUT_CLOSED, + checkout_jwt: SAMPLE_CHECKOUT_JWT, + checkout_hash: computeCheckoutHash(SAMPLE_CHECKOUT_JWT), + }), + { signer: agentKeypair, nonce: 'the-real-nonce', openMandatePresentation: openCheckoutPresentation }, + ); + + const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, { + openMandatePresentation: openCheckoutPresentation, + userPublicKey: userKeypair.publicKey(), + expectedNonce: 'a-different-nonce', + }); + expect(result.verified).toBe(false); + expect(result.error.message).toMatch(/"nonce"/); + }); + + test('verifyClosedCheckoutMandate requires expectedNonce -- a valid presentation cannot be replayed unnoticed', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + const cnf = { jwk: secp256r1PublicKeyToJwk(agentKeypair.publicKey()) }; + + const openCheckoutPresentation = await signOpenCheckoutMandate( + buildOpenCheckoutMandate({ + vct: VCT_CHECKOUT_OPEN, + constraints: [MINIMAL_LINE_ITEMS_CONSTRAINT], + cnf, + }), + { signer: userKeypair }, + ); + const closedCheckoutPresentation = await signClosedCheckoutMandate( + buildClosedCheckoutMandate({ + vct: VCT_CHECKOUT_CLOSED, + checkout_jwt: SAMPLE_CHECKOUT_JWT, + checkout_hash: computeCheckoutHash(SAMPLE_CHECKOUT_JWT), + }), + { signer: agentKeypair, nonce: 'nonce-1', openMandatePresentation: openCheckoutPresentation }, + ); + + const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, { + openMandatePresentation: openCheckoutPresentation, + userPublicKey: userKeypair.publicKey(), + }); + expect(result.verified).toBe(false); + expect(result.error.message).toMatch(/expectedNonce/); + }); + + test('verifyClosedPaymentMandate rejects a nonce that does not match expectedNonce', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + const cnf = { jwk: secp256r1PublicKeyToJwk(agentKeypair.publicKey()) }; + + const openPaymentPresentation = await signOpenPaymentMandate( + buildOpenPaymentMandate({ + vct: 'mandate.payment.open.1', + constraints: [{ type: 'payment.reference', conditional_transaction_id: 'digest-1' }], + cnf, + }), + { signer: userKeypair }, + ); + const closedPaymentPresentation = await signClosedPaymentMandate( + buildClosedPaymentMandate({ + vct: 'mandate.payment.1', + transaction_id: computeCheckoutHash(SAMPLE_CHECKOUT_JWT), + payee: { id: 'merchant_1', name: 'Demo Merchant', website: 'https://demo-merchant.example' }, + payment_amount: { amount: 19900, currency: 'USD' }, + payment_instrument: { id: 'stub', type: 'card', description: 'Card ****4242' }, + }), + { signer: agentKeypair, nonce: 'the-real-nonce', openMandatePresentation: openPaymentPresentation }, + ); + + const result = verifyClosedPaymentMandate(closedPaymentPresentation, { + openMandatePresentation: openPaymentPresentation, + userPublicKey: userKeypair.publicKey(), + expectedNonce: 'a-different-nonce', + }); + expect(result.verified).toBe(false); + expect(result.error.message).toMatch(/"nonce"/); + }); + + test('verifyClosedPaymentMandate requires expectedNonce -- a valid presentation cannot be replayed unnoticed', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + const cnf = { jwk: secp256r1PublicKeyToJwk(agentKeypair.publicKey()) }; + + const openPaymentPresentation = await signOpenPaymentMandate( + buildOpenPaymentMandate({ + vct: 'mandate.payment.open.1', + constraints: [{ type: 'payment.reference', conditional_transaction_id: 'digest-1' }], + cnf, + }), + { signer: userKeypair }, + ); + const closedPaymentPresentation = await signClosedPaymentMandate( + buildClosedPaymentMandate({ + vct: 'mandate.payment.1', + transaction_id: computeCheckoutHash(SAMPLE_CHECKOUT_JWT), + payee: { id: 'merchant_1', name: 'Demo Merchant', website: 'https://demo-merchant.example' }, + payment_amount: { amount: 19900, currency: 'USD' }, + payment_instrument: { id: 'stub', type: 'card', description: 'Card ****4242' }, + }), + { signer: agentKeypair, nonce: 'nonce-1', openMandatePresentation: openPaymentPresentation }, + ); + + const result = verifyClosedPaymentMandate(closedPaymentPresentation, { + openMandatePresentation: openPaymentPresentation, + userPublicKey: userKeypair.publicKey(), + }); + expect(result.verified).toBe(false); + expect(result.error.message).toMatch(/expectedNonce/); + }); +}); From 9a2fdb17f0295625cd4607f464bedf9176cdb2df Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Fri, 31 Jul 2026 16:38:04 -0400 Subject: [PATCH 06/11] docs(ap2): add example resolving userPublicKey from a User's DID 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 --- .changeset/strong-otters-verify.md | 4 + packages/ap2/README.md | 11 +- .../verify-mandate-with-did-user-key.mjs | 116 ++++++++++++++++++ packages/ap2/package.json | 1 + 4 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 packages/ap2/examples/verify-mandate-with-did-user-key.mjs diff --git a/.changeset/strong-otters-verify.md b/.changeset/strong-otters-verify.md index 4d4b7db34..b7b0a36d8 100644 --- a/.changeset/strong-otters-verify.md +++ b/.changeset/strong-otters-verify.md @@ -63,3 +63,7 @@ verification time, so a validly-signed Closed Mandate presentation could be replayed for a different transaction indefinitely. `expectedNonce` should be the single-use value the merchant/credential-provider itself generated and tracks for this specific transaction. + +Added an example, `examples/verify-mandate-with-did-user-key.mjs` (run via +`yarn example:did-mandate`), showing how to resolve `userPublicKey` from a +User's DID document and use it in the verification flow above. diff --git a/packages/ap2/README.md b/packages/ap2/README.md index 75d3c38bb..542a4346b 100644 --- a/packages/ap2/README.md +++ b/packages/ap2/README.md @@ -247,7 +247,7 @@ digests, key binding, delegation, audience, nonce, dates, constraints, `checkout_hash`, and payment transaction linkage — then pass that result as `mandateVerification` alongside `mandatePresentation`. -## Example +## Examples Run the complete payment receipt issue-and-verify example: @@ -256,3 +256,12 @@ yarn example:receipt ``` See [`examples/issue-and-verify-receipt.mjs`](examples/issue-and-verify-receipt.mjs). + +Run the Closed Checkout Mandate verification example, showing how to resolve +`userPublicKey` from a User's DID: + +```bash +yarn example:did-mandate +``` + +See [`examples/verify-mandate-with-did-user-key.mjs`](examples/verify-mandate-with-did-user-key.mjs). diff --git a/packages/ap2/examples/verify-mandate-with-did-user-key.mjs b/packages/ap2/examples/verify-mandate-with-did-user-key.mjs new file mode 100644 index 000000000..9d90829a6 --- /dev/null +++ b/packages/ap2/examples/verify-mandate-with-did-user-key.mjs @@ -0,0 +1,116 @@ +import { + buildOpenCheckoutMandate, + signOpenCheckoutMandate, + buildClosedCheckoutMandate, + signClosedCheckoutMandate, + verifyClosedCheckoutMandate, + computeCheckoutHash, +} from '@docknetwork/ap2'; +import { Secp256r1Keypair } from '@docknetwork/crypto-utils/keypairs'; +import { jwkToSecp256r1PublicKey, secp256r1PublicKeyToJwk } from '@docknetwork/crypto-utils/vc'; + +// verifyClosedCheckoutMandate/verifyClosedPaymentMandate require a +// `userPublicKey` -- the User's own key, verified against the Open Mandate's +// issuer signature, so that the cnf.jwk delegation inside it can be trusted +// as actually coming from that User rather than an entirely self-forged +// chain. This package deliberately has no opinion on *how* you get that key: +// it's an external trust decision, not something derivable from the mandate +// presentation itself. This example shows the shape of that lookup when the +// User is identified by a DID. + +// Stands in for a real DID resolver (e.g. a universal resolver, `did:web` +// over HTTPS, or `@docknetwork/credential-sdk`'s DID resolution module). +// What matters for this example is only the shape of what comes back: a DID +// Document whose `verificationMethod` entries carry a `publicKeyJwk`. +async function resolveDid(did, registry) { + const didDocument = registry.get(did); + if (!didDocument) { + throw new Error(`Unable to resolve "${did}"`); + } + return didDocument; +} + +function userPublicKeyFromDidDocument(didDocument) { + const verificationMethod = didDocument.verificationMethod?.[0]; + if (verificationMethod?.publicKeyJwk == null) { + throw new Error('DID document has no usable publicKeyJwk verification method'); + } + return jwkToSecp256r1PublicKey(verificationMethod.publicKeyJwk); +} + +// --- Set up the User's DID and a registry standing in for a resolver --- + +const userKeypair = Secp256r1Keypair.random(); +const userDid = 'did:example:user-123'; +const didRegistry = new Map([ + [userDid, { + id: userDid, + verificationMethod: [ + { + id: `${userDid}#key-1`, + type: 'JsonWebKey2020', + controller: userDid, + publicKeyJwk: secp256r1PublicKeyToJwk(userKeypair.publicKey()), + }, + ], + }], +]); + +// --- The rest of the flow is unaffected by where userPublicKey came from --- + +const agentKeypair = Secp256r1Keypair.random(); +const merchant = { id: 'merchant_1', name: 'Demo Merchant', website: 'https://demo-merchant.example' }; + +const openCheckoutContent = buildOpenCheckoutMandate({ + vct: 'mandate.checkout.open.1', + constraints: [ + { + type: 'checkout.line_items', + items: [{ id: 'line_1', quantity: 1, acceptable_items: [{ id: 'SKU-1', title: 'Widget' }] }], + }, + { type: 'checkout.allowed_merchants', allowed: [merchant] }, + ], + cnf: { jwk: secp256r1PublicKeyToJwk(agentKeypair.publicKey()) }, +}); +// Signed by the User's own key -- in a real Trusted Surface / wallet flow, +// this happens on the User's device, tied to the same key their DID resolves to. +const openCheckoutPresentation = await signOpenCheckoutMandate(openCheckoutContent, { + signer: userKeypair, +}); + +const checkoutJwt = 'eyJhbGciOiJFUzI1NiJ9.eyJvcmRlcl9pZCI6Im9yZGVyLTEifQ.sig'; +const closedCheckoutContent = buildClosedCheckoutMandate({ + vct: 'mandate.checkout.1', + checkout_jwt: checkoutJwt, + checkout_hash: computeCheckoutHash(checkoutJwt), +}); +const nonce = 'merchant-supplied-nonce-1'; +const closedCheckoutPresentation = await signClosedCheckoutMandate(closedCheckoutContent, { + signer: agentKeypair, + nonce, + openMandatePresentation: openCheckoutPresentation, +}); + +// --- The Merchant/Credential Provider verifies it --- + +// In production: the Merchant already knows (from earlier in the flow, e.g. +// an authentication step) which User this checkout belongs to, and resolves +// their DID to get a trusted key -- it does not extract a DID from the +// mandate presentation itself, since nothing in the presentation is trusted +// until after this resolution happens. +const didDocument = await resolveDid(userDid, didRegistry); +const userPublicKey = userPublicKeyFromDidDocument(didDocument); + +const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, { + openMandatePresentation: openCheckoutPresentation, + userPublicKey, + expectedNonce: nonce, +}); + +if (!result.verified) { + throw result.error; +} + +console.log('Resolved User DID:', userDid); +console.log('Verified Closed Checkout Mandate content:', result.content); +console.log('Open Mandate issuer verified:', result.openMandateIssuerVerified); diff --git a/packages/ap2/package.json b/packages/ap2/package.json index e3ce659e0..478bc2d42 100644 --- a/packages/ap2/package.json +++ b/packages/ap2/package.json @@ -61,6 +61,7 @@ "build": "yarn build:js && yarn build:types", "generate-schemas": "node scripts/generate-schemas.mjs", "example:receipt": "yarn build && node examples/issue-and-verify-receipt.mjs", + "example:did-mandate": "yarn build && node examples/verify-mandate-with-did-user-key.mjs", "test": "jest --runInBand", "lint": "eslint \"src/**/*.js\"", "prepublishOnly": "yarn build" From 928ba6f48eb28697ba5f7e0f11b88f3e0254472c Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Fri, 31 Jul 2026 16:44:15 -0400 Subject: [PATCH 07/11] docs(ap2): document the AP2 v0.2 revocation gap and exp's role 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 --- .changeset/strong-otters-verify.md | 7 ++++++ packages/ap2/README.md | 37 ++++++++++++++++++++++++++++++ packages/ap2/src/mandates.js | 15 ++++++++++++ 3 files changed, 59 insertions(+) diff --git a/.changeset/strong-otters-verify.md b/.changeset/strong-otters-verify.md index b7b0a36d8..dbbca8bde 100644 --- a/.changeset/strong-otters-verify.md +++ b/.changeset/strong-otters-verify.md @@ -67,3 +67,10 @@ tracks for this specific transaction. Added an example, `examples/verify-mandate-with-did-user-key.mjs` (run via `yarn example:did-mandate`), showing how to resolve `userPublicKey` from a User's DID document and use it in the verification flow above. + +Documented (README + JSDoc on `buildOpenCheckoutMandate`/ +`buildOpenPaymentMandate`) that AP2 v0.2 defines no Mandate revocation +mechanism at all -- it's explicitly out of scope per spec -- and that `exp` +is the only verifier-enforceable lifecycle control, RECOMMENDED but +schema-optional. No behavior changed; this closes a documentation gap +identified while reviewing the spec, not a code gap. diff --git a/packages/ap2/README.md b/packages/ap2/README.md index 542a4346b..66d694de7 100644 --- a/packages/ap2/README.md +++ b/packages/ap2/README.md @@ -215,6 +215,43 @@ placeholder string in the docs rather than a deliberate alternate meaning confirmed against a reference implementation or the normative "Delegate SD-JWT" individual draft this spec depends on for chain verification. +## Mandate lifecycle and revocation + +**AP2 v0.2 defines no revocation mechanism for Mandates, and this package +does not invent one.** The spec's only lifecycle-management guidance +(§ Mandate Management) reads: "In the case of external Trusted Surfaces it +could make sense to allow for management of delegated Mandates but that is +outside the scope of this specification." Managing (and revoking) a granted +Open Mandate is left entirely to the Shopping Agent / wallet UI layer as a +local, User-facing concern — there is no protocol-level status check, status +list, or `credentialStatus`-style claim a verifier can query, and no way for +a Merchant or Credential Provider to learn that a User revoked a mandate +after the fact. + +`exp` is therefore the *only* verifier-enforceable way a mandate's lifetime +ends, and it's schema-optional — `buildOpenCheckoutMandate`/ +`buildOpenPaymentMandate` both accept content with no `exp` at all, which +this package then treats as never expiring (see "accepts a mandate with no +exp" in the test suite). The spec (§ Autonomous) only RECOMMENDS setting +`exp` "to the smallest value that will allow the Shopping Agent to complete +the assigned task" — it does not require it, and this package does not +enforce it either, since doing so would exceed what the spec actually +mandates. **If your integration needs a shorter effective lifetime or a way +to invalidate a mandate mid-flight, set `exp` deliberately tight yourself** +(re-issuing Open Mandates as needed) and/or track revocation state out of +band on your own side (e.g. a Merchant's own record of voided orders, or a +Credential Provider invalidating the payment *token* it separately issued — +the one place the spec does gesture at invalidation, under § Security +Considerations' Double Spend discussion, and only as a `MAY`). + +Adding a proprietary status/revocation field would not help: it would be +invisible to any AP2-conformant counterparty that isn't running this exact +package, and the spec's Constraint mechanism explicitly requires unknown +constraint types to fail evaluation (§ Agent Authorization: "Any unknown +Constraints MUST be treated as failing evaluation"), so a custom +constraint-based revocation scheme would break interop with stock verifiers +rather than add safety. + ## Verification guarantees Receipt verification: diff --git a/packages/ap2/src/mandates.js b/packages/ap2/src/mandates.js index 8ece9ca59..db053f4b3 100644 --- a/packages/ap2/src/mandates.js +++ b/packages/ap2/src/mandates.js @@ -195,6 +195,14 @@ async function signMandateEnvelope(envelope, { signer, kid, typ }) { /** * Validates and clones an Open Checkout Mandate's disclosed content. + * + * `exp` is schema-optional, but the AP2 spec RECOMMENDS setting it to the + * smallest value that still lets the Shopping Agent complete its task -- + * it's the only verifier-enforceable way this mandate's lifetime ends. See + * the README's "Mandate lifecycle and revocation" section: AP2 v0.2 defines + * no revocation mechanism at all, so an Open Mandate issued with no `exp` + * remains valid, and closeable, indefinitely. + * * @param {object} content * @returns {object} */ @@ -215,6 +223,13 @@ export function buildClosedCheckoutMandate(content) { /** * Validates and clones an Open Payment Mandate's disclosed content. + * + * `exp` is schema-optional, but the AP2 spec RECOMMENDS setting it to the + * smallest value that still lets the Shopping Agent complete its task -- + * it's the only verifier-enforceable way this mandate's lifetime ends. See + * `buildOpenCheckoutMandate`'s doc comment and the README's "Mandate + * lifecycle and revocation" section. + * * @param {object} content * @returns {object} */ From 2dd4ed2575e6ae86634c8f72285ff70fe59b35a6 Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Fri, 31 Jul 2026 16:51:17 -0400 Subject: [PATCH 08/11] update versions --- .changeset/strong-otters-verify.md | 76 ----------------------------- packages/ap2/CHANGELOG.md | 77 ++++++++++++++++++++++++++++++ packages/ap2/package.json | 2 +- 3 files changed, 78 insertions(+), 77 deletions(-) delete mode 100644 .changeset/strong-otters-verify.md diff --git a/.changeset/strong-otters-verify.md b/.changeset/strong-otters-verify.md deleted file mode 100644 index dbbca8bde..000000000 --- a/.changeset/strong-otters-verify.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -"@docknetwork/ap2": minor ---- - -`verifyClosedCheckoutMandate` and `verifyClosedPaymentMandate` now require -`openMandatePresentation` and always derive the envelope's expected signing -key from the referenced Open Mandate's own `cnf.jwk`. The `publicKey`/ -`holderJwk` parameters are removed entirely — there is no longer a way to -verify a Closed Mandate against a caller-supplied key. Previously, a caller -that verified a signature against a self-supplied key had no guarantee that -key was ever the one the Open Mandate actually endorsed as its holder — a -Closed Mandate signed by an unauthorized key would still "verify" as long as -the caller supplied that same (wrong) key. Since this package has no -production usage yet, that insecure fallback is removed rather than kept -for backwards compatibility: calling either function without -`openMandatePresentation` now returns `{ verified: false, error }` instead -of silently trusting an unverified key. - -Both functions also now require `userPublicKey` — the User's own key, -resolved independently by the caller (e.g. via DID resolution or a wallet -registry) — and verify it against the referenced Open Mandate's own issuer -signature. Without this, deriving the verification key from `cnf.jwk` only -proves the Closed Mandate matches whatever the Open Mandate says, not that -the Open Mandate itself came from a real, authorized User: an attacker could -previously fabricate an entire Open + Closed Mandate chain naming their own -key in `cnf.jwk`, and every internal check (digest consistency, envelope -signature, `sd_hash`) would pass, since they're all self-referential to the -forged chain. `resolveOpenCheckoutMandateContent`/ -`resolveOpenPaymentMandateContent` gained a matching optional -`userPublicKey` parameter (returning `issuerVerified: true` when supplied and -valid) for callers using them directly; omitting it there still only checks -internal self-consistency, as before. - -Added `resolveOpenCheckoutMandateContent`, mirroring -`resolveOpenPaymentMandateContent` for Open Checkout Mandates, which the fix -above uses internally and which callers can also use directly (e.g. to -enforce checkout constraints or re-verify `cnf.jwk` before closing a mandate, -the way `issueClosedPaymentMandate`-style flows already do for payments). - -`verifyClosedPaymentMandate` also accepts a new optional -`openCheckoutMandatePresentation` parameter. When supplied alongside -`openMandatePresentation`, its issuer signature is verified against the same -`userPublicKey`, then the Open Payment Mandate's -`payment.reference.conditional_transaction_id` constraint is checked against -a fresh `sd_hash` of that Open Checkout Mandate presentation, returning -`referenceVerified` in the result. This was previously left entirely to the -caller (see the AP2 spec's Reference constraint) even though the package -already required both presentations for its other checks. - -Both `verifyClosedCheckoutMandate` and `verifyClosedPaymentMandate` now also -check the envelope's `typ` claim (`"kb+sd-jwt"`), rejecting an Open Mandate -presentation (`typ: "dc+sd-jwt"`) or any other envelope type fed in where a -Closed Mandate is expected. Other checks (schema shape, `aud`, -`checkout_hash`) would likely catch a swapped-in envelope in practice, but -this closes the gap explicitly rather than relying on that. - -Both functions also now require `expectedNonce`, checked against the -envelope's `nonce` claim. Closed Mandate envelopes carry no `exp` of their -own -- their only expiry comes transitively from the referenced Open -Mandate, which is typically valid for an entire shopping session -- and -`nonce` was previously accepted at signing time but never checked at -verification time, so a validly-signed Closed Mandate presentation could be -replayed for a different transaction indefinitely. `expectedNonce` should be -the single-use value the merchant/credential-provider itself generated and -tracks for this specific transaction. - -Added an example, `examples/verify-mandate-with-did-user-key.mjs` (run via -`yarn example:did-mandate`), showing how to resolve `userPublicKey` from a -User's DID document and use it in the verification flow above. - -Documented (README + JSDoc on `buildOpenCheckoutMandate`/ -`buildOpenPaymentMandate`) that AP2 v0.2 defines no Mandate revocation -mechanism at all -- it's explicitly out of scope per spec -- and that `exp` -is the only verifier-enforceable lifecycle control, RECOMMENDED but -schema-optional. No behavior changed; this closes a documentation gap -identified while reviewing the spec, not a code gap. diff --git a/packages/ap2/CHANGELOG.md b/packages/ap2/CHANGELOG.md index b4dcf87c3..ffde8ce4c 100644 --- a/packages/ap2/CHANGELOG.md +++ b/packages/ap2/CHANGELOG.md @@ -1,5 +1,82 @@ # @docknetwork/ap2 +## 0.5.0 + +### Minor Changes + +- 29dea58: `verifyClosedCheckoutMandate` and `verifyClosedPaymentMandate` now require + `openMandatePresentation` and always derive the envelope's expected signing + key from the referenced Open Mandate's own `cnf.jwk`. The `publicKey`/ + `holderJwk` parameters are removed entirely — there is no longer a way to + verify a Closed Mandate against a caller-supplied key. Previously, a caller + that verified a signature against a self-supplied key had no guarantee that + key was ever the one the Open Mandate actually endorsed as its holder — a + Closed Mandate signed by an unauthorized key would still "verify" as long as + the caller supplied that same (wrong) key. Since this package has no + production usage yet, that insecure fallback is removed rather than kept + for backwards compatibility: calling either function without + `openMandatePresentation` now returns `{ verified: false, error }` instead + of silently trusting an unverified key. + + Both functions also now require `userPublicKey` — the User's own key, + resolved independently by the caller (e.g. via DID resolution or a wallet + registry) — and verify it against the referenced Open Mandate's own issuer + signature. Without this, deriving the verification key from `cnf.jwk` only + proves the Closed Mandate matches whatever the Open Mandate says, not that + the Open Mandate itself came from a real, authorized User: an attacker could + previously fabricate an entire Open + Closed Mandate chain naming their own + key in `cnf.jwk`, and every internal check (digest consistency, envelope + signature, `sd_hash`) would pass, since they're all self-referential to the + forged chain. `resolveOpenCheckoutMandateContent`/ + `resolveOpenPaymentMandateContent` gained a matching optional + `userPublicKey` parameter (returning `issuerVerified: true` when supplied and + valid) for callers using them directly; omitting it there still only checks + internal self-consistency, as before. + + Added `resolveOpenCheckoutMandateContent`, mirroring + `resolveOpenPaymentMandateContent` for Open Checkout Mandates, which the fix + above uses internally and which callers can also use directly (e.g. to + enforce checkout constraints or re-verify `cnf.jwk` before closing a mandate, + the way `issueClosedPaymentMandate`-style flows already do for payments). + + `verifyClosedPaymentMandate` also accepts a new optional + `openCheckoutMandatePresentation` parameter. When supplied alongside + `openMandatePresentation`, its issuer signature is verified against the same + `userPublicKey`, then the Open Payment Mandate's + `payment.reference.conditional_transaction_id` constraint is checked against + a fresh `sd_hash` of that Open Checkout Mandate presentation, returning + `referenceVerified` in the result. This was previously left entirely to the + caller (see the AP2 spec's Reference constraint) even though the package + already required both presentations for its other checks. + + Both `verifyClosedCheckoutMandate` and `verifyClosedPaymentMandate` now also + check the envelope's `typ` claim (`"kb+sd-jwt"`), rejecting an Open Mandate + presentation (`typ: "dc+sd-jwt"`) or any other envelope type fed in where a + Closed Mandate is expected. Other checks (schema shape, `aud`, + `checkout_hash`) would likely catch a swapped-in envelope in practice, but + this closes the gap explicitly rather than relying on that. + + Both functions also now require `expectedNonce`, checked against the + envelope's `nonce` claim. Closed Mandate envelopes carry no `exp` of their + own -- their only expiry comes transitively from the referenced Open + Mandate, which is typically valid for an entire shopping session -- and + `nonce` was previously accepted at signing time but never checked at + verification time, so a validly-signed Closed Mandate presentation could be + replayed for a different transaction indefinitely. `expectedNonce` should be + the single-use value the merchant/credential-provider itself generated and + tracks for this specific transaction. + + Added an example, `examples/verify-mandate-with-did-user-key.mjs` (run via + `yarn example:did-mandate`), showing how to resolve `userPublicKey` from a + User's DID document and use it in the verification flow above. + + Documented (README + JSDoc on `buildOpenCheckoutMandate`/ + `buildOpenPaymentMandate`) that AP2 v0.2 defines no Mandate revocation + mechanism at all -- it's explicitly out of scope per spec -- and that `exp` + is the only verifier-enforceable lifecycle control, RECOMMENDED but + schema-optional. No behavior changed; this closes a documentation gap + identified while reviewing the spec, not a code gap. + ## 0.4.2 ### Patch Changes diff --git a/packages/ap2/package.json b/packages/ap2/package.json index 478bc2d42..1dc16da56 100644 --- a/packages/ap2/package.json +++ b/packages/ap2/package.json @@ -1,6 +1,6 @@ { "name": "@docknetwork/ap2", - "version": "0.4.2", + "version": "0.5.0", "description": "Basic AP2 support", "license": "MIT", "type": "module", From eb7b2bd1591c2259de2d53ca1c5954d134a417ac Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Fri, 31 Jul 2026 17:24:24 -0400 Subject: [PATCH 09/11] fix(ap2): fully resolve openCheckoutMandatePresentation before computing 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 --- .changeset/soft-actors-teach.md | 14 +++++++++++++ packages/ap2/src/mandates.js | 22 +++++++++++++------- packages/ap2/tests/mandates.test.js | 32 +++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 .changeset/soft-actors-teach.md diff --git a/.changeset/soft-actors-teach.md b/.changeset/soft-actors-teach.md new file mode 100644 index 000000000..10a2683f0 --- /dev/null +++ b/.changeset/soft-actors-teach.md @@ -0,0 +1,14 @@ +--- +"@docknetwork/ap2": patch +--- + +`verifyClosedPaymentMandate`'s `openCheckoutMandatePresentation` handling now +fully resolves that presentation (schema validation and its own `exp` check, +in addition to the issuer signature check it already did) before using it to +compute `referenceVerified`. Previously it only checked the raw envelope +signature, so an expired or otherwise schema-invalid Open Checkout Mandate +presentation could still produce `referenceVerified: true` — the exact class +of gap this package has been closing elsewhere (verification that exists at +one layer but isn't enforced end-to-end). An invalid `openCheckoutMandatePresentation` +now fails the whole `verifyClosedPaymentMandate` call rather than silently +computing a reference match from it. diff --git a/packages/ap2/src/mandates.js b/packages/ap2/src/mandates.js index db053f4b3..371e42754 100644 --- a/packages/ap2/src/mandates.js +++ b/packages/ap2/src/mandates.js @@ -638,12 +638,12 @@ export function verifyClosedCheckoutMandate( * Mandate's `checkout_jwt` is supplied, that `transaction_id` matches a * fresh hash of it. * - * When `openCheckoutMandatePresentation` is also supplied, also verifies its - * issuer envelope against the same `userPublicKey` (the AP2 flow's Open - * Checkout and Open Payment Mandates are both signed by the same User) and - * checks the Open Payment Mandate's - * `payment.reference.conditional_transaction_id` constraint against a fresh - * `sd_hash` of that Open Checkout Mandate presentation — the binding that + * When `openCheckoutMandatePresentation` is also supplied, it is fully + * resolved (issuer envelope against the same `userPublicKey` -- the AP2 flow's + * Open Checkout and Open Payment Mandates are both signed by the same User -- + * disclosed-content schema, and its own `exp`), then the Open Payment + * Mandate's `payment.reference.conditional_transaction_id` constraint is + * checked against a fresh `sd_hash` of that presentation — the binding that * ties this payment authorization to one specific checkout (see the AP2 * spec's Reference constraint). * @@ -701,10 +701,18 @@ export function verifyClosedPaymentMandate( let referenceVerified; if (openCheckoutMandatePresentation !== undefined) { + // Resolving (not just checking the raw envelope signature) also + // validates the disclosed content's schema and enforces its `exp` -- + // both of which live on the content, not the envelope, and would + // otherwise go unchecked here even though this Open Checkout Mandate + // is being used to authorize a payment. + resolveOpenCheckoutMandateContent( + openCheckoutMandatePresentation, + { userPublicKey, currentDate, clockTolerance }, + ); 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; diff --git a/packages/ap2/tests/mandates.test.js b/packages/ap2/tests/mandates.test.js index 9e2758ae8..f40e4be72 100644 --- a/packages/ap2/tests/mandates.test.js +++ b/packages/ap2/tests/mandates.test.js @@ -852,6 +852,38 @@ describe('AP2 mandates: payment.reference binding', () => { expect(result.verified).toBe(true); expect(result.referenceVerified).toBe(false); }); + + test('rejects when the supplied Open Checkout Mandate is expired, rather than computing referenceVerified from it anyway', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + + const openCheckoutPresentation = await signOpenCheckoutMandate( + buildOpenCheckoutMandate({ + vct: VCT_CHECKOUT_OPEN, + constraints: [MINIMAL_LINE_ITEMS_CONSTRAINT], + cnf: { jwk: secp256r1PublicKeyToJwk(userKeypair.publicKey()) }, + iat: 1000, + exp: 2000, + }), + { signer: userKeypair }, + ); + const conditionalTransactionId = computeSdHash(parseSdJwtPresentation(openCheckoutPresentation)); + const { openPaymentPresentation, closedPaymentPresentation } = await buildBoundClosedPaymentMandate( + userKeypair, + agentKeypair, + conditionalTransactionId, + ); + + const result = verifyClosedPaymentMandate(closedPaymentPresentation, { + openMandatePresentation: openPaymentPresentation, + openCheckoutMandatePresentation: openCheckoutPresentation, + userPublicKey: userKeypair.publicKey(), + expectedNonce: 'nonce-1', + currentDate: new Date(3000 * 1000), + }); + expect(result.verified).toBe(false); + expect(result.error.message).toMatch(/expired/); + }); }); describe('AP2 mandates: envelope typ confusion', () => { From 17c689e32c23aa5526c7501c7939a6bd60a0fd65 Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Fri, 31 Jul 2026 17:29:59 -0400 Subject: [PATCH 10/11] updated changelog --- .changeset/soft-actors-teach.md | 14 -------------- packages/ap2/CHANGELOG.md | 11 +++++++++++ 2 files changed, 11 insertions(+), 14 deletions(-) delete mode 100644 .changeset/soft-actors-teach.md diff --git a/.changeset/soft-actors-teach.md b/.changeset/soft-actors-teach.md deleted file mode 100644 index 10a2683f0..000000000 --- a/.changeset/soft-actors-teach.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@docknetwork/ap2": patch ---- - -`verifyClosedPaymentMandate`'s `openCheckoutMandatePresentation` handling now -fully resolves that presentation (schema validation and its own `exp` check, -in addition to the issuer signature check it already did) before using it to -compute `referenceVerified`. Previously it only checked the raw envelope -signature, so an expired or otherwise schema-invalid Open Checkout Mandate -presentation could still produce `referenceVerified: true` — the exact class -of gap this package has been closing elsewhere (verification that exists at -one layer but isn't enforced end-to-end). An invalid `openCheckoutMandatePresentation` -now fails the whole `verifyClosedPaymentMandate` call rather than silently -computing a reference match from it. diff --git a/packages/ap2/CHANGELOG.md b/packages/ap2/CHANGELOG.md index ffde8ce4c..a588c0507 100644 --- a/packages/ap2/CHANGELOG.md +++ b/packages/ap2/CHANGELOG.md @@ -4,6 +4,17 @@ ### Minor Changes +- eb7b2bd: `verifyClosedPaymentMandate`'s `openCheckoutMandatePresentation` handling now + fully resolves that presentation (schema validation and its own `exp` check, + in addition to the issuer signature check it already did) before using it to + compute `referenceVerified`. Previously it only checked the raw envelope + signature, so an expired or otherwise schema-invalid Open Checkout Mandate + presentation could still produce `referenceVerified: true` — the exact class + of gap this package has been closing elsewhere (verification that exists at + one layer but isn't enforced end-to-end). An invalid `openCheckoutMandatePresentation` + now fails the whole `verifyClosedPaymentMandate` call rather than silently + computing a reference match from it. + - 29dea58: `verifyClosedCheckoutMandate` and `verifyClosedPaymentMandate` now require `openMandatePresentation` and always derive the envelope's expected signing key from the referenced Open Mandate's own `cnf.jwk`. The `publicKey`/ From aa06d9f71a49048b5657735e7662125e2c5b6ad1 Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Wed, 5 Aug 2026 12:26:36 -0400 Subject: [PATCH 11/11] PR feedback --- packages/ap2/src/mandates.js | 75 ++++++++++++------ packages/ap2/src/utils.js | 16 ++-- packages/ap2/tests/mandates.test.js | 115 +++++++++++++++++++++++++++- 3 files changed, 169 insertions(+), 37 deletions(-) diff --git a/packages/ap2/src/mandates.js b/packages/ap2/src/mandates.js index 371e42754..02e244081 100644 --- a/packages/ap2/src/mandates.js +++ b/packages/ap2/src/mandates.js @@ -16,6 +16,7 @@ import { MANDATE_TYPE_PAYMENT_CLOSED, DEFAULT_CLOCK_TOLERANCE, validateMandateContent, + validateTimeOptions, } from './utils'; export { @@ -424,6 +425,18 @@ function checkNotExpired(payload, { currentDate, clockTolerance }, label) { } } +// exp alone only bounds one side of the validity window; a future-dated iat +// (clock skew or forgery) would otherwise pass. Mirrors validateReceiptTime. +function checkNotIssuedInFuture(payload, { currentDate, clockTolerance }, label) { + if (typeof payload.iat !== 'number') { + return; + } + const now = Math.floor(currentDate.getTime() / 1000); + if (payload.iat > now + clockTolerance) { + throw new Error(`${label} "iat" is in the future`); + } +} + function checkAudience(payload, expectedAud) { if (payload.aud !== expectedAud) { throw new Error(`Expected envelope "aud" of "${expectedAud}", got "${payload.aud}"`); @@ -483,7 +496,7 @@ function requireOpenMandatePresentation(openMandatePresentation) { } function requireUserPublicKey(userPublicKey) { - if (userPublicKey === undefined) { + if (userPublicKey === undefined || userPublicKey === null) { 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 ' @@ -547,9 +560,10 @@ function resolveCheckoutJwtClaim(content, disclosures, sdAlg) { * Mandate or other envelope type fed in where a Closed Mandate is expected), * the `checkout_jwt` disclosure against the content's `_sd` digest, * `checkout_hash` against a fresh hash of the revealed `checkout_jwt`, - * `aud === "merchant"`, expiry, `nonce` against the required `expectedNonce`, - * and `sd_hash` against `openMandatePresentation`. See - * `signClosedCheckoutMandate` for the `sd_hash` caveat. + * `aud === "merchant"`, expiry, that `iat` isn't in the future, `nonce` + * against the required `expectedNonce`, and `sd_hash` against + * `openMandatePresentation`. See `signClosedCheckoutMandate` for the + * `sd_hash` caveat. * * `expectedNonce` matters because Closed Mandate envelopes carry no `exp` of * their own -- their only expiry is inherited from the referenced Open @@ -573,6 +587,7 @@ export function verifyClosedCheckoutMandate( } = {}, ) { try { + validateTimeOptions(currentDate, clockTolerance); requireOpenMandatePresentation(openMandatePresentation); requireUserPublicKey(userPublicKey); requireExpectedNonce(expectedNonce); @@ -604,6 +619,7 @@ export function verifyClosedCheckoutMandate( checkAudience(payload, 'merchant'); checkNotExpired(payload, { currentDate, clockTolerance }, 'Closed Checkout Mandate'); + checkNotIssuedInFuture(payload, { currentDate, clockTolerance }, 'Closed Checkout Mandate'); checkNonce(payload, expectedNonce); checkSdHashAgainstOpenMandate(payload, openMandatePresentation, 'Open Checkout Mandate'); @@ -631,21 +647,20 @@ export function verifyClosedCheckoutMandate( * Mandate's own envelope) is what makes that delegation trustworthy in the * first place, rather than an entirely self-forgeable chain. Also verifies * the envelope's `typ === "kb+sd-jwt"`, `aud === "credential-provider"`, - * expiry, `nonce` against the required `expectedNonce` (see - * `verifyClosedCheckoutMandate` for why this matters -- Closed Mandate - * envelopes carry no `exp` of their own), and `sd_hash` against - * `openMandatePresentation`; when the corresponding Closed Checkout + * expiry, that `iat` isn't in the future, `nonce` against the required + * `expectedNonce` (see `verifyClosedCheckoutMandate` for why this matters -- + * Closed Mandate envelopes carry no `exp` of their own), and `sd_hash` + * against `openMandatePresentation`; when the corresponding Closed Checkout * Mandate's `checkout_jwt` is supplied, that `transaction_id` matches a * fresh hash of it. * * When `openCheckoutMandatePresentation` is also supplied, it is fully - * resolved (issuer envelope against the same `userPublicKey` -- the AP2 flow's - * Open Checkout and Open Payment Mandates are both signed by the same User -- - * disclosed-content schema, and its own `exp`), then the Open Payment - * Mandate's `payment.reference.conditional_transaction_id` constraint is - * checked against a fresh `sd_hash` of that presentation — the binding that - * ties this payment authorization to one specific checkout (see the AP2 - * spec's Reference constraint). + * resolved (issuer envelope against the same `userPublicKey`, disclosed-content + * schema, and its own `exp`), then the Open Payment Mandate's + * `payment.reference.conditional_transaction_id` constraint is checked + * against a fresh `sd_hash` of that presentation (see the AP2 spec's + * Reference constraint). A mismatch fails verification outright (`verified: + * false`), not just `referenceVerified: false`. * * @param {string} presentation * @param {{openMandatePresentation: string, userPublicKey: *, expectedNonce: string, @@ -663,6 +678,7 @@ export function verifyClosedPaymentMandate( } = {}, ) { try { + validateTimeOptions(currentDate, clockTolerance); requireOpenMandatePresentation(openMandatePresentation); requireUserPublicKey(userPublicKey); requireExpectedNonce(expectedNonce); @@ -687,6 +703,7 @@ export function verifyClosedPaymentMandate( checkAudience(payload, 'credential-provider'); checkNotExpired(payload, { currentDate, clockTolerance }, 'Closed Payment Mandate'); + checkNotIssuedInFuture(payload, { currentDate, clockTolerance }, 'Closed Payment Mandate'); checkNonce(payload, expectedNonce); let transactionIdVerified; @@ -701,21 +718,25 @@ export function verifyClosedPaymentMandate( let referenceVerified; if (openCheckoutMandatePresentation !== undefined) { - // Resolving (not just checking the raw envelope signature) also - // validates the disclosed content's schema and enforces its `exp` -- - // both of which live on the content, not the envelope, and would - // otherwise go unchecked here even though this Open Checkout Mandate - // is being used to authorize a payment. + // Resolving, not just checking the raw envelope signature, also + // enforces this content's own schema and exp. resolveOpenCheckoutMandateContent( openCheckoutMandatePresentation, { userPublicKey, currentDate, clockTolerance }, ); + // Schema-required (payment-mandate-open.json's "contains"), so always found. + const reference = openPaymentContent.constraints.find((c) => c?.type === 'payment.reference'); const { issuerJwt: checkoutIssuerJwt, disclosures: checkoutDisclosures } = parseSdJwtPresentation( openCheckoutMandatePresentation, ); - const reference = (openPaymentContent.constraints ?? []).find((c) => c?.type === 'payment.reference'); - const expectedTransactionId = computeSdHash({ issuerJwt: checkoutIssuerJwt, disclosures: checkoutDisclosures }); - referenceVerified = reference?.conditional_transaction_id === expectedTransactionId; + const actualTransactionId = computeSdHash({ issuerJwt: checkoutIssuerJwt, disclosures: checkoutDisclosures }); + if (reference.conditional_transaction_id !== actualTransactionId) { + throw new Error( + '"payment.reference.conditional_transaction_id" does not match a fresh sd_hash ' + + 'of the provided openCheckoutMandatePresentation', + ); + } + referenceVerified = true; } return { @@ -777,8 +798,8 @@ function resolvePaymentConstraintDisclosures(content, disclosures, sdAlg) { * What this always checks, regardless of `userPublicKey`: the SD-JWT's * internal digest/disclosure consistency (a hand-edited constraints blob * whose digest no longer matches `delegate_payload` is rejected), that the - * resolved content matches the Open Payment Mandate schema, and (when `exp` - * is present) that it hasn't passed. + * resolved content matches the Open Payment Mandate schema, and (when + * present) that `exp` hasn't passed and `iat` isn't in the future. * * @param {string} presentation * @param {{userPublicKey?: *, currentDate?: Date, clockTolerance?: number}} options @@ -788,6 +809,7 @@ export function resolveOpenPaymentMandateContent( presentation, { userPublicKey, currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE } = {}, ) { + validateTimeOptions(currentDate, clockTolerance); const { issuerJwt, disclosures } = parseSdJwtPresentation(presentation); if (userPublicKey !== undefined) { verifyOpenMandateIssuer(issuerJwt, userPublicKey, OPEN_PAYMENT_MANDATE_LABEL); @@ -804,6 +826,7 @@ export function resolveOpenPaymentMandateContent( // itself, not on the outer signed envelope payload (which is just // `{delegate_payload, _sd_alg}`) -- so it's checked against `content` here. checkNotExpired(content, { currentDate, clockTolerance }, OPEN_PAYMENT_MANDATE_LABEL); + checkNotIssuedInFuture(content, { currentDate, clockTolerance }, OPEN_PAYMENT_MANDATE_LABEL); return { content, @@ -864,6 +887,7 @@ export function resolveOpenCheckoutMandateContent( presentation, { userPublicKey, currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE } = {}, ) { + validateTimeOptions(currentDate, clockTolerance); const { issuerJwt, disclosures } = parseSdJwtPresentation(presentation); if (userPublicKey !== undefined) { verifyOpenMandateIssuer(issuerJwt, userPublicKey, OPEN_CHECKOUT_MANDATE_LABEL); @@ -880,6 +904,7 @@ export function resolveOpenCheckoutMandateContent( // other properties) lives on the disclosed content, not the outer envelope // payload -- same reasoning as `resolveOpenPaymentMandateContent`. checkNotExpired(content, { currentDate, clockTolerance }, OPEN_CHECKOUT_MANDATE_LABEL); + checkNotIssuedInFuture(content, { currentDate, clockTolerance }, OPEN_CHECKOUT_MANDATE_LABEL); return { content, diff --git a/packages/ap2/src/utils.js b/packages/ap2/src/utils.js index 5c1a7e8b4..31bedf20e 100644 --- a/packages/ap2/src/utils.js +++ b/packages/ap2/src/utils.js @@ -102,19 +102,13 @@ export function inferReceiptType(receipt) { : RECEIPT_TYPE_CHECKOUT; } -function validateTimeOptions(currentDate, clockTolerance, maxReceiptAge) { +export function validateTimeOptions(currentDate, clockTolerance) { if (!(currentDate instanceof Date) || Number.isNaN(currentDate.getTime())) { throw new TypeError('"currentDate" must be a valid Date'); } if (!Number.isFinite(clockTolerance) || clockTolerance < 0) { throw new TypeError('"clockTolerance" must be a non-negative number'); } - if ( - maxReceiptAge !== undefined - && (!Number.isFinite(maxReceiptAge) || maxReceiptAge < 0) - ) { - throw new TypeError('"maxReceiptAge" must be a non-negative number'); - } } export function validateReceiptTime( @@ -125,7 +119,13 @@ export function validateReceiptTime( maxReceiptAge, }, ) { - validateTimeOptions(currentDate, clockTolerance, maxReceiptAge); + validateTimeOptions(currentDate, clockTolerance); + if ( + maxReceiptAge !== undefined + && (!Number.isFinite(maxReceiptAge) || maxReceiptAge < 0) + ) { + throw new TypeError('"maxReceiptAge" must be a non-negative number'); + } const now = Math.floor(currentDate.getTime() / 1000); if (receipt.iat > now + clockTolerance) { throw new Error('Receipt "iat" is in the future'); diff --git a/packages/ap2/tests/mandates.test.js b/packages/ap2/tests/mandates.test.js index f40e4be72..a3fa35998 100644 --- a/packages/ap2/tests/mandates.test.js +++ b/packages/ap2/tests/mandates.test.js @@ -374,6 +374,27 @@ describe('AP2 mandates: resolveOpenPaymentMandateContent', () => { })).toThrow(/expired/); }); + test('rejects an Open Payment Mandate issued in the future', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + const cnf = { jwk: secp256r1PublicKeyToJwk(agentKeypair.publicKey()) }; + + const presentation = await signOpenPaymentMandate( + buildOpenPaymentMandate({ + vct: 'mandate.payment.open.1', + constraints: [{ type: 'payment.reference', conditional_transaction_id: 'digest-1' }], + cnf, + iat: 2000, + exp: 3000, + }), + { signer: userKeypair }, + ); + + expect(() => resolveOpenPaymentMandateContent(presentation, { + currentDate: new Date(1000 * 1000), + })).toThrow(/iat.*future/); + }); + test('accepts a mandate with no exp -- no expiry enforced', async () => { const userKeypair = Secp256r1Keypair.random(); const agentKeypair = Secp256r1Keypair.random(); @@ -489,6 +510,26 @@ describe('AP2 mandates: resolveOpenCheckoutMandateContent', () => { currentDate: new Date(3000 * 1000), })).toThrow(/expired/); }); + + test('rejects an Open Checkout Mandate issued in the future', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + + const presentation = await signOpenCheckoutMandate( + buildOpenCheckoutMandate({ + vct: VCT_CHECKOUT_OPEN, + constraints: [MINIMAL_LINE_ITEMS_CONSTRAINT], + cnf: { jwk: secp256r1PublicKeyToJwk(agentKeypair.publicKey()) }, + iat: 2000, + exp: 3000, + }), + { signer: userKeypair }, + ); + + expect(() => resolveOpenCheckoutMandateContent(presentation, { + currentDate: new Date(1000 * 1000), + })).toThrow(/iat.*future/); + }); }); describe('AP2 mandates: cnf is derived from the Open Mandate, not trusted from the caller', () => { @@ -699,6 +740,13 @@ describe('AP2 mandates: cnf is derived from the Open Mandate, not trusted from t }); expect(result.verified).toBe(false); expect(result.error.message).toMatch(/userPublicKey/); + + const nullResult = verifyClosedCheckoutMandate(closedCheckoutPresentation, { + openMandatePresentation: openCheckoutPresentation, + userPublicKey: null, + }); + expect(nullResult.verified).toBe(false); + expect(nullResult.error.message).toMatch(/userPublicKey/); }); test('verifyClosedPaymentMandate requires openMandatePresentation -- there is no caller-supplied-key fallback', async () => { @@ -816,7 +864,18 @@ describe('AP2 mandates: payment.reference binding', () => { expect(result.referenceVerified).toBe(true); }); - test('fails referenceVerified when payment.reference points at a different checkout', async () => { + // verifyClosedPaymentMandate's reference-binding check relies on every + // schema-valid Open Payment Mandate having a payment.reference constraint + // (enforced below) -- it doesn't need to handle one being absent. + test('buildOpenPaymentMandate rejects constraints with no payment.reference entry', () => { + expect(() => buildOpenPaymentMandate({ + vct: 'mandate.payment.open.1', + constraints: [{ type: 'payment.amount_range', currency: 'USD', max: 50000 }], + cnf: { jwk: secp256r1PublicKeyToJwk(Secp256r1Keypair.random().publicKey()) }, + })).toThrow(/constraints/); + }); + + test('rejects when payment.reference points at a different checkout, rather than reporting referenceVerified: false', async () => { const userKeypair = Secp256r1Keypair.random(); const agentKeypair = Secp256r1Keypair.random(); @@ -849,8 +908,8 @@ describe('AP2 mandates: payment.reference binding', () => { userPublicKey: userKeypair.publicKey(), expectedNonce: 'nonce-1', }); - expect(result.verified).toBe(true); - expect(result.referenceVerified).toBe(false); + expect(result.verified).toBe(false); + expect(result.error.message).toMatch(/conditional_transaction_id/); }); test('rejects when the supplied Open Checkout Mandate is expired, rather than computing referenceVerified from it anyway', async () => { @@ -879,7 +938,6 @@ describe('AP2 mandates: payment.reference binding', () => { openCheckoutMandatePresentation: openCheckoutPresentation, userPublicKey: userKeypair.publicKey(), expectedNonce: 'nonce-1', - currentDate: new Date(3000 * 1000), }); expect(result.verified).toBe(false); expect(result.error.message).toMatch(/expired/); @@ -1063,3 +1121,52 @@ describe('AP2 mandates: nonce binding', () => { expect(result.error.message).toMatch(/expectedNonce/); }); }); + +describe('AP2 mandates: currentDate/clockTolerance validation', () => { + test('resolveOpenPaymentMandateContent rejects a non-Date currentDate', async () => { + const userKeypair = Secp256r1Keypair.random(); + const presentation = await signOpenPaymentMandate( + buildOpenPaymentMandate({ + vct: 'mandate.payment.open.1', + constraints: [{ type: 'payment.reference', conditional_transaction_id: 'digest-1' }], + cnf: { jwk: secp256r1PublicKeyToJwk(userKeypair.publicKey()) }, + }), + { signer: userKeypair }, + ); + + expect(() => resolveOpenPaymentMandateContent(presentation, { + currentDate: '2024-01-01', + })).toThrow(/"currentDate" must be a valid Date/); + }); + + test('verifyClosedCheckoutMandate returns verified: false for a negative clockTolerance', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + + const openCheckoutPresentation = await signOpenCheckoutMandate( + buildOpenCheckoutMandate({ + vct: VCT_CHECKOUT_OPEN, + constraints: [MINIMAL_LINE_ITEMS_CONSTRAINT], + cnf: { jwk: secp256r1PublicKeyToJwk(agentKeypair.publicKey()) }, + }), + { signer: userKeypair }, + ); + const closedCheckoutPresentation = await signClosedCheckoutMandate( + buildClosedCheckoutMandate({ + vct: VCT_CHECKOUT_CLOSED, + checkout_jwt: SAMPLE_CHECKOUT_JWT, + checkout_hash: computeCheckoutHash(SAMPLE_CHECKOUT_JWT), + }), + { signer: agentKeypair, nonce: 'nonce-1', openMandatePresentation: openCheckoutPresentation }, + ); + + const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, { + openMandatePresentation: openCheckoutPresentation, + userPublicKey: userKeypair.publicKey(), + expectedNonce: 'nonce-1', + clockTolerance: -1, + }); + expect(result.verified).toBe(false); + expect(result.error.message).toMatch(/"clockTolerance" must be a non-negative number/); + }); +});