Articles / Cryptography

  • tls
  • post-quantum-cryptography
  • ml-kem
  • ecdhe
  • key-exchange
  • go

Why Post-Quantum TLS Still Keeps ECDHE Around

Somewhere, probably, a state-level adversary is recording your TLS traffic and doing nothing with it. Not because they can't, but because they don't need to yet. The plan is to decrypt it later, once a quantum computer exists that can break the ECDHE key exchange that protected it.

This is the "harvest now, decrypt later" threat model. It's the entire reason anyone bothers with post-quantum key exchange in TLS today, years before a machine capable of running Shor's algorithm against a 256-bit elliptic curve is known to exist. The recording is happening now. The decryption is a future problem, but the traffic being decrypted is today's.

NIST finalised ML-KEM in August 2024 as FIPS 203, a lattice-based key encapsulation mechanism derived from Kyber. The obvious move is to rip ECDHE out of the TLS 1.3 key_share extension and put ML-KEM in its place. That is not what happened, and the reason why is more interesting than "caution".

What shipped is a hybrid: both key exchanges in one group

Instead of a straight swap, the IETF TLS working group defined a hybrid named group, X25519MLKEM768, registered as codepoint 4588 (0x11EC) in the TLS Supported Groups registry. The handshake goes like this:

  1. A client offering this group sends both an X25519 key share and an ML-KEM-768 encapsulation key in the same key_share extension entry.
  2. The server responds with its own X25519 share and the ML-KEM ciphertext.
  3. Both sides now have two shared secrets, one from classical elliptic-curve Diffie-Hellman, one from a lattice-based KEM.
  4. These get combined into a single value that feeds into the same HKDF-based key schedule TLS 1.3 already uses.

There are no changes to the record layer, the handshake state machine, or the key schedule itself. From the protocol's point of view it's just a named group that happens to produce a bigger key share and a longer "shared secret".

Quick detour: the name lies about the byte order

The combination itself is deliberately boring: concatenate the two secrets and treat the result as the input keying material. The subtlety is in the order. You'd expect X25519MLKEM768 to concatenate X25519 first, then ML-KEM, matching the name.

It's actually the other way round: ML-KEM's 32-byte secret first, X25519's second, for a 64-byte total. The reason is FIPS compliance. FIPS-approved cryptographic modules require the FIPS-approved algorithm's contribution to lead the concatenation, and ML-KEM has FIPS 203 behind it while raw X25519 ECDH doesn't carry the same standing in a module boundary.

So the wire name and the byte order disagree, for compliance reasons that have nothing to do with security. Delightfully daft.

Why nobody trusts the new maths on its own yet

The hybrid design is a hedge, and it's worth taking seriously why the hedge exists rather than treating it as boilerplate caution. It comes down to a gap in scrutiny:

  • Elliptic curve cryptography has had roughly four decades of sustained cryptanalytic attention.
  • ML-KEM, as Kyber, has had a handful of years as a NIST competition finalist and a couple more since standardisation.

That gap is not hypothetical. SIKE, another NIST PQC round finalist built on supersingular isogenies, looked solid right up until 2022, when the Castryck-Decru attack recovered private keys in about an hour on an ordinary laptop. It had survived years of the same competition process ML-KEM went through. Nobody wants to be the protocol that bet everything on the next SIKE.

Hybrid key exchange means an attacker has to break both the classical and the post-quantum component to recover the shared secret, not just one:

  • If ML-KEM turns out to have a flaw nobody's found yet, ECDHE still holds against today's classical adversary (though not, eventually, against a quantum one).
  • If a large quantum computer eventually arrives, ML-KEM still holds.

It's belt and braces in the literal sense, and it's also policy. The NSA's CNSA 2.0 guidance for US national security systems explicitly recommends hybrid or dual-algorithm deployments through the transition period, rather than jumping straight to PQ-only.

The extra kilobyte that makes handshakes time out

The key shares are much bigger, and that's the bit nobody mentions until their handshake times out. The numbers:

  • ECDHE with X25519: 32 bytes for a public key.
  • ML-KEM-768: 1184 bytes for its encapsulation key.
  • ML-KEM-768: 1088 bytes for the ciphertext.

A hybrid ClientHello is well over a kilobyte bigger than a classical one purely from the key share, and that's before you account for the server's response. For TCP this mostly doesn't matter.

For QUIC it matters quite a lot. QUIC runs over UDP and has to worry about fragmentation, amplification limits, and middleboxes that assume a handshake fits in one packet.

Go's own release notes point at tldr.fail, a site cataloguing TLS servers and middleboxes that silently break or time out once the handshake grows past what they expected. Debugging a mysterious handshake timeout against a specific server after enabling post-quantum key exchange? This is usually where the fault lies, not in your code.

Go already does this by default

Go has had hybrid key exchange on by default since Go 1.23:

  • Go 1.23 shipped an experimental hybrid group, X25519Kyber768Draft00, enabled by default ahead of the final standard.
  • Go 1.24 removed it entirely and replaced it with the standardised X25519MLKEM768, also enabled by default.
  • Go 1.24 also added a new crypto/mlkem package implementing the primitive on its own.

As of Go 1.24, Config.CurvePreferences no longer controls ordering; the runtime decides that. It only filters which groups are eligible:

package main

import (
	"crypto/tls"
	"fmt"
	"log"
)

func main() {
	cfg := &tls.Config{
		ServerName: "example.com",
		// Only used to select which groups are eligible; Go 1.24+
		// decides the offer order itself.
		CurvePreferences: []tls.CurveID{tls.X25519MLKEM768, tls.X25519},
	}

	conn, err := tls.Dial("tcp", "example.com:443", cfg)
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()

	state := conn.ConnectionState()
	fmt.Printf("negotiated group: %v\n", state.CurveID)
}

Actually, this bit is amusing. ConnectionState.CurveID is a slightly odd name for a field that might report a lattice-based KEM rather than anything involving a curve, and the documentation says as much: it's called CurveID "for legacy reasons".

If you need to disable ML-KEM against a server that mishandles the larger handshake, GODEBUG=tlsmlkem=0 reverts to classical-only groups without touching your code.

Your certificate is still quantum-breakable, and that's fine for now

All of this only touches key exchange, the ephemeral secret that protects the confidentiality of a connection. The certificate your server presents is still signed with ECDSA or RSA, both of which a sufficiently large quantum computer would break just as thoroughly as ECDHE.

Nobody's in a hurry to hybridise certificate signatures the way key exchange has been hybridised, because the threat model doesn't match. Harvest-now-decrypt-later works against recorded ciphertext. Forging a signature only helps an attacker at the moment they use it, against a quantum computer that would need to exist at attack time, not decades later.

Post-quantum signatures like ML-DSA (FIPS 204) are coming, but the pressure behind them is different. The PKI ecosystem makes it a slower migration than swapping a key exchange group ever was, thanks to:

  • certificate sizes
  • CA compatibility
  • CT log entries