diff --git a/packages/ap2/CHANGELOG.md b/packages/ap2/CHANGELOG.md index 2de0c18d0..e36b226da 100644 --- a/packages/ap2/CHANGELOG.md +++ b/packages/ap2/CHANGELOG.md @@ -1,5 +1,29 @@ # @docknetwork/ap2 +## 0.3.0 + +### Minor Changes + +- 61e1eb5: Add AP2 v0.2 mandate support: build/sign/verify functions for Open and + Closed Checkout Mandates and Payment Mandates + (`buildOpenCheckoutMandate`, `signOpenCheckoutMandate`, + `buildClosedCheckoutMandate`, `signClosedCheckoutMandate`, + `verifyClosedCheckoutMandate`, `buildOpenPaymentMandate`, + `signOpenPaymentMandate`, `buildClosedPaymentMandate`, + `signClosedPaymentMandate`, `verifyClosedPaymentMandate`, + `computeCheckoutHash`, `computeDisclosureDigest`). Mandate verification now + lives in this package instead of requiring an external dependency. + + The four mandate JSON Schemas (`src/schemas/{checkout,payment}-mandate- +{open,closed}.json`) are now generated from a pinned upstream AP2 mirror + (`upstream-ap2-schemas/`) via `npm run generate-schemas`, rather than + hand-maintained. All six packaged JSON Schemas (the four mandate schemas + plus the existing checkout/payment receipt schemas) are now exported as + named exports (`checkoutMandateOpenSchema`, `checkoutMandateClosedSchema`, + `paymentMandateOpenSchema`, `paymentMandateClosedSchema`, + `checkoutReceiptSchema`, `paymentReceiptSchema`) for consumers that need + the raw schema content. + ## 0.2.1 ### Patch Changes diff --git a/packages/ap2/README.md b/packages/ap2/README.md index 09e5976d5..13631182e 100644 --- a/packages/ap2/README.md +++ b/packages/ap2/README.md @@ -1,8 +1,8 @@ # @docknetwork/ap2 -[AP2](https://ap2-protocol.org/) receipt issuance and verification using the -lightweight Dock crypto utilities. Receipts are compact ES256 JWTs compatible -with the AP2 receipt format. +[AP2](https://ap2-protocol.org/) mandate and receipt issuance/verification +using the lightweight Dock crypto utilities. Mandates and receipts are +compact ES256 SD-JWTs/JWTs compatible with the AP2 v0.2 spec. See the [AP2 documentation](https://ap2-protocol.org/) and [specification](https://ap2-protocol.org/ap2/specification/) for protocol @@ -14,10 +14,9 @@ import { computeMandateReference, signReceipt, verifyPaymentReceipt, + verifyClosedPaymentMandate, } from '@docknetwork/ap2'; import { Secp256r1Keypair } from '@docknetwork/crypto-utils/keypairs'; -// Mandate verification lives outside this package (e.g. @ar-agents/ap2). -import { verifyClosedPaymentMandate } from '@ar-agents/ap2'; const keypair = Secp256r1Keypair.random(); // Use the exact compact presentation received from the Shopping Agent. @@ -34,10 +33,10 @@ const jwt = await signReceipt(receipt, { signer: keypair, type: 'payment', }); -// Verify the mandate first (signatures, delegation, audience, etc.). -const mandateVerification = await verifyClosedPaymentMandate( +// Verify the mandate first (signature, cnf key-binding, checkout binding, aud, expiry). +const mandateVerification = verifyClosedPaymentMandate( closedMandatePresentation, - { /* issuer keys, expected transaction id, ... */ }, + { /* publicKey or holderJwk, checkoutJwt, openMandatePresentation, ... */ }, ); const result = verifyPaymentReceipt(jwt, { @@ -66,7 +65,11 @@ style. Successful results may also include `referenceVerified` and ## Payloads and signing `buildCheckoutReceipt` and `buildPaymentReceipt` validate against the packaged -AP2 JSON Schemas, clone the payload, and preserve extension properties. +AP2 JSON Schemas, clone the payload, and preserve extension properties. These +schemas are also available as named exports (`checkoutReceiptSchema`, +`paymentReceiptSchema`) for consumers that want the raw JSON Schema — e.g. for +form generation or documentation — without re-validating through this +package. `signReceipt` is the BYOK entry point. Its `signer.sign(data)` function may be synchronous or asynchronous and must return an ES256 signature in 64-byte JOSE @@ -90,6 +93,98 @@ selects its final SD-JWT, removes a trailing key-binding JWT, and delegates to that primitive. `encodeDisclosure([salt, claimName, value])` creates the base64url-encoded disclosure form used by both functions. +## Mandates + +AP2 v0.2 defines two mandate families — **Checkout Mandate** and **Payment +Mandate** — each with an **Open** and **Closed** state. Mandates are +self-signed SD-JWTs: Open Mandates are signed by the user's key (via a +Trusted Surface, e.g. a wallet), Closed Mandates by the Shopping Agent's key +endorsed in the Open Mandate's `cnf` claim. This package builds, signs, and +verifies all four shapes: + +```js +import { + buildOpenCheckoutMandate, + signOpenCheckoutMandate, + buildClosedCheckoutMandate, + signClosedCheckoutMandate, + verifyClosedCheckoutMandate, + buildOpenPaymentMandate, + signOpenPaymentMandate, + buildClosedPaymentMandate, + signClosedPaymentMandate, + verifyClosedPaymentMandate, + computeCheckoutHash, +} from '@docknetwork/ap2'; + +// 1. User's wallet signs an Open Checkout Mandate (once, up front). +const openCheckoutContent = buildOpenCheckoutMandate({ + vct: 'mandate.checkout.open.1', + constraints: [/* checkout.line_items, checkout.allowed_merchants */], + cnf: { jwk: agentPublicJwk }, +}); +const openCheckoutPresentation = await signOpenCheckoutMandate(openCheckoutContent, { + signer: userSigner, +}); + +// 2. Later, the Shopping Agent closes it against a specific merchant checkout. +const closedCheckoutContent = buildClosedCheckoutMandate({ + vct: 'mandate.checkout.1', + checkout_jwt: merchantSignedCheckoutJwt, + checkout_hash: computeCheckoutHash(merchantSignedCheckoutJwt), +}); +const closedCheckoutPresentation = await signClosedCheckoutMandate(closedCheckoutContent, { + signer: agentSigner, + nonce: 'merchant-supplied-nonce', + openMandatePresentation: openCheckoutPresentation, +}); + +// 3. The Merchant/Credential Provider verifies it. +const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, { + holderJwk: agentPublicJwk, // or publicKey + openMandatePresentation: openCheckoutPresentation, +}); +if (!result.verified) throw result.error; +``` + +Payment Mandates follow the same `build*`/`sign*`/`verifyClosedPaymentMandate` +pattern, binding to a Checkout Mandate via `transaction_id` (a hash of +`checkout_jwt`, verified against a `checkoutJwt` passed to +`verifyClosedPaymentMandate`). + +`build*` validates content against this package's JSON Schemas +(`src/schemas/*.json`), generated from the real upstream AP2 source +(`upstream-ap2-schemas/`, a pinned mirror of +[google-agentic-commerce/AP2](https://github.com/google-agentic-commerce/AP2)) +via `npm run generate-schemas` — re-run that script and rebuild after +re-vendoring a newer upstream tag; don't hand-edit `src/schemas/*.json` +directly. Two constraints are schema-required, not just conventional: an Open +Checkout Mandate's +`constraints` must contain at least one `checkout.line_items` entry, and an +Open Payment Mandate's `constraints` must contain a `payment.reference` entry +(with a `conditional_transaction_id` — see the Open Payment Mandate schema +for the full set of supported constraint types, including +`payment.allowed_payees`, `payment.allowed_payment_instruments`, +`payment.allowed_pisps`, `payment.amount_range`, `payment.budget`, +`payment.agent_recurrence`, and `payment.execution_date`). + +The `signer` for both signing functions uses the same BYOK +`signer.sign(data)` contract as `signReceipt` — bridge it to a wallet-held key +without ever exposing the private key to this package. + +**Known caveat:** `sd_hash` (the claim binding a Closed Mandate back to its +Open Mandate) is implemented here as the RFC 9901 hash of the referenced Open +Mandate's full presentation (`computeSdHash`), matching the Agent +Authorization Framework's "Mandate Receipt... calculated in the same manner +as sd_hash" language. The AP2 spec's own worked example for the Closed +Checkout Mandate shows an `sd_hash` value that instead numerically matches +the digest of its own `checkout_jwt` disclosure — this looks like a reused +placeholder string in the docs rather than a deliberate alternate meaning +(the same string implausibly also appears as an unrelated +`conditional_transaction_id` example elsewhere), but it has not been +confirmed against a reference implementation or the normative "Delegate +SD-JWT" individual draft this spec depends on for chain verification. + ## Verification guarantees Receipt verification: diff --git a/packages/ap2/package.json b/packages/ap2/package.json index d974d516f..6907fd141 100644 --- a/packages/ap2/package.json +++ b/packages/ap2/package.json @@ -1,6 +1,6 @@ { "name": "@docknetwork/ap2", - "version": "0.2.1", + "version": "0.3.0", "description": "Basic AP2 support", "license": "MIT", "type": "module", @@ -52,6 +52,7 @@ }, "scripts": { "build": "rollup -c", + "generate-schemas": "node scripts/generate-schemas.mjs", "example:receipt": "yarn build && node examples/issue-and-verify-receipt.mjs", "test": "jest --runInBand", "lint": "eslint \"src/**/*.js\"", diff --git a/packages/ap2/scripts/generate-schemas.mjs b/packages/ap2/scripts/generate-schemas.mjs new file mode 100644 index 000000000..6249f7912 --- /dev/null +++ b/packages/ap2/scripts/generate-schemas.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +/** + * Regenerates this package's mandate JSON Schemas + * (src/schemas/{checkout,payment}-mandate-{open,closed}.json) from the vendored + * upstream AP2 mirror in ./upstream-ap2-schemas. + * + * Source of truth: https://github.com/google-agentic-commerce/AP2/tree/v0.2.0/code/sdk/schemas/ap2 + * (mirrored, unmodified, in ./upstream-ap2-schemas - re-vendor from a newer tag and + * re-run this script when upstream AP2 changes; this repo owns no schema content of + * its own beyond that mirror). + * + * The only transform applied is inlining AP2's cross-file "types/*.json" refs + * (Merchant, Amount, PaymentInstrument, PISP) into local, same-document $defs, so + * each output file is a self-contained JSON Schema document that ajv can compile + * standalone (matching the $defs shape these schemas already use for their own + * inline constraint types). This is a general "make it portable" step, not a + * Truvera-specific one - schema-hosting/consumer-specific transforms (e.g. + * Truvera's own backfilled `type` siblings and empty `properties: {}` placeholders) + * are intentionally NOT applied here; those stay in whichever downstream consumer + * needs them. + * + * Usage: + * node scripts/generate-schemas.mjs + */ + +import { readFileSync, writeFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import path from 'path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const UPSTREAM_DIR = path.join(__dirname, '..', 'upstream-ap2-schemas'); +const OUTPUT_DIR = path.join(__dirname, '..', 'src', 'schemas'); + +// AP2 "types/*.json" refs to object-shaped types: fully inlined (type+properties+ +// required), no $ref left behind, so the output schema is self-contained. +const OBJECT_TYPE_REFS = { + 'types/amount.json': 'amount', + 'types/merchant.json': 'merchant', + 'types/payment_instrument.json': 'payment_instrument', + 'types/pisp.json': 'pisp', +}; + +// Upstream file name -> output file name. Only the four mandate schemas this +// package uses; upstream's receipt schemas aren't part of this generator. +const SCHEMA_FILES = { + 'open_checkout_mandate.json': 'checkout-mandate-open.json', + 'checkout_mandate.json': 'checkout-mandate-closed.json', + 'open_payment_mandate.json': 'payment-mandate-open.json', + 'payment_mandate.json': 'payment-mandate-closed.json', +}; + +function readJSON(dir, relPath) { + return JSON.parse(readFileSync(path.join(dir, relPath), 'utf8')); +} + +function loadTypeDef(ref) { + const body = readJSON(UPSTREAM_DIR, ref); + delete body.$schema; + delete body.$id; + return body; +} + +// Walks the schema, replacing any $ref to a known object-type file with its type/ +// properties/required inlined directly - the referencing node keeps its own +// `description` if it had one. +function inlineObjectRefs(node) { + if (Array.isArray(node)) { + node.forEach(inlineObjectRefs); + return; + } + if (node && typeof node === 'object') { + if (typeof node.$ref === 'string' && OBJECT_TYPE_REFS[node.$ref]) { + const typeBody = loadTypeDef(node.$ref); + delete node.$ref; + node.type = typeBody.type; + node.properties = typeBody.properties; + node.required = typeBody.required; + if (!node.description && typeBody.description) node.description = typeBody.description; + } + Object.values(node).forEach(inlineObjectRefs); + } +} + +function generate(upstreamFileName, outputFileName) { + const schema = readJSON(UPSTREAM_DIR, upstreamFileName); + schema.$schema = 'http://json-schema.org/draft-07/schema#'; + delete schema.$id; + + inlineObjectRefs(schema); + + const outPath = path.join(OUTPUT_DIR, outputFileName); + writeFileSync(outPath, `${JSON.stringify(schema, null, 2)}\n`); + console.log(`Generated ${outPath}`); +} + +for (const [upstreamFileName, outputFileName] of Object.entries(SCHEMA_FILES)) { + generate(upstreamFileName, outputFileName); +} diff --git a/packages/ap2/src/index.js b/packages/ap2/src/index.js index 145fa00d3..8f173104f 100644 --- a/packages/ap2/src/index.js +++ b/packages/ap2/src/index.js @@ -1,8 +1,16 @@ export * from './receipts'; +export * from './mandates'; export { computeSdHash } from '@docknetwork/crypto-utils/vc'; export { encodeDisclosure, inferReceiptType, validateReceipt, validateReceiptTime, + validateMandateContent, + checkoutReceiptSchema, + paymentReceiptSchema, + checkoutMandateOpenSchema, + checkoutMandateClosedSchema, + paymentMandateOpenSchema, + paymentMandateClosedSchema, } from './utils'; diff --git a/packages/ap2/src/mandates.js b/packages/ap2/src/mandates.js new file mode 100644 index 000000000..d7712e88e --- /dev/null +++ b/packages/ap2/src/mandates.js @@ -0,0 +1,664 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { + signJWT, + verifyJWT, + encodeSdJwtDisclosure, + parseSdJwtPresentation, + computeSdHash, + jwkToSecp256r1PublicKey, + decodeJwtPayload, + decodeJwtProtectedHeader, +} from '@docknetwork/crypto-utils/vc'; +import { + MANDATE_TYPE_CHECKOUT_OPEN, + MANDATE_TYPE_CHECKOUT_CLOSED, + MANDATE_TYPE_PAYMENT_OPEN, + MANDATE_TYPE_PAYMENT_CLOSED, + DEFAULT_CLOCK_TOLERANCE, + validateMandateContent, +} from './utils'; + +export { + MANDATE_TYPE_CHECKOUT_OPEN, + MANDATE_TYPE_CHECKOUT_CLOSED, + MANDATE_TYPE_PAYMENT_OPEN, + MANDATE_TYPE_PAYMENT_CLOSED, +} from './utils'; + +const OPEN_PAYMENT_MANDATE_LABEL = 'Open Payment Mandate'; + +const DEFAULT_SD_ALG = 'sha-256'; +const SD_HASH_ALGORITHMS = { + 'sha-256': 'sha256', + 'sha-384': 'sha384', + 'sha-512': 'sha512', +}; + +function resolveHashAlgorithm(sdAlg) { + const hashAlgorithm = SD_HASH_ALGORITHMS[sdAlg]; + if (!hashAlgorithm) { + throw new Error(`Unsupported SD-JWT hash algorithm: ${sdAlg}`); + } + return hashAlgorithm; +} + +function randomSalt() { + return randomBytes(16).toString('base64url'); +} + +/** + * Computes the RFC 9901 digest of a single encoded disclosure. This is the + * primitive behind every `_sd` array entry and every `{"...": digest}` + * array-element placeholder — distinct from `computeSdHash`, which hashes an + * entire issuer-signed JWT plus a set of disclosures (a presentation), not a + * single disclosure. + * + * @param {string} encodedDisclosure + * @param {string} sdAlg + * @returns {string} + */ +export function computeDisclosureDigest(encodedDisclosure, sdAlg = DEFAULT_SD_ALG) { + const hashAlgorithm = resolveHashAlgorithm(sdAlg); + return createHash(hashAlgorithm) + .update(Buffer.from(encodedDisclosure, 'ascii')) + .digest('base64url'); +} + +/** + * Computes checkout_hash / transaction_id: the base64url hash of the raw + * checkout_jwt string value (not of a disclosure wrapping it). + * + * @param {string} checkoutJwt + * @param {string} sdAlg + * @returns {string} + */ +export function computeCheckoutHash(checkoutJwt, sdAlg = DEFAULT_SD_ALG) { + if (typeof checkoutJwt !== 'string' || checkoutJwt.length === 0) { + throw new TypeError('"checkoutJwt" must be a non-empty string'); + } + const hashAlgorithm = resolveHashAlgorithm(sdAlg); + return createHash(hashAlgorithm) + .update(Buffer.from(checkoutJwt, 'ascii')) + .digest('base64url'); +} + +function discloseArrayElement(value, sdAlg) { + const disclosure = encodeSdJwtDisclosure([randomSalt(), value]); + const digest = computeDisclosureDigest(disclosure, sdAlg); + return { disclosure, digest, placeholder: { '...': digest } }; +} + +function discloseClaim(claimName, value, sdAlg) { + const disclosure = encodeSdJwtDisclosure([randomSalt(), claimName, value]); + const digest = computeDisclosureDigest(disclosure, sdAlg); + return { disclosure, digest }; +} + +function decodeDisclosure(encodedDisclosure) { + return JSON.parse(Buffer.from(encodedDisclosure, 'base64url').toString('utf8')); +} + +function decodeArrayElementDisclosure(encodedDisclosure) { + const decoded = decodeDisclosure(encodedDisclosure); + if (!Array.isArray(decoded) || decoded.length !== 2) { + throw new Error('Expected a 2-element [salt, value] array-element disclosure'); + } + return decoded[1]; +} + +function decodeClaimDisclosure(encodedDisclosure) { + const decoded = decodeDisclosure(encodedDisclosure); + if (!Array.isArray(decoded) || decoded.length !== 3) { + throw new Error('Expected a 3-element [salt, claimName, value] disclosure'); + } + return { claimName: decoded[1], value: decoded[2] }; +} + +function findDisclosureByDigest(disclosures, digest, sdAlg) { + return disclosures.find( + (disclosure) => computeDisclosureDigest(disclosure, sdAlg) === digest, + ); +} + +// Shared by both redact*Constraints functions below: collects array-element +// disclosures into `disclosures` as `redact` is mapped over revealed values. +function createArrayElementRedactor(sdAlg) { + const disclosures = []; + const redact = (entry) => { + const d = discloseArrayElement(entry, sdAlg); + disclosures.push(d.disclosure); + return d.placeholder; + }; + return { redact, disclosures }; +} + +// Redacts the array-element-disclosable fields of an Open Checkout Mandate's +// constraints (checkout.line_items[].acceptable_items, checkout.allowed_merchants.allowed), +// per the property tables on the AP2 Checkout Mandate spec page. +function redactCheckoutConstraints(constraints, sdAlg) { + const { redact, disclosures } = createArrayElementRedactor(sdAlg); + + const redacted = (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(redact) } + : item + )), + }; + } + if (constraint?.type === 'checkout.allowed_merchants' && Array.isArray(constraint.allowed)) { + return { ...constraint, allowed: constraint.allowed.map(redact) }; + } + return constraint; + }); + + return { redacted, disclosures }; +} + +// Redacts the array-element-disclosable fields of an Open Payment Mandate's +// constraints (payment.allowed_payees.allowed and +// payment.allowed_payment_instruments.allowed — marked +// "x-selectively-disclosable-array" in the published Truvera schema; +// payment.allowed_pisps.allowed is not marked disclosable and is left as-is). +function redactPaymentConstraints(constraints, sdAlg) { + const { redact, disclosures } = createArrayElementRedactor(sdAlg); + const disclosableTypes = ['payment.allowed_payees', 'payment.allowed_payment_instruments']; + + const redacted = (constraints ?? []).map((constraint) => { + if (disclosableTypes.includes(constraint?.type) && Array.isArray(constraint.allowed)) { + return { ...constraint, allowed: constraint.allowed.map(redact) }; + } + return constraint; + }); + + return { redacted, disclosures }; +} + +function wrapAsDelegatePayload(content, sdAlg) { + const d = discloseArrayElement(content, sdAlg); + return { delegatePayload: [d.placeholder], contentDisclosure: d.disclosure }; +} + +async function signMandateEnvelope(envelope, { signer, kid, typ }) { + if (signer == null || typeof signer.sign !== 'function') { + throw new TypeError('"signer" with a sign(data) function is required'); + } + return signJWT(signer, envelope, { + algorithm: 'ES256', + header: { typ, ...(kid === undefined ? {} : { kid }) }, + }); +} + +/** + * Validates and clones an Open Checkout Mandate's disclosed content. + * @param {object} content + * @returns {object} + */ +export function buildOpenCheckoutMandate(content) { + return validateMandateContent(content, MANDATE_TYPE_CHECKOUT_OPEN); +} + +/** + * Validates and clones a Closed Checkout Mandate's disclosed content. + * `checkout_jwt` is present here in fully-revealed form; `signClosedCheckoutMandate` + * replaces it with an `_sd` digest. + * @param {object} content + * @returns {object} + */ +export function buildClosedCheckoutMandate(content) { + return validateMandateContent(content, MANDATE_TYPE_CHECKOUT_CLOSED); +} + +/** + * Validates and clones an Open Payment Mandate's disclosed content. + * @param {object} content + * @returns {object} + */ +export function buildOpenPaymentMandate(content) { + return validateMandateContent(content, MANDATE_TYPE_PAYMENT_OPEN); +} + +/** + * Validates and clones a Closed Payment Mandate's disclosed content. + * @param {object} content + * @returns {object} + */ +export function buildClosedPaymentMandate(content) { + return validateMandateContent(content, MANDATE_TYPE_PAYMENT_CLOSED); +} + +/** + * Signs an Open Checkout Mandate (mandate.checkout.open.1) as a compact + * SD-JWT presentation, signed with the user's key (BYOK `signer.sign(data)`, + * matching the receipts.js contract so a wallet-held key can be bridged in + * without ever exposing the private key to this package). + * + * @param {object} content Output of `buildOpenCheckoutMandate`. + * @param {{signer: {sign: function(Uint8Array): *}, kid?: string, sdAlg?: string}} options + * @returns {Promise} + */ +export async function signOpenCheckoutMandate(content, { signer, kid, sdAlg = DEFAULT_SD_ALG } = {}) { + const validated = buildOpenCheckoutMandate(content); + const { redacted, disclosures: constraintDisclosures } = redactCheckoutConstraints( + validated.constraints, + sdAlg, + ); + const redactedContent = { ...validated, constraints: redacted }; + const { delegatePayload, contentDisclosure } = wrapAsDelegatePayload(redactedContent, sdAlg); + + const envelope = { delegate_payload: delegatePayload, _sd_alg: sdAlg }; + const issuerJwt = await signMandateEnvelope(envelope, { signer, kid, typ: 'dc+sd-jwt' }); + + return `${issuerJwt}~${[...constraintDisclosures, contentDisclosure].join('~')}~`; +} + +/** + * Signs an Open Payment Mandate (mandate.payment.open.1) as a compact + * SD-JWT presentation. See `signOpenCheckoutMandate` for the signer contract. + * + * @param {object} content Output of `buildOpenPaymentMandate`. + * @param {{signer: {sign: function(Uint8Array): *}, kid?: string, sdAlg?: string}} options + * @returns {Promise} + */ +export async function signOpenPaymentMandate(content, { signer, kid, sdAlg = DEFAULT_SD_ALG } = {}) { + const validated = buildOpenPaymentMandate(content); + const { redacted, disclosures: constraintDisclosures } = redactPaymentConstraints( + validated.constraints, + sdAlg, + ); + const redactedContent = { ...validated, constraints: redacted }; + const { delegatePayload, contentDisclosure } = wrapAsDelegatePayload(redactedContent, sdAlg); + + const envelope = { delegate_payload: delegatePayload, _sd_alg: sdAlg }; + const issuerJwt = await signMandateEnvelope(envelope, { signer, kid, typ: 'dc+sd-jwt' }); + + return `${issuerJwt}~${[...constraintDisclosures, contentDisclosure].join('~')}~`; +} + +/** + * Signs a Closed Checkout Mandate (mandate.checkout.1) as a compact SD-JWT + * presentation, binding it back to a specific Open Checkout Mandate + * presentation via `sd_hash`. + * + * NOTE ON `sd_hash`: computed here as the RFC 9901 hash over the referenced + * Open Mandate's issuer-signed JWT + its disclosures (the same primitive as + * `computeSdHash`), consistent with the Agent Authorization Framework's + * "Mandate Receipt.reference... calculated in the same manner as sd_hash" + * language and with `sd_hash`'s standard RFC 9901 meaning. The AP2 spec's own + * worked example for the Closed Checkout Mandate shows a `sd_hash` value that + * instead numerically matches the digest of its own `checkout_jwt` + * disclosure — this looks like reused placeholder text in the docs (the same + * string also appears, implausibly, as a `payment.reference.conditional_transaction_id` + * value on an unrelated page) rather than a deliberate alternate meaning, but + * this has NOT been confirmed against a reference implementation or the + * normative "Delegate SD-JWT" individual draft this spec depends on for chain + * verification. Treat this as the best-supported reading, not a certainty. + * + * @param {object} content Output of `buildClosedCheckoutMandate` (with `checkout_jwt` revealed). + * @param {{signer: {sign: function(Uint8Array): *}, kid?: string, nonce: string, + * openMandatePresentation: string, sdAlg?: string}} options + * @returns {Promise} + */ +export async function signClosedCheckoutMandate( + content, + { + signer, kid, nonce, openMandatePresentation, sdAlg = DEFAULT_SD_ALG, + } = {}, +) { + const validated = buildClosedCheckoutMandate(content); + if (typeof openMandatePresentation !== 'string' || openMandatePresentation.length === 0) { + throw new TypeError('"openMandatePresentation" is required to compute sd_hash'); + } + + const { checkout_jwt: checkoutJwt, ...rest } = validated; + const claimDisclosure = discloseClaim('checkout_jwt', checkoutJwt, sdAlg); + const redactedContent = { ...rest, _sd: [claimDisclosure.digest] }; + const { delegatePayload, contentDisclosure } = wrapAsDelegatePayload(redactedContent, sdAlg); + + const { issuerJwt: openIssuerJwt, disclosures: openDisclosures } = parseSdJwtPresentation( + openMandatePresentation, + ); + const sdHash = computeSdHash({ issuerJwt: openIssuerJwt, disclosures: openDisclosures }); + + const envelope = { + delegate_payload: delegatePayload, + iat: Math.floor(Date.now() / 1000), + aud: 'merchant', + nonce, + sd_hash: sdHash, + _sd_alg: sdAlg, + }; + const issuerJwt = await signMandateEnvelope(envelope, { signer, kid, typ: 'kb+sd-jwt' }); + + return `${issuerJwt}~${[claimDisclosure.disclosure, contentDisclosure].join('~')}~`; +} + +/** + * Signs a Closed Payment Mandate (mandate.payment.1) as a compact SD-JWT + * presentation, binding it back to a specific Open Payment Mandate + * presentation via `sd_hash`. See `signClosedCheckoutMandate`'s doc comment + * for the same caveat about `sd_hash`'s exact semantics. + * + * @param {object} content Output of `buildClosedPaymentMandate`. + * @param {{signer: {sign: function(Uint8Array): *}, kid?: string, nonce: string, + * openMandatePresentation: string, sdAlg?: string}} options + * @returns {Promise} + */ +export async function signClosedPaymentMandate( + content, + { + signer, kid, nonce, openMandatePresentation, sdAlg = DEFAULT_SD_ALG, + } = {}, +) { + const validated = buildClosedPaymentMandate(content); + if (typeof openMandatePresentation !== 'string' || openMandatePresentation.length === 0) { + throw new TypeError('"openMandatePresentation" is required to compute sd_hash'); + } + + const { delegatePayload, contentDisclosure } = wrapAsDelegatePayload(validated, sdAlg); + + const { issuerJwt: openIssuerJwt, disclosures: openDisclosures } = parseSdJwtPresentation( + openMandatePresentation, + ); + const sdHash = computeSdHash({ issuerJwt: openIssuerJwt, disclosures: openDisclosures }); + + const envelope = { + delegate_payload: delegatePayload, + iat: Math.floor(Date.now() / 1000), + aud: 'credential-provider', + nonce, + sd_hash: sdHash, + _sd_alg: sdAlg, + }; + const issuerJwt = await signMandateEnvelope(envelope, { signer, kid, typ: 'kb+sd-jwt' }); + + 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'); + } + const contentDigest = payload.delegate_payload[0]?.['...']; + if (typeof contentDigest !== 'string') { + throw new Error('"delegate_payload" entry must be a digest placeholder'); + } + const contentDisclosure = findDisclosureByDigest(disclosures, contentDigest, sdAlg); + if (!contentDisclosure) { + throw new Error('No disclosure found for the delegate_payload digest'); + } + return decodeArrayElementDisclosure(contentDisclosure); +} + +function getSdAlg(payload) { + // eslint-disable-next-line no-underscore-dangle -- protocol-mandated claim name + return payload._sd_alg || DEFAULT_SD_ALG; +} + +function checkNotExpired(payload, { currentDate, clockTolerance }, label) { + if (typeof payload.exp !== 'number') { + return; + } + const now = Math.floor(currentDate.getTime() / 1000); + if (payload.exp + clockTolerance < now) { + throw new Error(`${label} has expired`); + } +} + +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; + } + const { issuerJwt: openIssuerJwt, disclosures: openDisclosures } = parseSdJwtPresentation( + openMandatePresentation, + ); + const expectedSdHash = computeSdHash({ issuerJwt: openIssuerJwt, disclosures: openDisclosures }); + if (payload.sd_hash !== expectedSdHash) { + throw new Error(`"sd_hash" does not match the referenced ${label} presentation`); + } + return true; +} + +// 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. +function resolveCheckoutJwtClaim(content, disclosures, sdAlg) { + // eslint-disable-next-line no-underscore-dangle -- protocol-mandated claim name + const sdDigests = content._sd; + if (!Array.isArray(sdDigests) || typeof sdDigests[0] !== 'string') { + throw new Error('Closed Checkout Mandate content must have an "_sd" digest for checkout_jwt'); + } + const claimDisclosure = findDisclosureByDigest(disclosures, sdDigests[0], sdAlg); + if (!claimDisclosure) { + throw new Error('No disclosure found for the checkout_jwt digest'); + } + const { claimName, value: checkoutJwt } = decodeClaimDisclosure(claimDisclosure); + if (claimName !== 'checkout_jwt') { + throw new Error(`Expected a "checkout_jwt" disclosure, got "${claimName}"`); + } + return checkoutJwt; +} + +/** + * 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. + * + * @param {string} presentation + * @param {{publicKey?: *, holderJwk?: object, openMandatePresentation?: string, + * currentDate?: Date, clockTolerance?: number}} options + * @returns {{verified: boolean, content?: object, checkoutJwt?: string, + * protectedHeader?: object, sdHashVerified?: boolean, error?: Error}} + */ +export function verifyClosedCheckoutMandate( + presentation, + { + publicKey, holderJwk, openMandatePresentation, currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE, + } = {}, +) { + try { + const { issuerJwt, disclosures } = parseSdJwtPresentation(presentation); + const result = verifyJWT(issuerJwt, resolvePublicKey({ publicKey, holderJwk }), { algorithms: ['ES256'] }); + if (!result.verified) { + return result; + } + const { payload, protectedHeader } = result; + const sdAlg = getSdAlg(payload); + + const content = resolveMandateContent(payload, disclosures, sdAlg); + const checkoutJwt = resolveCheckoutJwtClaim(content, disclosures, sdAlg); + + const revealedContent = { ...content, checkout_jwt: checkoutJwt }; + // eslint-disable-next-line no-underscore-dangle -- protocol field being stripped before validation + delete revealedContent._sd; + const validatedContent = buildClosedCheckoutMandate(revealedContent); + if (validatedContent.checkout_hash !== computeCheckoutHash(checkoutJwt, sdAlg)) { + throw new Error('"checkout_hash" does not match a fresh hash of "checkout_jwt"'); + } + + checkAudience(payload, 'merchant'); + checkNotExpired(payload, { currentDate, clockTolerance }, 'Closed Checkout Mandate'); + const sdHashVerified = checkSdHashAgainstOpenMandate( + payload, + openMandatePresentation, + 'Open Checkout Mandate', + ); + + return { + verified: true, + content: validatedContent, + checkoutJwt, + protectedHeader, + ...(sdHashVerified === undefined ? {} : { sdHashVerified }), + }; + } catch (error) { + return { verified: false, error: error instanceof Error ? error : new Error(String(error)) }; + } +} + +/** + * 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. + * + * 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. + * + * @param {string} presentation + * @param {{publicKey?: *, holderJwk?: object, checkoutJwt?: string, openMandatePresentation?: string, + * currentDate?: Date, clockTolerance?: number}} options + * @returns {{verified: boolean, content?: object, protectedHeader?: object, + * transactionIdVerified?: boolean, sdHashVerified?: boolean, error?: Error}} + */ +export function verifyClosedPaymentMandate( + presentation, + { + publicKey, holderJwk, checkoutJwt, openMandatePresentation, currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE, + } = {}, +) { + try { + const { issuerJwt, disclosures } = parseSdJwtPresentation(presentation); + const result = verifyJWT(issuerJwt, resolvePublicKey({ publicKey, holderJwk }), { algorithms: ['ES256'] }); + if (!result.verified) { + return result; + } + const { payload, protectedHeader } = result; + const sdAlg = getSdAlg(payload); + + const content = resolveMandateContent(payload, disclosures, sdAlg); + const validatedContent = buildClosedPaymentMandate(content); + + checkAudience(payload, 'credential-provider'); + checkNotExpired(payload, { currentDate, clockTolerance }, 'Closed Payment Mandate'); + + let transactionIdVerified; + if (checkoutJwt !== undefined) { + if (validatedContent.transaction_id !== computeCheckoutHash(checkoutJwt, sdAlg)) { + throw new Error('"transaction_id" does not match a fresh hash of the provided checkout_jwt'); + } + transactionIdVerified = true; + } + + const sdHashVerified = checkSdHashAgainstOpenMandate( + payload, + openMandatePresentation, + OPEN_PAYMENT_MANDATE_LABEL, + ); + + return { + verified: true, + content: validatedContent, + protectedHeader, + ...(transactionIdVerified === undefined ? {} : { transactionIdVerified }), + ...(sdHashVerified === undefined ? {} : { sdHashVerified }), + }; + } catch (error) { + return { verified: false, error: error instanceof Error ? error : new Error(String(error)) }; + } +} + +// Reveals the array-element disclosures inside an Open Payment Mandate's +// resolved content -- the reverse of `redactPaymentConstraints`. Entries that +// are already plain objects (not SD-redacted, e.g. from a hand-built content +// object that skipped signing) are passed through unchanged. +function resolvePaymentConstraintDisclosures(content, disclosures, sdAlg) { + const disclosableTypes = ['payment.allowed_payees', 'payment.allowed_payment_instruments']; + const constraints = (content.constraints ?? []).map((constraint) => { + if (!disclosableTypes.includes(constraint?.type) || !Array.isArray(constraint.allowed)) { + return constraint; + } + const allowed = constraint.allowed.map((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 "${constraint.type}" array-element digest`); + } + return decodeArrayElementDisclosure(disclosure); + }); + return { ...constraint, allowed }; + }); + return { ...content, constraints }; +} + +/** + * Decodes and resolves an Open Payment Mandate presentation's own content + * (`constraints`, `cnf`, `exp`, ...) -- WITHOUT verifying an issuer signature. + * + * 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. + * + * 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. + * + * @param {string} presentation + * @param {{currentDate?: Date, clockTolerance?: number}} options + * @returns {{content: object, protectedHeader: object}} + */ +export function resolveOpenPaymentMandateContent( + presentation, + { currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE } = {}, +) { + const { issuerJwt, disclosures } = parseSdJwtPresentation(presentation); + const payload = decodeJwtPayload(issuerJwt, OPEN_PAYMENT_MANDATE_LABEL); + const protectedHeader = decodeJwtProtectedHeader(issuerJwt, OPEN_PAYMENT_MANDATE_LABEL); + const sdAlg = getSdAlg(payload); + + const rawContent = resolveMandateContent(payload, disclosures, sdAlg); + const resolvedContent = resolvePaymentConstraintDisclosures(rawContent, disclosures, sdAlg); + const content = buildOpenPaymentMandate(resolvedContent); + + // Unlike the Closed mandate envelopes, `exp` lives on the disclosed content + // 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); + + return { content, protectedHeader }; +} diff --git a/packages/ap2/src/schemas/checkout-mandate-closed.json b/packages/ap2/src/schemas/checkout-mandate-closed.json new file mode 100644 index 000000000..0f78772b6 --- /dev/null +++ b/packages/ap2/src/schemas/checkout-mandate-closed.json @@ -0,0 +1,36 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Checkout Mandate", + "description": "Agreement from a User or an Agent to authorize a particular Checkout action.", + "type": "object", + "required": [ + "vct", + "checkout_jwt", + "checkout_hash" + ], + "properties": { + "vct": { + "type": "string", + "description": "Verifiable Credential Type claim as defined in SD-JWT. MUST be 'mandate.checkout'.", + "const": "mandate.checkout.1", + "default": "mandate.checkout.1" + }, + "checkout_jwt": { + "type": "string", + "description": "base64url-encoded serialized merchant-signed JWT of the Checkout payload.", + "x-selectively-disclosable-field": true + }, + "checkout_hash": { + "type": "string", + "description": "base64url-encoded hash of the checkout_jwt field value, uniquely identifying this checkout. If this checkout mandate is presented as an sd-jwt and the _sd_alg field is present then the hash algorithm used MUST match the _sd_alg field. Otherwise, sha-256 MUST be used." + }, + "iat": { + "type": "integer", + "description": "The creation timestamp as a Unix epoch." + }, + "exp": { + "type": "integer", + "description": "The expiration timestamp as a Unix epoch." + } + } +} diff --git a/packages/ap2/src/schemas/checkout-mandate-open.json b/packages/ap2/src/schemas/checkout-mandate-open.json new file mode 100644 index 000000000..5b08f3c91 --- /dev/null +++ b/packages/ap2/src/schemas/checkout-mandate-open.json @@ -0,0 +1,163 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Open Checkout Mandate", + "description": "Agreement between a user and an agent (or chain of agents) to authorize future checkout actions.", + "type": "object", + "required": [ + "vct", + "constraints", + "cnf" + ], + "properties": { + "vct": { + "type": "string", + "description": "Verifiable Credential Type claim as defined in SD-JWT. MUST be 'mandate.checkout.open'.", + "const": "mandate.checkout.open.1", + "default": "mandate.checkout.open.1" + }, + "constraints": { + "type": "array", + "description": "Array of constraints that the future checkout action must abide by.", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/allowed_merchants" + }, + { + "$ref": "#/$defs/line_items" + } + ] + }, + "contains": { + "$ref": "#/$defs/line_items" + } + }, + "cnf": { + "type": "object", + "description": "Confirmation claim defined in RFC 7800 section 3.1. Used for key binding." + }, + "iat": { + "type": "integer", + "description": "The creation timestamp as a Unix epoch." + }, + "exp": { + "type": "integer", + "description": "The expiration timestamp as a Unix epoch." + } + }, + "$defs": { + "allowed_merchants": { + "type": "object", + "description": "Defines the set of possible merchants for this Checkout Mandate.", + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "checkout.allowed_merchants", + "default": "checkout.allowed_merchants" + }, + "allowed": { + "type": "array", + "description": "Array of allowed Merchant objects.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the merchant." + }, + "name": { + "type": "string", + "description": "Human-readable name of the merchant." + }, + "website": { + "type": "string", + "description": "Website belonging to the merchant." + } + }, + "required": [ + "id", + "name" + ], + "description": "Schema defining a Mechant object" + }, + "x-selectively-disclosable-array": true + } + }, + "required": [ + "type", + "allowed" + ] + }, + "line_items": { + "type": "object", + "description": "Defines the sets of line items that are to be present in the Checkout Mandate.", + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "checkout.line_items", + "default": "checkout.line_items" + }, + "items": { + "type": "array", + "description": "Array of line item requirements.", + "items": { + "$ref": "#/$defs/line_item_requirements" + }, + "minItems": 1 + } + }, + "required": [ + "type", + "items" + ] + }, + "line_item_requirements": { + "type": "object", + "description": "Defines a line item that must be present in the Checkout Mandate.", + "properties": { + "id": { + "type": "string", + "description": "Identifier for the line item requirement." + }, + "acceptable_items": { + "type": "array", + "description": "Defines a set of line items that are acceptable for this line item requirement. One and only one must be present in the Checkout Mandate.", + "items": { + "$ref": "#/$defs/item" + }, + "x-selectively-disclosable-array": true + }, + "quantity": { + "type": "integer", + "description": "Required quantity of matching items.", + "exclusiveMinimum": 0 + } + }, + "required": [ + "id", + "acceptable_items", + "quantity" + ] + }, + "item": { + "type": "object", + "description": "Defines an item that may be present in the Checkout Mandate.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the line item. Will often be the SKU." + }, + "title": { + "type": "string", + "description": "Title of the item." + } + }, + "required": [ + "id", + "title" + ] + } + } +} diff --git a/packages/ap2/src/schemas/payment-mandate-closed.json b/packages/ap2/src/schemas/payment-mandate-closed.json new file mode 100644 index 000000000..5c747cfe1 --- /dev/null +++ b/packages/ap2/src/schemas/payment-mandate-closed.json @@ -0,0 +1,126 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Payment Mandate", + "description": "Agreement from a User or an Agent to authorize a particular Payment action.", + "type": "object", + "required": [ + "vct", + "transaction_id", + "payee", + "payment_amount", + "payment_instrument" + ], + "properties": { + "vct": { + "type": "string", + "description": "Verifiable Credential Type claim as defined in SD-JWT. MUST be 'mandate.payment'.", + "const": "mandate.payment.1", + "default": "mandate.payment.1" + }, + "transaction_id": { + "type": "string", + "description": "base64url-encoded hash of the checkout_jwt field value, uniquely identifying the checkout associated with this. The hash algorithm used MUST be the same as the sd_hash field for this sd-jwt, or sha256 if absent." + }, + "payee": { + "description": "The merchant receiving the payment.", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the merchant." + }, + "name": { + "type": "string", + "description": "Human-readable name of the merchant." + }, + "website": { + "type": "string", + "description": "Website belonging to the merchant." + } + }, + "required": [ + "id", + "name" + ] + }, + "pisp": { + "description": "The Payment Initiation Service Provider.", + "type": "object", + "properties": { + "legal_name": { + "type": "string", + "description": "Legal name of the PISP." + }, + "brand_name": { + "type": "string", + "description": "Brand name of the PISP." + }, + "domain_name": { + "type": "string", + "description": "Domain name of the PISP as secured by the [eIDAS] QWAC certificate of the TPP." + } + }, + "required": [ + "legal_name", + "brand_name", + "domain_name" + ] + }, + "payment_amount": { + "description": "Transaction amount object containing currency (ISO 4217 code, e.g., \"USD\") and amount (integer minor units per ISO 4217, e.g., 27999 = $279.99). Final value confirmed by the user.", + "type": "object", + "properties": { + "amount": { + "type": "integer", + "description": "Amount in minor units, according to the ISO-4217 spec." + }, + "currency": { + "type": "string", + "description": "ISO-4217 3-letter alphabetic currency code of the payment." + } + }, + "required": [ + "amount", + "currency" + ] + }, + "payment_instrument": { + "description": "The payment instrument used.", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "unique identifier for this instrument" + }, + "type": { + "type": "string", + "description": "unique string identifying this category of instrument" + }, + "description": { + "type": "string", + "description": "Description of the instrument to be displayed to the user for informational purposes" + } + }, + "required": [ + "id", + "type" + ] + }, + "execution_date": { + "type": "string", + "description": "ISO8601 date of execution of payment. When absent indicates immediate execution." + }, + "risk_data": { + "type": "object", + "description": "An map of relevant risk signals collected by the trusted surface at time of mandate creation." + }, + "iat": { + "type": "integer", + "description": "The creation timestamp as a Unix epoch." + }, + "exp": { + "type": "integer", + "description": "The expiration timestamp as a Unix epoch." + } + } +} diff --git a/packages/ap2/src/schemas/payment-mandate-open.json b/packages/ap2/src/schemas/payment-mandate-open.json new file mode 100644 index 000000000..82240eb74 --- /dev/null +++ b/packages/ap2/src/schemas/payment-mandate-open.json @@ -0,0 +1,420 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Open Payment Mandate", + "description": "Agreement between a user and an agent (or chain of agents) to authorize future payment actions.", + "type": "object", + "required": [ + "vct", + "constraints", + "cnf" + ], + "properties": { + "vct": { + "type": "string", + "description": "Verifiable Credential Type claim as defined in SD-JWT. MUST be 'mandate.payment.open'.", + "const": "mandate.payment.open.1", + "default": "mandate.payment.open.1" + }, + "constraints": { + "type": "array", + "description": "Array of constraints that the future checkout action must abide by.", + "items": { + "oneOf": [ + { + "$ref": "#/$defs/agent_recurrence" + }, + { + "$ref": "#/$defs/allowed_payees" + }, + { + "$ref": "#/$defs/allowed_payment_instruments" + }, + { + "$ref": "#/$defs/allowed_pisps" + }, + { + "$ref": "#/$defs/amount_range" + }, + { + "$ref": "#/$defs/budget" + }, + { + "$ref": "#/$defs/execution_date" + }, + { + "$ref": "#/$defs/payment_reference" + } + ] + }, + "contains": { + "$ref": "#/$defs/payment_reference" + } + }, + "cnf": { + "type": "object", + "description": "Confirmation claim as defined in RFC 7800 section 3.1. Used for key binding." + }, + "payee": { + "description": "The merchant receiving the payment.", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the merchant." + }, + "name": { + "type": "string", + "description": "Human-readable name of the merchant." + }, + "website": { + "type": "string", + "description": "Website belonging to the merchant." + } + }, + "required": [ + "id", + "name" + ] + }, + "payment_amount": { + "description": "Transaction amount object containing currency (ISO 4217 code, e.g., \"USD\") and amount (integer minor units per ISO 4217, e.g., 27999 = $279.99). Pre-set by the user at time of mandate creation.", + "type": "object", + "properties": { + "amount": { + "type": "integer", + "description": "Amount in minor units, according to the ISO-4217 spec." + }, + "currency": { + "type": "string", + "description": "ISO-4217 3-letter alphabetic currency code of the payment." + } + }, + "required": [ + "amount", + "currency" + ] + }, + "payment_instrument": { + "description": "The payment instrument used.", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "unique identifier for this instrument" + }, + "type": { + "type": "string", + "description": "unique string identifying this category of instrument" + }, + "description": { + "type": "string", + "description": "Description of the instrument to be displayed to the user for informational purposes" + } + }, + "required": [ + "id", + "type" + ] + }, + "pisp": { + "description": "The Payment Initiation Service Provider.", + "type": "object", + "properties": { + "legal_name": { + "type": "string", + "description": "Legal name of the PISP." + }, + "brand_name": { + "type": "string", + "description": "Brand name of the PISP." + }, + "domain_name": { + "type": "string", + "description": "Domain name of the PISP as secured by the [eIDAS] QWAC certificate of the TPP." + } + }, + "required": [ + "legal_name", + "brand_name", + "domain_name" + ] + }, + "execution_date": { + "type": "string", + "description": "ISO8601 date of execution of payment. When absent indicates immediate execution." + }, + "risk_data": { + "type": "object", + "description": "An map of relevant risk signals collected by the trusted surface at time of mandate creation." + }, + "iat": { + "type": "integer", + "description": "The creation timestamp as a Unix epoch." + }, + "exp": { + "type": "integer", + "description": "The expiration timestamp as a Unix epoch." + } + }, + "$defs": { + "allowed_payees": { + "type": "object", + "description": "Defines the set of possible payees.", + "required": [ + "type", + "allowed" + ], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.allowed_payees", + "default": "payment.allowed_payees" + }, + "allowed": { + "type": "array", + "description": "Array of allowed Merchant objects.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the merchant." + }, + "name": { + "type": "string", + "description": "Human-readable name of the merchant." + }, + "website": { + "type": "string", + "description": "Website belonging to the merchant." + } + }, + "required": [ + "id", + "name" + ], + "description": "Schema defining a Mechant object" + }, + "x-selectively-disclosable-array": true + } + } + }, + "amount_range": { + "type": "object", + "description": "Defines the valid range for the payment amount", + "required": [ + "type", + "currency", + "max" + ], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.amount_range", + "default": "payment.amount_range" + }, + "currency": { + "type": "string", + "description": "ISO4217 Alpha-3 currency code." + }, + "max": { + "type": "integer", + "description": "Maximum allowed amount in minor (cents) unit of currency." + }, + "min": { + "type": "integer", + "description": "Minimal amount in minor (cents) unit of currency. If absent, there is no minimum." + } + } + }, + "payment_reference": { + "type": "object", + "description": "Constrains the payment to a specific checkout reference.", + "required": [ + "type", + "conditional_transaction_id" + ], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.reference", + "default": "payment.reference" + }, + "conditional_transaction_id": { + "type": "string", + "description": "Digest of the associated Open Checkout Mandate." + } + } + }, + "agent_recurrence": { + "type": "object", + "description": "Provides conditions for the agent to reuse this Payment Mandate multiple times.", + "required": [ + "type", + "frequency" + ], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.agent_recurrence", + "default": "payment.agent_recurrence" + }, + "frequency": { + "type": "string", + "description": "Frequency of allowed recurrences.", + "enum": [ + "ON_DEMAND", + "DAILY", + "WEEKLY", + "BIWEEKLY", + "MONTHLY", + "QUARTERLY", + "ANNUALLY" + ] + }, + "max_occurrences": { + "type": "integer", + "description": "Maximum number of allowed occurrences." + } + } + }, + "allowed_payment_instruments": { + "type": "object", + "description": "Defines the set of possible payment instruments for this Payment Mandate.", + "required": [ + "type", + "allowed" + ], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.allowed_payment_instruments", + "default": "payment.allowed_payment_instruments" + }, + "allowed": { + "type": "array", + "description": "Array of allowed payment instruments.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "unique identifier for this instrument" + }, + "type": { + "type": "string", + "description": "unique string identifying this category of instrument" + }, + "description": { + "type": "string", + "description": "Description of the instrument to be displayed to the user for informational purposes" + } + }, + "required": [ + "id", + "type" + ], + "description": "Instrument used for payment." + }, + "x-selectively-disclosable-array": true + } + } + }, + "allowed_pisps": { + "type": "object", + "description": "Defines the set of Payment Initiation Service Providers (PISPs) authorized to facilitate the transaction.", + "required": [ + "type", + "allowed" + ], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.allowed_pisps", + "default": "payment.allowed_pisps" + }, + "allowed": { + "type": "array", + "description": "Array of allowed PISPs.", + "items": { + "type": "object", + "properties": { + "legal_name": { + "type": "string", + "description": "Legal name of the PISP." + }, + "brand_name": { + "type": "string", + "description": "Brand name of the PISP." + }, + "domain_name": { + "type": "string", + "description": "Domain name of the PISP as secured by the [eIDAS] QWAC certificate of the TPP." + } + }, + "required": [ + "legal_name", + "brand_name", + "domain_name" + ], + "description": "Schema defining a Payment Initiation Service Provider (PISP) object" + } + } + } + }, + "budget": { + "type": "object", + "description": "Defines the maximum total amount that can be spent when using the payment.agent_recurrence constraint.", + "required": [ + "type", + "max", + "currency" + ], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.budget", + "default": "payment.budget" + }, + "max": { + "type": "number", + "description": "Maximum amount for the budget." + }, + "currency": { + "type": "string", + "description": "ISO4217 Alpha-3 defining the currency of the amount." + } + } + }, + "execution_date": { + "type": "object", + "description": "Defines the valid time window for the payment execution.", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.execution_date", + "default": "payment.execution_date" + }, + "not_before": { + "type": "string", + "description": "Earliest valid execution date." + }, + "not_after": { + "type": "string", + "description": "Latest valid execution date." + } + } + } + } +} diff --git a/packages/ap2/src/utils.js b/packages/ap2/src/utils.js index 3caf1d531..5c1a7e8b4 100644 --- a/packages/ap2/src/utils.js +++ b/packages/ap2/src/utils.js @@ -2,20 +2,51 @@ import Ajv from 'ajv'; import checkoutReceiptSchema from './schemas/checkout-receipt.json'; import paymentReceiptSchema from './schemas/payment-receipt.json'; +import checkoutMandateOpenSchema from './schemas/checkout-mandate-open.json'; +import checkoutMandateClosedSchema from './schemas/checkout-mandate-closed.json'; +import paymentMandateOpenSchema from './schemas/payment-mandate-open.json'; +import paymentMandateClosedSchema from './schemas/payment-mandate-closed.json'; export { encodeSdJwtDisclosure as encodeDisclosure, } from '@docknetwork/crypto-utils/vc'; +export { + checkoutReceiptSchema, + paymentReceiptSchema, + checkoutMandateOpenSchema, + checkoutMandateClosedSchema, + paymentMandateOpenSchema, + paymentMandateClosedSchema, +}; + export const RECEIPT_TYPE_CHECKOUT = 'checkout'; export const RECEIPT_TYPE_PAYMENT = 'payment'; export const DEFAULT_CLOCK_TOLERANCE = 30; +export const MANDATE_TYPE_CHECKOUT_OPEN = 'checkout-open'; +export const MANDATE_TYPE_CHECKOUT_CLOSED = 'checkout-closed'; +export const MANDATE_TYPE_PAYMENT_OPEN = 'payment-open'; +export const MANDATE_TYPE_PAYMENT_CLOSED = 'payment-closed'; + +export const MANDATE_VCT = { + [MANDATE_TYPE_CHECKOUT_OPEN]: 'mandate.checkout.open.1', + [MANDATE_TYPE_CHECKOUT_CLOSED]: 'mandate.checkout.1', + [MANDATE_TYPE_PAYMENT_OPEN]: 'mandate.payment.open.1', + [MANDATE_TYPE_PAYMENT_CLOSED]: 'mandate.payment.1', +}; + const ajv = new Ajv({ allErrors: true, strict: false }); const receiptValidators = { [RECEIPT_TYPE_CHECKOUT]: ajv.compile(checkoutReceiptSchema), [RECEIPT_TYPE_PAYMENT]: ajv.compile(paymentReceiptSchema), }; +const mandateContentValidators = { + [MANDATE_TYPE_CHECKOUT_OPEN]: ajv.compile(checkoutMandateOpenSchema), + [MANDATE_TYPE_CHECKOUT_CLOSED]: ajv.compile(checkoutMandateClosedSchema), + [MANDATE_TYPE_PAYMENT_OPEN]: ajv.compile(paymentMandateOpenSchema), + [MANDATE_TYPE_PAYMENT_CLOSED]: ajv.compile(paymentMandateClosedSchema), +}; function formatValidationErrors(errors = []) { return errors @@ -46,6 +77,23 @@ export function validateReceipt(receipt, type) { return { ...receipt }; } +export function validateMandateContent(content, type) { + if (content == null || typeof content !== 'object' || Array.isArray(content)) { + throw new TypeError('Mandate content must be an object'); + } + const validate = mandateContentValidators[type]; + if (!validate) { + throw new Error(`Unsupported mandate type: ${type}`); + } + if (!validate(content)) { + throw new TypeError( + `Invalid ${type} mandate content: ${formatValidationErrors(validate.errors)}`, + ); + } + + return { ...content }; +} + export function inferReceiptType(receipt) { return receipt != null && typeof receipt === 'object' diff --git a/packages/ap2/tests/mandates.test.js b/packages/ap2/tests/mandates.test.js new file mode 100644 index 000000000..67d457fd5 --- /dev/null +++ b/packages/ap2/tests/mandates.test.js @@ -0,0 +1,361 @@ +import { + Secp256r1Keypair, +} from '@docknetwork/crypto-utils/keypairs'; +import { + parseSdJwtPresentation, + decodeJwtPayload, + secp256r1PublicKeyToJwk, + computeSdHash, +} from '@docknetwork/crypto-utils/vc'; + +import { + buildOpenCheckoutMandate, + buildClosedCheckoutMandate, + buildOpenPaymentMandate, + buildClosedPaymentMandate, + signOpenCheckoutMandate, + signClosedCheckoutMandate, + signOpenPaymentMandate, + signClosedPaymentMandate, + verifyClosedCheckoutMandate, + verifyClosedPaymentMandate, + resolveOpenPaymentMandateContent, + computeDisclosureDigest, + computeCheckoutHash, +} from '../src'; + +// The exact encoded Open Checkout Mandate token from the AP2 spec's +// checkout_mandate page (agent-provider-key-1, "example+sd-jwt"), used to +// confirm this package's digest computation matches the spec's own bytes +// rather than only being self-consistent with itself. +const VCT_CHECKOUT_OPEN = 'mandate.checkout.open.1'; +const VCT_CHECKOUT_CLOSED = 'mandate.checkout.1'; +const SAMPLE_CHECKOUT_JWT = 'eyJhbGciOiJFUzI1NiJ9.eyJvcmRlcl9pZCI6Im9yZGVyLTEifQ.sig'; +// The published Truvera schema requires an Open Checkout Mandate's +// constraints to contain at least one checkout.line_items entry. +const MINIMAL_LINE_ITEMS_CONSTRAINT = { + type: 'checkout.line_items', + items: [{ id: 'line_1', acceptable_items: [{ id: 'SKU-1', title: 'Widget' }], quantity: 1 }], +}; + +const SPEC_OPEN_CHECKOUT_TOKEN = 'eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImV4YW1wbGUrc2Qtand0IiwgImtpZCI6ICJhZ2VudC1wcm92aWRlci1rZXktMSJ9.eyJkZWxlZ2F0ZV9wYXlsb2FkIjogW3siLi4uIjogIlF0WFRKdFdxZzk5OUNtVVdHakhGVFdNa1JQZ3VEZmVLM3dHU2FJbmQtZHcifV0sICJfc2RfYWxnIjogInNoYS0yNTYifQ.HvCGk7ye_c0LN2-NFG13wfyu3LA--rckTPGm36ugO2aRvsded7ngw1py8W3JF7wBpoQnsKr17tNTF3zLeYcoWA~WyI0bjNMXy0zX0ZtMkdneUZBRjhDdF9nIiwgeyJpZCI6ICJzdXBlcnNob2VfbGltaXRlZF9lZGl0aW9uX2dvbGRfc25lYWtlcl93b21lbnNfOV8wIiwgInRpdGxlIjogIlN1cGVyU2hvZSBMaW1pdGVkIEVkaXRpb24gR29sZCJ9XQ~WyIyelBMNnZxTEJnMldZQWRiVzktMWxRIiwgeyJpZCI6ICJtZXJjaGFudF8xIiwgIm5hbWUiOiAiRGVtbyBNZXJjaGFudCIsICJ3ZWJzaXRlIjogImh0dHBzOi8vZGVtby1tZXJjaGFudC5leGFtcGxlIn1d~WyJsYUFvV0tOUnVHbndSRWpKV1lKN3BnIiwgeyJ2Y3QiOiAibWFuZGF0ZS5jaGVja291dC5vcGVuLjEiLCAiY29uc3RyYWludHMiOiBbeyJ0eXBlIjogImNoZWNrb3V0LmxpbmVfaXRlbXMiLCAiaXRlbXMiOiBbeyJpZCI6ICJsaW5lXzEiLCAiYWNjZXB0YWJsZV9pdGVtcyI6IFt7Ii4uLiI6ICJ5M2FvY0FEMnJoWXBKUU9VTU4wMTZmYURGR2tUQkdFRFZsMVIxVFJIZGJ3In1dLCAicXVhbnRpdHkiOiAxfV19LCB7InR5cGUiOiAiY2hlY2tvdXQuYWxsb3dlZF9tZXJjaGFudHMiLCAiYWxsb3dlZCI6IFt7Ii4uLiI6ICJhNVVNQWR4Q2tfTVJheXlWZFJocElBWjBaaGpWTEVxMWcyQld5cndLVXdnIn1dfV0sICJjbmYiOiB7Imp3ayI6IHsiY3J2IjogIlAtMjU2IiwgImt0eSI6ICJFQyIsICJ4IjogIlFwU3l4UFFIeTM4eGNreXZEcjU0Z1ozVDQyemo5aUx0VjRrb3liNVUyN2MiLCAieSI6ICIzN0hMZDdKSmlueGpKSW44SjdIaWpzc29lY0JsZmhkVy1nVUw3ZmVJOWx3In19LCAiaWF0IjogMTc3NzM0MjM1NywgImV4cCI6IDE3NzczNDU5NTd9XQ~'; + +describe('AP2 mandates: spec byte-level verification', () => { + test('digest computation matches the AP2 checkout_mandate spec page example', () => { + const { issuerJwt, disclosures } = parseSdJwtPresentation(SPEC_OPEN_CHECKOUT_TOKEN); + const payload = decodeJwtPayload(issuerJwt); + + // eslint-disable-next-line no-underscore-dangle -- protocol-mandated claim name + expect(payload._sd_alg).toBe('sha-256'); + expect(payload.delegate_payload).toEqual([ + { '...': 'QtXTJtWqg999CmUWGjHFTWMkRPguDfeK3wGSaInd-dw' }, + ]); + expect(disclosures).toHaveLength(3); + + const digests = disclosures.map((d) => computeDisclosureDigest(d, 'sha-256')); + // The spec page's own pretty-printed JSON annotates the merchant + // disclosure's digest as "...ruKUwg", but independently recomputing + // sha256(base64url-disclosure) directly from the real compact token's + // bytes (outside this library, via plain node:crypto) yields "...rwKUwg" — + // a one-character typo in the docs' human-readable illustration, not a + // bug here. The other two digests (including the top-level + // delegate_payload digest) match the docs exactly. + expect(digests).toEqual([ + 'y3aocAD2rhYpJQOUMN016faDFGkTBGEDVl1R1TRHdbw', + 'a5UMAdxCk_MRayyVdRhpIAZ0ZhjVLEq1g2BWyrwKUwg', + 'QtXTJtWqg999CmUWGjHFTWMkRPguDfeK3wGSaInd-dw', + ]); + + // The last disclosure's digest is the one referenced by delegate_payload. + const contentDisclosure = disclosures[2]; + const decodedContent = JSON.parse( + Buffer.from(contentDisclosure, 'base64url').toString('utf8'), + ); + expect(decodedContent[1]).toMatchObject({ + vct: VCT_CHECKOUT_OPEN, + cnf: { + jwk: { + crv: 'P-256', + kty: 'EC', + x: 'QpSyxPQHy38xckyvDr54gZ3T42zj9iLtV4koyb5U27c', + y: '37HLd7JJinxjJIn8J7HijssoecBlfhdW-gUL7feI9lw', + }, + }, + }); + }); +}); + +describe('AP2 mandates: round trip', () => { + const merchant = { id: 'merchant_1', name: 'Demo Merchant', website: 'https://demo-merchant.example' }; + + test('signs and verifies an autonomous Checkout + Payment mandate chain', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + const cnf = { jwk: secp256r1PublicKeyToJwk(agentKeypair.publicKey()) }; + + const openCheckoutContent = buildOpenCheckoutMandate({ + vct: VCT_CHECKOUT_OPEN, + 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, + iat: 1000, + exp: 100000, + }); + const openCheckoutPresentation = await signOpenCheckoutMandate(openCheckoutContent, { + signer: userKeypair, + }); + + const checkoutJwt = SAMPLE_CHECKOUT_JWT; + const closedCheckoutContent = buildClosedCheckoutMandate({ + vct: VCT_CHECKOUT_CLOSED, + checkout_jwt: checkoutJwt, + checkout_hash: computeCheckoutHash(checkoutJwt), + }); + const closedCheckoutPresentation = await signClosedCheckoutMandate(closedCheckoutContent, { + signer: agentKeypair, + nonce: 'nonce-1', + openMandatePresentation: openCheckoutPresentation, + }); + + const checkoutResult = verifyClosedCheckoutMandate(closedCheckoutPresentation, { + publicKey: agentKeypair.publicKey(), + openMandatePresentation: openCheckoutPresentation, + }); + expect(checkoutResult.verified).toBe(true); + expect(checkoutResult.checkoutJwt).toBe(checkoutJwt); + expect(checkoutResult.sdHashVerified).toBe(true); + + const conditionalTransactionId = computeSdHash(parseSdJwtPresentation(openCheckoutPresentation)); + const openPaymentContent = buildOpenPaymentMandate({ + vct: 'mandate.payment.open.1', + constraints: [ + { + type: 'payment.amount_range', currency: 'USD', min: 0, max: 20000, + }, + { type: 'payment.allowed_payees', allowed: [merchant] }, + { type: 'payment.reference', conditional_transaction_id: conditionalTransactionId }, + ], + cnf, + iat: 1000, + exp: 100000, + }); + const openPaymentPresentation = await signOpenPaymentMandate(openPaymentContent, { + signer: userKeypair, + }); + + const closedPaymentContent = buildClosedPaymentMandate({ + vct: 'mandate.payment.1', + transaction_id: computeCheckoutHash(checkoutJwt), + payee: merchant, + payment_amount: { amount: 19900, currency: 'USD' }, + payment_instrument: { id: 'stub', type: 'card', description: 'Card ****4242' }, + }); + const closedPaymentPresentation = await signClosedPaymentMandate(closedPaymentContent, { + signer: agentKeypair, + nonce: 'nonce-2', + openMandatePresentation: openPaymentPresentation, + }); + + const paymentResult = verifyClosedPaymentMandate(closedPaymentPresentation, { + publicKey: agentKeypair.publicKey(), + checkoutJwt, + openMandatePresentation: openPaymentPresentation, + }); + expect(paymentResult.verified).toBe(true); + expect(paymentResult.transactionIdVerified).toBe(true); + expect(paymentResult.sdHashVerified).toBe(true); + }); + + test('rejects a Closed Checkout Mandate with a tampered checkout_hash', 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 checkoutJwt = SAMPLE_CHECKOUT_JWT; + const closedCheckoutPresentation = await signClosedCheckoutMandate( + buildClosedCheckoutMandate({ + vct: VCT_CHECKOUT_CLOSED, + checkout_jwt: checkoutJwt, + checkout_hash: 'not-the-real-hash', + }), + { + signer: agentKeypair, + nonce: 'nonce-1', + openMandatePresentation: openCheckoutPresentation, + }, + ); + + const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, { + publicKey: agentKeypair.publicKey(), + }); + expect(result.verified).toBe(false); + expect(result.error.message).toMatch(/checkout_hash/); + }); + + test('rejects a Closed Checkout Mandate signed by the wrong key', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + const otherKeypair = 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 checkoutJwt = SAMPLE_CHECKOUT_JWT; + const closedCheckoutPresentation = await signClosedCheckoutMandate( + buildClosedCheckoutMandate({ + vct: VCT_CHECKOUT_CLOSED, + checkout_jwt: checkoutJwt, + checkout_hash: computeCheckoutHash(checkoutJwt), + }), + { + signer: agentKeypair, + nonce: 'nonce-1', + openMandatePresentation: openCheckoutPresentation, + }, + ); + + const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, { + publicKey: otherKeypair.publicKey(), + }); + expect(result.verified).toBe(false); + }); +}); + +describe('AP2 mandates: resolveOpenPaymentMandateContent', () => { + const merchant = { id: 'merchant_1', name: 'Demo Merchant', website: 'https://demo-merchant.example' }; + const instrument = { id: 'card_1', type: 'card', description: 'Card ****4242' }; + + test('resolves budget, allowed_payees, and allowed_payment_instruments constraints', 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.budget', max: 20000, currency: 'USD' }, + { type: 'payment.allowed_payees', allowed: [merchant] }, + { type: 'payment.allowed_payment_instruments', allowed: [instrument] }, + { type: 'payment.reference', conditional_transaction_id: 'digest-1' }, + ], + cnf, + }), + { signer: userKeypair }, + ); + + const { content, protectedHeader } = resolveOpenPaymentMandateContent(presentation); + + expect(content.cnf).toEqual(cnf); + expect(protectedHeader.typ).toBe('dc+sd-jwt'); + expect(content.constraints).toEqual( + expect.arrayContaining([ + { type: 'payment.budget', max: 20000, currency: 'USD' }, + { type: 'payment.allowed_payees', allowed: [merchant] }, + { type: 'payment.allowed_payment_instruments', allowed: [instrument] }, + ]), + ); + }); + + test('rejects a hand-edited presentation whose content no longer matches its digest', 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.budget', max: 100, currency: 'USD' }, + { type: 'payment.reference', conditional_transaction_id: 'digest-1' }, + ], + cnf, + }), + { signer: userKeypair }, + ); + + // Hand-edit the content disclosure (the last '~'-delimited segment) to + // claim a much larger budget, without re-signing. + const parts = presentation.split('~'); + const contentDisclosure = parts[parts.length - 2]; + const decoded = JSON.parse(Buffer.from(contentDisclosure, 'base64url').toString('utf8')); + decoded[1].constraints[0].max = 999999999; + const tamperedDisclosure = Buffer.from(JSON.stringify(decoded)).toString('base64url'); + const tamperedPresentation = [...parts.slice(0, -2), tamperedDisclosure, ''].join('~'); + + expect(() => resolveOpenPaymentMandateContent(tamperedPresentation)).toThrow( + /No disclosure found/, + ); + }); + + test('rejects an expired Open Payment Mandate', 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: 1000, + exp: 2000, + }), + { signer: userKeypair }, + ); + + expect(() => resolveOpenPaymentMandateContent(presentation, { + currentDate: new Date(3000 * 1000), + })).toThrow(/expired/); + }); + + test('accepts a mandate with no exp -- no expiry enforced', 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, + }), + { signer: userKeypair }, + ); + + const { content } = resolveOpenPaymentMandateContent(presentation, { + currentDate: new Date(Date.now() + 1000 * 365 * 24 * 60 * 60 * 1000), + }); + expect(content.vct).toBe('mandate.payment.open.1'); + }); +}); diff --git a/packages/ap2/upstream-ap2-schemas/checkout_mandate.json b/packages/ap2/upstream-ap2-schemas/checkout_mandate.json new file mode 100644 index 000000000..8755db60a --- /dev/null +++ b/packages/ap2/upstream-ap2-schemas/checkout_mandate.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ap2-protocol.org/schemas/checkout_mandate.json", + "title": "Checkout Mandate", + "description": "Agreement from a User or an Agent to authorize a particular Checkout action.", + "type": "object", + "required": [ + "vct", + "checkout_jwt", + "checkout_hash" + ], + "properties": { + "vct": { + "type": "string", + "description": "Verifiable Credential Type claim as defined in SD-JWT. MUST be 'mandate.checkout'.", + "const": "mandate.checkout.1", + "default": "mandate.checkout.1" + }, + + "checkout_jwt": { + "type": "string", + "description": "base64url-encoded serialized merchant-signed JWT of the Checkout payload.", + "x-selectively-disclosable-field": true + }, + "checkout_hash": { + "type": "string", + "description": "base64url-encoded hash of the checkout_jwt field value, uniquely identifying this checkout. If this checkout mandate is presented as an sd-jwt and the _sd_alg field is present then the hash algorithm used MUST match the _sd_alg field. Otherwise, sha-256 MUST be used." + }, + "iat": { + "type": "integer", + "description": "The creation timestamp as a Unix epoch." + }, + "exp": { + "type": "integer", + "description": "The expiration timestamp as a Unix epoch." + } + } +} diff --git a/packages/ap2/upstream-ap2-schemas/checkout_receipt.json b/packages/ap2/upstream-ap2-schemas/checkout_receipt.json new file mode 100644 index 000000000..3268a578e --- /dev/null +++ b/packages/ap2/upstream-ap2-schemas/checkout_receipt.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ap2-protocol.org/schemas/checkout_receipt.json", + "title": "Checkout Receipt", + "description": "Receipt that supplies information about the final state of a checkout.", + "type": "object", + "properties": { + "status": { + "description": "The status of the checkout.", + "$ref": "types/receipt_status.json" + }, + "iss": { + "type": "string", + "description": "The issuer of the receipt." + }, + "iat": { + "type": "integer", + "description": "The creation timestamp as a Unix epoch." + }, + "reference": { + "type": "string", + "description": "The hash of the closed Mandate that this receipt is binding to." + }, + "error": { + "type": "string", + "description": "A unique error code. Present if and only if status is Error." + }, + "error_description": { + "type": "string", + "description": "A human-readable error description. Present if and only if status is Error." + }, + "order_id": { + "type": "string", + "description": "A reference to the order for the checkout. Present if and only if status is Success." + } + }, + "required": [ + "status", + "iss", + "iat", + "reference" + ], + "oneOf": [ + { + "title": "Checkout Receipt Success", + "properties": { + "status": { + "const": "Success" + } + }, + "required": [ + "order_id" + ] + }, + { + "title": "Checkout Receipt Error", + "properties": { + "status": { + "const": "Error" + } + }, + "required": [ + "error", + "error_description" + ] + } + ] +} diff --git a/packages/ap2/upstream-ap2-schemas/open_checkout_mandate.json b/packages/ap2/upstream-ap2-schemas/open_checkout_mandate.json new file mode 100644 index 000000000..e3d9cafa7 --- /dev/null +++ b/packages/ap2/upstream-ap2-schemas/open_checkout_mandate.json @@ -0,0 +1,145 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ap2-protocol.org/schemas/open_checkout_mandate", + "title": "Open Checkout Mandate", + "description": "Agreement between a user and an agent (or chain of agents) to authorize future checkout actions.", + "type": "object", + "required": [ + "vct", + "constraints", + "cnf" + ], + "properties": { + "vct": { + "type": "string", + "description": "Verifiable Credential Type claim as defined in SD-JWT. MUST be 'mandate.checkout.open'.", + "const": "mandate.checkout.open.1", + "default": "mandate.checkout.open.1" + }, + "constraints": { + "type": "array", + "description": "Array of constraints that the future checkout action must abide by.", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/allowed_merchants" + }, + { + "$ref": "#/$defs/line_items" + } + ] + }, + "contains": { + "$ref": "#/$defs/line_items" + } + }, + "cnf": { + "type": "object", + "description": "Confirmation claim defined in RFC 7800 section 3.1. Used for key binding." + }, + "iat": { + "type": "integer", + "description": "The creation timestamp as a Unix epoch." + }, + "exp": { + "type": "integer", + "description": "The expiration timestamp as a Unix epoch." + } + }, + "$defs": { + "allowed_merchants": { + "type": "object", + "description": "Defines the set of possible merchants for this Checkout Mandate.", + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "checkout.allowed_merchants", + "default": "checkout.allowed_merchants" + }, + "allowed": { + "type": "array", + "description": "Array of allowed Merchant objects.", + "items": { + "$ref": "types/merchant.json" + }, + "x-selectively-disclosable-array": true + } + }, + "required": [ + "type", + "allowed" + ] + }, + "line_items": { + "type": "object", + "description": "Defines the sets of line items that are to be present in the Checkout Mandate.", + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "checkout.line_items", + "default": "checkout.line_items" + }, + "items": { + "type": "array", + "description": "Array of line item requirements.", + "items": { + "$ref": "#/$defs/line_item_requirements" + }, + "minItems": 1 + } + }, + "required": [ + "type", + "items" + ] + }, + "line_item_requirements": { + "type": "object", + "description": "Defines a line item that must be present in the Checkout Mandate.", + "properties": { + "id": { + "type": "string", + "description": "Identifier for the line item requirement." + }, + "acceptable_items": { + "type": "array", + "description": "Defines a set of line items that are acceptable for this line item requirement. One and only one must be present in the Checkout Mandate.", + "items": { + "$ref": "#/$defs/item" + }, + "x-selectively-disclosable-array": true + }, + "quantity": { + "type": "integer", + "description": "Required quantity of matching items.", + "exclusiveMinimum": 0 + } + }, + "required": [ + "id", + "acceptable_items", + "quantity" + ] + }, + "item": { + "type": "object", + "description": "Defines an item that may be present in the Checkout Mandate.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the line item. Will often be the SKU." + }, + "title": { + "type": "string", + "description": "Title of the item." + } + }, + "required": [ + "id", + "title" + ] + } + } +} diff --git a/packages/ap2/upstream-ap2-schemas/open_payment_mandate.json b/packages/ap2/upstream-ap2-schemas/open_payment_mandate.json new file mode 100644 index 000000000..22c9dcaa1 --- /dev/null +++ b/packages/ap2/upstream-ap2-schemas/open_payment_mandate.json @@ -0,0 +1,294 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ap2-protocol.org/schemas/payment_mandate_open.json", + "title": "Open Payment Mandate", + "description": "Agreement between a user and an agent (or chain of agents) to authorize future payment actions.", + "type": "object", + "required": [ + "vct", + "constraints", + "cnf" + ], + "properties": { + "vct": { + "type": "string", + "description": "Verifiable Credential Type claim as defined in SD-JWT. MUST be 'mandate.payment.open'.", + "const": "mandate.payment.open.1", + "default": "mandate.payment.open.1" + }, + "constraints": { + "type": "array", + "description": "Array of constraints that the future checkout action must abide by.", + "items": { + "oneOf": [ + { + "$ref": "#/$defs/agent_recurrence" + }, + { + "$ref": "#/$defs/allowed_payees" + }, + { + "$ref": "#/$defs/allowed_payment_instruments" + }, + { + "$ref": "#/$defs/allowed_pisps" + }, + { + "$ref": "#/$defs/amount_range" + }, + { + "$ref": "#/$defs/budget" + }, + { + "$ref": "#/$defs/execution_date" + }, + { + "$ref": "#/$defs/payment_reference" + } + ] + }, + "contains": { + "$ref": "#/$defs/payment_reference" + } + }, + "cnf": { + "type": "object", + "description": "Confirmation claim as defined in RFC 7800 section 3.1. Used for key binding." + }, + "payee": { + "description": "The merchant receiving the payment.", + "$ref": "types/merchant.json" + }, + "payment_amount": { + "description": "Transaction amount object containing currency (ISO 4217 code, e.g., \"USD\") and amount (integer minor units per ISO 4217, e.g., 27999 = $279.99). Pre-set by the user at time of mandate creation.", + "$ref": "types/amount.json" + }, + "payment_instrument": { + "description": "The payment instrument used.", + "$ref": "types/payment_instrument.json" + }, + "pisp": { + "description": "The Payment Initiation Service Provider.", + "$ref": "types/pisp.json" + }, + "execution_date": { + "type": "string", + "description": "ISO8601 date of execution of payment. When absent indicates immediate execution." + }, + "risk_data": { + "type": "object", + "description": "An map of relevant risk signals collected by the trusted surface at time of mandate creation." + }, + "iat": { + "type": "integer", + "description": "The creation timestamp as a Unix epoch." + }, + "exp": { + "type": "integer", + "description": "The expiration timestamp as a Unix epoch." + } + }, + "$defs": { + "allowed_payees": { + "type": "object", + "description": "Defines the set of possible payees.", + "required": [ + "type", + "allowed" + ], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.allowed_payees", + "default": "payment.allowed_payees" + }, + "allowed": { + "type": "array", + "description": "Array of allowed Merchant objects.", + "items": { + "$ref": "types/merchant.json" + }, + "x-selectively-disclosable-array": true + } + } + }, + "amount_range": { + "type": "object", + "description": "Defines the valid range for the payment amount", + "required": [ + "type", + "currency", + "max" + ], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.amount_range", + "default": "payment.amount_range" + }, + "currency": { + "type": "string", + "description": "ISO4217 Alpha-3 currency code." + }, + "max": { + "type": "integer", + "description": "Maximum allowed amount in minor (cents) unit of currency." + }, + "min": { + "type": "integer", + "description": "Minimal amount in minor (cents) unit of currency. If absent, there is no minimum." + } + } + }, + "payment_reference": { + "type": "object", + "description": "Constrains the payment to a specific checkout reference.", + "required": [ + "type", + "conditional_transaction_id" + ], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.reference", + "default": "payment.reference" + }, + "conditional_transaction_id": { + "type": "string", + "description": "Digest of the associated Open Checkout Mandate." + } + } + }, + "agent_recurrence": { + "type": "object", + "description": "Provides conditions for the agent to reuse this Payment Mandate multiple times.", + "required": [ + "type", + "frequency" + ], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.agent_recurrence", + "default": "payment.agent_recurrence" + }, + "frequency": { + "type": "string", + "description": "Frequency of allowed recurrences.", + "enum": [ + "ON_DEMAND", + "DAILY", + "WEEKLY", + "BIWEEKLY", + "MONTHLY", + "QUARTERLY", + "ANNUALLY" + ] + }, + "max_occurrences": { + "type": "integer", + "description": "Maximum number of allowed occurrences." + } + } + }, + "allowed_payment_instruments": { + "type": "object", + "description": "Defines the set of possible payment instruments for this Payment Mandate.", + "required": [ + "type", + "allowed" + ], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.allowed_payment_instruments", + "default": "payment.allowed_payment_instruments" + }, + "allowed": { + "type": "array", + "description": "Array of allowed payment instruments.", + "items": { + "$ref": "types/payment_instrument.json" + }, + "x-selectively-disclosable-array": true + } + } + }, + "allowed_pisps": { + "type": "object", + "description": "Defines the set of Payment Initiation Service Providers (PISPs) authorized to facilitate the transaction.", + "required": [ + "type", + "allowed" + ], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.allowed_pisps", + "default": "payment.allowed_pisps" + }, + "allowed": { + "type": "array", + "description": "Array of allowed PISPs.", + "items": { + "$ref": "types/pisp.json" + } + } + } + }, + "budget": { + "type": "object", + "description": "Defines the maximum total amount that can be spent when using the payment.agent_recurrence constraint.", + "required": [ + "type", + "max", + "currency" + ], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.budget", + "default": "payment.budget" + }, + "max": { + "type": "number", + "description": "Maximum amount for the budget." + }, + "currency": { + "type": "string", + "description": "ISO4217 Alpha-3 defining the currency of the amount." + } + } + }, + "execution_date": { + "type": "object", + "description": "Defines the valid time window for the payment execution.", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.execution_date", + "default": "payment.execution_date" + }, + "not_before": { + "type": "string", + "description": "Earliest valid execution date." + }, + "not_after": { + "type": "string", + "description": "Latest valid execution date." + } + } + } + } +} diff --git a/packages/ap2/upstream-ap2-schemas/payment_mandate.json b/packages/ap2/upstream-ap2-schemas/payment_mandate.json new file mode 100644 index 000000000..fa93b0dfd --- /dev/null +++ b/packages/ap2/upstream-ap2-schemas/payment_mandate.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ap2-protocol.org/schemas/payment_mandate.json", + "title": "Payment Mandate", + "description": "Agreement from a User or an Agent to authorize a particular Payment action.", + "type": "object", + "required": [ + "vct", + "transaction_id", + "payee", + "payment_amount", + "payment_instrument" + ], + "properties": { + "vct": { + "type": "string", + "description": "Verifiable Credential Type claim as defined in SD-JWT. MUST be 'mandate.payment'.", + "const": "mandate.payment.1", + "default": "mandate.payment.1" + }, + "transaction_id": { + "type": "string", + "description": "base64url-encoded hash of the checkout_jwt field value, uniquely identifying the checkout associated with this. The hash algorithm used MUST be the same as the sd_hash field for this sd-jwt, or sha256 if absent." + }, + "payee": { + "description": "The merchant receiving the payment.", + "$ref": "types/merchant.json" + }, + "pisp": { + "description": "The Payment Initiation Service Provider.", + "$ref": "types/pisp.json" + }, + "payment_amount": { + "description": "Transaction amount object containing currency (ISO 4217 code, e.g., \"USD\") and amount (integer minor units per ISO 4217, e.g., 27999 = $279.99). Final value confirmed by the user.", + "$ref": "types/amount.json" + }, + "payment_instrument": { + "description": "The payment instrument used.", + "$ref": "types/payment_instrument.json" + }, + "execution_date": { + "type": "string", + "description": "ISO8601 date of execution of payment. When absent indicates immediate execution." + }, + "risk_data": { + "type": "object", + "description": "An map of relevant risk signals collected by the trusted surface at time of mandate creation." + }, + "iat": { + "type": "integer", + "description": "The creation timestamp as a Unix epoch." + }, + "exp": { + "type": "integer", + "description": "The expiration timestamp as a Unix epoch." + } + } +} diff --git a/packages/ap2/upstream-ap2-schemas/payment_receipt.json b/packages/ap2/upstream-ap2-schemas/payment_receipt.json new file mode 100644 index 000000000..f58ef0450 --- /dev/null +++ b/packages/ap2/upstream-ap2-schemas/payment_receipt.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ap2-protocol.org/schemas/payment_receipt.json", + "title": "Payment Receipt", + "description": "Receipt that supplies information about the final state of a payment.", + "type": "object", + "properties": { + "status": { + "description": "The status of the payment.", + "$ref": "types/receipt_status.json" + }, + "iss": { + "type": "string", + "description": "The issuer of the receipt." + }, + "iat": { + "type": "integer", + "description": "The creation timestamp as a Unix epoch." + }, + "reference": { + "type": "string", + "description": "The hash of the closed Mandate that this receipt is binding to." + }, + "error": { + "type": "string", + "description": "A unique error code. Present if and only if status is Error." + }, + "error_description": { + "type": "string", + "description": "A human-readable error description. Present if and only if status is Error." + }, + "payment_id": { + "type": "string", + "description": "A unique identifier for the payment." + }, + "psp_confirmation_id": { + "type": "string", + "description": "A unique identifier for the transaction confirmation at the PSP. Present only if status is Success." + }, + "network_confirmation_id": { + "type": "string", + "description": "A unique identifier for the transaction confirmation at the network. Present only if status is Success." + } + }, + "required": [ + "status", + "iss", + "iat", + "reference", + "payment_id" + ], + "oneOf": [ + { + "title": "Payment Receipt Success", + "properties": { + "status": { + "const": "Success" + } + }, + "required": [ + "psp_confirmation_id", + "network_confirmation_id" + ] + }, + { + "title": "Payment Receipt Error", + "properties": { + "status": { + "const": "Error" + } + }, + "required": [ + "error", + "error_description" + ] + } + ] +} diff --git a/packages/ap2/upstream-ap2-schemas/types/amount.json b/packages/ap2/upstream-ap2-schemas/types/amount.json new file mode 100644 index 000000000..0631cd413 --- /dev/null +++ b/packages/ap2/upstream-ap2-schemas/types/amount.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ap2-protocol.org/schemas/types/amount.json", + "title": "Amount", + "description": "Schema defining an Amount and Current object", + "type": "object", + "properties": { + "amount": { + "type": "integer", + "description": "Amount in minor units, according to the ISO-4217 spec." + }, + "currency": { + "type": "string", + "description": "ISO-4217 3-letter alphabetic currency code of the payment." + } + }, + "required": [ + "amount", + "currency" + ] +} diff --git a/packages/ap2/upstream-ap2-schemas/types/merchant.json b/packages/ap2/upstream-ap2-schemas/types/merchant.json new file mode 100644 index 000000000..2e44a4dc9 --- /dev/null +++ b/packages/ap2/upstream-ap2-schemas/types/merchant.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ap2-protocol.org/schemas/types/merchant.json", + "title": "Merchant", + "description": "Schema defining a Mechant object", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the merchant." + }, + "name": { + "type": "string", + "description": "Human-readable name of the merchant." + }, + "website": { + "type": "string", + "description": "Website belonging to the merchant." + } + }, + "required": [ + "id", + "name" + ] +} \ No newline at end of file diff --git a/packages/ap2/upstream-ap2-schemas/types/payment_instrument.json b/packages/ap2/upstream-ap2-schemas/types/payment_instrument.json new file mode 100644 index 000000000..99857b370 --- /dev/null +++ b/packages/ap2/upstream-ap2-schemas/types/payment_instrument.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ap2-protocol.org/schemas/types/payment_instrument.json", + "title": "Payment Instrument", + "description": "Instrument used for payment.", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "unique identifier for this instrument" + }, + "type": { + "type": "string", + "description": "unique string identifying this category of instrument" + }, + "description": { + "type": "string", + "description": "Description of the instrument to be displayed to the user for informational purposes" + } + }, + "required": [ + "id", + "type" + ] +} \ No newline at end of file diff --git a/packages/ap2/upstream-ap2-schemas/types/pisp.json b/packages/ap2/upstream-ap2-schemas/types/pisp.json new file mode 100644 index 000000000..db63c7e5f --- /dev/null +++ b/packages/ap2/upstream-ap2-schemas/types/pisp.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ap2-protocol.org/schemas/types/pisp.json", + "title": "PISP", + "description": "Schema defining a Payment Initiation Service Provider (PISP) object", + "type": "object", + "properties": { + "legal_name": { + "type": "string", + "description": "Legal name of the PISP." + }, + "brand_name": { + "type": "string", + "description": "Brand name of the PISP." + }, + "domain_name": { + "type": "string", + "description": "Domain name of the PISP as secured by the [eIDAS] QWAC certificate of the TPP." + } + }, + "required": [ + "legal_name", + "brand_name", + "domain_name" + ] +} diff --git a/packages/ap2/upstream-ap2-schemas/types/receipt_status.json b/packages/ap2/upstream-ap2-schemas/types/receipt_status.json new file mode 100644 index 000000000..f53bd8ba2 --- /dev/null +++ b/packages/ap2/upstream-ap2-schemas/types/receipt_status.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ap2-protocol.org/schemas/receipt-status.json", + "title": "Receipt Status", + "description": "The status of a receipt.", + "type": "string", + "enum": [ + "Success", + "Error" + ] +}