/*!
* wgp-seal.js — WGP end-to-end body sealing (HPKE, RFC 9180)
* Version: 1.0.0 | Released: 2026-07-02 | Status: first public release
* Suite: DHKEM(P-256, HKDF-SHA256) + HKDF-SHA256 + AES-256-GCM
* Pure WebCrypto. No dependencies. Runs in the browser and in Node.
* SemVer: MAJOR = wire/format break, MINOR = compatible feature, PATCH = fix.
*
* Small on purpose: the whole trust of the scheme rests on this file,
* so it is meant to be read, hashed (SRI) and audited.
*/
(function (root) {
"use strict";
var subtle = (root.crypto || (typeof require === "function" ? require("node:crypto").webcrypto : null)).subtle;
// ---- byte helpers -------------------------------------------------------
var TE = new TextEncoder();
function u8(a) { return a instanceof Uint8Array ? a : new Uint8Array(a); }
function cat() {
var t = 0, i, arr = arguments;
for (i = 0; i < arr.length; i++) t += arr[i].length;
var out = new Uint8Array(t), o = 0;
for (i = 0; i < arr.length; i++) { out.set(arr[i], o); o += arr[i].length; }
return out;
}
function i2osp2(n) { return new Uint8Array([(n >> 8) & 0xff, n & 0xff]); }
function b64e(bytes) {
var s = "", b = u8(bytes), i;
for (i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
return (root.btoa ? root.btoa(s) : Buffer.from(b).toString("base64"));
}
function b64d(str) {
if (root.atob) { var s = root.atob(str), a = new Uint8Array(s.length), i; for (i = 0; i < s.length; i++) a[i] = s.charCodeAt(i); return a; }
return u8(Buffer.from(str, "base64"));
}
// ---- suite constants ----------------------------------------------------
var KEM_ID = 0x0010, KDF_ID = 0x0001, AEAD_ID = 0x0002;
var Nsecret = 32, Nk = 32, Nn = 12, Nh = 32;
var SUITE_KEM = cat(TE.encode("KEM"), i2osp2(KEM_ID));
var SUITE_HPKE = cat(TE.encode("HPKE"), i2osp2(KEM_ID), i2osp2(KDF_ID), i2osp2(AEAD_ID));
var HPKE_V1 = TE.encode("HPKE-v1");
var SUITE_STR = "p256-hkdf-sha256-aes256gcm";
// ---- HMAC / HKDF (extract + expand separately) --------------------------
async function hmac(key, data) {
var k = await subtle.importKey("raw", key.length ? key : new Uint8Array(32), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
return u8(await subtle.sign("HMAC", k, data));
}
async function extract(salt, ikm) { return hmac(salt && salt.length ? salt : new Uint8Array(32), ikm); }
async function expand(prk, info, L) {
var out = new Uint8Array(L), t = new Uint8Array(0), n = Math.ceil(L / 32), o = 0, i;
for (i = 1; i <= n; i++) {
t = await hmac(prk, cat(t, info, new Uint8Array([i])));
out.set(t.subarray(0, Math.min(32, L - o)), o); o += 32;
}
return out;
}
function labeledExtract(salt, label, ikm, suite) { return extract(salt, cat(HPKE_V1, suite, TE.encode(label), ikm)); }
function labeledExpand(prk, label, info, L, suite) { return expand(prk, cat(i2osp2(L), HPKE_V1, suite, TE.encode(label), info), L); }
// ---- DHKEM(P-256) -------------------------------------------------------
async function importPub(raw) { return subtle.importKey("raw", raw, { name: "ECDH", namedCurve: "P-256" }, false, []); }
async function dh(privKey, pubRaw) {
var pub = await importPub(pubRaw);
return u8(await subtle.deriveBits({ name: "ECDH", public: pub }, privKey, 256)); // X coordinate (32 bytes)
}
async function extractAndExpand(dhBytes, kemCtx) {
var prk = await labeledExtract(new Uint8Array(0), "eae_prk", dhBytes, SUITE_KEM);
return labeledExpand(prk, "shared_secret", kemCtx, Nsecret, SUITE_KEM);
}
async function genKeyPair() {
var kp = await subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
var pub = u8(await subtle.exportKey("raw", kp.publicKey));
return { privateKey: kp.privateKey, publicKey: kp.publicKey, publicRaw: pub };
}
async function encap(pkRraw) {
var e = await subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
var enc = u8(await subtle.exportKey("raw", e.publicKey));
var dhb = await dh(e.privateKey, pkRraw);
var shared = await extractAndExpand(dhb, cat(enc, pkRraw));
return { shared: shared, enc: enc };
}
async function decap(enc, skR, pkRraw) {
var dhb = await dh(skR, enc);
return extractAndExpand(dhb, cat(enc, pkRraw));
}
// ---- key schedule (base mode) ------------------------------------------
async function keySchedule(shared, info) {
var pskIdHash = await labeledExtract(new Uint8Array(0), "psk_id_hash", new Uint8Array(0), SUITE_HPKE);
var infoHash = await labeledExtract(new Uint8Array(0), "info_hash", info, SUITE_HPKE);
var ksc = cat(new Uint8Array([0x00]), pskIdHash, infoHash);
var secret = await labeledExtract(shared, "secret", new Uint8Array(0), SUITE_HPKE);
var key = await labeledExpand(secret, "key", ksc, Nk, SUITE_HPKE);
var baseNonce = await labeledExpand(secret, "base_nonce", ksc, Nn, SUITE_HPKE);
var exp = await labeledExpand(secret, "exp", ksc, Nh, SUITE_HPKE);
return { key: key, baseNonce: baseNonce, exp: exp };
}
async function exportSecret(ctx, context, L) { return labeledExpand(ctx.exp, "sec", context, L, SUITE_HPKE); }
// ---- AEAD (AES-256-GCM) -------------------------------------------------
async function aeadEnc(keyBytes, nonce, aad, pt) {
var k = await subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt"]);
return u8(await subtle.encrypt({ name: "AES-GCM", iv: nonce, additionalData: aad, tagLength: 128 }, k, pt));
}
async function aeadDec(keyBytes, nonce, aad, ct) {
var k = await subtle.importKey("raw", keyBytes, "AES-GCM", false, ["decrypt"]);
return u8(await subtle.decrypt({ name: "AES-GCM", iv: nonce, additionalData: aad, tagLength: 128 }, k, ct));
}
// ---- high-level seal / open (single-shot) -------------------------------
var INFO = TE.encode("wgp-sealed-body/v1");
var RESP_CTX = TE.encode("wgp-response");
async function sealToServer(pkRraw, aad, plaintext) {
var e = await encap(pkRraw);
var ctx = await keySchedule(e.shared, INFO);
var ct = await aeadEnc(ctx.key, ctx.baseNonce, aad, plaintext);
var respKey = await exportSecret(ctx, RESP_CTX, 32);
return { enc: e.enc, ct: ct, respKey: respKey };
}
async function openFromClient(enc, skR, pkRraw, aad, ct) {
var shared = await decap(enc, skR, pkRraw);
var ctx = await keySchedule(shared, INFO);
var pt = await aeadDec(ctx.key, ctx.baseNonce, aad, ct);
var respKey = await exportSecret(ctx, RESP_CTX, 32);
return { pt: pt, respKey: respKey };
}
// ---- envelope (JSON wire format for the demo; CBOR in production) --------
function aadBytes(h) { return TE.encode([h.v, h.suite, h.key_id, h.ts, h.rid, h.method, h.path].join("|")); }
async function sealRequest(pkRraw, keyId, method, path, obj) {
var h = { v: 1, suite: SUITE_STR, key_id: keyId, ts: Date.now(), rid: b64e(root.crypto.getRandomValues(new Uint8Array(12))), method: method, path: path };
var pt = TE.encode(JSON.stringify(obj));
var s = await sealToServer(pkRraw, aadBytes(h), pt);
return { envelope: { h: h, enc: b64e(s.enc), ct: b64e(s.ct) }, respKey: s.respKey };
}
async function openRequest(envelope, skR, pkRraw) {
var h = envelope.h;
var r = await openFromClient(b64d(envelope.enc), skR, pkRraw, aadBytes(h), b64d(envelope.ct));
return { obj: JSON.parse(new TextDecoder().decode(r.pt)), respKey: r.respKey, header: h };
}
async function sealResponse(respKey, reqHeader, obj) {
var iv = root.crypto.getRandomValues(new Uint8Array(12));
var h = { v: 1, resp: true, ts: Date.now(), rid: reqHeader.rid };
var aad = TE.encode([h.v, "resp", h.ts, h.rid].join("|"));
var ct = await aeadEnc(respKey, iv, aad, TE.encode(JSON.stringify(obj)));
return { h: h, iv: b64e(iv), ct: b64e(ct) };
}
async function openResponse(respEnvelope, respKey) {
var h = respEnvelope.h;
var aad = TE.encode([h.v, "resp", h.ts, h.rid].join("|"));
var pt = await aeadDec(respKey, b64d(respEnvelope.iv), aad, b64d(respEnvelope.ct));
return JSON.parse(new TextDecoder().decode(pt));
}
// ---- browser SDK: transparent fetch wrapper -----------------------------
var cfg = { keyUrl: "/.well-known/wgp-key.json", endpoints: [], pk: null, keyId: null, onSeal: null };
async function loadKey() {
var r = await (WGP._origFetch || root.fetch)(cfg.keyUrl); var j = await r.json();
cfg.pk = b64d(j.pk); cfg.keyId = j.key_id; return j;
}
function matches(url) {
try { var p = new URL(url, root.location ? root.location.href : "http://x").pathname; return cfg.endpoints.indexOf(p) >= 0; }
catch (e) { return false; }
}
async function sealedFetch(url, opts) {
opts = opts || {};
if (!cfg.pk) await loadKey();
var method = (opts.method || "GET").toUpperCase();
var path = new URL(url, root.location.href).pathname;
var body = opts.body ? (typeof opts.body === "string" ? JSON.parse(opts.body) : opts.body) : {};
var sealed = await sealRequest(cfg.pk, cfg.keyId, method, path, body);
if (cfg.onSeal) try { cfg.onSeal(sealed.envelope); } catch (e) {}
var res = await (WGP._origFetch || root.fetch)(url, { method: method, headers: { "content-type": "application/wgp-sealed+json" }, body: JSON.stringify(sealed.envelope) });
var respEnv; try { respEnv = await res.json(); } catch (e) { respEnv = null; }
// Guard: only decrypt if the server actually returned a sealed reply.
if (!res.ok || !respEnv || !respEnv.iv || !respEnv.ct) {
return { ok: res.ok, status: res.status, json: function () { return Promise.resolve(respEnv); }, _plain: respEnv, _sealedResponse: null, error: (respEnv && respEnv.error) || ("HTTP " + res.status) };
}
var plain = await openResponse(respEnv, sealed.respKey);
return { ok: res.ok, status: res.status, json: function () { return Promise.resolve(plain); }, _plain: plain, _sealedResponse: respEnv };
}
function configure(o) {
for (var k in o) cfg[k] = o[k];
if (root.fetch && !root.__wgpWrapped) {
var orig = root.fetch.bind(root);
root.fetch = function (url, opts) {
if (opts && (opts.method || "GET").toUpperCase() === "POST" && matches(url)) return sealedFetch(url, opts);
return orig(url, opts);
};
root.__wgpWrapped = true;
WGP._origFetch = orig;
}
}
var WGP = {
VERSION: "1.0.0",
SUITE: SUITE_STR,
genKeyPair: genKeyPair,
sealRequest: sealRequest, openRequest: openRequest,
sealResponse: sealResponse, openResponse: openResponse,
// low-level for tests
_encap: encap, _decap: decap, _keySchedule: keySchedule, _b64e: b64e, _b64d: b64d,
// browser SDK
configure: configure, loadKey: loadKey, sealedFetch: sealedFetch
};
if (typeof module !== "undefined" && module.exports) module.exports = WGP;
if (root.window) root.window.WGPSeal = WGP;
root.WGPSeal = WGP;
})(typeof self !== "undefined" ? self : (typeof globalThis !== "undefined" ? globalThis : this));
/*
* server.js — WGP E2EE origin (Node.js sample).
*
* Runs the *decrypt/encrypt* side of the sealed body. Zero dependencies —
* only Node's built-ins. Uses the SAME wgp-seal.js as the browser, so client
* and server are guaranteed to interoperate.
*
* node server.js # then open http://localhost:8080
* PORT=3000 node server.js
*
* Requires Node.js 18 or newer (built-in WebCrypto).
*
* In production, put the private key in an HSM/KMS instead of a file, and
* mount the two crypto calls (openRequest / sealResponse) as middleware on
* your own endpoints. See README.md.
*/
"use strict";
const http = require("http");
const fs = require("fs");
const path = require("path");
const { webcrypto } = require("node:crypto");
const WGP = require("./wgp-seal.js");
const PORT = process.env.PORT || 8080;
const HERE = __dirname;
const KEY_FILE = path.join(HERE, "server-key.json");
const KEY_ID = "enisa-origin-key-1";
/* ---- key material: persisted so the public key is stable across restarts ----
PRODUCTION: replace this whole block with an HSM/KMS handle. */
async function loadOrCreateKey() {
const subtle = webcrypto.subtle;
if (fs.existsSync(KEY_FILE)) {
const j = JSON.parse(fs.readFileSync(KEY_FILE, "utf8"));
const priv = await subtle.importKey("pkcs8", Buffer.from(j.pkcs8, "base64"),
{ name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]);
return { privateKey: priv, publicRaw: new Uint8Array(Buffer.from(j.pubRaw, "base64")) };
}
const kp = await subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
const pkcs8 = Buffer.from(await subtle.exportKey("pkcs8", kp.privateKey)).toString("base64");
const pubRaw = Buffer.from(await subtle.exportKey("raw", kp.publicKey)).toString("base64");
fs.writeFileSync(KEY_FILE, JSON.stringify({ pkcs8, pubRaw }), { mode: 0o600 });
const priv = await subtle.importKey("pkcs8", Buffer.from(pkcs8, "base64"),
{ name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]);
return { privateKey: priv, publicRaw: new Uint8Array(Buffer.from(pubRaw, "base64")) };
}
let SRV = null;
const seen = new Map(); // rid -> ts (replay guard)
function send(res, code, type, body, extra) {
res.writeHead(code, Object.assign({ "content-type": type, "cache-control": "no-store" }, extra || {}));
res.end(body);
}
function serveStatic(res, rel) {
const p = path.join(HERE, rel);
if (!p.startsWith(HERE) || !fs.existsSync(p)) return send(res, 404, "text/plain", "not found");
const t = rel.endsWith(".js") ? "application/javascript; charset=utf-8"
: rel.endsWith(".html") ? "text/html; charset=utf-8" : "text/plain";
send(res, 200, t, fs.readFileSync(p));
}
function readBody(req) {
return new Promise((resolve) => { let d = ""; req.on("data", c => { d += c; if (d.length > 1e6) req.destroy(); }); req.on("end", () => resolve(d)); });
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, "http://x");
if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) return serveStatic(res, "index.html");
if (req.method === "GET" && url.pathname === "/wgp-seal.js") return serveStatic(res, "wgp-seal.js");
// publish the origin's public key (the browser fetches this)
if (req.method === "GET" && url.pathname === "/wgp-key.json") {
return send(res, 200, "application/json; charset=utf-8",
JSON.stringify({ key_id: KEY_ID, suite: WGP.SUITE, pk: WGP._b64e(SRV.publicRaw) }));
}
// the sealed endpoint
if (req.method === "POST" && url.pathname === "/api/submit") {
let env; try { env = JSON.parse(await readBody(req)); } catch { return send(res, 400, "application/json", '{"error":"bad envelope"}'); }
if (!env || !env.h || !env.enc || !env.ct) return send(res, 400, "application/json", '{"error":"bad envelope"}');
// freshness + replay (header is bound in AAD, so tampering it also fails to decrypt)
const now = Date.now(), h = env.h;
if (!h.ts || Math.abs(now - h.ts) > 30000) return send(res, 400, "application/json", '{"error":"stale"}');
if (seen.has(h.rid)) return send(res, 409, "application/json", '{"error":"replay"}');
seen.set(h.rid, now); for (const [k, t] of seen) if (now - t > 30000) seen.delete(k);
// origin decrypts with its private key
let opened;
try { opened = await WGP.openRequest(env, SRV.privateKey, SRV.publicRaw); }
catch { return send(res, 400, "application/json", '{"error":"decrypt failed"}'); }
// ---- your application logic (this is the only place the plaintext exists) ----
const reply = {
status: "received",
ref: "REQ-" + Date.now().toString(36),
echo: opened.obj,
note: "Decrypted at the origin only."
};
// seal the reply back to the same session and return it
const respEnv = await WGP.sealResponse(opened.respKey, opened.header, reply);
return send(res, 200, "application/wgp-sealed+json; charset=utf-8", JSON.stringify(respEnv));
}
send(res, 404, "text/plain", "not found");
});
loadOrCreateKey().then((k) => {
SRV = k;
server.listen(PORT, () => {
console.log("WGP E2EE origin (Node) on http://localhost:" + PORT);
console.log("key_id=" + KEY_ID + " public key (first bytes): " + WGP._b64e(k.publicRaw).slice(0, 20) + "…");
console.log("private key persisted in server-key.json (use an HSM in production).");
});
});