A hybrid TLS offer can still negotiate X25519

Eight controlled rustls handshakes show why a hybrid offer alone does not predict failure. The mixed ClientHello carries a separate classic key share.

5 min read
RSS

An HTTP 525 on Heretic's health-check path prompted a change from rustls's ring provider to AWS-LC. The service recovered. That observation justified investigating TLS compatibility, but it did not tell us which groups the failed client had offered.

We tested that distinction with rustls 0.23.43. A client restricted to X25519MLKEM768 failed against the ring server. Adding X25519 to the client's supported groups let the same server complete the handshake. The presence of a hybrid group in a client's configuration was insufficient to predict failure.

Eight handshakes with explicit providers

The fixture uses two server configurations and four client configurations. Every client uses AWS-LC; its key-exchange group list changes between cases. The servers use either ring or AWS-LC with their default group lists. Both endpoints enable only TLS 1.3.

Each case generates a localhost certificate and adds it to that client's trust store. Client and server exchange TLS records through memory buffers. The program opens no sockets, contacts no production service and supplies no application request. This isolates the handshake from DNS, HTTP routing and network reachability.

These are the recorded results from 7 October 2026 on Darwin arm64, using rustc 1.97.1. Each successful cell names the negotiated group.

Client group configuration ring server AWS-LC server
X25519MLKEM768 only NoKxGroupsInCommon X25519MLKEM768
X25519 only X25519 X25519
P-256 only secp256r1 secp256r1
X25519MLKEM768, then X25519 X25519 X25519MLKEM768

The classic-only cases matter. They demonstrate successful handshakes with the same certificate procedure and both providers. The mixed case then changes the failed ring test by admitting one group that ring supports.

For the client, the relevant configuration is small:

rust
let mut provider = aws_lc_rs::default_provider();
provider.kx_groups = vec![X25519MLKEM768, X25519];
let client = rustls::ClientConfig::builder_with_provider(Arc::new(provider))
    .with_protocol_versions(&[&rustls::version::TLS13])?
    .with_root_certificates(roots)
    .with_no_client_auth();

The server receives its own provider explicitly. We avoid a process-wide default because a test that accidentally reused the same provider for both servers would erase the comparison.

The record loop transfers client output into the server, processes it, then transfers server output back into the client. A case succeeds only when both report that handshaking has finished. The fixture also reads the negotiated group from the client. The failed case returns the server error PeerIncompatible(NoKxGroupsInCommon).

Overlap explains this result, not every TLS failure

For this fixture, let C be the client's configured group set and S the server's supported group set. The ring provider in rustls 0.23.43 declares X25519, P-256 and P-384. Its intersection with the hybrid-only client's set is empty. With X25519 added to C, the intersection contains X25519, and the recorded handshake succeeds.

This set calculation explains the controlled comparison. A nonempty intersection alone is not a general proof that TLS will succeed. Certificate validation, signatures and other negotiated parameters still have to work. The fixture keeps those conditions compatible and checks completion rather than inferring success from the group lists.

TLS distinguishes supported groups from the actual key shares sent in a particular ClientHello. A client need not send a share for every supported group. The protocol provides a HelloRetryRequest mechanism when the server selects an acceptable group for which it needs a share. See RFC 8446, sections 4.2.7 and 4.2.8.

We extended the fixture on 9 October to decode the initial plaintext ClientHello before passing it to the server. The mixed configuration actually sent both shares:

Initial ClientHello field Recorded value
supported_groups 0x11ec, 0x001d
key_share entry 0x11ec, X25519MLKEM768 1,216 bytes
key_share entry 0x001d, X25519 32 bytes

The hybrid-only case listed only 0x11ec and sent only its 1,216-byte share. The group identifiers match the IANA TLS registry. These observations separate configured support from transmitted data. In the mixed case, the ring server had a supported classic share available in the first ClientHello. The extension repeated all eight outcomes from the original matrix. It records group identifiers and lengths, without retaining ephemeral key material. We did not measure handshake latency or count retries.

AWS-LC's default list in the pinned release includes X25519MLKEM768 alongside classic groups. The feature controlling post-quantum preference changes ordering. Supporting a group and preferring it are separate configuration facts. Our table reports what these exact configurations negotiated.

What this tells us about the health check

Heretic's status checks had one shared sensor response feeding two component labels. When that request failed, both labels became unavailable. A separate local experiment injected healthy, measurement-not-ready, recording-not-ready and HTTP 525 responses into the status implementation. Explicit readiness fields separated the component failures; the failed request left both unknown.

That dependency explains why two unavailable labels could share one transport problem. It does not turn an unavailable observation into a measurement of two failed services.

The provider replacement restored the actual production health-check path, as recorded during the repair. We did not retain the failed client's ClientHello for this investigation. The hybrid-only case therefore demonstrates a compatibility boundary rather than replaying the incident. The mixed-offer result is the reason to preserve that distinction: the ring server completed a handshake with a client configured to offer hybrid when X25519 remained available.

To reproduce the controlled matrix, download the Cargo project and recorded results, extract it, then run:

sh
cargo run --locked --manifest-path tls-provider-matrix/Cargo.toml

The executable asserts all eight expected outcomes and prints the negotiated groups or error. Dependencies must be available locally or downloaded by Cargo; the handshake executable itself uses memory buffers throughout.

Next article A CPU quota does not change Chromium 153's processor count A two-CPU quota left Chromium reporting fourteen processors. Restricting affinity changed the count to two. Nine sessions show why throughput needs its own measurement.

The probe runs on your own browser.

The public demo returns the verdict and its named findings.