Skip to content

Pure TLS: offer X25519MLKEM768 by default, accept it on the server (#2483) - #3137

Merged
paul-hammant merged 2 commits into
mainfrom
feat/2483-pure-tls-pq-default
Oct 24, 2026
Merged

paul-hammant merged 2 commits into
mainfrom
feat/2483-pure-tls-pq-default

Conversation

@paul-hammant

@paul-hammant paul-hammant commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #2483.

The pure-Aether TLS client used X25519MLKEM768 only through the opt-in connect_pq, and the pure server didn't accept it at all. Now:

  • Client. Every ClientHello carries three key shares: X25519MLKEM768 (1216 B), x25519 and secp256r1. connect(), connect_mtls() and the std.http.client fallback in builds without OpenSSL all get PQ from any server that supports it. connect_pq() is now a thin wrapper. The new conn_group(conn) returns the group the server chose.
  • Server. It prefers the hybrid when a client offers one. A share that fails the FIPS 203 §7.2 check (via mlkem768_encaps, from ML-KEM: encaps and decaps skip the FIPS 203 input checks, and the Wycheproof invalid branch cannot fail #2482), or has the wrong length, gets a fatal illegal_parameter alert (RFC 10024 §4.2). The reply is the 1120-byte share (ct ‖ x25519).

Two commits:

  1. Jules's implementation.
  2. Review fixes:
    • The HelloRetryRequest path was broken. After a retry it freed the caller's x25519 scalar, which is a double free in the caller. It also derived the P-256 secret from the original key instead of the retry key. Since we now send a share for every supported group, any retry the server asks for is one RFC 8446 §4.1.4 says to abort on. So the client aborts, and the retry code is gone.
    • Adopted sockets leaked. The commit stopped connect_full closing an adopted socket on failure. std.http hands the descriptor over and relies on the pure client to close it (aether_http.c:2115), so every failed fallback handshake leaked a descriptor. The close is restored.
    • Server. A refused ClientHello now frees what the parser allocated. It's answered with a fitting alert (decode_error, handshake_failure, protocol_version or illegal_parameter) instead of illegal_parameter for everything. An all-zero x25519 result gets illegal_parameter (RFC 8446 §7.4.2).

Tests

https_pure_tls_vertical gains:

  • Group check. The pure client and pure server must negotiate group 4588 (X25519MLKEM768).
  • bad_share.ae probe. One share fails the §7.2 modulus check (every coefficient decodes to 4095 ≥ q). Another is 500 bytes. Each must get alert 2:47, and the server keeps serving.

Negative controls (backup copies):

  • With main's server the group check fails: both ends settle on x25519 (29).
  • With ML-KEM's modulus check disabled, the probe gets a ServerHello (record type 22) instead of the alert.

Interop with Go 1.24 (local; not in CI):

Aether client → Go server offering negotiated
X25519MLKEM768 only 4588
x25519 only 29
P-256 only 23
Go default 4588
Go client offering → Aether pure server result
X25519MLKEM768 only ok
x25519 only ok
Go default ok
P-256 only handshake_failure alert (unchanged: the pure server has no P-256 or HRR)

Against OpenSSL 3.0 (this box), the larger ClientHello falls back to x25519, as permutation 1 shows.

Valgrind:

  • The client's PQ path against Go is clean.
  • The server through a PQ handshake and both bad shares is clean, apart from 8 bytes per failed handshake, which is pre-existing (below).

Gates: fmt, check-docs, check-changelog and check-tests pass. crypto_tls13_{client,hs,server_hs}, https_client_pure_tls, https_windows_trust, http_ipv6 and c_callback_multi_tu pass. https_pure_tls_vertical passes 4/4 run in parallel.

Not in this PR: pure server socket ownership

pure_tls_server_accept wraps the connection's fd, and on teardown close_conn closes it. Then conn_close in aether_http_server.c closes the same number again, on success and on the cert or key load failure paths. That's the double close aether_http.c already guards against on the client side. On a failed handshake the 8-byte wrapper also leaks. It's pre-existing and lives in std/net C: #3138.

🤖 Generated with Claude Code

google-labs-jules Bot and others added 2 commits October 24, 2026 21:47
…server

Update the pure-Aether TLS 1.3 client to offer X25519MLKEM768 by default alongside classical key shares (X25519 and SECP256R1), and update the pure TLS server to accept X25519MLKEM768 key shares with FIPS 203 §7.2 encapsulation key checks. Convert connect_pq into a thin wrapper.

Co-authored-by: paul-hammant <82182+paul-hammant@users.noreply.github.com>
…to end

Review fixes on Jules's commit for #2483.

Client (tls13_client.connect_full):
- A HelloRetryRequest now ends the handshake. Every group we support
  already has a key share in the ClientHello, so the server can only name
  one of them, which RFC 8446 §4.1.4 says to abort on. The retry path
  this replaces freed the caller's x25519 scalar, then derived the P-256
  secret from the wrong key.
- Failures close the adopted socket again. std.http hands its descriptor
  over and relies on the pure client to close it on every failure
  (aether_http.c); skipping the close leaked the descriptor.
- The hybrid key exchange moves into hybrid_shared(). The new
  conn_group(conn) reports the negotiated group.

Server (tls13_server.accept):
- An all-zero x25519 result gets illegal_parameter (RFC 8446 §7.4.2),
  not handshake_failure.
- A refused ClientHello frees what parse_client_hello allocated and is
  answered with an alert that fits: illegal_parameter for a bad hybrid
  share, handshake_failure when no group or suite is shared,
  protocol_version, or decode_error.

Tests (https_pure_tls_vertical):
- The pure client and pure server must negotiate group 4588.
- A new bad_share probe sends a share that fails the FIPS 203 §7.2
  modulus check, and one of the wrong length. Each must get a fatal
  illegal_parameter alert.
- Negative controls: with main's server the group check fails (29).
  Without the §7.2 check, the probe gets a ServerHello.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@paul-hammant
paul-hammant merged commit c0c3b8a into main Oct 24, 2026
43 checks passed
@paul-hammant
paul-hammant deleted the feat/2483-pure-tls-pq-default branch October 24, 2026 22:35
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.

Pure TLS: offer X25519MLKEM768 by default on the client, and accept it on the server

1 participant