Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions bin/moshpit-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -27,6 +37,7 @@ const proxy = createProxy({
listenPort: config.listenPort,
tlds: config.tlds,
tofu: config.tofu,
requirePq,
log,
});

Expand All @@ -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'");
Expand All @@ -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));
});
}
6 changes: 6 additions & 0 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type Config = {
dir: string;
tlds: string[];
tofu: boolean;
requirePq: boolean;
overrides: Record<string, string[]>;
logging: boolean;
};
Expand All @@ -39,6 +40,11 @@ export function loadConfig(env: Record<string, string | undefined> = 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"),
};
Expand Down
253 changes: 253 additions & 0 deletions lib/pq.ts
Original file line number Diff line number Diff line change
@@ -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<TLSSocket["getEphemeralKeyInfo"]>;
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<DetectorProbe> {
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<number>((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<void>((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") };
}
Loading
Loading