A first look at ML-KEM, the post-quantum key exchange

ML-KEM became a NIST standard in 2024. This post explains what it does and how it differs from the key exchange we use today.

On this page

Why this matters now

TLS 1.3, the protocol that protects HTTPS connections, and many other protocols can agree on a key with elliptic curve Diffie-Hellman. Two sides combine their key pairs and reach the same secret. X25519 is one example, and I explain it in Key exchange in plain words, with X25519. Its safety rests on a hard math problem called the discrete logarithm.

A large quantum computer could solve that problem. FIPS 203 (opens in a new tab), the ML-KEM standard, says that key exchange based on factoring or discrete logarithms would then be at risk. That includes elliptic curves.

Nobody knows when such a computer will exist, yet the risk starts today. The Canadian Centre for Cyber Security calls it "harvest now, decrypt later". An attacker records encrypted traffic now and decrypts it once a strong enough quantum computer exists.

So data that must stay private for years needs a key exchange that will hold for years. The Government of Canada roadmap (opens in a new tab) asks that its high priority systems move to post-quantum cryptography by the end of 2031. Its other systems follow by the end of 2035.

A key encapsulation mechanism

NIST published ML-KEM in FIPS 203 on 13 August 2024. The name stands for Module-Lattice-Based Key-Encapsulation Mechanism. It comes from CRYSTALS-Kyber, a design that a team of researchers submitted to NIST's post-quantum standardization process and that NIST selected.

This post calls ML-KEM a key exchange, because that is the everyday name for its job. Its exact type is a key encapsulation mechanism, or KEM. A KEM has three algorithms:

  • KeyGen makes an encapsulation key, which anyone may see, and a decapsulation key, which stays private.
  • Encaps takes the encapsulation key. It returns a new shared secret and a ciphertext, which is encrypted data.
  • Decaps takes the decapsulation key and the ciphertext. It returns the same shared secret.

FIPS 203 does not use the terms "public key" and "private key" for ML-KEM, and this post follows it. Figure 1 shows the steps between Alice and Bob. In the figure, ek is the encapsulation key, dk is the decapsulation key, c is the ciphertext and K is the shared secret.

One ML-KEM-768 exchange between Alice and BobAlice runs KeyGen and keeps the decapsulation key dk. She sends the encapsulation key ek, 1,184 bytes, to Bob. Bob runs Encaps with ek. He keeps the 32-byte shared secret K and sends the ciphertext c, 1,088 bytes, back to Alice. Alice runs Decaps with dk and c and gets the same 32-byte K.Alice (client)open networkBob (server)1. KeyGen()makes ek and dkdk3. Decaps(dk, c)gets the same K2. Encaps(ek)makes K and cek, 1,184 bytesencapsulation keyc, 1,088 bytesciphertextK, 32 bytesK, 32 bytesthe same secretsecret, never sentsent over the network
Figure 1 The three steps of ML-KEM-768. Only ek and c cross the network.

Inside Encaps, Bob picks 32 random bytes. He feeds them, together with a hash of Alice's encapsulation key, into another hash function. A hash function turns any input into a short value of fixed size, and nobody can work back to the input. The first 32 bytes of its 64-byte output are the shared secret. He also encrypts the 32 random bytes with her encapsulation key. That encrypted value is the ciphertext.

Alice decrypts the ciphertext and gets the 32 bytes back. Then she repeats Bob's steps and checks that she gets the same ciphertext. This check is part of the Fujisaki-Okamoto transform. It keeps ML-KEM safe when an attacker sends crafted ciphertexts.

How it differs from X25519

With X25519, both sides make a key pair and send a public key. Each side combines its own private key with the other public key, and both get the same value. Their roles are the same.

A KEM gives the two sides different jobs. Only Alice has a key pair. Bob creates the secret from his random bytes and sends it in a form that only her decapsulation key can open.

This fits TLS 1.3: the client speaks first, so it plays Alice and sends an encapsulation key. The server answers with a ciphertext. Both go in the TLS handshake, the first messages where client and server agree on keys, in the same place where X25519 public keys go today.

Size is the biggest practical change. An X25519 public key is 32 bytes. An ML-KEM-768 encapsulation key is 1,184 bytes, 37 times larger, and the ciphertext adds 1,088 bytes on the way back. In both methods the shared secret is 32 bytes.

Lattices, in simple terms

Linear equations are easy to solve. Gaussian elimination, a standard method, finds the unknowns quickly. Add a small random error to each equation, and that method stops working. Finding the secret from many such "noisy" equations is the Learning With Errors problem, described by Regev in 2005.

ML-KEM uses a variant called Module Learning With Errors. Each number in the equations becomes a polynomial, a sum such as 3 + 5X + 2X2. In ML-KEM, every polynomial has 256 coefficients, the numbers in front of the powers of X. Each coefficient is a number modulo 3329, so it stays between 0 and 3328. Products are reduced modulo X256 + 1, so they keep 256 coefficients. A vector here is a list of k polynomials. A module is the set of all such lists, which you can add together or multiply by a polynomial. A lattice is a regular grid of points, like the corners on graph paper, but in hundreds of dimensions. The hard problems behind ML-KEM live in module lattices, which gives the scheme its name.

