UNPKG

@x402-hpke/node

Version:

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

275 lines (274 loc) 12.1 kB
import sodium from "libsodium-wrappers"; import { buildAadFromTransport } from "./aad.js"; import { jwkToPublicKeyBytes, jwkToPrivateKeyBytes } from "./keys.js"; import { synthesizePaymentHeaderValue } from "./payment.js"; import { isApprovedExtensionHeader } from "./extensions.js"; import { createHmac, timingSafeEqual } from "crypto"; import { AeadMismatchError, AeadUnsupportedError, AadMismatchError, EcdhLowOrderError, InvalidEnvelopeError, KidMismatchError, PublicKeyNotInAadError, } from "./errors.js"; function b64u(bytes) { return Buffer.from(bytes).toString("base64").replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); } function b64uToBytes(s) { return new Uint8Array(Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64")); } function hkdfSha256(ikm, info, length) { const salt = new Uint8Array(32); // zeros const prk = createHmac("sha256", Buffer.from(salt)).update(Buffer.from(ikm)).digest(); const n = Math.ceil(length / 32); let t = Buffer.alloc(0); let okm = Buffer.alloc(0); for (let i = 1; i <= n; i++) { const h = createHmac("sha256", prk).update(Buffer.concat([t, Buffer.from(info), Buffer.from([i])])).digest(); // Normalize Buffer generic types by re-wrapping const hb = Buffer.from(h); t = hb; okm = Buffer.concat([okm, hb]); } return new Uint8Array(okm.slice(0, length)); } function isAllZero(bytes) { for (let i = 0; i < bytes.length; i++) { if (bytes[i] !== 0) return false; } return true; } export async function seal(args) { await sodium.ready; const { namespace, kem, kdf, aead, kid, recipientPublicJwk, transport } = args; if (aead !== "CHACHA20-POLY1305") { throw new AeadUnsupportedError("AEAD_UNSUPPORTED"); } const core = transport.getHeader(); const exts = transport.getExtensions() || []; const body = transport.getBody() || {}; const httpResponseCode = transport.getHttpResponseCode(); const headers = (core ? [core] : []).concat(exts || []); const { aadBytes, headersNormalized, bodyNormalized } = buildAadFromTransport(namespace, headers, body); const plaintext = new TextEncoder().encode(JSON.stringify(bodyNormalized)); const eph = args.__testEphSeed32 ? sodium.crypto_kx_seed_keypair(args.__testEphSeed32) : sodium.crypto_kx_keypair(); const recipientPub = jwkToPublicKeyBytes(recipientPublicJwk); if (isAllZero(recipientPub)) throw new EcdhLowOrderError("ECDH_LOW_ORDER"); const shared = sodium.crypto_scalarmult(eph.privateKey, recipientPub); if (isAllZero(shared)) throw new EcdhLowOrderError("ECDH_LOW_ORDER"); const info = new TextEncoder().encode(`x402-hpke:v1|KDF=${kdf}|AEAD=${aead}|ns=${namespace}|enc=${b64u(eph.publicKey)}|pkR=${b64u(recipientPub)}`); const okm = hkdfSha256(shared, info, 32 + 12); const key = okm.slice(0, 32); const nonce = okm.slice(32); const ct = sodium.crypto_aead_chacha20poly1305_ietf_encrypt(plaintext, aadBytes, null, nonce, key); const envelope = { typ: "hpke-envelope", ver: "1", suite: "X25519-HKDF-SHA256-CHACHA20POLY1305", ns: namespace, kid, kem, kdf, aead, enc: b64u(eph.publicKey), aad: b64u(aadBytes), ct: b64u(ct), }; const makePub = args.makeEntitiesPublic; if (!makePub) return { envelope }; const select = (all) => { if (makePub === "all" || makePub === "*") return [...all]; if (Array.isArray(makePub)) { const set = new Set(makePub.map((s) => String(s).toLowerCase())); return all.filter((k) => set.has(String(k).toLowerCase())); } return []; }; let headerNames = headersNormalized.map((h) => h.header); if (httpResponseCode === 402) { headerNames = headerNames.filter((h) => { const s = String(h).toUpperCase(); return s !== "X-PAYMENT" && s !== "X-PAYMENT-RESPONSE"; }); } const headerAllow = select(headerNames); const bodyKeys = Object.keys(bodyNormalized); const bodyAllow = select(bodyKeys); const publicHeaders = {}; for (const hn of headerAllow) { const found = headersNormalized.find((h) => String(h.header).toLowerCase() === String(hn).toLowerCase()); if (!found) continue; publicHeaders[found.header] = synthesizePaymentHeaderValue(found.value); } const publicBody = {}; for (const bk of bodyAllow) if (bk in bodyNormalized) publicBody[bk] = bodyNormalized[bk]; const hasHeaders = Object.keys(publicHeaders).length > 0; const hasBody = Object.keys(publicBody).length > 0; if (!hasHeaders && !hasBody) return { envelope }; return { envelope, publicHeaders: hasHeaders ? publicHeaders : undefined, publicBody: hasBody ? publicBody : undefined }; } export async function open(args) { await sodium.ready; const { namespace, expectedKid, recipientPrivateJwk, envelope } = args; if (envelope.ver !== "1" || envelope.ns.toLowerCase() === "x402") { throw new InvalidEnvelopeError("INVALID_ENVELOPE"); } if (envelope.aead !== args.aead) { throw new AeadMismatchError("AEAD_MISMATCH"); } if (args.aead !== "CHACHA20-POLY1305") { throw new AeadUnsupportedError("AEAD_UNSUPPORTED"); } if (expectedKid && envelope.kid !== expectedKid) { throw new KidMismatchError("KID_MISMATCH"); } // Namespace binding: must match envelope.ns if (namespace !== envelope.ns) { throw new InvalidEnvelopeError("NS_MISMATCH"); } const aadBytes = b64uToBytes(envelope.aad); const sidecarHeaders = args.publicHeaders ?? args.publicJson; const sidecarBody = args.publicBody; const sk = jwkToPrivateKeyBytes(recipientPrivateJwk); const ephPub = b64uToBytes(envelope.enc); if (isAllZero(ephPub)) { throw new EcdhLowOrderError("ECDH_LOW_ORDER"); } const shared = sodium.crypto_scalarmult(sk, ephPub); if (isAllZero(shared)) { throw new EcdhLowOrderError("ECDH_LOW_ORDER"); } const pkR = sodium.crypto_scalarmult_base(sk); const info = new TextEncoder().encode(`x402-hpke:v1|KDF=${args.kdf}|AEAD=${args.aead}|ns=${envelope.ns}|enc=${envelope.enc}|pkR=${b64u(pkR)}`); const okm = hkdfSha256(shared, info, 32 + 12); const key = okm.slice(0, 32); const nonce = okm.slice(32); const ct = b64uToBytes(envelope.ct); let pt = sodium.crypto_aead_chacha20poly1305_ietf_decrypt(null, ct, aadBytes, nonce, key); // Normalize plaintext JSON string formatting to compact if parseable to preserve prior test expectations try { const s = new TextDecoder().decode(pt); const j = JSON.parse(s); pt = new TextEncoder().encode(JSON.stringify(j)); } catch { } // Parse AAD; support both V2 (headers/body) and legacy V1 (primary/extensions) const aadStr = Buffer.from(aadBytes).toString("utf8"); const parts = aadStr.split("|"); if (parts.length < 4) throw new InvalidEnvelopeError("INVALID_ENVELOPE"); const seg2 = parts[2]; const seg3 = parts[3]; let isV2 = false; let headers = []; try { const probe = JSON.parse(seg2); if (Array.isArray(probe)) { isV2 = true; headers = probe; } } catch { } if (isV2) { let body; try { const obj = JSON.parse(seg3); if (obj && typeof obj === "object" && !Array.isArray(obj)) body = obj; } catch { } if (sidecarHeaders) { for (const [k, v] of Object.entries(sidecarHeaders)) { const found = headers.find(h => h.header.toLowerCase() === String(k).toLowerCase()); if (!found) throw new PublicKeyNotInAadError("PUBLIC_KEY_NOT_IN_AAD"); const expect = synthesizePaymentHeaderValue(found.value); const got = String(v).trim(); const a = Buffer.from(expect, "utf8"); const b = Buffer.from(got, "utf8"); if (a.length !== b.length || !timingSafeEqual(a, b)) throw new AadMismatchError("AAD_MISMATCH"); } } if (sidecarBody && body) { for (const [k, v] of Object.entries(sidecarBody)) { if (!(k in body)) throw new PublicKeyNotInAadError("PUBLIC_KEY_NOT_IN_AAD"); const expectStr = JSON.stringify(body[k]); const gotStr = JSON.stringify(v); const a = Buffer.from(expectStr, "utf8"); const b = Buffer.from(gotStr, "utf8"); if (a.length !== b.length || !timingSafeEqual(a, b)) throw new AadMismatchError("AAD_MISMATCH"); } } return { plaintext: pt, body, headers }; } // Legacy V1 parse: primary_json|extensions_json const primaryJson = seg2; const extensionsJson = seg3; const primaryPayload = JSON.parse(primaryJson); const extensions = extensionsJson ? JSON.parse(extensionsJson) : undefined; let request, response, x402; if (primaryPayload && typeof primaryPayload === "object" && !Array.isArray(primaryPayload) && ("header" in primaryPayload)) { x402 = primaryPayload; } else if (primaryPayload && typeof primaryPayload === "object" && !Array.isArray(primaryPayload) && Object.keys(primaryPayload).some(k => ["action", "userId", "params", "resource", "get", "post"].includes(k))) { request = primaryPayload; } else { response = primaryPayload; } // Verify sidecar payment/extension headers against AAD if provided if (sidecarHeaders) { const findHeader = (k) => { const found = Object.keys(sidecarHeaders).find((h) => h.toLowerCase() === k.toLowerCase()); return found ? String(sidecarHeaders[found]).trim() : undefined; }; const xp = findHeader("X-PAYMENT"); const xpr = findHeader("X-PAYMENT-RESPONSE"); if (xp && x402 && x402.header === "X-Payment") { const expect = synthesizePaymentHeaderValue(x402.payload); const a = Buffer.from(expect, "utf8"); const b = Buffer.from(xp, "utf8"); if (a.length !== b.length || !timingSafeEqual(a, b)) throw new AadMismatchError("AAD_MISMATCH"); } if (xpr && x402 && x402.header === "X-Payment-Response") { const expect = synthesizePaymentHeaderValue(x402.payload); const a = Buffer.from(expect, "utf8"); const b = Buffer.from(xpr, "utf8"); if (a.length !== b.length || !timingSafeEqual(a, b)) throw new AadMismatchError("AAD_MISMATCH"); } // Extensions verification for (const k of Object.keys(sidecarHeaders)) { if (!isApprovedExtensionHeader(k)) continue; const extList = (extensions && Array.isArray(extensions)) ? extensions : []; const found = extList.find((e) => String(e.header).toLowerCase() === k.toLowerCase()); if (!found) throw new PublicKeyNotInAadError("PUBLIC_KEY_NOT_IN_AAD"); const expect = synthesizePaymentHeaderValue(found.payload); const got = String(sidecarHeaders[k]).trim(); const a = Buffer.from(expect, "utf8"); const b = Buffer.from(got, "utf8"); if (a.length !== b.length || !timingSafeEqual(a, b)) throw new AadMismatchError("AAD_MISMATCH"); } } // Populate legacy fields for backward compatibility const out = { plaintext: pt, body: primaryPayload, headers: extensions }; if (x402) out.x402 = x402; if (request) out.request = request; if (response) out.response = response; if (extensions) out.extensions = extensions; return out; }