Add X25519MLKEM768 post-quantum hybrid key exchange - #195
Conversation
Implements the hybrid ML-KEM-768 + X25519 named group (draft-ietf-tls-ecdhe-mlkem, code point 0x11EC) as an opt-in key-exchange group, using the ML-KEM primitives that OTP 28 exposes through the crypto module: - client share: ML-KEM encapsulation key || X25519 public (1216 bytes) - server share: ML-KEM ciphertext || X25519 public (1120 bytes) - shared secret: ML-KEM secret || X25519 secret (64 bytes) The server side of the exchange is an encapsulation rather than a keygen + ECDH, so quic_crypto gains server_key_exchange/2 covering both shapes; the classical groups keep their exact previous behaviour through it. Everything else rides the existing multi-group machinery: supported_groups negotiation, HelloRetryRequest to the hybrid group, and the key_share plumbing are untouched apart from the new code point. The group is negotiable only when the crypto library reports ML-KEM-768 support (quic_crypto:group_supported/1); on older crypto the new code is inert and the e2e suite skips itself. Motivation: harvest-now-decrypt-later makes quantum-safe key exchange the part of PQC with a deadline, and QUIC stacks built on OpenSSL only gained hybrid support with OpenSSL 3.5. With this, a pure-Erlang QUIC endpoint negotiates quantum-safe key exchange with any modern peer (OpenSSL 3.5+, browsers, OTP 28 ssl) at the cost of ~1.2 kB extra in the ClientHello.
|
Thanks, this is nice work. Suite passes on OTP 29, and I checked the wire format on my side too: 1216 / 1120 / 64 bytes, ML-KEM secret first. The Before merging, please fix the size on the wire: With the hybrid group the ClientHello goes from 173 to 1359 bytes, and it leaves as a single datagram of 1445 bytes. I measured it with a plain UDP socket in place of the server. On the server side the ServerHello Initial is 1225 bytes (from its qlog). The Handshake flight is fine, it is already chunked. The tests do not see it because loopback has a 16k MTU. On a real path 1445 + headers = 1473, so it passes on clean ethernet and not below: IPv6 over PPPoE is 1492 and already fails, WireGuard ~1420, mobile ~1400. And before the handshake there is no PMTU, so the safe size is 1200. We already have what is needed here:
Small things:
I approved the CI run so you get the checks. |
Addresses review on benoitc#195. The hybrid X25519MLKEM768 ClientHello (~1360 bytes) and ServerHello Initial (~1225 bytes) exceed the 1200-byte size that is safe before PMTU validation, so a single Initial datagram is dropped on paths with an MTU below ~1470 (IPv6-over-PPPoE, WireGuard, mobile). Loopback's 16k MTU hid this in the tests. Initial-level CRYPTO now goes through the same chunking the Handshake level already used (benoitc#134): send_initial_crypto/3 splits the payload into per-packet pieces via a budget that accounts for the Initial token field, and the ClientHello, the ServerHello, and the CH2 built in response to a HelloRetryRequest all route through it. The retransmit buffer keeps every chunk of the flight (initial_crypto_frames) rather than a single frame, and replays them all; after an HRR the buffer is replaced with the CH2 chunks (the outstanding flight), which also corrects a latent staleness where a retransmit resent CH1. A new CT suite (quic_pqc_mtu_SUITE) drives a full hybrid handshake through a socket adapter and asserts every Initial datagram on the wire stays within 1200 bytes and that the ClientHello spans more than one Initial. Also from review: - validate_groups/1 rejects a `groups' option naming an unsupported key-exchange group up front from connect/4 and start_server/3 as {error, {unsupported_group, _}}, instead of crashing in crypto during the handshake. On OTP 27 (no ML-KEM) x25519mlkem768 hits this path. - rebar3 fmt: reflow the compute_shared_secret/3 head. - CHANGELOG entry under [Unreleased]. - Document the hybrid group and the unsupported-group error in docs/CLIENT_GUIDE.md, not only features.md.
|
Thanks for the careful review — all addressed, pushed. The wire size (the important one). You're right, and it was the Initial path specifically. The ClientHello, the ServerHello, and the CH2 built in Good catch on the retransmit — New CT suite The small things:
Full suite green here (2247 eunit, HRR + PQC CT, fmt --check). Ping me if anything else. |
Adding the groups validation nested a second case in connect/4 and pushed it to 33 lines, over the elvis max_function_length limit of 30. Compose the two client-side checks in validate_client_opts/2 instead, which restores connect/4 to its original shape.
|
The Elvis job on the last run was my fault: wiring in the groups validation nested a second Verified locally before pushing this time: The new run needs your approval to start (fork PR gate). |
This adds the hybrid ML-KEM-768 + X25519 named group (draft-ietf-tls-ecdhe-mlkem, code point
0x11EC) as an opt-in key-exchange group, making erlang_quic able to negotiate quantum-safe key exchange with any modern peer (OpenSSL 3.5+, current browsers, OTP 28ssl).Design
crypto(generate_key(mlkem768, ...),encapsulate_key/2,decapsulate_key/3) — no new dependencies.quic_cryptogainsserver_key_exchange/2covering both shapes; classical groups keep their exact previous behavior through it.supported_groupsnegotiation, HelloRetryRequest into the hybrid group, key_share plumbing — with just the new code point added.quic_crypto:group_supported/1); the new CT suite skips itself there.Testing
quic_pqc_e2e_SUITE: direct hybrid negotiation, classical-client interop against a hybrid-preferring server, hybrid negotiated via HRR (exercises hybrid keygen on the CH2 retry), and wire-format/shared-secret invariants.quic_hrr_e2e_SUITE, TLS negotiation/compliance/server eunit modules — 0 failures.Why: harvest-now-decrypt-later makes quantum-safe key exchange the PQC piece with a deadline; C-based QUIC stacks only get it via OpenSSL 3.5+. With this, a pure-Erlang QUIC endpoint offers it from any OTP 28 install, at ~1.2 kB extra ClientHello when the group is offered. Defaults are unchanged (
default_groups()is still[x25519]) — flipping the default hybrid-first could be a follow-up decision.Related: on the OTP side I have a PR open exposing the negotiated group via
ssl:connection_information(erlang/otp#11440) for the same PQC-observability motivation.