Alice's keys follow this idea. A public matrix A comes from a 32-byte seed, a short random value that the matrix is made from. Her secret s and the error e are vectors of polynomials with small coefficients. Her encapsulation key holds t = As + e and the seed. Without e, anyone could solve for s. With e, no fast method is known. FIPS 203 says ML-KEM is believed to stay secure even against a quantum computer.

From this you can work out the key size. A coefficient below 3329 fits in 12 bits, since 212 is 4096. One polynomial takes 256 × 12 = 3,072 bits, which is 384 bytes. ML-KEM-768 uses vectors of 3 polynomials, so the key is 3 × 384 bytes for t plus the 32-byte seed: 1,184 bytes.

Three parameter sets

FIPS 203 defines three parameter sets. The main difference is k, the number of polynomials in each vector. A few smaller parameters change too. A larger k means more security and larger keys. Each set targets a NIST security category. Category 1 means an attack needs at least the work of a key search on AES-128, a widely used cipher with a 128-bit key. Categories 3 and 5 compare in the same way with AES-192 and AES-256.

Parameter setCategorykEncapsulation keyDecapsulation keyCiphertextShared secret
ML-KEM-512128001,63276832
ML-KEM-768331,1842,4001,08832
ML-KEM-1024541,5683,1681,56832
Table 1 Sizes in bytes from FIPS 203 Table 3. The value of k is from Table 2, and the categories are from section 8.

NIST recommends ML-KEM-768 as the default.

In rare cases the two sides end with different secrets. For ML-KEM-768, FIPS 203 puts that chance at 2-164.8, which is about 1 in 4 × 1049.

Hybrid key exchange in TLS 1.3

Browsers use ML-KEM in a hybrid. The TLS handshake runs X25519 and ML-KEM-768 side by side and combines the two secrets. RFC 9954 (opens in a new tab) gives the goal: the shared secret stays safe while at least one part is unbroken. If ML-KEM has a flaw, X25519 still protects the connection against today's computers. A quantum computer that breaks X25519 still faces ML-KEM. The hybrid protects the key, and so the recorded traffic. Certificates and signatures in TLS are still classical, and that is a separate migration.

In TLS, this hybrid is the named group X25519MLKEM768, which is TLS's name for one key exchange method. RFC 10024 (opens in a new tab), a standard from August 2026 by the IETF (the Internet Engineering Task Force, which writes internet standards), defines it. Its code point, the number that names it in the handshake, is 0x11EC. Of the three groups in that RFC, only this one is marked as recommended. Before it became an RFC, the document was the draft draft-ietf-tls-ecdhe-mlkem.

  • The client sends its ML-KEM-768 encapsulation key, then its X25519 public key: 1,184 + 32 = 1,216 bytes.
  • The server sends the ML-KEM ciphertext, then its own X25519 public key: 1,088 + 32 = 1,120 bytes.
  • Both sides join the ML-KEM secret and the X25519 secret, in that order, into 64 bytes. TLS feeds them into its key schedule, the steps that turn a secret into session keys.

On the Google Security Blog (opens in a new tab), the Chrome team announced that Chrome 131 would move to this group. The Firefox documentation for administrators (opens in a new tab) says that Firefox offers X25519 with ML-KEM-768 by default.

Try it in .NET 10

.NET 10 adds the MLKem class (opens in a new tab) in System.Security.Cryptography. The class has no [Experimental] attribute. That attribute makes the compiler stop with an error until you opt in, and some of the methods do have it. It needs a system crypto library with ML-KEM: OpenSSL 3.5 or newer, or Windows CNG with post-quantum support. So check MLKem.IsSupported first.

C#
using System.Security.Cryptography;

// ML-KEM needs support from the system crypto library, so check first.
if (!MLKem.IsSupported)
{
    Console.WriteLine("ML-KEM is not supported on this platform.");
    return;
}

// Alice makes a key pair and sends the encapsulation key to Bob.
using MLKem alice = MLKem.GenerateKey(MLKemAlgorithm.MLKem768);
byte[] encapsulationKey = alice.ExportEncapsulationKey();

// Bob loads the encapsulation key. He gets a new secret and a ciphertext.
using MLKem bob = MLKem.ImportEncapsulationKey(MLKemAlgorithm.MLKem768, encapsulationKey);
bob.Encapsulate(out byte[] ciphertext, out byte[] bobSecret);

// Alice opens the ciphertext with her decapsulation key.
byte[] aliceSecret = alice.Decapsulate(ciphertext);
bool same = CryptographicOperations.FixedTimeEquals(aliceSecret, bobSecret);

