X25519MLKEM768, byte by byte: post-quantum key exchange on the wire

Post-quantum migration usually gets discussed in slideware — mandates, deadlines, "crypto agility." But the actual change on a TLS 1.3 connection is concrete and small enough to read by hand: one codepoint and about 1.2 KB of key material. Here is exactly what it looks like.

The named group

A TLS 1.3 client advertises the key-exchange groups it supports in the supported_groups extension, and puts its actual public keys in key_share. The hybrid post-quantum group is X25519MLKEM768, IANA codepoint 0x11EC. "Hybrid" means it carries two key exchanges at once: classical X25519 and post-quantum ML-KEM-768 (the key-encapsulation mechanism standardized as FIPS 203). The session secret is derived from both, so the connection stays secure as long as either primitive holds — you get post-quantum protection without betting everything on a young algorithm. The group is defined in the IETF draft draft-ietf-tls-ecdhe-mlkem.

The codepoint trap

0x11EC is 4588 in decimal. That hex/decimal slip bites people constantly: someone reads "4588," writes 0x4588 in a config or a C literal, and now they are pointing at a completely different, unassigned group — then wondering why the handshake quietly falls back to classical. 0x4588 is 17800 in decimal; it means nothing. The lesson that recurs every time you touch this: don't trust the label, read the bytes.

What's in the key_share

Classically, an X25519 key_share entry is 32 bytes. With X25519MLKEM768 the client's entry jumps to 1216 bytes: an ML-KEM-768 encapsulation key concatenated with the X25519 public key — ML-KEM first.

client key_share (X25519MLKEM768):
  [ ML-KEM-768 encapsulation key : 1184 bytes ]
  [ X25519 public key            :   32 bytes ]
    total                        : 1216 bytes

The server answers with 1120 bytes — an ML-KEM-768 ciphertext (1088 bytes) followed by its X25519 public key (32 bytes).

That size jump is not cosmetic. A ClientHello that used to fit comfortably in one packet now pushes past common boundaries — you start seeing it span multiple TLS records and TCP segments. Anything that parses or rewrites handshakes has to reassemble the stream before it can even read the key_share. This is exactly where a lot of middleboxes quietly break on PQC.

See it yourself

You don't have to take my word for any of it:

openssl s_client -connect cloudflare.com:443 \
    -groups X25519MLKEM768 -trace

Read the key_share extension in the trace: you'll see the 0x11EC group ID and the ~1.2 KB blob. If the server answers with the same group in its ServerHello, you negotiated post-quantum key exchange end to end. Cloudflare, Google, and AWS already do.

Where it gets interesting: when the two sides disagree

The hard part of migration isn't a greenfield handshake — it's that your clients and servers won't move in lockstep. A modern client offers X25519MLKEM768; a legacy origin only understands X25519. Or the reverse. Normally that means no PQC until both ends upgrade — which, across a real fleet of appliances, embedded devices, and third-party software, is never.

A component sitting in the path can resolve the disagreement: present X25519MLKEM768 to the side that speaks it, run classical to the side that doesn't, and translate between the two key schedules — upgrading the connection on the wire without either endpoint changing. That's the idea behind TLS Lane, and it's why I think about PQC at the byte level rather than the policy level. The migration is something you can actually do today, one connection at a time.

If you want to know what your own fleet is negotiating right now, that's where I start.

← All posts