Post-Quantum Cryptography

Post-quantum cryptography has already shipped, and whether your connection uses it was decided by a default you never set. The two libraries this module reads disagree: one offers a hybrid group first, the other's default list has none. Worse, one of them prefers post-quantum signatures when signing and refuses them when verifying. The algorithms are finished; the deployment is a configuration question with four separate answers.

Launch Simulator →

What you'll explore

  1. 01

    An Argument That Needs No Date

    The usual framing of this risk invites the usual objection. Someone says records are being captured today to be decrypted once a quantum computer exists, and the reply is that nobody knows when that will be — which is true, and is also not a defence. Neither pinned tree states when such a machine will arrive; there is no constant, no comment and no schedule in either, and this module therefore gives no year. It does not need one. The argument has three premises and a date is not among them. The first is that the capture is cheap and has already happened. A TLS 1.3 key exchange is a public value on the wire: X25519_PUBLIC_VALUE_LEN is 32 (BoringSSL include/openssl/curve25519.h:37), so recording everything an adversary might one day want to read costs them 32 bytes per connection plus the ciphertext. Nothing about that step requires a quantum computer, or any cryptanalysis at all. The second is that the decryption is retroactive. Breaking the key agreement later recovers the session key, and the session key decrypts the recording — so the protection you deployed is not evaluated against today's adversary but against every adversary for as long as the data matters. The third premise is the only one you supply, and it is the only one worth arguing about: how long this data must stay secret. Set that honestly and the decision falls out without a prediction. If the answer is a quarter, a classical exchange is a defensible risk. If the answer is a human lifetime, then any non-zero probability of the machine arriving inside that window makes the classical exchange a bet you have already lost if it lands, because the traffic is in someone's archive and you cannot recall it. That is the whole structure, and it is why the honest version of this section contains no date. What quantum computing does to symmetric cryptography is a separate question and the answer is reassuring, which is worth stating precisely because the panic is usually undifferentiated. The key agreement is the exposed part. The bulk encryption is not: ML-KEM-768's output is MLKEM_SHARED_SECRET_BYTES, 32 bytes (BoringSSL include/openssl/mlkem.h:84) — 256 bits fed into the key schedule — and OpenSSL records the KEM's own claimed strength as ML_KEM_768_SECBITS 192 (OpenSSL include/crypto/ml_kem.h:109), a category rather than a key length. The practical reading is that AES-256 is not what this migration is about, and a project that spends its effort re-keying symmetric algorithms has mistaken which half of the handshake is at risk.

  2. 02

    Two Algorithms, One Name, Identical Sizes

    Start with the distinction that most writing on this subject elides, because both halves of it are in one tree. BoringSSL ships crypto/mlkem/, which is FIPS 203, and it also ships crypto/kyber/, which is not — and the header says so in its own words: 'Kyber is the pre-standard version of ML-KEM. This was once exported as public API, but is now internal and only used by libssl. It will be removed entirely in the future' (BoringSSL crypto/kyber/internal.h:23-25), implementing 'the round-3 specification of Kyber' (:27). So Kyber and ML-KEM are not two names for one algorithm; they are two algorithms, one of which is awaiting deletion, and the standardisation process changed the construction between them. Now the reason the confusion is so durable: their sizes coincide. KYBER_PUBLIC_KEY_BYTES is 1184 (BoringSSL crypto/kyber/internal.h:50) and MLKEM768_PUBLIC_KEY_BYTES is 1184 (BoringSSL include/openssl/mlkem.h:51); the ciphertexts match too, at 1088 (crypto/kyber/internal.h:74 and include/openssl/mlkem.h:81). Nothing on the wire distinguishes them by length, so a packet capture cannot tell you which one you negotiated and neither can a size table. Only the group code point can, which is why the pre-standard group has a name that says so — X25519Kyber768Draft00. The second thing to get right is what kind of primitive this is, and the vocabulary actively misleads. TLS calls X25519MLKEM768 a 'group', a word inherited from elliptic curves by way of finite-field Diffie-Hellman, and BoringSSL's own documentation says plainly that neither term fits: 'ML-KEM is not a Diffie-Hellman primitive. ML-KEM is a key encapsulation mechanism (KEM). Rather than both sides exchanging public keys, KEMs have distinct sender and recipient sides' (BoringSSL docs/tls_named_groups.md:17). That asymmetry is visible in the function names rather than inferred from them: one side calls MLKEM768_generate_key() and publishes a key, the other calls MLKEM768_encap() to produce a ciphertext and a secret in one step, and the first side calls MLKEM768_decap() to recover the same secret (BoringSSL crypto/mlkem/mlkem.cc:22, :42, :48). In a Diffie-Hellman exchange both parties do the same thing; here they do different things, and only the recipient ever holds a private key. TLS 1.3 absorbed this without a protocol change because the key_share extension happens to fit — the client's entry carries a public key and the server's carries a ciphertext (docs/tls_named_groups.md:19) — which is the reason a post-quantum KEM could be deployed as 'just another group' and also the reason the terminology is now permanently wrong. The sizes are the part that changes engineering decisions. An X25519 public value is 32 bytes; ML-KEM-768's public key is 1184 and its ciphertext is 1088 (OpenSSL agrees: OSSL_ML_KEM_768_PUBLIC_KEY_BYTES 1184 and OSSL_ML_KEM_768_CIPHERTEXT_BYTES 1088, OpenSSL include/openssl/ml_kem.h:24 and :23). That is thirty-seven times the client's key-exchange bytes, in the first flight, before anything is authenticated — which is why post-quantum key establishment shows up as a connection-setup and packet-size problem long before it shows up as a CPU problem. Beware one figure in circulation: 800 bytes is sometimes quoted as the ML-KEM-768 public key and it is ML-KEM-512's, a different parameter set (OSSL_ML_KEM_512_PUBLIC_KEY_BYTES, OpenSSL include/openssl/ml_kem.h:19). The parameter set is the thing you have to name; the family name settles nothing.

  3. 03

    The Expensive Half, and a Library Arguing With Itself

    Key establishment gets the attention and signatures are where the bytes actually are. ML-DSA-65's signature is MLDSA65_SIGNATURE_BYTES, 3309 bytes, with a 1952-byte public key (BoringSSL include/openssl/mldsa.h:74 and :70), and OpenSSL's internal header states the identical pair plus the private key — ML_DSA_65_SIG_LEN 3309, ML_DSA_65_PUB_LEN 1952, ML_DSA_65_PRIV_LEN 4032 (OpenSSL include/crypto/ml_dsa.h:33, :32, :31), sourced in a comment to 'FIPS 204 Section 4 Table 1 & Table 2' (:30). Two independent trees, the same numbers — worth noticing, because the two libraries in this project's certificate module disagreed about their shared parameter. Against the classical baseline that is a different order of magnitude: Ed25519 is 64 bytes (ED25519_SIGNATURE_LEN, BoringSSL include/openssl/curve25519.h:73), an ECDSA-P256 DER signature is at most 72, and an RSA-2048 signature is the modulus at 256 bytes (RSA_size, BoringSSL include/openssl/rsa.h:477-479). So ML-DSA-65 costs roughly fifty times Ed25519, and the comparison is worse than it looks because a handshake carries more than one signature: the leaf's, plus one per certificate in the chain, plus the CertificateVerify. Going up a level is worse again — MLDSA87_SIGNATURE_BYTES is 4627 (BoringSSL include/openssl/mldsa.h:224) — and the tree volunteers an opinion about it that is unusual to find in a header: 'ML-DSA-87 is excessively and unnecessarily large. Use -65 unless you are specifically attempting to achieve CNSA 2.0 compliance' (:187-188). Then the finding that reorganises the topic, and it is an argument inside one library at one commit. BoringSSL keeps two default signature-algorithm lists in one file. The signing list opens with post-quantum: kSignSignatureAlgorithms begins SSL_SIGN_ML_DSA_44, SSL_SIGN_ML_DSA_65, SSL_SIGN_ML_DSA_87 under the comment 'List our preferred algorithms first' (BoringSSL ssl/extensions.cc:311-315, the comment at :312). The verifying list contains no ML-DSA at all: kVerifySignatureAlgorithms runs ECDSA-P256, the RSA variants, and SHA-1 last (ssl/extensions.cc:291-306). Those two facts are not merely different preferences, because the verify list is the one that gets ADVERTISED — tls12_add_verify_sigalgs() (:346) writes the signature_algorithms extension from tls12_get_verify_sigalgs() (:339), which returns that list whenever the embedder has configured nothing (:341). A peer sends only what you advertised. So a default-configured BoringSSL endpoint prefers to sign with ML-DSA and will never be sent an ML-DSA signature it would accept: the asymmetry is total, and it means 'we support post-quantum signatures' can be simultaneously true of signing and false of verifying in the same process. Note what this is NOT. It is not a gap in TLS or in the library: the code points exist and are implemented — SSL_SIGN_ML_DSA_65 is 0x0905 (BoringSSL include/openssl/ssl.h:1173), with _44 at 0x0904 and _87 at 0x0906 (:1172, :1174). What is absent is one default list's entry. That distinction is the difference between 'the protocol cannot do this' and 'nobody turned it on', and only the second one is actionable.

  4. 04

    Two Secrets, One Extract — and Two Different Things Called Hybrid

    The security claim for a hybrid key exchange is that an adversary must break both halves, and in BoringSSL that claim is a property of eleven lines rather than a slogan. X25519MLKEM768KeyShare::Encap() allocates its output as MLKEM_SHARED_SECRET_BYTES + X25519_SHARED_KEY_LEN (BoringSSL ssl/ssl_key_share.cc:311-312) — 32 plus 32, a 64-byte buffer. It writes the X25519 result at offset 32 (:327) and the ML-KEM secret at offset 0 (:335), so the result is a plain concatenation with no combiner of its own, and Decap() builds the identical layout (:353-363). The failure handling is not symmetric between the two directions, and the asymmetry is a property of the primitive rather than an implementation quirk: in Decap() both halves sit inside one short-circuiting condition (:358-363), so a bad ciphertext in either aborts the handshake with an illegal_parameter alert, whereas in Encap() only the X25519 half can fail that way — that condition runs :321-328 with the alert at :329-331, while MLKEM768_encap() at :335 sits after it and returns void, so it cannot fail at all (BoringSSL include/openssl/mlkem.h:89). Encapsulating is arithmetic against an already-parsed public key; decapsulating is the step that can reject what it was sent. Either way no half-filled secret reaches the key schedule. The reason the concatenation is sufficient is what happens next: the whole buffer becomes a single HKDF-Extract input. tls13_advance_key_schedule() is handed the secret (ssl/tls13_client.cc:636) and reaches hkdf_extract_to_secret(), which calls HKDF_extract over in.data() with in.size() (ssl/tls13_enc.cc:57-58) — 64 bytes, not 32. An adversary who breaks one component still faces 32 bytes of unknown input material to the extract, so the derived traffic keys remain out of reach. That is the mechanism, and it is worth holding onto, because it also tells you what a hybrid does NOT buy: nothing about authentication, and nothing retroactive for traffic already recorded under a classical-only exchange. Two wire details repay attention. The component order is part of the specification rather than a convention: the ML-KEM half goes first in the ML-KEM draft (:300-301 on the client side, :337-340 for the server's reply), while the older draft group puts X25519 first (:212-213). The order flipped between drafts, which is the kind of detail that makes an interoperability bug look like a cryptographic one. And the sizes compose as you would expect: a client key share is 1184 + 32 bytes and the server's reply is 1088 + 32, the latter checked explicitly at :358-359. Now the ambiguity this section exists to clear up, because the word 'hybrid' is doing two jobs. One is hybrid INSIDE a group, which is everything above: a single key share, two secrets, one extract. The other is offering TWO key shares, at most one of them post-quantum — which is a separate mechanism with its own code: 'We'll try to include one post-quantum and one classical initial key share' (BoringSSL ssl/extensions.cc:2428-2429), implemented by skipping any candidate whose post-quantum-ness matches the first pick (:2431-2432), where that test is a three-case switch over the registered post-quantum groups (:107-113). These are not the same thing and they fail differently. A within-group hybrid is hybrid whatever the server does, because the server either selects that group or does not. Two offered shares are only as post-quantum as the server's choice: it may pick the classical one, and then nothing about the connection is post-quantum although the client did everything right. Conflating them produces the most confident wrong sentence in this subject — that offering a post-quantum group makes a connection post-quantum. What the client offers is a proposal. For comparison, a real combiner does exist in the same tree, just not in TLS: X-Wing absorbs both secrets, the X25519 ciphertext, the X25519 public key and a six-byte label into SHA3 (BoringSSL crypto/xwing/xwing.cc:106-125), and it is not registered as a TLS group at all — so the thing TLS 1.3 actually ships is the concatenation, and its sufficiency rests on the extract that follows it.

  5. 05

    Four Answers to One Question

    Ask whether a deployment is post-quantum and there is no single answer, because the question decomposes into four that are settled independently — and in the two pinned trees they genuinely disagree. First, is it implemented? Yes in both, and they are close to each other: BoringSSL registers X25519Kyber768Draft00, X25519MLKEM768 and MLKEM1024 in one table (BoringSSL ssl/ssl_key_share.cc:443-446), while OpenSSL advertises six — mlkem512, mlkem768, mlkem1024 and three hybrids — every one flagged as a KEM rather than a key agreement (OpenSSL providers/common/capabilities.c:88-93, the is_kem field at :35). Second, is it OFFERED by default? Here they part company completely. OpenSSL's built-in list leads with it: TLS_DEFAULT_GROUP_LIST is '?*X25519MLKEM768 / ?*X25519:?secp256r1 / ...' (OpenSSL ssl/t1_lib.c:204-205), and the asterisk is not decoration — a group so prefixed is one the client sends a key share for (doc/man3/SSL_CTX_set1_curves.pod:134-135), while the question mark only means 'skip if unimplemented' (:138-139). BoringSSL's DefaultSupportedGroupIds() is X25519, P-256 and P-384, with no post-quantum group at all (BoringSSL ssl/ssl_key_share.cc:456-463). Same version of the same question, opposite answers, and neither is a bug. Third, is it offered for SIGNING, and fourth, is it ACCEPTED when verifying? Those come apart inside BoringSSL alone, in the way the signatures section sets out: signing prefers ML-DSA, verifying does not list it (ssl/extensions.cc:311-315 against :291-306). So one library at one commit answers 'implemented' yes, 'offered for key establishment' no, 'preferred for signing' yes, 'accepted for verifying' no. Four answers, one library. Two consequences follow for anyone planning this work. The intersection is narrower than either tree's catalogue: X25519MLKEM768 is the only hybrid both pinned trees implement, which is what makes it the interoperable choice — note that implementing is not offering: OpenSSL's default list leads with it (ssl/t1_lib.c:204-205) and BoringSSL's leads with no post-quantum group at all (ssl/ssl_key_share.cc:456-463), which is the paragraph's own point two sentences up, and the P-curve hybrids are OpenSSL-only here while pure ML-KEM-768 is a group OpenSSL advertises and BoringSSL does not register. If you need two independent stacks to negotiate post-quantum key establishment today, that is the one. And BoringSSL's omission is a DEFAULT rather than a capability gap, which is the single most important distinction in this section: the group is registered and constructed on request (:445, :478), so an embedder that configures it gets it, and the library's defaults simply decline to choose for them. That is why 'does my client do post-quantum' cannot be answered from the library's name — only from what the embedder configured and what the peer agreed to. Finally, the catalogue, since a migration plan has to be written against algorithms that exist. Both trees implement ML-KEM (FIPS 203), ML-DSA (FIPS 204) and SLH-DSA; BoringSSL additionally carries the pre-standard Kyber awaiting removal and the X-Wing combiner. Several schemes discussed during standardisation are in neither tree, and an algorithm absent from both of these libraries is not something to plan a 2020s-era TLS deployment around, whatever its merits on paper.

Validated against Two trees, split by LAYER — and the split matters more here than anywhere else in this library, because both trees publish a post-quantum header under the SAME directory prefix and the names differ by ONE UNDERSCORE. OpenSSL (openssl-3.5.7) owns the ALGORITHMS: crypto/ml_kem/ and crypto/ml_dsa/ implement FIPS 203 and FIPS 204, the provider entry points expose them, and its public size header is OpenSSL include/openssl/ml_kem.h. BoringSSL (eada60b7) owns what TLS CARRIES: the hybrid key share and the NamedGroup registry in ssl/ssl_key_share.cc, the signature-algorithm lists in ssl/extensions.cc, the key schedule in ssl/tls13_enc.cc, and its public size headers are BoringSSL include/openssl/mlkem.h and BoringSSL include/openssl/mldsa.h. So include/openssl/ml_kem.h is OPENSSL's and include/openssl/mlkem.h is BORINGSSL's; an underscore is the only thing that tells them apart, which is why every kernel callout below opens with the name of the library that owns it. One asymmetry is worth stating in advance because it shapes what can be cited: OpenSSL publishes no include/openssl/ml_dsa.h at all — its ML-DSA sizes live in the internal include/crypto/ml_dsa.h — while BoringSSL publishes its own in include/openssl/mldsa.h. The two trees nevertheless agree on every ML-DSA-65 figure, which is worth noticing because the libraries in this project's certificate module disagreed on theirs. Neither tree dates the arrival of a quantum computer, states a migration deadline, or contains a transition schedule; every such figure is policy written somewhere else, and none of it is in this code. openssl-3.5.7 (ML-KEM / ML-DSA algorithms) + boringssl eada60b7 (TLS key share, group and signature-algorithm defaults) — crypto/ml_kem/ml_kem.c and friends. Every simulated behavior cites a real source function, and a server-side correlation engine computes how each layer's state cascades into the next. See the methodology →

This module is part of zerohop Pro — it unlocks alongside the full kata library, progress tracking, and weak-area analysis.