3.1 Kyber / ML-KEM
How the post-quantum key exchange standard works — from the math to the bytes on the wire.
Every time you connect to a website over HTTPS, your browser and the server run a key exchange protocol to agree on a shared secret without revealing it to anyone listening on the network. Today, most deployments use X25519 (Diffie-Hellman over Curve 25519). That will be broken by a quantum computer running Shor’s algorithm.
ML-KEM (FIPS 203, formerly CRYSTALS-Kyber) is its post-quantum replacement. It’s a Key Encapsulation Mechanism: one party generates a key pair, the other “encapsulates” a random shared secret inside a ciphertext using the public key, and the first party “decapsulates” to recover the same shared secret. The result: two parties agree on a random secret, and an eavesdropper learns nothing.
Intuition
Think of Kyber (ML-KEM) as a lockbox that anyone can close but only one party can open. In a TLS connection your browser generates a key pair and sends the open lockbox, the public key, to the website. The website puts a fresh random secret inside, closes it, and sends it back.
To an observer the closed box looks like random numbers: recovering the secret from it is a Learning With Errors problem, which no known classical or quantum algorithm solves efficiently at these sizes. Your browser holds the private key and opens the box, and both sides now share the same secret.
The Mathematical Setting
Kyber operates in the polynomial ring . Elements of this ring are polynomials with 256 coefficients, each an integer from 0 to 3328. Arithmetic is polynomial arithmetic modulo both (for coefficients) and (to keep degree below 256).
Why ? It’s a small prime with (since ), so has primitive 256th roots of unity but no 512th ones. That is enough for an incomplete Number Theoretic Transform (NTT) for : seven NTT layers split into 128 quadratic factors, and multiplication finishes with cheap products of degree-1 polynomials modulo those quadratics. The NTT turns polynomial multiplication from an operation into an one — critical for performance.
Parameter Sets
Kyber comes in three sizes, trading off security for key/ciphertext size. The module rank controls how many ring elements are in the key vectors:
ML-KEM-512 (k=2)
~128 bits of security (NIST Level 1). Smallest keys.
Public key: 800 bytes
Secret key: 1,632 bytes
Ciphertext: 768 bytes
Suitable for constrained environments where bandwidth matters most.
ML-KEM-768 (k=3)
~192 bits of security (NIST Level 3). Recommended default.
Public key: 1,184 bytes
Secret key: 2,400 bytes
Ciphertext: 1,088 bytes
Chrome enables it by default for TLS, as the X25519MLKEM768 hybrid.
ML-KEM-1024 (k=4)
~256 bits of security (NIST Level 5). Maximum security.
Public key: 1,568 bytes
Secret key: 3,168 bytes
Ciphertext: 1,568 bytes
Used where long-term data secrecy matters (classified, financial).
Step 1: Key Generation
The recipient generates a key pair. The public key is an LWE instance; the secret key is the hidden secret. Here’s what happens, explained plainly:
ML-KEM Key Generation
1. Generate a random 32-byte seed and expand it deterministically into a matrix using SHAKE-128. This matrix is public — anyone can expand it from the seed.
2. Sample a secret vector with small coefficients (drawn from the centered binomial distribution — each coefficient is between and , where or 3).
3. Sample a small error vector similarly.
4. Compute . This is the Module-LWE sample — a noisy linear function of the secret.
Public key: — 1,184 bytes for ML-KEM-768.
Secret key: — kept private.
Step 2: Encapsulation
The sender (who only has the public key) wants to generate a shared secret and send an encrypted version of it. The process looks like a second LWE sample, but with the roles of the matrix transposed.
ML-KEM Encapsulation
1. Sample a random 32-byte message . Hash it together with the public key hash to derive deterministic randomness.
2. Sample a small secret and errors .
3. Compute:
(analogous to the public key step, transposed)
4. The ciphertext is , compressed to save bytes.
The shared secret is derived from and the public key hash.
The sender sends the ciphertext and uses the shared secret to encrypt application data (via AES-GCM or similar).
Step 3: Decapsulation
The recipient uses their secret key to recover the shared secret from the ciphertext. The math works because the LWE noise terms are small enough to cancel out during decryption.
ML-KEM Decapsulation — Why It Works
The recipient computes :
The term is small (all the vectors involved have tiny coefficients). Rounding this to the nearest multiple of recovers exactly, as long as the noise doesn’t exceed — which the parameter choices guarantee with overwhelming probability.
Security: The FO Transform
The scheme above (Kyber.PKE) is only secure against passive eavesdroppers. An attacker who can send chosen ciphertexts and observe whether decryption succeeds or fails could potentially extract the secret key. To prevent this, Kyber applies the Fujisaki-Okamoto (FO) transform — a standard compiler that converts a passively secure scheme into one that’s secure against active attackers (called CCA2 security).
The key idea: during decapsulation, the recipient re-runs encapsulation using the recovered message and checks that the result matches the received ciphertext. If it doesn’t match (someone tampered with the ciphertext), a pseudorandom value derived from the secret key and the ciphertext is returned instead. This “implicit rejection” prevents attackers from learning whether decryption succeeded, closing the chosen-ciphertext attack vector.
Implementation Choices
Centered binomial distribution
Kyber avoids discrete Gaussian sampling — which is slow and notoriously hard to implement in constant time — and instead uses the centered binomial distribution : sample random bits, compute their alternating sum. This is fast, simple, and statistically close enough to Gaussian for security.
Constant-time implementation
Side-channel attacks read secret information from timing variations and power consumption. Kyber is designed so all operations run in constant time regardless of secret values: NTT operations have data-independent access patterns; the FO re-encryption check uses constant-time comparison; rejection uses implicit rejection (no branch on secret data).
Performance (AVX2, x86-64)
ML-KEM-768 on modern hardware:
KeyGen: ~14 μs
Encapsulate: ~18 μs
Decapsulate: ~17 μs
For comparison, an X25519 key agreement also takes tens of microseconds, so ML-KEM’s compute cost is competitive with classical ECDH. Its real cost is bandwidth: keys and ciphertexts are about a kilobyte.
Deployment status
Google Chrome shipped X25519Kyber768 behind a flag in Chrome 116 (2023), enabled it by default on desktop in Chrome 124 (2024), and switched to the standardized X25519MLKEM768 in Chrome 131. Cloudflare, AWS, and Signal have all deployed PQC key exchange. OpenSSL 3.x and BoringSSL include ML-KEM. The migration from classical to post-quantum key exchange is already underway at internet scale.
Kyber Key Generation — Step by Step
Here is the full key generation procedure at the level of concrete arithmetic, using ML-KEM-768 () as the running example. Every step maps directly to lines in the FIPS 203 specification.
Step 1 — Generate the seed. Sample 32 uniformly random bytes from the OS CSPRNG. Derive two 32-byte seeds by hashing: where . is the public seed for expanding ; is the private seed for sampling secrets.
Step 2 — Expand the matrix. Generate by calling SHAKE-128 with input for each entry . Each entry is a polynomial in with 256 uniformly random coefficients in . is generated directly in NTT domain, so no forward NTT is needed. The seed is public — anyone can re-derive from it.
Step 3 — Sample the secret and error vectors. Using as a PRF key and a counter, sample:
Each polynomial’s coefficients are drawn from the centered binomial distribution with . To sample one coefficient: draw 4 bits and return . This avoids the need for Gaussian sampling (which is slow and hard to implement in constant time).
Step 4 — Compute the public key polynomial. Apply the NTT to and , then compute:
where are the NTT representations of . The product is a matrix-vector multiplication done coefficient-wise in NTT domain — pointwise polynomial multiplications for ML-KEM-768, each consisting of 128 small degree-1 base-case products (the NTT is incomplete).
Result. The public key is . Unlike the ciphertext, is not compressed: each coefficient is stored in 12 bits, so takes bytes and the public key is = 1,184 bytes. The secret key retains in NTT form plus a copy of the public key, its hash , and a random 32-byte value used for implicit rejection.
Encapsulation and Decapsulation — Detailed Steps
Encapsulation and decapsulation are mirror operations. The sender uses the public key to encrypt a random 32-byte value; the receiver uses the secret key to recover it. Here is the complete arithmetic.
Encapsulation
1. Sample message. Draw 32 random bytes . Hash with the public key hash: . The randomness seeds all sampling; is the shared secret itself.
2. Sample ephemeral secret and errors. Using as PRF seed, sample and and from .
3. Compute ciphertext components.
The term encodes each bit of as either 0 or (rounded), so it sits in the middle of the coefficient range — far from both 0 and , giving maximum noise tolerance.
4. Compress. is compressed from 12-bit to 10-bit coefficients; from 12-bit to 4-bit. This compression is the main source of the (negligible) decryption failure probability. Total ciphertext: 1,088 bytes for ML-KEM-768.
5. Output. Ciphertext and shared secret from step 1. FIPS 203 outputs directly; the Round-3 Kyber submission instead ran a further SHAKE-256 KDF over and a hash of the ciphertext.
Decapsulation
1. Decompress and recover . Decompress and . Compute:
Substituting: this equals plus compression rounding errors. The noise terms are all small. Round each coefficient to 0 or to recover bit by bit.
2. Re-encapsulate. Run the full encapsulation algorithm using the recovered and the stored public key. Recompute .
3. Check ciphertext equality (implicit rejection). Compare the recomputed ciphertext to the received one in constant time. If they match, output (the shared secret). If they differ (indicating a tampered or malformed ciphertext), output a pseudorandom value derived from the secret value and the ciphertext . The caller cannot distinguish these cases without knowing the secret key — this is the FO transform’s guarantee of CCA2 security.
Kyber in TLS — Hybrid Key Exchange
Kyber is not deployed alone in TLS. The standard approach is a hybrid key exchange: run both X25519 (classical) and ML-KEM-768 (post-quantum) in the same handshake, then combine their shared secrets cryptographically. This gives security against both a classical attacker today and a quantum attacker in the future.
The IETF standard for this is X25519MLKEM768 (RFC 10024, August 2026, built on the RFC 9954 hybrid design framework). See the hybrid TLS guide for deployment. The TLS 1.3 key share extension carries both an X25519 public key (32 bytes) and an ML-KEM-768 public key (1,184 bytes). The server responds with both shares. Each side independently derives two shared secrets and concatenates them (ML-KEM first) as the input to the TLS 1.3 key schedule:
The concatenation-then-HKDF approach ensures that if either component is secure, the combined secret is secure. An attacker who breaks X25519 (e.g., with a quantum computer) still cannot break ML-KEM-768; an attacker who breaks ML-KEM-768 (e.g., due to an unforeseen classical attack) still cannot break X25519.
In Chrome’s implementation (behind a flag in Chrome 116, on by default for desktop since Chrome 124), the combined key share adds roughly 1.2 KB to the TLS ClientHello. Benchmarks show this adds less than 1 ms of latency on a typical connection — dominated by network round-trip time, not by the cryptography.
Cloudflare’s servers support X25519MLKEM768 across their infrastructure, and AWS offers hybrid post-quantum TLS on service endpoints such as KMS, ACM and Secrets Manager. OpenSSL 3.5 includes ML-KEM-512, ML-KEM-768, and ML-KEM-1024 as named groups in the default build.
Code Example — Using ML-KEM in Practice
The following pseudocode shows the API surface of ML-KEM-768 as exposed by two real production libraries: liboqs (C, via Open Quantum Safe project) and pqcrypto (Rust). These are the libraries you would actually call in a production system today.
liboqs (C)
#include <oqs/oqs.h>
// Key generation
OQS_KEM *kem = OQS_KEM_new(OQS_KEM_alg_ml_kem_768);
uint8_t *pk = malloc(kem->length_public_key); // 1184 bytes
uint8_t *sk = malloc(kem->length_secret_key); // 2400 bytes
OQS_KEM_keypair(kem, pk, sk);
// Encapsulation (sender side)
uint8_t *ct = malloc(kem->length_ciphertext); // 1088 bytes
uint8_t *ss_sender = malloc(kem->length_shared_secret); // 32 bytes
OQS_KEM_encaps(kem, ct, ss_sender, pk);
// Decapsulation (receiver side)
uint8_t *ss_recv = malloc(kem->length_shared_secret); // 32 bytes
OQS_KEM_decaps(kem, ss_recv, ct, sk);
// ss_sender == ss_recv: use as AES-256-GCM key
assert(memcmp(ss_sender, ss_recv, 32) == 0);
OQS_KEM_free(kem);pqcrypto (Rust)
use pqcrypto_mlkem::mlkem768;
use pqcrypto_traits::kem::{PublicKey, SecretKey, SharedSecret, Ciphertext};
// Key generation
let (pk, sk) = mlkem768::keypair();
// pk: 1184 bytes, sk: 2400 bytes
// Encapsulation (sender)
let (ss_sender, ct) = mlkem768::encapsulate(&pk);
// ct: 1088 bytes, ss_sender: 32 bytes
// Decapsulation (receiver)
let ss_recv = mlkem768::decapsulate(&ct, &sk);
// ss_recv: 32 bytes
assert_eq!(ss_sender.as_bytes(), ss_recv.as_bytes());
// Now use ss_sender as a key for AES-256-GCM or ChaCha20-Poly1305For hybrid TLS with Go (using the standard library’s experimental PQ support):
// Go 1.24+ includes X25519MLKEM768 in crypto/tls
// It is enabled automatically when CurvePreferences is not set.
cfg := &tls.Config{
// X25519MLKEM768 is negotiated first when the client supports it.
// No explicit configuration needed in Go 1.24+.
MinVersion: tls.VersionTLS13,
}
// To verify which key exchange was used:
conn.ConnectionState().TLSUnique // or inspect via crypto/tls internals
// Chrome DevTools > Security panel shows "X25519MLKEM768" when negotiated.Check Your Understanding
Q1. Why does TLS deploy the hybrid X25519MLKEM768 instead of ML-KEM on its own?
Q2. After computing v − sᵀu, the recipient has the message plus a small leftover noise term. Why can the message still be recovered exactly?
Q3. What does ML-KEM decapsulation do when it receives a tampered ciphertext?
Q4. Why does Kyber sample its secrets and errors from a centered binomial distribution rather than a discrete Gaussian?
Key library references for production use:
- liboqs (C/C++/Python/Go/Rust/Java wrappers): https://github.com/open-quantum-safe/liboqs
- pqcrypto (Rust, safe bindings to reference implementations): https://crates.io/crates/pqcrypto-mlkem
- FIPS 203 (the normative specification): https://doi.org/10.6028/NIST.FIPS.203
- BoringSSL (Google’s fork of OpenSSL, includes ML-KEM natively): https://boringssl.googlesource.com/boringssl
- OpenSSL 3.5+ includes ML-KEM as first-class named groups in the default build.