Observe and optionally enforce post-quantum key exchange on the origin leg - #4
Merged
Conversation
…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>
vu1nz Security Review0 finding(s) in PR #? No security issues found. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-proxyconfigures 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: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
x25519and 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.pqSessions/classicalSessions/refusedClassical, printed at shutdown, with awarnline per fallback.MOSHPIT_PROXY_REQUIRE_PQ=1turns 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 isgetEphemeralKeyInfo(), which goes throughSSL_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(){}{ type, name: "X25519", size }Inferring a positive from an absence is fragile — a future Node/OpenSSL that teaches
SSL_get_peer_tmp_keyabout 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.Testing
35 pass (27 existing + 8 new),
tsc --noEmitclean understrict. 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 whenrequirePqis off, refused when on.Not addressed here
Swapping the browser path to MTP/1 outright. The gateway routes on
$ssl_preread_server_nameand MTP/1'sbuildClientHello()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