@x402-hpke/node
Version:
Provider-agnostic HPKE envelope library for x402 (Node) — cross-language interop with Python
170 lines (169 loc) • 7.21 kB
JavaScript
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 };
}