/** Private durable recovery for @x402/* 2.27.0; Node ESM, usable from TypeScript. * One client and a new file in a user-owned 0700 directory per purchase. * * Two verified endings exist. An answer: interpret label and coverage before * acting. A terminal no-charge non-answer (a signed receipt whose state is in * TERMINAL_NO_CHARGE_STATES with billing.charged "no", on HTTP 200 or 503): * nothing was charged, never retry the credential, archive the * record; a new purchase later is allowed only when hints.new_quote_allowed is * true. Plain-text ingress 503s and explicitly transient service errors are retryable. */ import fs from 'node:fs'; import path from 'node:path'; import { createHash, createPublicKey, randomBytes, verify as verifySignature } from 'node:crypto'; import { pathToFileURL } from 'node:url'; // Receipt states that end a purchase with nothing charged. With a VERIFIED // receipt and billing.charged "no" they are final: polling cannot change them. export const TERMINAL_NO_CHARGE_STATES = ['service_failed', 'refused', 'closed_no_charge', 'frozen_unsettled']; export function canonical(value) { if (value === null || typeof value === 'boolean') return JSON.stringify(value); if (typeof value === 'number') { if (!Number.isFinite(value)) throw Error('invalid_json_number'); return JSON.stringify(value); } if (typeof value === 'string') { if (Buffer.from(value).toString() !== value) throw Error('invalid_unicode'); return JSON.stringify(value); } if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']'; if (typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) return '{' + Object.keys(value).sort().map(k => canonical(k) + ':' + canonical(value[k])).join(',') + '}'; throw Error('invalid_json_value'); } const sha = value => createHash('sha256').update(value).digest('hex'); const equal = (a, b) => canonical(a) === canonical(b); export function canonicalRequest(body, accepted) { const q = accepted.extra.seconded; return canonical({v: 1, input: body.input, product: body.product, product_schema_version: q.product_schema_version, predicate_version: q.predicate_version, tier: q.tier, price_atomic: accepted.amount, asset: accepted.network + '/erc20:' + accepted.asset, network: accepted.network, scheme: accepted.scheme, pay_to: accepted.payTo, billing_mode: 'paid'}); } function normalizedAuthorization(authorization) { if (!equal(Object.keys(authorization).sort(), ['from','nonce','to','validAfter','validBefore','value'])) throw Error('authorization_fields'); for (const [k, size] of [['from', 40], ['to', 40], ['nonce', 64]]) if (typeof authorization[k] !== 'string' || authorization[k].length !== size + 2 || !new RegExp(`^0x[0-9a-fA-F]{${size}}$`).test(authorization[k])) throw Error('authorization_hex'); const auth = Object.fromEntries(['from', 'to', 'nonce'].map(k => [k, authorization[k].toLowerCase()])); for (const k of ['value', 'validAfter', 'validBefore']) { const raw = authorization[k]; if (!((typeof raw === 'string' && /^(0|[1-9][0-9]*)$/.test(raw) && raw === BigInt(raw).toString()) || (typeof raw === 'number' && Number.isSafeInteger(raw) && raw >= 0))) throw Error('authorization_integer'); if (BigInt(raw) < 0n || BigInt(raw) >= 2n**256n) throw Error('authorization_uint256'); auth[k] = BigInt(raw).toString(); } if (BigInt(auth.validAfter) >= BigInt(auth.validBefore)) throw Error('authorization_window'); return auth; } function validAssociation(value) { return value && typeof value === 'object' && equal(Object.keys(value).sort(), ['sha256','v']) && value.v === 1 && typeof value.sha256 === 'string' && /^[0-9a-f]{64}$/.test(value.sha256); } export function purchaseAssociation(body, accepted, authorization) { const auth = normalizedAuthorization(authorization); const terms = structuredClone(accepted); delete terms.extra.ticket; const value = {authorization: auth, accepted: terms, request: JSON.parse(canonicalRequest(body, accepted))}; return {v: 1, sha256: sha(Buffer.concat([Buffer.from('seconded-purchase-association/v1\0'), Buffer.from(canonical(value))]))}; } function exists(file) { try { fs.lstatSync(file); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } } export function guardNewPayment(file) { if (exists(file) || exists(file + '.lock')) throw Error('purchase_record_exists_recover_same_credential'); } function privateParent(file) { const parent = path.dirname(file), info = fs.lstatSync(parent); if (!info.isDirectory() || info.uid !== process.getuid() || (info.mode & 0o077)) throw Error('recovery_directory_must_be_private'); return parent; } export function durableWrite(file, record) { const parent = privateParent(file); guardNewPayment(file); const lock = file + '.lock', lockfd = fs.openSync(lock, 'wx', 0o600); let temporary; try { if (exists(file)) throw Error('purchase_record_exists'); temporary = path.join(parent, '.recovery-' + randomBytes(16).toString('hex')); const fd = fs.openSync(temporary, 'wx', 0o600); try { fs.writeFileSync(fd, canonical(record)); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } fs.renameSync(temporary, file); temporary = undefined; const dirfd = fs.openSync(parent, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY); try { fs.fsyncSync(dirfd); } finally { fs.closeSync(dirfd); } } finally { fs.closeSync(lockfd); if (temporary) fs.unlinkSync(temporary); fs.unlinkSync(lock); } } export function capture(file, url, body, accepted, paymentSignature) { const payload = JSON.parse(Buffer.from(paymentSignature, 'base64').toString()); if (payload.x402Version !== 2 || !equal(payload.accepted, accepted)) throw Error('payment_record_mismatch'); normalizedAuthorization(payload.payload.authorization); const request = canonicalRequest(body, accepted), q = accepted.extra.seconded; const legacy = Object.hasOwn(q, 'commitment_salt'); if (legacy && (!/^[0-9a-f]{64}$/.test(q.commitment_salt) || sha(Buffer.concat([Buffer.from('seconded-request/v1'), Buffer.from(q.commitment_salt, 'hex'), Buffer.from(request)])) !== q.request_commitment)) throw Error('request_commitment_mismatch'); const record = {url, body, accepted, quote: q, payment_signature: paymentSignature, canonical_request: request, payment_signature_sha256: sha(paymentSignature), state: 'unresolved'}; if (!legacy) Object.assign(record, {format: 'seconded-door-recovery/v2', purchase_association: purchaseAssociation(body, accepted, payload.payload.authorization)}); durableWrite(file, record); return record; } export async function installHooks(client, file, url, body) { const { encodePaymentSignatureHeader } = await import('@x402/core/http'); body = structuredClone(body); client.onBeforePaymentCreation(async () => { guardNewPayment(file); }); client.onAfterPaymentCreation(async context => { if (context.paymentRequired.resource.url !== url) throw Error('resource_url_mismatch'); capture(file, url, body, context.selectedRequirements, encodePaymentSignatureHeader(context.paymentPayload)); }); return client; } export function loadRecord(file) { privateParent(file); const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); try { const info = fs.fstatSync(fd); if (!info.isFile() || info.uid !== process.getuid() || (info.mode & 0o077)) throw Error('record_not_private'); return JSON.parse(fs.readFileSync(fd, 'utf8')); } finally { fs.closeSync(fd); } } export function verify(record, receipt, keys, chainCheck) { try { for (const field of ['url','body','payment_signature','accepted','quote','canonical_request','state','payment_signature_sha256']) if (!Object.hasOwn(record, field)) return 'unverified'; if (!['unresolved','resolved'].includes(record.state) || !new URL(record.url).hostname) return 'unverified'; const modern = record.format === 'seconded-door-recovery/v2'; if (record.format !== undefined && !modern) return 'unverified'; for (const field of (modern ? [] : ['offer_id','quote_id','check_id'])) if (!/^[0-9a-f]{32}$/.test(record.quote[field])) return 'unverified'; for (const field of (modern ? [] : ['commitment_salt','request_commitment'])) if (!/^[0-9a-f]{64}$/.test(record.quote[field])) return 'unverified'; for (const field of ['expires_at','max_valid_before','min_remaining_s']) if (!Number.isSafeInteger(record.quote[field]) || record.quote[field] <= 0) return 'unverified'; const accepted = record.accepted; if (accepted.scheme !== 'exact' || !/^eip155:[0-9]+$/.test(accepted.network) || typeof accepted.amount !== 'string' || !/^[0-9]+$/.test(accepted.amount) || BigInt(accepted.amount) <= 0n || !Number.isSafeInteger(accepted.maxTimeoutSeconds) || accepted.maxTimeoutSeconds <= 0 || !accepted.extra.name || !accepted.extra.version || !/^[0-9a-f]{64}$/.test(accepted.extra.ticket)) return 'unverified'; const e = receipt.envelope, kid = receipt.key_id; if (e.key_id !== kid || ![1, 2, 3].includes(e.v)) return 'unverified'; const matches = keys.keys.filter(k => k.key_id === kid && k.algorithm === 'Ed25519'); if (matches.length !== 1 || !/^[0-9a-f]{64}$/i.test(matches[0].public_key_hex)) return 'unverified'; const key = createPublicKey({key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), Buffer.from(matches[0].public_key_hex, 'hex')]), format: 'der', type: 'spki'}); if (!verifySignature(null, Buffer.from(`SECONDED-RECEIPT/v${e.v}\0` + canonical(e)), key, Buffer.from(receipt.sig, 'base64url'))) return 'unverified'; const a = record.accepted, q = record.quote, header = record.payment_signature; if (sha(header) !== record.payment_signature_sha256) return 'unverified'; const payload = JSON.parse(Buffer.from(header, 'base64').toString()); normalizedAuthorization(payload.payload.authorization); if (payload.x402Version !== 2 || !equal(payload.accepted, a) || !equal(a.extra.seconded, q)) return 'unverified'; const request = canonicalRequest(record.body, a); if (request !== record.canonical_request) return 'unverified'; if (modern) { const association = purchaseAssociation(record.body, a, payload.payload.authorization); if (!validAssociation(record.purchase_association) || !validAssociation(e.purchase_association) || !equal(association, record.purchase_association) || !equal(association, e.purchase_association) || !['tier','product_schema_version','predicate_version'].every(k => e[k] === q[k]) || e.product !== record.body.product) return 'unverified'; } else { if (!/^[0-9a-f]{64}$/.test(q.commitment_salt)) return 'unverified'; const commitment = sha(Buffer.concat([Buffer.from('seconded-request/v1'), Buffer.from(q.commitment_salt, 'hex'), Buffer.from(request)])); if (commitment !== q.request_commitment) return 'unverified'; if (e.purchase_association !== undefined) { if (!validAssociation(e.purchase_association) || !equal(e.purchase_association, purchaseAssociation(record.body, a, payload.payload.authorization))) return 'unverified'; } else if (commitment !== e.request_commitment || e.check_id !== q.check_id) return 'unverified'; } const auth = payload.payload.authorization, b = e.billing; if (!/^0x[0-9a-fA-F]{64}$/.test(auth.nonce) || !/^0x[0-9a-fA-F]{130}$/.test(payload.payload.signature) || BigInt(auth.validAfter) < 0n || BigInt(auth.validAfter) >= BigInt(auth.validBefore)) return 'unverified'; if (!(b.network === a.network && b.asset === a.network + '/erc20:' + a.asset && b.amount_atomic === String(auth.value) && b.amount_atomic === a.amount && b.payer.toLowerCase() === auth.from.toLowerCase() && b.pay_to.toLowerCase() === auth.to.toLowerCase() && b.pay_to.toLowerCase() === a.payTo.toLowerCase())) return 'unverified'; if (chainCheck && b.tx !== null && chainCheck(b, auth) !== true) return 'unverified'; return 'verified'; } catch { return 'unverified'; } } const transientErrors = new Set(['store_unavailable', 'internal_error', 'verification_unavailable']); const pendingStates = new Set(['running', 'settling', 'delayed', 'unavailable']); const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); function terminalEnvelope(envelope) { return !Object.hasOwn(envelope, 'answer') && TERMINAL_NO_CHARGE_STATES.includes(envelope.state) && object(envelope.billing) && envelope.billing.charged === 'no'; } function pollDelay(response, reply, wallTime) { const hints = Object.hasOwn(reply, 'hints') ? reply.hints : {}; if (!object(hints)) throw Error('invalid_retry_delay_retain_record'); const raw = response.headers.get('Retry-After'); let delay = 0; if (raw !== null && raw !== undefined && raw !== '') { delay = Number(raw); if (Number.isNaN(delay)) { const date = Date.parse(raw); if (!Number.isFinite(date)) throw Error('invalid_retry_delay_retain_record'); delay = Math.max(0, date / 1000 - wallTime()); } } const rawHint = hints.poll_after_s ?? 0; if (!['number', 'string'].includes(typeof rawHint)) throw Error('invalid_retry_delay_retain_record'); const hint = Number(rawHint); if (![delay, hint].every(value => Number.isFinite(value) && value >= 0)) throw Error('invalid_retry_delay_retain_record'); return Math.max(delay, hint, 1); } export async function recover(record, keys, { attempts = 20, send = fetch, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), timeout = 300, now = () => performance.now() / 1000, wallTime = () => Date.now() / 1000, } = {}) { if (!Number.isSafeInteger(attempts) || attempts < 0 || typeof timeout !== 'number' || !Number.isFinite(timeout) || timeout <= 0) throw Error('invalid_recovery_budget_retain_record'); const url = new URL(record.url); if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', '[::1]'].includes(url.hostname))) throw Error('https_or_loopback_required'); const deadline = now() + timeout, destination = record.url, header = record.payment_signature; const requestBody = JSON.stringify(record.body); for (let i = 0; i < attempts; i++) { const remaining = deadline - now(); if (remaining <= 0) throw Error('pending_retain_record'); let response; try { response = await send(destination, {method: 'POST', redirect: 'error', signal: AbortSignal.timeout(Math.max(1, Math.ceil(Math.min(35, remaining) * 1000))), headers: {'Content-Type': 'application/json', 'PAYMENT-SIGNATURE': header, 'Prefer': 'wait=25'}, body: requestBody}); } catch { throw Error('transport_error_retain_record'); } if (now() >= deadline) throw Error('pending_retain_record'); const status = response.status; if (![200, 202, 503].includes(status)) throw Error('unverified_or_non_answer_reply_retain_record'); let reply; try { reply = await response.json(); } catch { // Pre-app Uvicorn overload is a plain-text 503. if (status !== 503) throw Error('invalid_reply_shape_retain_record'); reply = {}; } if (now() >= deadline) throw Error('pending_retain_record'); if (!object(reply)) throw Error('invalid_reply_shape_retain_record'); if (status === 503) { const hints = reply.hints; const ingress = !Object.hasOwn(reply, 'error') && !Object.hasOwn(reply, 'receipt'); const transient = transientErrors.has(reply.error) && object(hints) && hints.do_not_resign === true && hints.new_quote_allowed === false; if (!ingress && !transient) { const receipt = reply.receipt; if (object(receipt) && object(receipt.envelope) && verify(record, receipt, keys) === 'verified' && terminalEnvelope(receipt.envelope)) { if (now() >= deadline) throw Error('pending_retain_record'); return reply; } throw Error('unverified_or_non_answer_reply_retain_record'); } } else if (status === 200 || Object.hasOwn(reply, 'receipt')) { const receipt = reply.receipt; if (!object(receipt) || !object(receipt.envelope) || verify(record, receipt, keys) !== 'verified') throw Error('unverified_or_non_answer_reply_retain_record'); const envelope = receipt.envelope; if (status === 200) { if (terminalEnvelope(envelope)) { if (now() >= deadline) throw Error('pending_retain_record'); return reply; } if (['included', 'final', 'released'].includes(envelope.state) && object(envelope.answer) && Object.keys(envelope.answer).length) { if (now() >= deadline) throw Error('pending_retain_record'); return reply; } throw Error('unverified_or_non_answer_reply_retain_record'); } if (!pendingStates.has(envelope.state) || Object.hasOwn(envelope, 'answer') || response.headers.has('payment-response')) throw Error('unverified_or_non_answer_reply_retain_record'); } const delay = pollDelay(response, reply, wallTime); if (i + 1 >= attempts || now() + delay >= deadline) throw Error('pending_retain_record'); await sleep(delay * 1000); } throw Error('pending_retain_record'); } export function archiveTerminal(file, reply) { // After a VERIFIED terminal no-charge receipt only: move the record aside, // freeing the path for a deliberate NEW purchase (fresh 402, new record // file), and keep the record plus the terminal reply as evidence. privateParent(file); const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+/, ''); const archived = file + '.terminal-' + stamp; const fd = fs.openSync(archived + '.reply', 'wx', 0o600); try { fs.writeFileSync(fd, JSON.stringify(reply)); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } fs.renameSync(file, archived); return archived; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { try { const [file, keyfile, receiptfile] = process.argv.slice(2); if (file === '--help' || file === '-h') { console.log('Usage: node recover.mjs RECORD KEYS [RECEIPT]\nVerify a saved receipt offline, or recover with the same credential; never re-sign.'); process.exit(0); } const record = loadRecord(file), keys = JSON.parse(fs.readFileSync(keyfile, 'utf8')); if (receiptfile) { const result = verify(record, JSON.parse(fs.readFileSync(receiptfile, 'utf8')), keys); console.log(result); process.exitCode = result === 'verified' ? 0 : 1; } else { const body = await recover(record, keys), e = body.receipt.envelope; if (e.answer !== undefined) { console.log('verified answer recovered; interpret label and coverage before acting'); } else { const archived = archiveTerminal(file, body); const guidance = body.hints?.new_quote_allowed ? 'a deliberate new purchase later, from a fresh 402 and a new record file, is allowed' : 'do not start a new purchase for this input yet'; console.log(`terminal no-charge result: state=${e.state} reason=${e.reason}. Nothing was charged. ` + `Never retry this credential; ${guidance}. Record archived at ${archived}`); } } } catch { console.error('recovery_failed_retain_private_record'); process.exitCode = 1; } }