import { createHash, randomBytes, timingSafeEqual } from 'node:crypto' export function canonicalQuery(input = '') { const params = new URLSearchParams(String(input).replace(/^\?/, '')) params.sort() return params.toString() } export function canonicalProxyTarget(input) { if (typeof input !== 'string' || !input.startsWith('/') || input.startsWith('//')) throw new Error('invalid confirmation path') let url try { url = new URL(input, 'http://confirmation.local') } catch { throw new Error('invalid confirmation path') } if (url.origin !== 'http://confirmation.local' || url.hash) throw new Error('invalid confirmation path') return `${url.pathname}${canonicalQuery(url.search) ? `?${canonicalQuery(url.search)}` : ''}` } export function bodyDigest(body) { const stable = value => { if (value === undefined) return 'undefined' if (Buffer.isBuffer(value)) return value if (value === null || typeof value !== 'object') return JSON.stringify(value) if (Array.isArray(value)) return `[${value.map(stable).join(',')}]` return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stable(value[key])}`).join(',')}}` } const value = stable(body) return createHash('sha256').update(value).digest('hex') } export class ConfirmationStore { #entries = new Map() constructor({ ttlMs = 30_000, maxEntries = 1024, now = Date.now } = {}) { this.ttlMs = ttlMs; this.maxEntries = maxEntries; this.now = now } prepare(binding) { const now = this.now() for (const [key, entry] of this.#entries) if (entry.expiresAt <= now) this.#entries.delete(key) while (this.#entries.size >= this.maxEntries) this.#entries.delete(this.#entries.keys().next().value) const token = randomBytes(32).toString('base64url') const expiresAt = now + this.ttlMs this.#entries.set(token, { ...binding, expiresAt }) return { token, expiresAt } } consume(token, binding) { if (typeof token !== 'string' || token.length < 16) return false const entry = this.#entries.get(token) if (!entry) return false this.#entries.delete(token) if (entry.expiresAt <= this.now()) return false const expected = Buffer.from(JSON.stringify(binding)) const actual = Buffer.from(JSON.stringify(Object.fromEntries(Object.keys(binding).map(key => [key, entry[key]])))) const matches = expected.length === actual.length && timingSafeEqual(expected, actual) return matches } } export function confirmationBinding({ instance, revision, method, target, body }) { return { instanceId: instance.id, revision, origin: new URL(instance.url).origin, method: String(method).toUpperCase(), target: canonicalProxyTarget(target), bodyDigest: bodyDigest(body), } }