// Writes contracts/vectors/positive/ucp-closed-chains.json: one purchase closed over UCP // (ucp-profile.md §4, §7), so that the seller, the buyer and the verifier are held to the same // bytes on the binding: the session id as the hops' nonce, the seller's merchant authorization // reattached as checkout_jwt, its SHA-256 as checkout_hash and as the payment's transaction_id. // // The seller's part is this repository's own code, run here: the quote from search_catalog on // the UCP MCP endpoint (a variant's id and its signed quote), the checkout session opened on it // over REST and signed (ap2.merchant_authorization), and the signed completion, which must // complete or nothing is written. The other two sides' code cannot run from here, so their parts // are written the way they write them: the roots as agent-authority's ap2.issue makes them with // the reference SDK, the hops as agent-buyer's ap2.close closes them over UCP. Each side's own // test holds its code to the result (README, vectors/positive/). // // Every key is made for this vector alone. The buyer's and the seller's private halves stay in // it on purpose: the tests close the roots again and sign another checkout to derive what must // be refused. // // node contracts/generate-ucp-vector.mjs import { mkdtemp, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createApp } from '../src/app.mjs'; import { publicJwks, thumbprint } from '../src/authorization.mjs'; import { loadConfig } from '../src/config.mjs'; import { signRequest } from '../src/httpsig.mjs'; import { InstanceIdentity } from '../src/identity.mjs'; import { b64url, canonical, fromB64url, sha256b64url } from '../src/shared.mjs'; import { AP2_CAPABILITY, CATALOG_SEARCH_CAPABILITY, CHECKOUT_CAPABILITY, UCP_VERSION, namespaceOf } from '../src/ucp.mjs'; const out = new URL('./vectors/positive/ucp-closed-chains.json', import.meta.url); const temp = await mkdtemp(join(tmpdir(), 'agent-bench-ucp-vector-')); const now = () => Math.floor(Date.now() / 1000); async function keypair() { const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); const { kty, crv, x, y, d } = await crypto.subtle.exportKey('jwk', pair.privateKey); const pub = { kty, crv, x, y }; // ES256 as JWS and RFC 9421 both carry it: fixed-width r||s, base64url. const raw = async (text) => b64url(new Uint8Array(await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, pair.privateKey, new TextEncoder().encode(text)))); return { pub, priv: { ...pub, d }, kid: await thumbprint(pub), raw }; } const SELLER = 'https://travel.example.test'; const HOST = 'travel.example.test'; const MERCHANT_ID = 'demo-travel-seller'; const AUTHORITY = 'https://authority.example.test'; const BUYER_DOMAIN = 'buyer.example.test'; const PLATFORM = `https://${BUYER_DOMAIN}/.well-known/ucp`; const SUB = 'person-7f3a'; const authority = await keypair(); const buyer = await keypair(); const seller = await keypair(); // Shaped like the live buyer's (spiffe:///buyer//): the seller holds act.sub // to the domain of the profile that signs the completion. const buyerSpiffe = `spiffe://${BUYER_DOMAIN}/buyer/personal/${buyer.kid}`; // agent-buyer's ap2.INSTRUMENT: a label, the rail charges the seller's own test card. const INSTRUMENT = { id: 'opaque-sandbox-card', type: 'card', description: "the merchant's sandbox test card; the instrument is a label in this POC" }; // The platform profile the seller fetches to verify the requests, as agent-buyer's // ucp.platform_profile writes it for this domain and this authority. const spec = `https://ucp.dev/${UCP_VERSION}`; const platformProfile = { ucp: { version: UCP_VERSION, services: {}, capabilities: { [CHECKOUT_CAPABILITY]: [{ version: UCP_VERSION, spec: `${spec}/specification/shopping/checkout/`, schema: `${spec}/schemas/shopping/checkout.json` }], [AP2_CAPABILITY]: [{ version: UCP_VERSION, spec: `${spec}/specification/payment/extensions/ap2-mandates/`, schema: `${spec}/schemas/common/payment_ap2_mandate.json`, extends: CHECKOUT_CAPABILITY, config: { 'test.example.buyer.mandate_issuers': [AUTHORITY] } }], [CATALOG_SEARCH_CAPABILITY]: [{ version: UCP_VERSION, spec: `${spec}/specification/shopping/catalog/search/`, schema: `${spec}/schemas/shopping/catalog_search.json` }] }, payment_handlers: {} }, keys: [{ ...buyer.pub, kid: buyer.kid, alg: 'ES256', use: 'sig' }] }; const authorityJwks = publicJwks([{ ...authority.pub, kid: authority.kid }]); const fetchImpl = async (url) => { const target = String(url); const json = (body, status = 200) => ({ ok: status < 300, status, headers: { get: () => null }, json: async () => body, text: async () => JSON.stringify(body) }); if (target === PLATFORM) return json(platformProfile); if (target === `${AUTHORITY}/.well-known/jwks.json`) return json(authorityJwks); if (target.startsWith(`${AUTHORITY}/mandates/`)) return json({ jti: decodeURIComponent(target.slice(`${AUTHORITY}/mandates/`.length)), status: 'active' }); return json({}, 404); }; const config = loadConfig({ DEMO_MODE: 'simulation', AUTHORIZATION_PUBLIC_JWKS: JSON.stringify(publicJwks([{ ...(await keypair()).pub, kid: 'unused' }])), AUTHORIZATION_ISSUER: 'unused', AUTHORIZATION_AUDIENCE: 'unused', MERCHANT_DOMAIN: HOST, MERCHANT_ID, CURRENCY: 'EUR', PUBLIC_BASE_URL: SELLER, MANDATE_ISSUERS: AUTHORITY, AUDIT_LOG_PATH: join(temp, 'ledger.ndjson'), INSTANCE_KEY_PATH: join(temp, 'instance.json') }); // The instance key is handed in, not made on first boot, so that its private half can go into the vector. const app = createApp(config, { fetch: fetchImpl, instance: new InstanceIdentity({ read: async () => seller.priv, write: async () => {} }, config) }); // A request as agent-buyer's UcpSellerClient sends one: the profile named, a fresh // Idempotency-Key, an RFC 9421 signature under the buyer's key. async function call(method, path, body) { const rawBody = JSON.stringify(body); const headers = { host: HOST, 'ucp-agent': `profile="${PLATFORM}"`, accept: 'application/json', 'idempotency-key': crypto.randomUUID(), 'content-type': 'application/json' }; Object.assign(headers, await signRequest({ method, authority: HOST, path, headers, rawBody, kid: buyer.kid, sign: buyer.raw })); const result = await app.route({ method, host: HOST, path, query: '', headers, rawBody, body, requestId: crypto.randomUUID() }); return { status: result.status, body: JSON.parse(result.raw) }; } // --- the seller: the quote, the session, its merchant authorization ------------------------ const departure = new Date(Date.now() + 30 * 86400 * 1000).toISOString().slice(0, 10); // The catalog as agent-buyer searches it: the route and its correlation id under the seller's namespace. const ns = namespaceOf(config); const searched = await call('POST', '/ucp/mcp', { jsonrpc: '2.0', id: crypto.randomUUID(), method: 'tools/call', params: { name: 'search_catalog', arguments: { meta: { 'ucp-agent': { profile: PLATFORM } }, catalog: { [`${ns}.flight`]: { origin: 'MAD', destination: 'LHR', departure_date: departure, adults: 1 }, [`${ns}.correlation_id`]: crypto.randomUUID() } } } }); const [variant] = searched.body.result.structuredContent.products[0].variants; const signedQuote = variant[`${ns}.quote`]; const quoted = JSON.parse(fromB64url(signedQuote.jws.split('.')[1]).toString('utf8')); const quote = { id: variant.id, offer_id: quoted.offer_id, basis: quoted.basis, total: variant.price.amount, currency: variant.price.currency, expires_at: signedQuote.expires_at, quote_jws: signedQuote.jws, quote_hash: signedQuote.hash }; const created = await call('POST', '/ucp/v1/checkout-sessions', { line_items: [{ item: { id: quote.id }, quantity: 1 }], currency: quote.currency }); if (created.status !== 201 || created.body.status !== 'ready_for_complete') throw new Error(`the seller did not open a session ready to complete: ${JSON.stringify(created.body)}`); const checkout = created.body; const [instanceKey] = (await app.route({ method: 'GET', host: HOST, path: '/.well-known/ucp', query: '', headers: { host: HOST }, rawBody: '', body: {} })).body.keys; if (instanceKey.kid !== seller.kid) throw new Error('the profile does not publish the instance key this script handed in'); // The merchant authorization reattached (ucp-profile.md §4): the checkout_jwt of the closed Checkout Mandate. const [protectedHeader, , signature] = checkout.ap2.merchant_authorization.split('.'); const { ap2: _ap2, ...signed } = checkout; const checkoutJwt = `${protectedHeader}.${b64url(canonical(signed))}.${signature}`; const checkoutHash = sha256b64url(checkoutJwt); // What each side's JSON looks like on the wire. The reference SDK the authority issues with // serializes as Python's json.dumps does by default (", " and ": ", non-ASCII escaped); the // buyer's jws.py and sdjwt.py compactly, which is JSON.stringify's output. const python = (v) => (Array.isArray(v) ? `[${v.map(python).join(', ')}]` : v !== null && typeof v === 'object' ? `{${Object.entries(v).map(([k, x]) => `${python(k)}: ${python(x)}`).join(', ')}}` : JSON.stringify(v).replace(/[\u0080-￿]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`)); const compact = (v) => JSON.stringify(v); const salt = () => b64url(crypto.getRandomValues(new Uint8Array(16))); const disclosure = (json, value, name) => b64url(json(name === undefined ? [salt(), value] : [salt(), name, value])); const jws = async (key, header, payload, json) => { const input = `${b64url(json(header))}.${b64url(json(payload))}`; return `${input}.${await key.raw(input)}`; }; // --- the authority: the two roots of one approval ------------------------------------------ // As agent-authority's ap2.issue makes them with the reference SDK: the open content built from // the quote's merchant and offer, one disclosure per element of the arrays the SDK marks // selectively disclosable (the merchant, the acceptable item), then the whole content as one // under delegate_payload, header {alg, typ example+sd-jwt, kid}. async function root(content, paths = []) { const copy = structuredClone(content); const disclosures = []; for (const path of paths) { const holder = path.slice(0, -1).reduce((at, part) => at[part], copy); const name = path.at(-1); holder[name] = holder[name].map((element) => { const d = disclosure(python, element); disclosures.push(d); return { '...': sha256b64url(d) }; }); } const item = disclosure(python, copy); disclosures.push(item); const jwt = await jws(authority, { alg: 'ES256', typ: 'example+sd-jwt', kid: authority.kid }, { delegate_payload: [{ '...': sha256b64url(item) }], _sd_alg: 'sha-256' }, python); return `${jwt}~${disclosures.join('~')}~`; } const q = JSON.parse(fromB64url(quote.quote_jws.split('.')[1]).toString('utf8')); const merchant = { id: q.merchant.id, name: q.merchant.name, website: q.merchant.website }; const offer = q.line_items[0].item; const jti = crypto.randomUUID(); const issuedAt = now(); const { cnf, ...claims } = { cnf: { jwk: buyer.pub }, iat: issuedAt, exp: issuedAt + 300, iss: AUTHORITY, sub: SUB, act: { sub: buyerSpiffe }, jti, policy_version: '2026-09-11' }; const checkoutRoot = await root({ vct: 'mandate.checkout.open.1', constraints: [{ type: 'checkout.allowed_merchants', allowed: [merchant] }, { type: 'checkout.line_items', items: [{ id: 'line_1', acceptable_items: [{ id: offer.id, title: offer.title }], quantity: 1 }] }], cnf, ...claims }, [['constraints', 0, 'allowed'], ['constraints', 1, 'items', 0, 'acceptable_items']]); const paymentRoot = await root({ vct: 'mandate.payment.open.1', constraints: [{ type: 'payment.reference', conditional_transaction_id: sha256b64url(checkoutRoot) }], cnf, payee: merchant, payment_amount: { amount: quote.total, currency: quote.currency }, ...claims }); // --- the buyer: the two hops, closed for the session ---------------------------------------- // As agent-buyer's ap2.close closes them over UCP: typ kb+sd-jwt and no kid (the key is the // root's cnf.jwk to name), the closed content as one disclosure with checkout_jwt a property // disclosure inside it, sd_hash over the root as issued, aud the seller, nonce the session id. async function hop(rootText, closedContent, properties, nonce, iat) { const item = disclosure(compact, closedContent); const jwt = await jws(buyer, { alg: 'ES256', typ: 'kb+sd-jwt' }, { delegate_payload: [{ '...': sha256b64url(item) }], iat, aud: SELLER, nonce, sd_hash: sha256b64url(rootText), _sd_alg: 'sha-256' }, compact); return { chain: `${rootText.replace(/~$/, '')}~~${[jwt, item, ...properties].join('~')}~`, reference: sha256b64url(jwt) }; } const closedAt = now(); const closed = { checkout: { vct: 'mandate.checkout.1', checkout_hash: checkoutHash, checkout_jwt: checkoutJwt }, payment: { vct: 'mandate.payment.1', transaction_id: checkoutHash, payee: merchant, payment_amount: { amount: quote.total, currency: quote.currency }, payment_instrument: INSTRUMENT } }; const checkoutJwtDisclosure = disclosure(compact, checkoutJwt, 'checkout_jwt'); const checkoutHop = await hop(checkoutRoot, { _sd: [sha256b64url(checkoutJwtDisclosure)], vct: closed.checkout.vct, checkout_hash: checkoutHash }, [checkoutJwtDisclosure], checkout.id, closedAt); const paymentHop = await hop(paymentRoot, closed.payment, [], checkout.id, closedAt); // --- the completion, as agent-buyer's send_checkout posts it: the closed Payment Mandate as the // handler's credential, the closed Checkout Mandate under ap2. The seller must complete it. const [handler] = Object.values(checkout.ucp.payment_handlers)[0]; const completion = { payment: { instruments: [{ id: 'instr_1', handler_id: handler.id, type: 'card', display: { description: INSTRUMENT.description }, credential: { type: 'ap2_mandate', token: paymentHop.chain } }] }, ap2: { checkout_mandate: checkoutHop.chain } }; const done = await call('POST', `/ucp/v1/checkout-sessions/${checkout.id}/complete`, completion); if (done.status !== 200 || done.body.status !== 'completed') throw new Error(`the seller did not complete the purchase: ${JSON.stringify(done.body.messages ?? done.body)}`); const vector = { generated_at: new Date().toISOString(), generated_by: 'agent-bench contracts/generate-ucp-vector.mjs (generated in the seller repository; see README)', description: 'One purchase closed over UCP (ucp-profile.md §4, §7), with keys made for this vector alone. roots: the open Checkout and Payment Mandate as agent-authority issues them with the reference SDK, under authority_jwks (verifying_jwk is the same key). checkout: the session the seller opened on the quote, as it answered the create over REST, with ap2.merchant_authorization, a detached JWS by the instance key (seller.instance_public_jwk, kid its RFC 7638 thumbprint) over the JCS checkout minus ap2. presentations: the two chains as the buyer closes them for that session, a kb+sd-jwt hop under buyer.public_jwk (the roots\' cnf.jwk) on each root, aud the seller, nonce the session id, checkout_jwt the merchant authorization reattached; completion: the body the buyer posts to complete them (the Payment Mandate as the handler\'s credential.token, the Checkout Mandate as ap2.checkout_mandate), signed under RFC 9421 by the key buyer.ucp_profile publishes; closed: what each hop discloses. expected: what a verifier derives, the nonce (the session id), checkout_jwt, checkout_hash and transaction_id (base64url SHA-256 of checkout_jwt), aud, the quote, the merchant, the amount and currency, the pair\'s jti, each root\'s sd_hash and each hop\'s reference (SHA-256 of its JWT) as the ledgers name them. The seller completed it when the vector was written; verify as of closed_at. The buyer\'s and the seller\'s private keys are inside on purpose, for the variants each side\'s test derives.', ucp_version: UCP_VERSION, format: 'ap2-v0.2', issued_at: issuedAt, closed_at: closedAt, issuer: AUTHORITY, verifying_jwk: authorityJwks.keys[0], authority_jwks: authorityJwks, sub: SUB, buyer: { spiffe_id: buyerSpiffe, kid: buyer.kid, public_jwk: buyer.pub, private_jwk: buyer.priv, profile: PLATFORM, ucp_profile: platformProfile }, seller: { issuer: SELLER, merchant_id: MERCHANT_ID, instance_kid: seller.kid, instance_public_jwk: instanceKey, instance_private_jwk: seller.priv }, quote: { id: quote.id, offer_id: quote.offer_id, basis: quote.basis, total: quote.total, currency: quote.currency, expires_at: quote.expires_at, quote_jws: quote.quote_jws, quote_hash: quote.quote_hash }, roots: { jti, checkout: checkoutRoot, payment: paymentRoot }, checkout, presentations: { checkout: checkoutHop.chain, payment: paymentHop.chain }, closed, completion, expected: { nonce: checkout.id, checkout_jwt: checkoutJwt, checkout_hash: checkoutHash, transaction_id: checkoutHash, aud: SELLER, quote_id: quote.id, merchant_id: MERCHANT_ID, amount: quote.total, currency: quote.currency, jti, sd_hashes: { checkout: sha256b64url(checkoutRoot), payment: sha256b64url(paymentRoot) }, references: { checkout: checkoutHop.reference, payment: paymentHop.reference } } }; await writeFile(out, JSON.stringify(vector, null, 2) + '\n'); console.log(`Vector written to ${out.pathname}`);