@x402-hpke/node
Version:
Provider-agnostic HPKE envelope library for x402 (Node) — cross-language interop with Python
92 lines (91 loc) • 3.4 kB
JavaScript
import sodium from "libsodium-wrappers";
import { InvalidEnvelopeError, JwksHttpsRequiredError, JwksHttpError, JwksInvalidError, JwksKeyInvalidError, JwksKeyUseInvalidError, JwksKidInvalidError } from "./errors.js";
const jwksCache = new Map();
function b64u(bytes) {
const s = Buffer.from(bytes).toString("base64").replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
return s;
}
export async function generateKeyPair() {
await sodium.ready;
const kp = sodium.crypto_kx_keypair();
const publicJwk = { kty: "OKP", crv: "X25519", x: b64u(kp.publicKey) };
const privateJwk = { ...publicJwk, d: b64u(kp.privateKey) };
return { publicJwk, privateJwk };
}
export async function generatePublicJwk() {
const { publicJwk } = await generateKeyPair();
return publicJwk;
}
export function selectJwkFromJwks(jwks, kid) {
return (jwks.keys || []).find((k) => k.kid === kid);
}
export function jwkToPublicKeyBytes(jwk) {
if (jwk.kty !== "OKP" || jwk.crv !== "X25519" || !jwk.x)
throw new InvalidEnvelopeError("INVALID_ENVELOPE");
return Buffer.from(jwk.x.replace(/-/g, "+").replace(/_/g, "/"), "base64");
}
export function jwkToPrivateKeyBytes(jwk) {
if (!jwk.d)
throw new InvalidEnvelopeError("INVALID_ENVELOPE");
return Buffer.from(jwk.d.replace(/-/g, "+").replace(/_/g, "/"), "base64");
}
function clamp(n, lo, hi) {
return Math.max(lo, Math.min(hi, n));
}
function parseCacheHeaders(headers) {
const cc = headers.get("cache-control")?.toLowerCase();
if (cc) {
const m = cc.match(/(?:s-maxage|max-age)=(\d+)/);
if (m)
return parseInt(m[1], 10) * 1000;
}
const exp = headers.get("expires");
if (exp) {
const t = Date.parse(exp);
if (!Number.isNaN(t))
return Math.max(0, t - Date.now());
}
return undefined;
}
export async function fetchJwks(url, opts) {
if (!url.startsWith("https://"))
throw new JwksHttpsRequiredError("JWKS_HTTPS_REQUIRED");
const now = Date.now();
const cached = jwksCache.get(url);
if (cached && cached.exp > now)
return cached.jwks;
const res = await fetch(url);
if (!res.ok)
throw new JwksHttpError(`JWKS_HTTP_${res.status}`);
const jwks = (await res.json());
if (!jwks || !Array.isArray(jwks.keys))
throw new JwksInvalidError("JWKS_INVALID");
for (const k of jwks.keys) {
if (k.kty !== "OKP" || k.crv !== "X25519" || typeof k.x !== "string")
throw new JwksKeyInvalidError("JWKS_KEY_INVALID");
if (k.use && k.use !== "enc")
throw new JwksKeyUseInvalidError("JWKS_KEY_USE_INVALID");
if (!k.kid || typeof k.kid !== "string")
throw new JwksKidInvalidError("JWKS_KID_INVALID");
}
let ttl = parseCacheHeaders(res.headers) ?? 300_000;
ttl = clamp(ttl, opts?.minTtlMs ?? 60_000, opts?.maxTtlMs ?? 3_600_000);
jwksCache.set(url, { jwks, exp: now + ttl });
return jwks;
}
export function setJwks(url, jwks, ttlMs = 300_000) {
jwksCache.set(url, { jwks, exp: Date.now() + ttlMs });
}
export function generateJwks(keys) {
return {
keys: keys.map(({ jwk, kid }) => ({
...jwk,
kid,
use: "enc",
alg: "ECDH-ES"
}))
};
}
export function generateSingleJwks(jwk, kid) {
return generateJwks([{ jwk, kid }]);
}