#!/usr/bin/env node // A guest buyer in one file (guest-buyer.md). No dependencies; Node 20.18 or later. // // node guest-buyer.mjs keygen make guest-key.json, print the TXT record // node guest-buyer.mjs record print the TXT record again // node guest-buyer.mjs check --domain example.org is the record published, and validated? // node guest-buyer.mjs search --domain example.org MAD LHR [YYYY-MM-DD] // node guest-buyer.mjs buy --domain example.org MAD LHR [YYYY-MM-DD] [--fastest] // // Options: --key (guest-key.json), --seller (https://travel.agenthaven.dev), // --sub (owner@), --trace (what was sent and what came // back, as JSON: the envelopes, the mandate, the answers). The private key never leaves the // key file. Nothing is charged: the seller stops at a payment intent. import { readFile, writeFile } from 'node:fs/promises'; const args = process.argv.slice(2); const command = args.shift(); const option = (name, fallback) => { const at = args.indexOf(`--${name}`); if (at < 0) return fallback; const [, value] = args.splice(at, 2); return value; }; const flag = (name) => { const at = args.indexOf(`--${name}`); if (at < 0) return false; args.splice(at, 1); return true; }; const keyFile = option('key', 'guest-key.json'); const seller = option('seller', 'https://travel.agenthaven.dev').replace(/\/+$/, ''); const domain = option('domain', null); const subject = option('sub', domain ? `owner@${domain}` : null); const fastest = flag('fastest'); const traceFile = option('trace', null); const trace = []; const RESOLVER = 'https://cloudflare-dns.com/dns-query'; const EC = { name: 'ECDSA', namedCurve: 'P-256' }; const b64url = (bytes) => Buffer.from(bytes).toString('base64url'); const sha256hex = async (text) => Buffer.from(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))).toString('hex'); // RFC 7638: the required members, in lexicographic order, and nothing else. const thumbprint = async (jwk) => b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y })))); const recordOf = (jwk) => `v=agentkey1; kty=EC; crv=P-256; x=${jwk.x}; y=${jwk.y}`; const need = (value, what) => { if (!value) { console.error(`Missing ${what}.`); process.exit(2); } return value; }; async function loadKey() { const jwk = JSON.parse(await readFile(keyFile, 'utf8')); const key = await crypto.subtle.importKey('jwk', { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y, d: jwk.d }, EC, false, ['sign']); const kid = await thumbprint(jwk); const sign = async (header, payload) => { const input = `${b64url(JSON.stringify({ alg: 'ES256', kid, ...header }))}.${b64url(JSON.stringify(payload))}`; return `${input}.${b64url(await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, key, new TextEncoder().encode(input)))}`; }; return { jwk, kid, sign }; } function printRecord(jwk, kid) { console.log(`Key id (RFC 7638 thumbprint): ${kid}`); console.log(`Publish this TXT record in a DNSSEC-signed zone, TTL 300 or less:\n`); console.log(` _agent-keys.${domain || ''}. 300 IN TXT "${recordOf(jwk)}"\n`); } async function published(kid) { const name = `_agent-keys.${need(domain, '--domain')}`; const answer = await (await fetch(`${RESOLVER}?name=${encodeURIComponent(name)}&type=TXT`, { headers: { accept: 'application/dns-json' } })).json(); const values = (answer.Answer || []).filter((item) => item.type === 16).map((item) => String(item.data).replace(/^"|"$/g, '').replace(/"\s+"/g, '')); const mine = []; for (const value of values) { const tags = Object.fromEntries(value.split(';').map((part) => part.split('=').map((s) => s.trim())).filter((pair) => pair.length === 2)); if (tags.v === 'agentkey1' && tags.x && tags.y && await thumbprint({ kty: 'EC', crv: 'P-256', x: tags.x, y: tags.y }) === kid) mine.push(value); } return { name, records: values.length, found: mine.length > 0, validated: answer.AD === true }; } // One signed call: the envelope binds the method, the path and the exact bytes of the body. async function call(key, path, body) { const raw = JSON.stringify(body); const envelope = await key.sign({ typ: 'request+jwt' }, { iss: `spiffe://${domain}/guest-buyer`, aud: seller, iat: Math.floor(Date.now() / 1000), nonce: crypto.randomUUID(), correlation_id: body.correlation_id, method: 'POST', path, body_sha256: await sha256hex(raw) }); const response = await fetch(`${seller}${path}`, { method: 'POST', headers: { authorization: `Bearer ${envelope}`, 'content-type': 'application/json' }, body: raw }); const answer = await response.json(); trace.push({ method: 'POST', path, body, envelope_jws: envelope, status: response.status, answer }); if (!response.ok) { await saveTrace(); const error = answer.error || answer; console.error(`REFUSED by the seller (HTTP ${response.status}): ${error.code}${error.step ? ` at check ${error.step}` : ''}\n ${error.message}`); process.exit(1); } return answer; } async function saveTrace(extra = {}) { if (!traceFile) return; const key = await loadKey(); await writeFile(traceFile, `${JSON.stringify({ generated_by: 'contracts/guest-buyer.mjs', seller, domain, kid: key.kid, public_jwk: { kty: 'EC', crv: 'P-256', x: key.jwk.x, y: key.jwk.y }, record: { name: `_agent-keys.${domain}`, type: 'TXT', value: recordOf(key.jwk) }, ...extra, calls: trace }, null, 2)}\n`); } async function search(key, correlation) { const [origin, destination, date] = args; need(origin && destination, 'origin and destination (IATA codes)'); const departure = date || new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10); const answer = await call(key, '/v1/commerce/flight-search', { origin, destination, departure_date: departure, correlation_id: correlation }); for (const option of answer.options) console.log(` ${(option.basis || []).join('+').padEnd(16)} ${(option.total / 100).toFixed(2)} ${option.currency} ${option.id} valid until ${option.expires_at}`); return answer.options; } if (command === 'keygen') { const pair = await crypto.subtle.generateKey(EC, true, ['sign', 'verify']); const jwk = await crypto.subtle.exportKey('jwk', pair.privateKey); await writeFile(keyFile, JSON.stringify({ kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y, d: jwk.d }), { mode: 0o600, flag: 'wx' }); console.log(`Wrote ${keyFile} (private; keep it).`); printRecord(jwk, await thumbprint(jwk)); } else if (command === 'record') { const key = await loadKey(); printRecord(key.jwk, key.kid); } else if (command === 'check') { const key = await loadKey(); const state = await published(key.kid); console.log(`${state.name}: ${state.records} TXT record(s); this key ${state.found ? 'is' : 'is NOT'} among them; the resolver ${state.validated ? 'validated the answer (AD)' : 'did NOT validate the answer: sign the zone and publish its DS'}.`); const health = await (await fetch(`${seller}/health`)).json(); console.log(`${seller}: ${health.guests?.accepted ? `takes guests, up to ${(health.guests.ceiling_minor / 100).toFixed(2)} ${health.guests.currency}, ${health.guests.purchases_today} of ${health.guests.purchases_per_day} guest purchases today` : 'does not take guests'}.`); process.exit(state.found && state.validated && health.guests?.accepted ? 0 : 1); } else if (command === 'search') { need(domain, '--domain'); await search(await loadKey(), crypto.randomUUID()); } else if (command === 'buy') { need(domain, '--domain'); const key = await loadKey(); const correlation = crypto.randomUUID(); const options = await search(key, correlation); const quote = options.find((option) => (option.basis || []).includes(fastest ? 'fastest' : 'cheapest')) || options[0]; const health = await (await fetch(`${seller}/health`)).json(); const now = Math.floor(Date.now() / 1000); // The guest mandate: this domain says the purchase is its own, for exactly this quote. const claims = { iss: `https://${domain}`, sub: subject, act: { sub: `spiffe://${domain}/guest-buyer` }, aud: seller, merchant_id: health.identity.merchant_id, action: 'purchase', stage: 'cart', amount: { value: quote.total, currency: quote.currency }, quote_hash: quote.quote_hash, cnf: { jkt: key.kid }, jti: crypto.randomUUID(), policy_version: 'guest', iat: now, nbf: now - 5, exp: now + 300 }; const mandate = await key.sign({ typ: 'mandate+jwt' }, claims); const answer = await call(key, '/v1/commerce/checkout', { quote_id: quote.id, quote_hash: quote.quote_hash, merchant_id: health.identity.merchant_id, currency: quote.currency, mandate_jws: mandate, correlation_id: correlation }); await saveTrace({ mandate: { jws: mandate, header: { alg: 'ES256', kid: key.kid, typ: 'mandate+jwt' }, payload: claims }, quote: { id: quote.id, total: quote.total, currency: quote.currency, quote_hash: quote.quote_hash } }); console.log(`\nBOUGHT ${(answer.checkout.amount / 100).toFixed(2)} ${answer.checkout.currency}: payment intent ${answer.payment_intent.id} (${answer.payment_intent.status}); nothing is charged.`); console.log(`correlation_id ${answer.correlation_id}\nledger entry ${answer.evidence.audit_hash} (${seller}/ledger)\nreceipt ${answer.receipt_jws}`); } else { console.error('Usage: node guest-buyer.mjs keygen | record | check | search | buy (see the top of this file, and guest-buyer.md)'); process.exit(2); }