diff --git a/packages/ap2/CHANGELOG.md b/packages/ap2/CHANGELOG.md index b4dcf87c3..a588c0507 100644 --- a/packages/ap2/CHANGELOG.md +++ b/packages/ap2/CHANGELOG.md @@ -1,5 +1,93 @@ # @docknetwork/ap2 +## 0.5.0 + +### 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`/ + `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/README.md b/packages/ap2/README.md index a95f2b634..66d694de7 100644 --- a/packages/ap2/README.md +++ b/packages/ap2/README.md @@ -33,10 +33,23 @@ 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. 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, - { /* publicKey or holderJwk, checkoutJwt, openMandatePresentation, ... */ }, + { + openMandatePresentation, userPublicKey, expectedNonce, checkoutJwt, openCheckoutMandatePresentation, + }, ); const result = verifyPaymentReceipt(jwt, { @@ -145,10 +158,21 @@ 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. +// 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. 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, { - holderJwk: agentPublicJwk, // or publicKey openMandatePresentation: openCheckoutPresentation, + userPublicKey, + expectedNonce: 'merchant-supplied-nonce', }); if (!result.verified) throw result.error; ``` @@ -191,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: @@ -223,7 +284,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: @@ -232,3 +293,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..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", @@ -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" diff --git a/packages/ap2/src/mandates.js b/packages/ap2/src/mandates.js index d7712e88e..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 { @@ -26,6 +27,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 = { @@ -194,6 +196,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} */ @@ -214,6 +224,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} */ @@ -378,16 +395,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'); @@ -418,18 +425,58 @@ 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}"`); } } -// 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; +// 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 +// 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, ); @@ -437,7 +484,37 @@ 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', + ); + } +} + +function requireUserPublicKey(userPublicKey) { + 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 ' + + '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 @@ -463,33 +540,70 @@ 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 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. + * + * 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 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"`, and — when `openMandatePresentation` is supplied — - * `sd_hash` against that presentation. 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 + * 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 {{publicKey?: *, holderJwk?: object, openMandatePresentation?: string, + * @param {{openMandatePresentation: string, userPublicKey: *, expectedNonce: string, * 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, { - publicKey, holderJwk, openMandatePresentation, currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE, + openMandatePresentation, userPublicKey, expectedNonce, + currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE, } = {}, ) { try { + validateTimeOptions(currentDate, clockTolerance); + requireOpenMandatePresentation(openMandatePresentation); + requireUserPublicKey(userPublicKey); + requireExpectedNonce(expectedNonce); const { issuerJwt, disclosures } = parseSdJwtPresentation(presentation); - const result = verifyJWT(issuerJwt, resolvePublicKey({ publicKey, holderJwk }), { algorithms: ['ES256'] }); + const verificationKey = jwkToSecp256r1PublicKey( + resolveOpenCheckoutMandateContent( + openMandatePresentation, + { userPublicKey, currentDate, clockTolerance }, + ).content.cnf.jwk, + ); + const result = verifyJWT(issuerJwt, verificationKey, { algorithms: ['ES256'] }); if (!result.verified) { return result; } const { payload, protectedHeader } = result; + checkTyp(protectedHeader); const sdAlg = getSdAlg(payload); const content = resolveMandateContent(payload, disclosures, sdAlg); @@ -505,18 +619,17 @@ export function verifyClosedCheckoutMandate( checkAudience(payload, 'merchant'); checkNotExpired(payload, { currentDate, clockTolerance }, 'Closed Checkout Mandate'); - const sdHashVerified = checkSdHashAgainstOpenMandate( - payload, - openMandatePresentation, - 'Open Checkout Mandate', - ); + checkNotIssuedInFuture(payload, { currentDate, clockTolerance }, 'Closed Checkout Mandate'); + checkNonce(payload, expectedNonce); + checkSdHashAgainstOpenMandate(payload, openMandatePresentation, 'Open Checkout Mandate'); return { verified: true, content: validatedContent, checkoutJwt, protectedHeader, - ...(sdHashVerified === undefined ? {} : { sdHashVerified }), + sdHashVerified: true, + openMandateIssuerVerified: true, }; } catch (error) { return { verified: false, error: error instanceof Error ? error : new Error(String(error)) }; @@ -526,39 +639,63 @@ 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. + * 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, 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 + * the envelope's `typ === "kb+sd-jwt"`, `aud === "credential-provider"`, + * 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. * - * 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, it is fully + * 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 {{publicKey?: *, holderJwk?: object, checkoutJwt?: string, openMandatePresentation?: string, + * @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, error?: Error}} + * transactionIdVerified?: boolean, sdHashVerified?: boolean, referenceVerified?: boolean, + * openMandateIssuerVerified?: boolean, error?: Error}} */ export function verifyClosedPaymentMandate( presentation, { - publicKey, holderJwk, checkoutJwt, openMandatePresentation, currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE, + checkoutJwt, openMandatePresentation, userPublicKey, expectedNonce, openCheckoutMandatePresentation, + currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE, } = {}, ) { try { + validateTimeOptions(currentDate, clockTolerance); + requireOpenMandatePresentation(openMandatePresentation); + requireUserPublicKey(userPublicKey); + requireExpectedNonce(expectedNonce); const { issuerJwt, disclosures } = parseSdJwtPresentation(presentation); - const result = verifyJWT(issuerJwt, resolvePublicKey({ publicKey, holderJwk }), { algorithms: ['ES256'] }); + + const openPaymentContent = resolveOpenPaymentMandateContent( + openMandatePresentation, + { userPublicKey, currentDate, clockTolerance }, + ).content; + const verificationKey = jwkToSecp256r1PublicKey(openPaymentContent.cnf.jwk); + + const result = verifyJWT(issuerJwt, verificationKey, { algorithms: ['ES256'] }); if (!result.verified) { return result; } const { payload, protectedHeader } = result; + checkTyp(protectedHeader); const sdAlg = getSdAlg(payload); const content = resolveMandateContent(payload, disclosures, sdAlg); @@ -566,6 +703,8 @@ 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; if (checkoutJwt !== undefined) { @@ -575,18 +714,39 @@ export function verifyClosedPaymentMandate( transactionIdVerified = true; } - const sdHashVerified = checkSdHashAgainstOpenMandate( - payload, - openMandatePresentation, - OPEN_PAYMENT_MANDATE_LABEL, - ); + checkSdHashAgainstOpenMandate(payload, openMandatePresentation, OPEN_PAYMENT_MANDATE_LABEL); + + let referenceVerified; + if (openCheckoutMandatePresentation !== undefined) { + // 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 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 { verified: true, content: validatedContent, protectedHeader, ...(transactionIdVerified === undefined ? {} : { transactionIdVerified }), - ...(sdHashVerified === undefined ? {} : { sdHashVerified }), + ...(referenceVerified === undefined ? {} : { referenceVerified }), + sdHashVerified: true, + openMandateIssuerVerified: true, }; } catch (error) { return { verified: false, error: error instanceof Error ? error : new Error(String(error)) }; @@ -621,32 +781,39 @@ 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 + * present) that `exp` hasn't passed and `iat` isn't in the future. * * @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 } = {}, ) { + validateTimeOptions(currentDate, clockTolerance); 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); @@ -659,6 +826,89 @@ 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, + protectedHeader, + ...(userPublicKey === undefined ? {} : { issuerVerified: true }), + }; +} + +// 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`, ...). 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 {{userPublicKey?: *, currentDate?: Date, clockTolerance?: number}} options + * @returns {{content: object, protectedHeader: object, issuerVerified?: boolean}} + */ +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); + } + const payload = decodeJwtPayload(issuerJwt, OPEN_CHECKOUT_MANDATE_LABEL); + const protectedHeader = decodeJwtProtectedHeader(issuerJwt, OPEN_CHECKOUT_MANDATE_LABEL); + const sdAlg = getSdAlg(payload); - return { content, protectedHeader }; + 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); + checkNotIssuedInFuture(content, { currentDate, clockTolerance }, OPEN_CHECKOUT_MANDATE_LABEL); + + return { + content, + protectedHeader, + ...(userPublicKey === undefined ? {} : { issuerVerified: true }), + }; } 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 67d457fd5..a3fa35998 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, @@ -132,12 +133,14 @@ describe('AP2 mandates: round trip', () => { }); const checkoutResult = verifyClosedCheckoutMandate(closedCheckoutPresentation, { - publicKey: agentKeypair.publicKey(), openMandatePresentation: openCheckoutPresentation, + userPublicKey: userKeypair.publicKey(), + expectedNonce: 'nonce-1', }); 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({ @@ -151,7 +154,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, @@ -171,13 +174,15 @@ describe('AP2 mandates: round trip', () => { }); const paymentResult = verifyClosedPaymentMandate(closedPaymentPresentation, { - publicKey: agentKeypair.publicKey(), checkoutJwt, openMandatePresentation: openPaymentPresentation, + userPublicKey: userKeypair.publicKey(), + expectedNonce: 'nonce-2', }); 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,13 +214,15 @@ describe('AP2 mandates: round trip', () => { ); const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, { - publicKey: agentKeypair.publicKey(), + openMandatePresentation: openCheckoutPresentation, + userPublicKey: userKeypair.publicKey(), + expectedNonce: 'nonce-1', }); 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(); @@ -231,6 +238,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, @@ -238,14 +246,16 @@ 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, + userPublicKey: userKeypair.publicKey(), + expectedNonce: 'nonce-1', }); expect(result.verified).toBe(false); }); @@ -287,6 +297,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(); @@ -339,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(); @@ -359,3 +415,758 @@ 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('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(); + + 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/); + }); + + 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', () => { + test('verifyClosedCheckoutMandate rejects a mandate closed by an unauthorized 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, + }, + ); + + // 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, { + openMandatePresentation: openCheckoutPresentation, + userPublicKey: userKeypair.publicKey(), + expectedNonce: 'nonce-1', + }); + expect(result.verified).toBe(false); + }); + + test('verifyClosedPaymentMandate rejects a mandate closed by an unauthorized 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 result = verifyClosedPaymentMandate(closedPaymentPresentation, { + openMandatePresentation: openPaymentPresentation, + userPublicKey: userKeypair.publicKey(), + expectedNonce: 'nonce-1', + }); + 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(), + expectedNonce: 'nonce-1', + }); + 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(), + expectedNonce: 'nonce-1', + }); + 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(); + 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('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/); + + 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 () => { + 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/); + }); + + 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', () => { + 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, + userPublicKey: userKeypair.publicKey(), + expectedNonce: 'nonce-1', + }); + expect(result.verified).toBe(true); + expect(result.referenceVerified).toBe(true); + }); + + // 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(); + + 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, + userPublicKey: userKeypair.publicKey(), + expectedNonce: 'nonce-1', + }); + 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 () => { + 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', + }); + expect(result.verified).toBe(false); + expect(result.error.message).toMatch(/expired/); + }); +}); + +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(), + // 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"/); + }); + + 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(), + 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/); + }); +}); + +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/); + }); +});