UNPKG

@x402-hpke/node

Version:

Provider-agnostic HPKE envelope library for x402 (Node) — cross-language interop with Python

170 lines (169 loc) 7.21 kB
import { TextEncoder } from "node:util"; import { NsForbiddenError, X402HeaderError, X402PayloadError, X402ExtensionUnapprovedError, X402ExtensionDuplicateError, X402ExtensionPayloadError } from "./errors.js"; import { isApprovedExtensionHeader, canonicalizeExtensionHeader } from "./extensions.js"; const enc = new TextEncoder(); function deepCanonicalize(value) { if (value === null || typeof value !== "object") return value; if (Array.isArray(value)) return value.map(deepCanonicalize); const keys = Object.keys(value).sort(); const out = {}; for (const k of keys) out[k] = deepCanonicalize(value[k]); return out; } function canonicalizeHeaderCase(h) { const s = String(h).toLowerCase(); if (s === "x-payment") return "X-Payment"; if (s === "x-payment-response") return "X-Payment-Response"; if (s === "") return ""; throw new X402HeaderError("X402_HEADER"); } export function validateX402Core(x) { const header = canonicalizeHeaderCase(x?.header); const payload = x?.payload; if (header !== "" && (!payload || typeof payload !== "object" || Array.isArray(payload) || Object.keys(payload).length === 0)) { throw new X402PayloadError("X402_PAYLOAD"); } const extra = {}; for (const k of Object.keys(x || {})) { if (k === "header" || k === "payload") continue; extra[k] = x[k]; } return { header, payload, ...extra }; } function canonicalJson(obj) { return JSON.stringify(deepCanonicalize(obj)); } export function buildCanonicalAad(namespace, payload, extensions) { if (!namespace || namespace.toLowerCase() === "x402") throw new NsForbiddenError("NS_FORBIDDEN"); const { request, response, x402 } = payload; let primaryJson = ""; let x402Normalized, requestNormalized, responseNormalized; if (x402) { const x = validateX402Core(x402); primaryJson = canonicalJson(x); x402Normalized = JSON.parse(primaryJson); } else if (request) { primaryJson = canonicalJson(request); requestNormalized = JSON.parse(primaryJson); } else if (response) { primaryJson = canonicalJson(response); responseNormalized = JSON.parse(primaryJson); } let extensionsNormalized; let extensionsJson = ""; if (Array.isArray(extensions) && extensions.length > 0) { const seen = new Set(); const exts = []; for (const e of extensions) { const hdr = String(e?.header || ""); if (!isApprovedExtensionHeader(hdr)) throw new X402ExtensionUnapprovedError("X402_EXTENSION_UNAPPROVED"); const canonHdr = canonicalizeExtensionHeader(hdr); if (seen.has(canonHdr.toLowerCase())) throw new X402ExtensionDuplicateError("X402_EXTENSION_DUPLICATE"); const extPayload = e?.payload; if (!extPayload || typeof extPayload !== "object" || Array.isArray(extPayload) || Object.keys(extPayload).length === 0) { throw new X402ExtensionPayloadError("X402_EXTENSION_PAYLOAD"); } const extExtra = {}; for (const k2 of Object.keys(e)) if (k2 !== "header" && k2 !== "payload") extExtra[k2] = e[k2]; exts.push({ header: canonHdr, payload: extPayload, ...extExtra }); seen.add(canonHdr.toLowerCase()); } // Sort extensions by header (case-insensitive) exts.sort((a, b) => a.header.toLowerCase().localeCompare(b.header.toLowerCase())); extensionsNormalized = exts.map((e) => deepCanonicalize(e)); extensionsJson = canonicalJson(extensionsNormalized); } const prefix = `${namespace}|v1|`; const suffix = extensionsJson ? `|${extensionsJson}` : "|"; const full = prefix + primaryJson + suffix; return { aadBytes: enc.encode(full), x402Normalized, requestNormalized, responseNormalized, extensionsNormalized }; } export function canonicalAad(namespace, payload, extensions) { return buildCanonicalAad(namespace, payload, extensions).aadBytes; } export function canonicalizeCoreHeaderName(h) { const s = String(h || ""); if (s === "") return ""; const sl = s.toLowerCase(); if (sl === "x-payment") return "X-Payment"; if (sl === "x-payment-response") return "X-Payment-Response"; return s; } function canonicalJsonCompact(obj) { return JSON.stringify(deepCanonicalize(obj)); } export function buildCanonicalAadHeadersBody(namespace, privateHeaders, privateBody) { if (!namespace || namespace.toLowerCase() === "x402") throw new NsForbiddenError("NS_FORBIDDEN"); // Normalize headers const headersIn = Array.isArray(privateHeaders) ? privateHeaders : []; const seen = new Set(); const headersNormalized = headersIn.map((e) => { const rawHdr = String(e?.header ?? ""); let hdr = canonicalizeCoreHeaderName(rawHdr); if (hdr !== "X-Payment" && hdr !== "X-Payment-Response" && hdr !== "") { // extension header path if (!isApprovedExtensionHeader(hdr)) { throw new X402ExtensionUnapprovedError("X402_EXTENSION_UNAPPROVED"); } hdr = canonicalizeExtensionHeader(hdr); } const key = hdr.toLowerCase(); if (seen.has(key)) throw new X402ExtensionDuplicateError("X402_EXTENSION_DUPLICATE"); seen.add(key); const out = { ...e }; out.header = hdr; // Canonicalize value structure for AAD stability out.value = deepCanonicalize(e?.value); return out; }); headersNormalized.sort((a, b) => a.header.toLowerCase().localeCompare(b.header.toLowerCase())); // Normalize body const bodyNormalized = privateBody ? deepCanonicalize(privateBody) : {}; const prefix = `${namespace}|v1|`; const headersJson = canonicalJsonCompact(headersNormalized); const bodyJson = canonicalJsonCompact(bodyNormalized); const full = prefix + headersJson + "|" + bodyJson; return { aadBytes: enc.encode(full), headersNormalized, bodyNormalized }; } // Unified transport AAD builder: uses header names verbatim (no canonicalization), // deep-canonicalizes values and body, and sorts headers by case-insensitive name for stability export function buildAadFromTransport(namespace, headers, body) { if (!namespace || namespace.toLowerCase() === "x402") throw new NsForbiddenError("NS_FORBIDDEN"); const headersNormalized = (headers || []).map((h) => ({ header: String(h?.header ?? ""), value: deepCanonicalize(h?.value), })); headersNormalized.sort((a, b) => a.header.toLowerCase().localeCompare(b.header.toLowerCase())); const bodyNormalized = deepCanonicalize(body || {}); const prefix = `${namespace}|v1|`; const headersJson = JSON.stringify(headersNormalized); const bodyJson = JSON.stringify(bodyNormalized); const full = prefix + headersJson + "|" + bodyJson; return { aadBytes: enc.encode(full), headersNormalized, bodyNormalized }; }