Console.WriteLine($"Encapsulation key: {encapsulationKey.Length} bytes");
Console.WriteLine($"Ciphertext:        {ciphertext.Length} bytes");
Console.WriteLine($"Shared secret:     {aliceSecret.Length} bytes");
Console.WriteLine($"Same secret:       {same}");

Bob only loads the encapsulation key, so his object cannot decapsulate. FixedTimeEquals compares in constant time, so the check takes the same time whatever the bytes are. On Windows 11 (version 10.0.26300) with .NET 10.0.12, IsSupported was true and the program printed:

Output
Encapsulation key: 1184 bytes
Ciphertext:        1088 bytes
Shared secret:     32 bytes
Same secret:       True

When the ciphertext changes

Decaps does not report a changed ciphertext. FIPS 203 calls this implicit rejection. When its check fails, Decaps returns a different secret, made from a hidden random value in the decapsulation key and the ciphertext. That secret is wrong, and the same each time. This sample changes one bit:

C#
using System.Security.Cryptography;

if (!MLKem.IsSupported)
{
    Console.WriteLine("ML-KEM is not supported on this platform.");
    return;
}

// One key pair is enough here: a key with its private part can also encapsulate.
using MLKem key = MLKem.GenerateKey(MLKemAlgorithm.MLKem768);
key.Encapsulate(out byte[] ciphertext, out byte[] sent);

// Change one bit, as a broken network or an attacker could.
ciphertext[0] ^= 0x01;

// Decapsulate does not throw. It returns a secret that does not match.
byte[] first = key.Decapsulate(ciphertext);
byte[] second = key.Decapsulate(ciphertext);

bool matchesSent = CryptographicOperations.FixedTimeEquals(sent, first);
bool repeatable = CryptographicOperations.FixedTimeEquals(first, second);

Console.WriteLine($"Matches the sent secret:   {matchesSent}");
Console.WriteLine($"Same result when repeated: {repeatable}");
Output
Matches the sent secret:   False
Same result when repeated: True

So your protocol must confirm the key before it trusts any data. In TLS 1.3, the Finished messages, which end the handshake with a check over everything sent so far, only succeed when both sides hold the same keys. FIPS 203 says that, under the conditions in NIST SP 800-227 (opens in a new tab), the shared secret can be used directly as a symmetric key, which is one key that both sides use for the same cipher. Use it with an authenticated cipher such as AES-GCM, and a wrong secret fails at the first message.

Like X25519, ML-KEM does not prove who sent the encapsulation key. TLS adds that with certificates. Outside TLS, you need your own way to authenticate the exchange, for example with signatures.

Test with a NIST vector

A test with random keys only shows that the two sides agree with each other. To check the code against the standard, you need a known answer test, with fixed inputs and published outputs. NIST publishes ML-KEM test vectors (opens in a new tab) in its ACVP project. The same 64-byte seed always gives the same keys. This sample loads the seed of test case 1, an ML-KEM-512 case, and checks the encapsulation key:

C#
using System.Security.Cryptography;

if (!MLKem.IsSupported)
{
    Console.WriteLine("ML-KEM is not supported on this platform.");
    return;
}

// NIST ACVP test vector ML-KEM-keyGen-FIPS203, test case 1 (ML-KEM-512).
// The private seed is d followed by z, 32 bytes each.
byte[] seed = Convert.FromHexString(
    "47B893474672BA92E4B12EE44FB32953AF8E8503B5FB471D1614FB8A021A660A" + // d
    "1F8CB39E9E30BC458A0DC5408884B1187FB217018DF760FA57317703B844A0A9");  // z

// SHA-256 of the 800 byte "ek" value in the same test case.
const string ExpectedHash = "7E4A2B716A684C1AD33C43C808782DA9E1A72F14CCDA82723F712D49F53A9F28";

using MLKem key = MLKem.ImportPrivateSeed(MLKemAlgorithm.MLKem512, seed);
byte[] ek = key.ExportEncapsulationKey();
string actualHash = Convert.ToHexString(SHA256.HashData(ek));

Console.WriteLine($"Encapsulation key: {ek.Length} bytes");
Console.WriteLine($"Matches NIST:      {actualHash == ExpectedHash}");
Output
Encapsulation key: 800 bytes
Matches NIST:      True

To keep the sample short, it compares SHA-256 hashes. SHA-256 is a hash function that turns any input into a 32-byte value, so a match shows that the 800 bytes are the same. A longer version of this sample compares all 800 bytes, and they match. FIPS 203 lets you store the 64-byte seed in place of the decapsulation key, which is 2,400 bytes for ML-KEM-768. The seed needs the same protection as that key.

The methods for the X.509 and PKCS#8 key formats are still experimental. With the .NET SDK 10.0.401, a call to ExportSubjectPublicKeyInfoPem stops the build with error SYSLIB5006. To go on, put #pragma warning disable SYSLIB5006 before the call, or set NoWarn to SYSLIB5006 in your project. The methods in these samples build cleanly.

Read more

More posts