Skip to content

Add X25519MLKEM768 post-quantum hybrid key exchange - #195

Open
jbevemyr wants to merge 3 commits into
benoitc:mainfrom
jbevemyr:x25519mlkem768-hybrid-kex
Open

Add X25519MLKEM768 post-quantum hybrid key exchange#195
jbevemyr wants to merge 3 commits into
benoitc:mainfrom
jbevemyr:x25519mlkem768-hybrid-kex

Conversation

@jbevemyr

@jbevemyr jbevemyr commented Aug 9, 2026

Copy link
Copy Markdown

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 28 ssl).

Design

  • Uses the ML-KEM primitives OTP 28 exposes via crypto (generate_key(mlkem768, ...), encapsulate_key/2, decapsulate_key/3) — no new dependencies.
  • Wire format per the draft: client share = encapsulation key ‖ X25519 public (1216 B), server share = ciphertext ‖ X25519 public (1120 B), shared secret = ML-KEM secret ‖ X25519 secret (64 B). Verified against OTP ssl's implementation of the same group.
  • The server side of a KEM exchange is an encapsulation rather than keygen+ECDH, so quic_crypto gains server_key_exchange/2 covering both shapes; classical groups keep their exact previous behavior through it.
  • Everything else rides the existing multi-group machinery — supported_groups negotiation, HelloRetryRequest into the hybrid group, key_share plumbing — with just the new code point added.
  • Inert on crypto without ML-KEM (quic_crypto:group_supported/1); the new CT suite skips itself there.

Testing

  • New 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.
  • Full existing test suite passes: 2247 eunit tests, 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.

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.
@benoitc benoitc assigned benoitc and unassigned benoitc Aug 10, 2026
@benoitc

benoitc commented Aug 10, 2026

Copy link
Copy Markdown
Owner

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 server_key_exchange/2 split is the right call, an encapsulation is not a keygen + ECDH.

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: send_handshake_crypto/2 + chunk_crypto/3 + handshake_crypto_budget/1, added in #134 for exactly the same problem one level up ("a single oversized datagram is dropped by strict clients"). The Initial path never needed it until now. So the ClientHello, the HRR retry in handle_hello_retry_request/3 (same shape) and the ServerHello should all go through that chunking.

Attention: initial_crypto_frame keeps a single frame for the retransmit, it will have to keep every chunk.

Small things:

  • rebar3 fmt --check fails on src/quic_crypto.erl, the compute_shared_secret/3 head is too long.
  • no CHANGELOG entry.
  • group_supported/1 is exported but never called in src/. On OTP 27, someone who sets groups => [x25519mlkem768] gets a crash from crypto:generate_key instead of a clean error, better to check the option at connect/listen.
  • docs/CLIENT_GUIDE.md documents groups as well, not only features.md.

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.
@jbevemyr

Copy link
Copy Markdown
Author

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 handle_hello_retry_request/3 now all go through a send_initial_crypto/3 that chunks the flight the same way send_handshake_crypto/2 does one level up (#134), with a budget that accounts for the Initial token field. Each Initial packet is separately padded to 1200, so a hybrid ClientHello leaves as two 1200-byte datagrams.

Good catch on the retransmit — initial_crypto_frame became initial_crypto_frames, the whole flight is stored and replayed. After an HRR the buffer is replaced with the CH2 chunks (the outstanding flight), which also fixes a latent staleness where a retransmit would have resent CH1.

New CT suite quic_pqc_mtu_SUITE measures it the way you did — drives a full hybrid handshake through a socket adapter and asserts every Initial datagram on the wire is ≤ 1200 and that the ClientHello spans more than one Initial. Captured sizes: [1200,1200,1200,1200,1200].

The small things:

  • group_supported/1 is now wired in: validate_groups/1 rejects an unsupported groups entry from both connect/4 and start_server/3 as {error, {unsupported_group, _}} — so an OTP 27 node setting x25519mlkem768 gets a clean error, not a crypto:generate_key crash. Test added.
  • rebar3 fmt --check is clean now (reflowed the compute_shared_secret/3 head).
  • CHANGELOG entry under [Unreleased].
  • docs/CLIENT_GUIDE.md documents the hybrid group, the auto-chunking, and the unsupported-group error.

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.
@jbevemyr

Copy link
Copy Markdown
Author

The Elvis job on the last run was my fault: wiring in the groups validation nested a second case in connect/4 and pushed it to 33 lines, over the max_function_length limit of 30. Composed the two client-side checks into validate_client_opts/2 instead, so connect/4 is back to its original shape. Pushed as 81bdfe7.

Verified locally before pushing this time: rebar3 lint, fmt --check, dialyzer, eunit (2247), and the PQC/MTU/HRR CT suites all exit 0. Everything else in that run was already green, including all four OTP versions.

The new run needs your approval to start (fork PR gate).

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.

2 participants