Skip to content

Observe and optionally enforce post-quantum key exchange on the origin leg - #4

Merged
ralyodio merged 1 commit into
mainfrom
worktree-pq-origin-leg
Aug 1, 2026
Merged

Observe and optionally enforce post-quantum key exchange on the origin leg#4
ralyodio merged 1 commit into
mainfrom
worktree-pq-origin-leg

Conversation

@ralyodio

@ralyodio ralyodio commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

What prompted this

A question about whether the Moshpit stack could do quantum-safe E2E "instead of SSL". Checking the code turned up something better than a rewrite: on the leg that matters, it already is — and there was no way to tell when it wasn't.

The measurement

moshpit-proxy configures no TLS groups anywhere; the origin leg uses Node defaults. On Node 24 / OpenSSL 3.5 those defaults already offer the hybrid first. 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)

So harvest-now-decrypt-later is already defeated on the browser path — provided the origin is also on 3.5+.

The actual gap

An origin on OpenSSL 3.0–3.4 (what Ubuntu 22.04 and 24.04 ship) has no ML-KEM. The handshake silently falls back to x25519 and succeeds. Nothing logged it, nothing counted it, nothing could enforce against it. A guarantee nobody can observe isn't one.

What this adds

  • lib/pq.ts — classifies the key exchange on the upstream leg.
  • Every upstream leg counted: pqSessions / classicalSessions / refusedClassical, printed at shutdown, with a warn line per fallback.
  • MOSHPIT_PROXY_REQUIRE_PQ=1 turns fallback into a refusal. Off by default — switching it on today takes every pre-3.5 origin offline. The counters are how you find out when it's safe to flip.

The one subtle part

Node exposes no binding for SSL_get_negotiated_group(). What it has is getEphemeralKeyInfo(), which goes through SSL_get_peer_tmp_key — that call can't represent a hybrid KEM, so it returns {}, while any classical group comes back named. That inverts into a usable signal:

getEphemeralKeyInfo() means
{} PQ hybrid
{ type, name: "X25519", size } classical

Inferring a positive from an absence is fragile — a future Node/OpenSSL that teaches SSL_get_peer_tmp_key about ML-KEM would silently invert the meaning. So it's proven, not trusted: probeDetector() runs two loopback handshakes at startup, asserts both halves of the mapping, prints the result, and enforcement refuses to engage if the proof fails.

[proxy] post-qm  X25519MLKEM768 offered to every origin, observed only
[proxy]           verified: X25519MLKEM768 reads as hybrid, X25519 reads as classical

Testing

35 pass (27 existing + 8 new), tsc --noEmit clean under strict. New tests cover both halves of the detector mapping, the unconfigured-default case, and end-to-end proxy behaviour against an origin pinned to a classical group — passing when requirePq is off, refused when on.

Not addressed here

Swapping the browser path to MTP/1 outright. The gateway routes on $ssl_preread_server_name and MTP/1's buildClientHello() takes no arguments — it carries no name at all. That's a real design fork, noted in the conversation, not something to smuggle into this PR.

🤖 Generated with Claude Code

…n leg

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) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@ralyodio
ralyodio marked this pull request as ready for review August 1, 2026 17:00
@ralyodio
ralyodio merged commit b40b05f into main Aug 1, 2026
3 checks passed
@ralyodio
ralyodio deleted the worktree-pq-origin-leg branch August 1, 2026 17:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant