From ff9b6697f8b987ad365854d4e998c3f865a56dfa Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 1 Aug 2026 16:58:16 +0000 Subject: [PATCH] Observe and optionally enforce post-quantum key exchange on the origin leg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy's right-hand session is the one that crosses the network, and on Node 24 against OpenSSL 3.5 it already negotiates X25519MLKEM768 with no configuration at all — the ClientHello it sends puts the hybrid first and carries a 1216-byte ML-KEM key share. What it could not do was notice when that failed. An origin on OpenSSL 3.0-3.4 (Ubuntu 22.04/24.04) has no ML-KEM, so the handshake silently falls back to x25519 and succeeds. A guarantee nobody can observe is not one. Every upstream leg is now classified and counted, with a warn line on fallback, and MOSHPIT_PROXY_REQUIRE_PQ=1 turns the fallback into a refusal. It stays off by default because switching it on today takes every pre-3.5 origin offline; the counters are how you find out when it is safe. Node exposes no SSL_get_negotiated_group() binding, so the group is read via getEphemeralKeyInfo(), which cannot represent a hybrid KEM and returns {} for one while naming any classical group. Inferring a positive from an absence is fragile, so it is proven rather than trusted: probeDetector() runs two loopback handshakes at startup, asserts both halves of the mapping, prints the result, and enforcement declines to engage if the proof fails. 35 tests pass (8 new), tsc --noEmit clean under strict. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 39 +++++++ bin/moshpit-proxy.ts | 20 ++++ lib/config.ts | 6 + lib/pq.ts | 253 +++++++++++++++++++++++++++++++++++++++++++ lib/proxy.ts | 56 +++++++++- tests/pq.test.ts | 221 +++++++++++++++++++++++++++++++++++++ 6 files changed, 593 insertions(+), 2 deletions(-) create mode 100644 lib/pq.ts create mode 100644 tests/pq.test.ts diff --git a/README.md b/README.md index ce3b996..4843a1f 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,45 @@ whenever we like — no CA, no root program, no CA/B ballot. Browsers don't support PQ signatures in TLS yet, but the proxy terminates for the browser, so the proxy↔origin leg can run ML-DSA before the public web can. +### It is on by default, and that is measured + +The proxy configures no groups at all — the origin leg uses Node's defaults. +On Node 24 against OpenSSL 3.5 those defaults already put the hybrid first and +send a real ML-KEM key share in the first flight. Read off the wire from the +ClientHello the proxy actually sends: + +``` +supported_groups: X25519MLKEM768, x25519, secp256r1, x448, secp384r1, ... +key_share sent : X25519MLKEM768(1216B), x25519(32B) +``` + +### The gap was that nobody could tell when it didn't happen + +An origin on OpenSSL 3.0–3.4 — what Ubuntu 22.04 and 24.04 still ship — has no +ML-KEM, so the handshake silently falls back to `x25519` and succeeds. The +session is fine against every adversary that exists today and decryptable by one +that doesn't yet. Nothing said so, which made the guarantee unobservable. + +Every upstream leg is now classified and counted: + +``` +[proxy] ok scrambled.eggs (registry) TLSv1.3 hybrid-pq +[proxy] warn old.eggs: origin has no ML-KEM, fell back to classical/X25519 +... +[proxy] 41 post-quantum, 3 classical +``` + +`MOSHPIT_PROXY_REQUIRE_PQ=1` turns the fallback into a refusal. **Leave it off +until the counters say the grid is ready** — switching it on today takes every +pre-3.5 origin offline. + +Node exposes no binding for `SSL_get_negotiated_group()`, so the group is read +through `getEphemeralKeyInfo()`, which cannot represent a hybrid KEM and returns +`{}` for one while naming any classical group. That is an inference from an +absence, so it is never trusted on faith: two loopback handshakes at startup +prove both halves of the mapping, the result is printed, and enforcement refuses +to engage if the proof fails. See `lib/pq.ts`. + ## Why Node and not Bun The rest of the Moshpit stack is Bun. This is not, and the reason is specific diff --git a/bin/moshpit-proxy.ts b/bin/moshpit-proxy.ts index 50fa669..661de16 100755 --- a/bin/moshpit-proxy.ts +++ b/bin/moshpit-proxy.ts @@ -5,10 +5,20 @@ import { createLocalCa } from "../lib/ca.ts"; import { createPinClient } from "../lib/pins.ts"; import { createProxy } from "../lib/proxy.ts"; import { loadConfig } from "../lib/config.ts"; +import { probeDetector, HYBRID_GROUP } from "../lib/pq.ts"; const config = loadConfig(); const log = config.logging ? (line: string) => console.log(`[proxy] ${line}`) : () => {}; +// Prove the post-quantum detector before anything depends on it. Two loopback +// handshakes, once, at startup — cheap enough to be unconditional, and the +// alternative is enforcing a policy on a signal nobody checked. +const probe = await probeDetector(); +const requirePq = config.requirePq && probe.usable; +if (config.requirePq && !probe.usable) { + console.warn(`[proxy] MOSHPIT_PROXY_REQUIRE_PQ ignored — ${probe.detail}`); +} + const ca = createLocalCa({ dir: `${config.dir}/ca`, tlds: config.tlds }); await ca.ensure(); @@ -27,6 +37,7 @@ const proxy = createProxy({ listenPort: config.listenPort, tlds: config.tlds, tofu: config.tofu, + requirePq, log, }); @@ -42,6 +53,11 @@ if (Object.keys(config.overrides).length) { if (config.tofu) { console.warn("[proxy] TOFU IS ON — the first key seen for a name is accepted unverified"); } +console.log( + `[proxy] post-qm ${probe.hybridAvailable ? `${HYBRID_GROUP} offered to every origin` : "UNAVAILABLE on this build"}` + + `${requirePq ? ", required" : ", observed only"}`, +); +console.log(`[proxy] ${probe.detail}`); console.log(`[proxy] root CA ${ca.rootCertPath()}`); console.log(`[proxy] ${await ca.fingerprint()}`); console.log("[proxy] trust it once: see README, 'Trusting the local root'"); @@ -53,6 +69,10 @@ for (const signal of ["SIGINT", "SIGTERM"] as const) { `\n[proxy] ${s.verified} verified, ${s.refusedNoPin} unpinned, ` + `${s.refusedBadPin} key mismatches, ${s.upstreamErrors} upstream errors`, ); + console.log( + `[proxy] ${s.pqSessions} post-quantum, ${s.classicalSessions} classical` + + `${s.refusedClassical ? `, ${s.refusedClassical} refused for it` : ""}`, + ); void proxy.close().then(() => process.exit(0)); }); } diff --git a/lib/config.ts b/lib/config.ts index 5c79369..4d00167 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -17,6 +17,7 @@ export type Config = { dir: string; tlds: string[]; tofu: boolean; + requirePq: boolean; overrides: Record; logging: boolean; }; @@ -39,6 +40,11 @@ export function loadConfig(env: Record = process.env // connection into an act of faith; it exists so a grid can come up before // the registry serves pins, not because it is good. tofu: truthy(env.MOSHPIT_PROXY_TOFU), + // Off by default because turning it on takes every origin without ML-KEM + // offline, and today that is most of them — anything on OpenSSL below 3.5, + // which includes what Ubuntu 22.04 and 24.04 ship. Run without it first and + // read the pq/classical counters at shutdown to find out where the grid is. + requirePq: truthy(env.MOSHPIT_PROXY_REQUIRE_PQ), overrides: loadOverrides(env.MOSHPIT_PROXY_PINS || join(dir, "pins.json")), logging: truthy(env.MOSHPIT_PROXY_LOG ?? "1"), }; diff --git a/lib/pq.ts b/lib/pq.ts new file mode 100644 index 0000000..6cc1c5c --- /dev/null +++ b/lib/pq.ts @@ -0,0 +1,253 @@ +// Whether the session that actually crosses the network is post-quantum. +// +// The proxy's whole claim is that the right-hand leg — proxy to origin, through +// the gateway's SNI passthrough — is the real one. That leg is TLS 1.3, and on +// Node 24 against OpenSSL 3.5 it already offers `X25519MLKEM768` first and +// sends a real ML-KEM key share in the first flight, with no configuration at +// all. Measured, not assumed: +// +// supported_groups: X25519MLKEM768, x25519, secp256r1, x448, ... +// key_share sent : X25519MLKEM768(1216B), x25519(32B) +// +// So the leg is usually already quantum-safe against harvest-now-decrypt-later. +// The problem is the word "usually": an origin on OpenSSL 3.0–3.4 — which is +// what Ubuntu 22.04 and 24.04 still ship — has no ML-KEM, so the handshake +// silently falls back to plain x25519 and succeeds. Nothing anywhere says so. +// A guarantee nobody can observe is not a guarantee, it is a hope. +// +// ## How the group is read, and why it looks backwards +// +// Node exposes no binding for `SSL_get_negotiated_group()`. What it has is +// `getEphemeralKeyInfo()`, which goes through `SSL_get_peer_tmp_key` — and that +// call cannot represent a hybrid KEM, so it fails and Node reports `{}`. For a +// classical group it succeeds and reports the name. That inverts into a usable +// signal, on a TLS 1.3 client socket: +// +// {} -> a PQ hybrid was negotiated +// { type, name: "X25519", size } -> a classical group was negotiated +// +// Inferring a positive from an absence is fragile on purpose-built code, and +// this is exactly that. If a future Node or OpenSSL teaches `SSL_get_peer_tmp_key` +// about ML-KEM, `{}` stops meaning "hybrid" and every session silently +// re-labels itself as classical — or worse, the reverse. So the inference is +// never trusted on faith: `probeDetector()` proves both halves of the mapping +// against real loopback handshakes at startup, and the caller refuses to +// enforce anything if the proof fails. +// +// TLS 1.3 has no static key exchange — every handshake is (EC)DHE or a PSK — so +// an empty result cannot mean "not ephemeral" here the way it could under 1.2. +// The proxy never passes a `session`, so there is no PSK resumption path to +// confuse it either. + +import { createServer, connect } from "node:tls"; +import type { TLSSocket } from "node:tls"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { promisify } from "node:util"; + +const run = promisify(execFile); + +/** The hybrid group OpenSSL 3.5 puts first, and the one worth asking for. */ +export const HYBRID_GROUP = "X25519MLKEM768"; + +/** A classical group that every build back to OpenSSL 1.1 has, for the control trial. */ +const CLASSICAL_GROUP = "x25519"; + +export type KeyExchange = { + /** True when the key exchange was a post-quantum hybrid. */ + postQuantum: boolean; + /** The named group, when Node can name it. Null for a hybrid, by construction. */ + group: string | null; + /** The negotiated protocol, carried through for logs. */ + protocol: string | null; +}; + +/** + * Classify the key exchange on a **client** socket. + * + * Returns `postQuantum: false` for a server socket, where + * `getEphemeralKeyInfo()` returns null and there is nothing to read — the + * caller should only ever ask about the upstream leg. + */ +export function describeKeyExchange(socket: TLSSocket): KeyExchange { + const protocol = socket.getProtocol(); + + // Below 1.3 there is no hybrid group to negotiate in the first place, so the + // question is settled before the key info is consulted. + if (protocol !== "TLSv1.3") return { postQuantum: false, group: null, protocol }; + + let info: ReturnType; + try { + info = socket.getEphemeralKeyInfo(); + } catch { + return { postQuantum: false, group: null, protocol }; + } + + // Server socket: nothing to say. + if (!info) return { postQuantum: false, group: null, protocol }; + + const name = (info as { name?: string }).name; + if (typeof name === "string" && name.length > 0) { + return { postQuantum: false, group: name, protocol }; + } + + // Empty object on a TLS 1.3 client socket — the hybrid case. See the header. + return { postQuantum: true, group: null, protocol }; +} + +export type DetectorProbe = { + /** Both halves of the mapping held. Enforcement is safe to switch on. */ + usable: boolean; + /** Whether this build can negotiate the hybrid group at all. */ + hybridAvailable: boolean; + /** Human-readable reason, always set — logged verbatim at startup. */ + detail: string; +}; + +/** + * Prove the detector against real handshakes before relying on it. + * + * Two loopback sessions against an ephemeral self-signed certificate: one + * forced to the hybrid group, one forced to a classical group. The mapping in + * the header has to hold for both. Anything else — a build without ML-KEM, a + * Node that learned to name hybrids, an openssl that will not mint a cert — + * comes back `usable: false` with the reason, and the caller degrades to + * observing instead of enforcing. + * + * Costs two handshakes and one keygen, once, at startup. + */ +export async function probeDetector(): Promise { + let dir: string | null = null; + try { + dir = await mkdtemp(join(tmpdir(), "moshpit-pq-probe-")); + const { cert, key } = await ephemeralCert(dir); + + const hybrid = await handshake(cert, key, HYBRID_GROUP); + if (!hybrid.ok) { + return { + usable: false, + hybridAvailable: false, + detail: + `this build cannot negotiate ${HYBRID_GROUP} (${hybrid.error}) — ` + + "needs Node 24+ against OpenSSL 3.5+", + }; + } + + const classical = await handshake(cert, key, CLASSICAL_GROUP); + if (!classical.ok) { + return { usable: false, hybridAvailable: true, detail: `control handshake failed: ${classical.error}` }; + } + + // The mapping, both directions. Either half being wrong makes the signal + // meaningless, and a meaningless signal must not gate traffic. + if (!hybrid.kx.postQuantum) { + return { + usable: false, + hybridAvailable: true, + detail: + `detector broken: a forced ${HYBRID_GROUP} session reported ` + + `${classicalLabel(hybrid.kx)} instead of a hybrid`, + }; + } + if (classical.kx.postQuantum) { + return { + usable: false, + hybridAvailable: true, + detail: `detector broken: a forced ${CLASSICAL_GROUP} session reported a hybrid`, + }; + } + + return { + usable: true, + hybridAvailable: true, + detail: `verified: ${HYBRID_GROUP} reads as hybrid, ${classicalLabel(classical.kx)} reads as classical`, + }; + } catch (error) { + return { usable: false, hybridAvailable: false, detail: `probe failed: ${(error as Error)?.message ?? error}` }; + } finally { + if (dir) await rm(dir, { recursive: true, force: true }).catch(() => {}); + } +} + +function classicalLabel(kx: KeyExchange): string { + return kx.group ?? "an unnamed group"; +} + +/** One loopback TLS 1.3 session with the client pinned to a single group. */ +async function handshake( + cert: string, + key: string, + group: string, +): Promise<{ ok: true; kx: KeyExchange } | { ok: false; error: string }> { + const server = createServer({ cert, key }); + try { + server.on("tlsClientError", () => {}); + const port = await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + resolve(typeof address === "object" && address ? address.port : 0); + }); + }); + + return await new Promise((resolve) => { + const socket = connect({ + host: "127.0.0.1", + port, + ecdhCurve: group, + // A throwaway certificate for a loopback probe. The probe is about the + // key exchange, not about identity, and there is no identity here. + rejectUnauthorized: false, + }); + const timer = setTimeout(() => { + socket.destroy(); + resolve({ ok: false, error: "timed out" }); + }, 5_000); + + socket.once("secureConnect", () => { + clearTimeout(timer); + const kx = describeKeyExchange(socket); + socket.destroy(); + resolve({ ok: true, kx }); + }); + socket.once("error", (error: Error) => { + clearTimeout(timer); + socket.destroy(); + resolve({ ok: false, error: error.message }); + }); + }); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +} + +/** + * A throwaway certificate for the probe, from openssl for the same reason + * `ca.ts` gives: there is no X.509 creation API in node:crypto, and this is not + * the place to hand-roll one. + */ +async function ephemeralCert(dir: string): Promise<{ cert: string; key: string }> { + const certPath = join(dir, "probe.crt"); + const keyPath = join(dir, "probe.key"); + const cnf = join(dir, "probe.cnf"); + + await writeFile(cnf, `[ req ] +distinguished_name = dn +prompt = no + +[ dn ] +CN = moshpit-pq-probe +`); + + await run("openssl", [ + "req", "-x509", "-new", "-nodes", + "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", + "-sha256", "-days", "1", + "-keyout", keyPath, "-out", certPath, + "-config", cnf, + ]); + + return { cert: await readFile(certPath, "utf8"), key: await readFile(keyPath, "utf8") }; +} diff --git a/lib/proxy.ts b/lib/proxy.ts index f367d95..559c18e 100644 --- a/lib/proxy.ts +++ b/lib/proxy.ts @@ -14,6 +14,14 @@ // end-to-end; the gateway learns which name was asked for and how many bytes // moved, which is what a router learns. // +// The right-hand session is also where post-quantum confidentiality either +// happens or quietly does not. Node 24 on OpenSSL 3.5 offers X25519MLKEM768 +// first with no configuration, so it usually happens; an origin on an older +// OpenSSL has no ML-KEM and the handshake falls back to x25519 without +// complaint. Every upstream leg is classified and counted, and `requirePq` +// turns the fallback into a refusal. See `pq.ts` for how the group is read and +// why the reading is proven at startup rather than trusted. +// // Known limitation, stated where someone will find it: ALPN is forced to // http/1.1 in both directions. Raw bytes are piped between two independent TLS // sessions, so the application protocol has to match on both, and the upstream @@ -27,6 +35,7 @@ import type { Server, TLSSocket } from "node:tls"; import type { LocalCa } from "./ca.ts"; import type { PinClient } from "./pins.ts"; import { pinFromPeer, pinMatches } from "./spki.ts"; +import { describeKeyExchange } from "./pq.ts"; export type ProxyStats = { accepted: number; @@ -34,6 +43,12 @@ export type ProxyStats = { refusedNoName: number; refusedNoPin: number; refusedBadPin: number; + /** Origins turned away for negotiating a classical group, under requirePq. */ + refusedClassical: number; + /** Upstream legs whose key exchange was a post-quantum hybrid. */ + pqSessions: number; + /** Upstream legs that fell back to a classical group. */ + classicalSessions: number; upstreamErrors: number; }; @@ -53,6 +68,17 @@ export function createProxy(options: { listenPort?: number; tlds: string[]; tofu?: boolean; + /** + * Refuse an origin whose key exchange was not post-quantum. + * + * Off by default, and it has to stay that way for now: an origin on + * OpenSSL < 3.5 has no ML-KEM and would go dark the moment this flipped. + * Turn it on once the grid is known to be on 3.5, using the counters below + * to find out. The caller must only pass true when `probeDetector()` came + * back usable — enforcing on a signal that has not been proven is worse + * than not enforcing at all. + */ + requirePq?: boolean; connectTimeoutMs?: number; idleTimeoutMs?: number; log?: (line: string) => void; @@ -63,12 +89,14 @@ export function createProxy(options: { const connectTimeoutMs = options.connectTimeoutMs ?? 10_000; const idleTimeoutMs = options.idleTimeoutMs ?? 120_000; const tofu = options.tofu ?? false; + const requirePq = options.requirePq ?? false; const log = options.log ?? (() => {}); const suffixes = options.tlds.map((t) => `.${t.replace(/^\.+/, "").toLowerCase()}`); const stats: ProxyStats = { accepted: 0, verified: 0, - refusedNoName: 0, refusedNoPin: 0, refusedBadPin: 0, upstreamErrors: 0, + refusedNoName: 0, refusedNoPin: 0, refusedBadPin: 0, refusedClassical: 0, + pqSessions: 0, classicalSessions: 0, upstreamErrors: 0, }; function inNamespace(name: string): boolean { @@ -182,8 +210,32 @@ export function createProxy(options: { return; } + // Identity is settled; now record what kind of key exchange carried it. + // This is checked after the pin on purpose — an origin that failed + // verification learns nothing about the policy it would have faced. + const kx = describeKeyExchange(upstream); + const kxLabel = kx.postQuantum ? "hybrid-pq" : `classical/${kx.group ?? "unknown"}`; + + if (kx.postQuantum) { + stats.pqSessions++; + } else { + stats.classicalSessions++; + if (requirePq) { + stats.refusedClassical++; + log(`refuse ${name}: key exchange was ${kxLabel}, post-quantum required`); + upstream.destroy(); + browser.destroy(); + return; + } + // Not an error — the session is still confidential against everything + // that exists today. It is recorded because a transcript captured now + // is decryptable by a quantum adversary later, and the operator cannot + // fix what nobody told them about. + log(`warn ${name}: origin has no ML-KEM, fell back to ${kxLabel}`); + } + stats.verified++; - log(`ok ${name} (${allowed?.source ?? "tofu"})`); + log(`ok ${name} (${allowed?.source ?? "tofu"}) ${kx.protocol ?? "?"} ${kxLabel}`); upstream.setTimeout(idleTimeoutMs, () => upstream.destroy()); browser.resume(); diff --git a/tests/pq.test.ts b/tests/pq.test.ts new file mode 100644 index 0000000..7770ba4 --- /dev/null +++ b/tests/pq.test.ts @@ -0,0 +1,221 @@ +// Is the leg that crosses the network actually post-quantum, and do we know? +// +// The detector in `pq.ts` infers "hybrid" from an *absence* — an empty +// `getEphemeralKeyInfo()` — so the tests that matter here are the ones that +// pin both halves of that mapping against real handshakes. If a future Node or +// OpenSSL changes what an empty result means, these fail loudly rather than +// letting the proxy relabel every session in silence. + +import { after, describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { createServer as createTlsServer, connect } from "node:tls"; +import type { Server, TLSSocket } from "node:tls"; +import { createLocalCa } from "../lib/ca.ts"; +import { createPinClient } from "../lib/pins.ts"; +import { createProxy } from "../lib/proxy.ts"; +import { pinFromCertData } from "../lib/spki.ts"; +import { describeKeyExchange, probeDetector, HYBRID_GROUP } from "../lib/pq.ts"; +import { selfSigned, tempDir } from "./helpers.ts"; + +const cleanup: Array<() => Promise | void> = []; +after(async () => { + for (const fn of cleanup.reverse()) await fn(); +}); + +/** An echo origin. `ecdhCurve` pins the groups it will accept, when given. */ +async function startOrigin(cert: string, key: string, ecdhCurve?: string) { + const server: Server = createTlsServer( + { cert, key, ALPNProtocols: ["http/1.1"], ...(ecdhCurve ? { ecdhCurve } : {}) }, + (socket: TLSSocket) => { + socket.on("data", (chunk) => socket.write(`echo:${chunk.toString()}`)); + }, + ); + server.on("tlsClientError", () => {}); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + cleanup.push(() => new Promise((r) => server.close(() => r()))); + const address = server.address(); + return { port: typeof address === "object" && address ? address.port : 0, server }; +} + +async function startProxy(opts: { + originPort: number; + pins: Record; + requirePq?: boolean; +}) { + const dir = await tempDir("moshpit-proxy-pq-"); + const ca = createLocalCa({ dir, tlds: ["moshpit"] }); + await ca.ensure(); + + const proxy = createProxy({ + pins: createPinClient({ + overrides: opts.pins, + fetchImpl: (async () => { + throw new Error("no registry"); + }) as unknown as typeof fetch, + }), + ca, + gatewayHost: "127.0.0.1", + gatewayPort: opts.originPort, + listenHost: "127.0.0.1", + listenPort: 0, + tlds: ["moshpit"], + requirePq: opts.requirePq, + connectTimeoutMs: 5_000, + }); + + const port = await proxy.listen(); + cleanup.push(() => proxy.close()); + return { proxy, port, rootPem: await ca.rootCertPem() }; +} + +function request(port: number, rootPem: string, servername: string, payload: string) { + return new Promise((resolve, reject) => { + const socket = connect({ + host: "127.0.0.1", port, servername, ca: rootPem, ALPNProtocols: ["http/1.1"], + }); + let settled = false; + const fail = (error: Error) => { + if (settled) return; + settled = true; + socket.destroy(); + reject(error); + }; + socket.setTimeout(8_000, () => fail(new Error("timeout"))); + socket.once("secureConnect", () => socket.write(payload)); + socket.on("data", (chunk) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(chunk.toString()); + }); + socket.once("error", fail); + socket.once("close", () => fail(new Error("closed with no data"))); + }); +} + +/** One client handshake against `port`, pinned to `group`, classified. */ +async function classify(port: number, group?: string) { + return new Promise>((resolve, reject) => { + const socket = connect({ + host: "127.0.0.1", port, rejectUnauthorized: false, + ...(group ? { ecdhCurve: group } : {}), + }); + socket.setTimeout(8_000, () => { socket.destroy(); reject(new Error("timeout")); }); + socket.once("secureConnect", () => { + const kx = describeKeyExchange(socket); + socket.destroy(); + resolve(kx); + }); + socket.once("error", reject); + }); +} + +describe("post-quantum detection", () => { + test("the detector proves both halves of its own mapping", async () => { + const probe = await probeDetector(); + // This build is Node 24 on OpenSSL 3.5, so the hybrid must be available. + // If this fails on some future toolchain, the message says which half broke. + assert.equal(probe.hybridAvailable, true, probe.detail); + assert.equal(probe.usable, true, probe.detail); + }); + + test(`a session forced to ${HYBRID_GROUP} reads as post-quantum`, async () => { + const dir = await tempDir(); + const origin = await selfSigned(dir, "hybrid.moshpit"); + const { port } = await startOrigin(origin.cert, origin.key); + + const kx = await classify(port, HYBRID_GROUP); + assert.equal(kx.protocol, "TLSv1.3"); + assert.equal(kx.postQuantum, true); + }); + + test("a session forced to a classical group reads as classical, and is named", async () => { + const dir = await tempDir(); + const origin = await selfSigned(dir, "classical.moshpit"); + const { port } = await startOrigin(origin.cert, origin.key); + + const kx = await classify(port, "x25519"); + assert.equal(kx.protocol, "TLSv1.3"); + assert.equal(kx.postQuantum, false); + // Named, so a log line can say what actually happened instead of "not PQ". + assert.equal(kx.group, "X25519"); + }); + + test("an unconfigured client gets the hybrid — the default is already safe", async () => { + const dir = await tempDir(); + const origin = await selfSigned(dir, "default.moshpit"); + const { port } = await startOrigin(origin.cert, origin.key); + + // No ecdhCurve anywhere: this is exactly how the proxy dials an origin. + const kx = await classify(port); + assert.equal(kx.postQuantum, true); + }); +}); + +describe("proxy post-quantum policy", () => { + test("counts a post-quantum upstream leg", async () => { + const dir = await tempDir(); + const origin = await selfSigned(dir, "pq.moshpit"); + const { port: originPort } = await startOrigin(origin.cert, origin.key); + + const { proxy, port, rootPem } = await startProxy({ + originPort, + pins: { "pq.moshpit": [pinFromCertData(origin.cert)] }, + }); + + assert.equal(await request(port, rootPem, "pq.moshpit", "hello"), "echo:hello"); + assert.equal(proxy.stats().pqSessions, 1); + assert.equal(proxy.stats().classicalSessions, 0); + }); + + test("an origin without ML-KEM still passes, and is counted as classical", async () => { + const dir = await tempDir(); + const origin = await selfSigned(dir, "old.moshpit"); + // Stands in for an origin on OpenSSL < 3.5: it will only do x25519. + const { port: originPort } = await startOrigin(origin.cert, origin.key, "x25519"); + + const { proxy, port, rootPem } = await startProxy({ + originPort, + pins: { "old.moshpit": [pinFromCertData(origin.cert)] }, + }); + + // Traffic is not broken by the fallback — that is the whole reason the + // policy is off by default. + assert.equal(await request(port, rootPem, "old.moshpit", "hello"), "echo:hello"); + assert.equal(proxy.stats().classicalSessions, 1); + assert.equal(proxy.stats().pqSessions, 0); + assert.equal(proxy.stats().refusedClassical, 0); + }); + + test("requirePq refuses an origin that fell back to a classical group", async () => { + const dir = await tempDir(); + const origin = await selfSigned(dir, "strict.moshpit"); + const { port: originPort } = await startOrigin(origin.cert, origin.key, "x25519"); + + const { proxy, port, rootPem } = await startProxy({ + originPort, + pins: { "strict.moshpit": [pinFromCertData(origin.cert)] }, + requirePq: true, + }); + + await assert.rejects(request(port, rootPem, "strict.moshpit", "hello")); + assert.equal(proxy.stats().refusedClassical, 1); + assert.equal(proxy.stats().verified, 0); + }); + + test("requirePq lets a post-quantum origin through untouched", async () => { + const dir = await tempDir(); + const origin = await selfSigned(dir, "strictok.moshpit"); + const { port: originPort } = await startOrigin(origin.cert, origin.key); + + const { proxy, port, rootPem } = await startProxy({ + originPort, + pins: { "strictok.moshpit": [pinFromCertData(origin.cert)] }, + requirePq: true, + }); + + assert.equal(await request(port, rootPem, "strictok.moshpit", "hello"), "echo:hello"); + assert.equal(proxy.stats().verified, 1); + assert.equal(proxy.stats().refusedClassical, 0); + }); +});