Chapter 11: ML-KEM (FIPS 203) from scratch
ML-KEM can be read as the Regev-style encryption pattern from Chapter 10, moved to the Module-LWE setting from Chapter 9 (National Institute of Standards and Technology, 2024). The noise distribution switches from the toy uniform distribution used in Chapter 10 to the centered binomial . The ciphertext is compressed to shave bytes. The specific compression map was introduced in the Kyber NIST PQC submission and standardized by FIPS 203. The whole thing is wrapped in the Fujisaki-Okamoto transform to lift it from IND-CPA to IND-CCA2. The underlying decryption identity is the same one from Chapter 10: recovers plus a bounded noise term. Here is FIPS 203’s round-half-up image of message bit at . What changes is the algebra the secret lives in, the distribution the noise is drawn from, and how the ciphertext is packaged and verified on decapsulation.
The chapter builds the whole construction at the ML-KEM-768 parameter set specified in FIPS 203 (National Institute of Standards and Technology, 2024), and it matches the NIST Automated Cryptographic Validation Protocol (ACVP) test vectors byte-for-byte. That second claim is an implementation result rather than anything the standard reports, and tests/ch11/test_vectors.py is what checks it. Chapter 13 handles the primal and dual lattice attacks that set the three standardized parameter sets.
K-PKE as Module-Regev with compression
Section titled “K-PKE as Module-Regev with compression”K-PKE is the public-key encryption component underlying ML-KEM (National Institute of Standards and Technology, 2024). It inherits Regev’s secret-cancellation identity from Chapter 10 and changes four things. First, the secret lives in rather than . Second, the noise distribution is rather than the toy uniform distribution from Chapter 10. Third, the ciphertext is compressed to and bits per coordinate to shave transmission bytes. Fourth, every polynomial multiplication runs in the NTT domain because admits a specialized transform at .
The FO wrapper does not touch the K-PKE internals (Fujisaki & Okamoto, 1999). It sits around K-PKE, deriving the encryption randomness from a hash of the message and re-encrypting on decapsulation to detect tampered ciphertexts. When the re-encryption check fails, the decapsulator returns a pseudorandom value derived from a per-key rejection seed so that adversarial ciphertexts cannot probe the secret through an error oracle (Hofheinz et al., 2017).
A concrete ML-KEM-768 parameter and seed derivation
Section titled “A concrete ML-KEM-768 parameter and seed derivation”ML-KEM-768 works with module rank and noise widths , compression widths , at NIST security category 3 (National Institute of Standards and Technology, 2024). The encapsulation key is bytes, the decapsulation key is bytes, the ciphertext is bytes, and the shared secret is bytes. The byte lengths are fixed by the parameter set and computed in the code below from FIPS 203 §8 Table 3 (byte lengths) and the K-PKE and ML-KEM algorithms in §5–§7.
The other two sets differ from ML-KEM-768 in four numbers and agree with it in everything else. All three fix , , and , and all three return a -byte shared secret.
| Set | Category | ||||||
|---|---|---|---|---|---|---|---|
| ML-KEM-512 | 2 | 3 | (10, 4) | 1 | 800 | 1632 | 768 |
| ML-KEM-768 | 3 | 2 | (10, 4) | 3 | 1184 | 2400 | 1088 |
| ML-KEM-1024 | 4 | 2 | (11, 5) | 5 | 1568 | 3168 | 1568 |
Sizes are in bytes. The parameter columns are FIPS 203 §8 Table 2, the size columns are §8 Table 3, and the category column is the surrounding §8 text rather than either table (National Institute of Standards and Technology, 2024).
A concrete parameter instantiation at NIST ACVP test-case 26 fixes the seeds and reproduces the exact byte lengths. This block derives and the fixed byte lengths; it does not run a full encapsulation, which the step-by-step construction below builds up to.
import hashlib
# ML-KEM-768 parameter constants from FIPS 203 Section 8 Table 2.n, q, k = 256, 3329, 3eta_1, eta_2 = 2, 2d_u, d_v = 10, 4
# Derived byte lengths from FIPS 203 Section 8 Table 3 and the K-PKE/ML-KEM algorithms.ek_len = 384 * k + 32dk_pke_len = 384 * kdk_len = dk_pke_len + ek_len + 32 + 32ct_len = 32 * (d_u * k + d_v)ss_len = 32
# NIST ACVP test case ML-KEM-768 tcId = 26 seeds.d_hex = "A2B4BCA315A6EA4600B4A316E09A2578AA1E8BCE919C8DF3A96C71C843F5B38B"z_hex = "D6BF055CB7B375E3271ED131F1BA31F83FEF533A239878A71074578B891265D1"d = bytes.fromhex(d_hex)z = bytes.fromhex(z_hex)
# The K-PKE seed derivation: G(d || k) splits SHA3-512 into (rho, sigma).rho_sigma = hashlib.sha3_512(d + bytes([k])).digest()rho = rho_sigma[:32]sigma = rho_sigma[32:]
print("ek_len =", ek_len)print("dk_len =", dk_len)print("ct_len =", ct_len)print("ss_len =", ss_len)print("rho[:8] =", rho[:8].hex())print("sigma[:8] =", sigma[:8].hex())# ==> ek_len = 1184# ==> dk_len = 2400# ==> ct_len = 1088# ==> ss_len = 32# ==> rho[:8] = e2212400769de8e1# ==> sigma[:8] = ea2f872a82cc0c20Every Python block this chapter prints is also a standalone file in the companion repository, under chapter-code/ch11/, one file per block. Appendix C covers the clone and the environment they run on.
The key generator takes the -byte seed and applies to the concatenation , where the byte is a domain separator equal to the module rank. The two -byte halves of become the matrix seed and the noise seed . FIPS 203 fixes the exact byte order so two implementations hashing the same produce identical derived seeds (National Institute of Standards and Technology, 2024). K-PKE.KeyGen and the ML-KEM wrapper are verified against the NIST test vectors by pytest tests/ch11/test_vectors.py. The committed fixture files live at tests/ch11/vectors/ml_kem_{512,768,1024}_acvp.json. The fixtures are vendored from the NIST ACVP server. The FIPS-validation program that consumes those vectors is the Cryptographic Algorithm Validation Program (CAVP), which is the name used in the standard for the testing scope referenced by FIPS 203 §6 (National Institute of Standards and Technology, 2024).
The ML-KEM math preliminaries
Section titled “The ML-KEM math preliminaries”Four objects show up repeatedly: the ring and its partial NTT, the Module-LWE problem at rank , the centered binomial distribution , and the compression and decompression maps on .
The ring at . The ring is . The integer is prime. The integer divides , so has a primitive -th root of unity. The integer does not divide , so has no primitive -th root (National Institute of Standards and Technology, 2024). There are primitive -th roots in . FIPS 203 fixes as the specific one used by ML-KEM and notes that , which can be verified directly.
The polynomial factors over into quadratic polynomials rather than into linear polynomials, because only a primitive -th root of unity is available. The factorization is
where reverses the seven-bit binary representation of . The NTT on is therefore a partial NTT: it splits into a product of quadratic extensions rather than into copies of . Multiplication in the NTT domain is not pointwise: each adjacent pair of coefficients represents a degree- polynomial in a different quadratic extension, and the base-case multiply takes two such pairs to a new pair. The section on the specialized partial NTT below gives the full definition. FIPS 203 §4.3 and Algorithms 9, 10, 11, and 12 formalize it (National Institute of Standards and Technology, 2024).
Module-LWE at rank . Chapter 9 introduced Module-LWE as the intermediate problem between Ring-LWE and flat LWE (Langlois & Stehlé, 2015). A general Module-LWE instance at rank with samples draws a secret , a matrix , and an error . The instance emits the sample with . In K-PKE the number of rows is set equal to the module rank, so and . The three ML-KEM parameter sets instantiate this at corresponding to ML-KEM-, ML-KEM-, and ML-KEM-. The ring and modulus are fixed at across all three, so scaling security is done by raising , not by enlarging the ring.
The centered binomial distribution. ML-KEM draws secret and error polynomials from , a discrete distribution with support (National Institute of Standards and Technology, 2024). The definition is
where each and is an independent fair coin flip. At the support is with probabilities . At the support is with the corresponding binomial weights. The distribution is symmetric and has mean zero and variance , so has variance and has variance . FIPS 203 §4.2.2 uses in place of the toy uniform noise from Chapter 10’s pedagogical Regev (National Institute of Standards and Technology, 2024) (Regev’s original paper used discrete Gaussians, not uniform: the uniform sampler in Chapter 10 was a teaching simplification).
Compression and decompression. A map takes and returns the index in of the nearest multiple of on the cycle:
where is round-half-up to the nearest integer (National Institute of Standards and Technology, 2024). The inverse maps back into . Compression is lossy. The round-trip error is bounded by the nearest-multiple half-width:
where the absolute value is taken in symmetric representatives on . At and the bound is . At the bound is . These two numbers feed directly into the K-PKE noise budget, which has to cover the Module-LWE error on top of the compression error. The special case governs message encoding: and at , by round-half-up at the midpoint . The encoded message therefore lives in rather than (National Institute of Standards and Technology, 2024).
The extended noise budget. K-PKE decrypts correctly whenever the total symmetric-representative distance between the decoder input and the encoded message (which equals , an element of ) stays below in each coordinate. The decoder input is , where and are the decompressed ciphertext parts. The compressed ciphertext decompresses to , so expanding with the secret-cancellation identity from Chapter 10 gives
where is the encryption ephemeral vector (FIPS 203 Algorithm 14’s name, and the chapter reserves for the -byte coin seed), and , are the compression errors on and respectively. The compression error on enters the decoder multiplied by the secret, as , not as a bare , because the decompressed ciphertext is and the inner product with propagates the term through.
The two Module-LWE inner-product terms and each combine ring multiplications of -distributed polynomials, so each output coefficient is itself a signed sum of products of CBD samples under negacyclic convolution. The scalar term is a single -sampled polynomial in with no convolution. The secret-multiplied compression term inherits the same -ring-product structure as , with the role of played by the per-coefficient compression error bounded by . That asymmetry between and is why FIPS 203 spends the finer compression width on : reaches the decoder bare, while arrives amplified through products with the secret.
The aggregate concentrates around its typical value by sub-Gaussian arguments. Correctness requires the coefficient error to stay within the -sized decoding region around the encoded value in each coordinate. Because FIPS 203 rounds half-integers up and bit encodes to , the exact decode boundary is asymmetric by one integer. Errors of through decode correctly around , but only through around . The chapter treats as the conceptual noise-budget threshold and relies on the FIPS 203 decapsulation-failure analysis for the exact rate.
FIPS 203 chooses so the probability that any coordinate leaves the decoding region, the decryption failure rate , is cryptographically negligible across all three parameter sets. FIPS 203 §3.2 Table 1 lists the decapsulation failure rates as for ML-KEM-512, for ML-KEM-768, and for ML-KEM-1024 (National Institute of Standards and Technology, 2024).
Step-by-step construction
Section titled “Step-by-step construction”The construction walks from the byte-level primitives outward to K-PKE and the ML-KEM wrapper. Inline blocks use numpy and hashlib only so they run standalone. The full implementation is at solutions/ch11-mlkem/.
Serialization: ByteEncode and ByteDecode
Section titled “Serialization: ByteEncode and ByteDecode”FIPS 203 §4.2.1 (Algorithms 5 and 6) specifies bit-packed serialization of polynomials via ByteEncode_d and ByteDecode_d (National Institute of Standards and Technology, 2024). A length- polynomial with coefficients in (or for ) packs into exactly bytes using bits per coefficient. The encoding is little-endian within each -bit field and little-endian across fields. ByteEncode_d and ByteDecode_d are inverses when the input coefficients fit in bits. For the decoder reduces modulo so malformed -bit values get canonicalized.
The direct-integer form is short. Treat the coefficients as digits of a large integer in base and let int.to_bytes emit the little-endian byte string. The decoder reverses the packing with int.from_bytes and a bit mask.
import numpy as np
Q = 3329N = 256
def byte_encode_d(f, d): assert f.shape == (N,) # Precondition: for d < 12 the coefficients are already reduced # modulo 2^d; for d = 12 they are reduced modulo q. FIPS ByteEncode # does not itself mask, so assert the range rather than rely on it. if d == 12: assert bool(np.all((0 <= f) & (f < Q))) else: assert bool(np.all((0 <= f) & (f < (1 << d)))) mask = (1 << d) - 1 big = 0 for i in range(N): big |= (int(f[i]) & mask) << (i * d) return big.to_bytes(32 * d, "little")
def byte_decode_d(B, d): assert len(B) == 32 * d mask = (1 << d) - 1 big = int.from_bytes(B, "little") f = np.zeros(N, dtype=np.int64) for i in range(N): coeff = (big >> (i * d)) & mask if d == 12: coeff %= Q f[i] = coeff return f
rng = np.random.default_rng(seed=20260411)f = rng.integers(0, Q, size=N, dtype=np.int64)encoded = byte_encode_d(f, 12)decoded = byte_decode_d(encoded, 12)print("encoded length =", len(encoded))print("round trip equal =", bool(np.array_equal(decoded, f)))# ==> encoded length = 384# ==> round trip equal = TrueEvery polynomial in serializes into bytes at , and every compressed polynomial serializes into bytes. A vector in just concatenates such byte strings. ML-KEM-768 encodes the public vector at , producing bytes, and appends the -byte matrix seed , giving the -byte encapsulation key computed earlier. The byte layer is the contract with the NIST test vectors: any implementation that matches test vectors must produce identical bytes at this layer.
Hash primitives: H, G, PRF, XOF, J
Section titled “Hash primitives: H, G, PRF, XOF, J”ML-KEM uses five hash primitives, all built from the Keccak permutation exposed by Python’s hashlib, and all fixed by FIPS 203 §4.1 (National Institute of Standards and Technology, 2024).
| Function | Instantiation | Absorbs | Squeezes | Used for |
|---|---|---|---|---|
| SHA3-256 | any byte string | bytes | hashing , into and into ‘s input | |
| SHA3-512 | any byte string | bytes, split in two | in KeyGen, in Encaps | |
| SHAKE-256 | -byte seed, one-byte nonce | bytes | the CBD sampler | |
| SHAKE-128 | -byte seed, two index bytes | streamed | the rejection sampler for | |
| SHAKE-256 | any byte string | bytes | the implicit-rejection value in Decaps |
is called at two sites with two different labelings for its two halves. The K-PKE KeyGen construction below labels the split , the two seeds for and for CBD noise; the KEM’s encapsulation and decapsulation paths label it , the shared-secret half and the re-encryption randomness. The two labelings name the same 64-byte-into-two-32-byte-halves pattern applied in different call sites.
From-scratch Keccak lives elsewhere. The construction below treats SHA-3 and SHAKE as opaque oracles.
import hashlib
def H(data): return hashlib.sha3_256(data).digest()
def G(data): digest = hashlib.sha3_512(data).digest() return digest[:32], digest[32:]
def PRF(eta, seed, nonce): assert eta in (2, 3) shake = hashlib.shake_256() shake.update(seed + bytes([nonce])) return shake.digest(64 * eta)
def XOF(shake_input, outlen): shake = hashlib.shake_128() shake.update(shake_input) return shake.digest(outlen)
def J(data): return hashlib.shake_256(data).digest(32)
# A sanity check: G applied to the empty byte string is SHA3-512 of# the empty byte string split into two 32-byte halves. Compare the# concatenation against the canonical FIPS 202 digest.k_half, r_half = G(b"")full = k_half + r_halfexpected = bytes.fromhex( "a69f73cca23a9ac5c8b567dc185a756e97c982164fe25859e0d1dcc1475c80a6" "15b2123af1f5f94c11e3e9402c3ac558f500199d95b6d3e301758586281dcd26")print("H len =", len(H(b"abc")))print("PRF(2) len =", len(PRF(2, b"\x00" * 32, 0)))print("J len =", len(J(b"")))print("G matches SHA3-512 on empty input =", full == expected)# ==> H len = 32# ==> PRF(2) len = 128# ==> J len = 32# ==> G matches SHA3-512 on empty input = TrueThe nonce in PRF(eta, seed, nonce) is a one-byte domain separator. K-PKE.KeyGen uses nonces for the secret and nonces for the error , so the same seed produces independent-looking output for different rows. K-PKE.Encrypt uses nonces for the ephemeral vector , nonces for , and nonce for the scalar . The nonce discipline is fixed by FIPS 203 so two implementations produce identical derived randomness.
Sampling: CBD_eta and rejection-sampled uniform
Section titled “Sampling: CBD_eta and rejection-sampled uniform”Two distinct samplers feed K-PKE. The first is (FIPS 203 Algorithm 8), which takes a -byte string from the PRF and produces a polynomial with coefficients drawn from the centered binomial distribution. The second is the uniform-NTT sampler (FIPS 203 Algorithm 7), which rejection-samples bytes from the XOF stream until it has coefficients strictly less than . The uniform sampler is used to expand the matrix directly in the NTT domain from a -byte seed .
import hashlibimport numpy as np
Q = 3329N = 256
def _bytes_to_bits(b): bits = [] for byte in b: for j in range(8): bits.append((byte >> j) & 1) return bits
def cbd_eta(byte_string, eta): assert len(byte_string) == 64 * eta bits = _bytes_to_bits(byte_string) f = np.zeros(N, dtype=np.int64) for i in range(N): x = sum(bits[2 * i * eta + j] for j in range(eta)) y = sum(bits[2 * i * eta + eta + j] for j in range(eta)) f[i] = (x - y) % Q return f
def sample_ntt(shake_input): shake = hashlib.shake_128() shake.update(shake_input) bytes_requested = 168 * 5 raw = shake.digest(bytes_requested) idx = 0 out = np.zeros(N, dtype=np.int64) j = 0 while j < N: if idx + 3 > len(raw): bytes_requested += 168 raw = shake.digest(bytes_requested) b0, b1, b2 = raw[idx], raw[idx + 1], raw[idx + 2] idx += 3 d1 = b0 | ((b1 & 0x0F) << 8) d2 = (b1 >> 4) | (b2 << 4) if d1 < Q: out[j] = d1 j += 1 if j < N and d2 < Q: out[j] = d2 j += 1 return out
# An all-zero CBD input at eta=2, standing in for PRF output, produces# the zero polynomial because every paired-bit x = y = 0 gives f[i] = 0.# Other inputs produce coefficients in {-2, -1, 0, 1, 2} in Z_q.f_zero = cbd_eta(b"\x00" * 128, 2)print("CBD(0 bytes, eta=2) sums =", int(f_zero.sum()))
# The rejection sampler consumes bytes until it has accepted 256# coefficients below q.# Two distinct seeds produce distinct polynomials with overwhelming# probability.a = sample_ntt(b"\x00" * 34)b = sample_ntt(b"\x01" * 34)print("sample_ntt output length =", int(a.shape[0]))print("distinct seeds distinct output =", bool(not np.array_equal(a, b)))# ==> CBD(0 bytes, eta=2) sums = 0# ==> sample_ntt output length = 256# ==> distinct seeds distinct output = TrueThe CBD sampler reads the bits in consecutive blocks of , one block per coefficient. The coefficient is the number of ones in the block’s first bits minus the number of ones in its last bits (FIPS 203 Algorithm 8: ). At the result is an integer in . The sampler reduces modulo to keep outputs in the canonical range , so negative samples appear as values close to .
The rejection sampler interprets the SHAKE-128 byte stream in groups of three, reading each group as two -bit candidates. A candidate in is accepted; a candidate in is rejected. The rejection rate is , so roughly one in five candidates is dropped and accepted coefficients typically need fewer than bytes of SHAKE output. The sampler requests bytes up front as headroom and squeezes more if the rare unlucky stream runs out.
The specialized partial NTT at (256, 3329)
Section titled “The specialized partial NTT at (256, 3329)”FIPS 203 §4.3 defines a specialized NTT on (National Institute of Standards and Technology, 2024). The map is not the full negacyclic NTT from Chapter 9 because has a primitive -th root of unity () but not a primitive -th root. The ring therefore splits into quadratic extensions rather than copies of . The forward NTT runs an iterative Cooley-Tukey butterfly scheme (FIPS 203 Algorithm 9) with bit-reversed twiddle factors. The inverse (Algorithm 10) runs Gentleman-Sande butterflies and scales by at the end. Multiplication in the NTT domain is not pointwise: FIPS 203 Algorithm 11 runs BaseCaseMultiply over pairs of adjacent coefficients, each representing a degree- polynomial in a different factor. The gamma values are .
The NTT is the only primitive in the chapter that is long enough to warrant a dedicated code block. The inline block below defines bit_rev_7, the zeta tables, the forward and inverse NTT, and the multiply_ntts routine that runs BaseCaseMultiply over the slots. A self-consistency check verifies that the round-trip inverse_ntt(multiply_ntts(ntt(f), ntt(g))) equals an independent schoolbook polynomial multiplication in .
import numpy as np
Q = 3329N = 256ZETA = 17INV_128 = pow(128, -1, Q) # 3303
def bit_rev_7(i): out = 0 for _ in range(7): out = (out << 1) | (i & 1) i >>= 1 return out
ZETAS_NTT = [pow(ZETA, bit_rev_7(k), Q) for k in range(128)]ZETAS_MUL = [pow(ZETA, 2 * bit_rev_7(k) + 1, Q) for k in range(128)]
def ntt(f): f_hat = np.asarray(f, dtype=np.int64).copy() % Q i = 1 length = 128 while length >= 2: start = 0 while start < N: zeta = ZETAS_NTT[i] i += 1 for j in range(start, start + length): t = (zeta * int(f_hat[j + length])) % Q f_hat[j + length] = (int(f_hat[j]) - t) % Q f_hat[j] = (int(f_hat[j]) + t) % Q start += 2 * length length //= 2 return f_hat
def inverse_ntt(f_hat): f = np.asarray(f_hat, dtype=np.int64).copy() % Q i = 127 length = 2 while length <= 128: start = 0 while start < N: zeta = ZETAS_NTT[i] i -= 1 for j in range(start, start + length): t = int(f[j]) f[j] = (t + int(f[j + length])) % Q f[j + length] = (zeta * (int(f[j + length]) - t)) % Q start += 2 * length length *= 2 return (f * INV_128) % Q
def multiply_ntts(f_hat, g_hat): out = np.zeros(N, dtype=np.int64) for i in range(128): gamma = ZETAS_MUL[i] a0, a1 = int(f_hat[2 * i]), int(f_hat[2 * i + 1]) b0, b1 = int(g_hat[2 * i]), int(g_hat[2 * i + 1]) out[2 * i] = (a0 * b0 + a1 * b1 * gamma) % Q out[2 * i + 1] = (a0 * b1 + a1 * b0) % Q return out
# Self-consistency check: the NTT product equals schoolbook convolution# in R_q, for a random polynomial pair.def schoolbook(f, g): h = np.zeros(N, dtype=np.int64) for i in range(N): fi = int(f[i]) for j in range(N): k = i + j term = (fi * int(g[j])) % Q if k < N: h[k] = (int(h[k]) + term) % Q else: h[k - N] = (int(h[k - N]) - term) % Q return h
rng = np.random.default_rng(seed=20260411)f = rng.integers(0, Q, size=N, dtype=np.int64)g = rng.integers(0, Q, size=N, dtype=np.int64)h_ntt = inverse_ntt(multiply_ntts(ntt(f), ntt(g)))h_school = schoolbook(f, g)print("NTT product equals schoolbook =", bool(np.array_equal(h_ntt, h_school)))print("ZETAS_NTT[1] =", ZETAS_NTT[1])# ==> NTT product equals schoolbook = True# ==> ZETAS_NTT[1] = 1729The check passes because the forward NTT, the inverse NTT, the bit-reversed zeta table, and the slot-wise BaseCaseMultiply are all correct by construction. The value is the first non-trivial twiddle and appears in FIPS 203 Appendix A (National Institute of Standards and Technology, 2024). It is a useful landmark rather than a complete test. A wrong primitive root changes it, and so does any bit-reversal error that touches index 1. A permutation error confined to positions the block does not print leaves it standing. The schoolbook cross-check is the independent computation path. Tautological checks like ntt(f) == ntt(f) would not catch bugs in the NTT implementation itself.
K-PKE.KeyGen
Section titled “K-PKE.KeyGen”K-PKE.KeyGen takes a -byte seed and emits the K-PKE public key and secret key . The derivation starts at , which splits the SHA3-512 digest into a matrix seed and a noise seed. The matrix is expanded directly in the NTT domain from via the rejection sampler, with one call per entry. FIPS 203 specifies the XOF input for entry as (column byte first, row byte second) so that two implementations reading the same produce identical matrix entries (National Institute of Standards and Technology, 2024).
The secret vector and error vector are sampled from using . K-PKE.KeyGen uses nonces for and nonces for , so the same seed produces independent-looking output for different rows and the two vectors never collide. Both vectors are then transformed into the NTT domain: and .
The public vector is , all multiplications in the NTT domain. The encapsulation key is , which has length . The decryption key is , of length . Both are stored in the NTT domain, not in the coefficient domain, because every subsequent multiplication will happen in NTT space.
A short inline block reproduces the first eight secret and error samples for ML-KEM-768 at the NIST tcId=26 seeds, using the noise seed derived above.
import hashlibimport numpy as np
def PRF(eta, seed, nonce): shake = hashlib.shake_256() shake.update(seed + bytes([nonce])) return shake.digest(64 * eta)
def cbd_eta(byte_string, eta): bits = [] for byte in byte_string: for j in range(8): bits.append((byte >> j) & 1) f = np.zeros(256, dtype=np.int64) for i in range(256): x = sum(bits[2 * i * eta + j] for j in range(eta)) y = sum(bits[2 * i * eta + eta + j] for j in range(eta)) f[i] = (x - y) % 3329 return f
# Reconstruct sigma from the NIST tcId=26 d seed.d = bytes.fromhex( "A2B4BCA315A6EA4600B4A316E09A2578AA1E8BCE919C8DF3A96C71C843F5B38B")k = 3sigma = hashlib.sha3_512(d + bytes([k])).digest()[32:]
# Sample s row 0 (nonce 0) and e row 0 (nonce k = 3) with eta_1 = 2.s_row_0 = cbd_eta(PRF(2, sigma, 0), 2)e_row_0 = cbd_eta(PRF(2, sigma, k), 2)
# Put the outputs into symmetric representatives for inspection.def sym(f): return [int(x) - 3329 if int(x) > 1664 else int(x) for x in f]
print("s_row_0[:8] =", sym(s_row_0[:8]))print("e_row_0[:8] =", sym(e_row_0[:8]))print("nonce 0 vs nonce 3 distinct =", bool(not np.array_equal(s_row_0, e_row_0)))# ==> s_row_0[:8] = [0, 0, -1, 0, 2, 0, 1, -1]# ==> e_row_0[:8] = [0, 0, 0, -1, 1, 0, 0, 0]# ==> nonce 0 vs nonce 3 distinct = TrueThe coefficients are in , the support of . The printed check establishes only that the two rows differ. Independence is a property of the PRF: under FIPS 203 the nonce is a domain separator, so and derived from the same under different nonces are computationally indistinguishable from independently sampled polynomials, and an array comparison cannot verify that. A bug in the nonce scheme (for example, reusing nonce for both and ) collapses the error into the secret and silently breaks the security reduction. Such a bug passes every round-trip test that runs the same implementation on both sides, because key generation, encryption and decryption stay mutually consistent, and nothing fails until an adversary exploits it. The byte-for-byte match against the NIST test vectors catches it at once, by comparing the resulting and bytes to the standard.
K-PKE.Encrypt
Section titled “K-PKE.Encrypt”K-PKE.Encrypt takes the bytes, a -byte message , and a -byte coin seed , and produces the compressed ciphertext . The first step decodes and the matrix seed from . FIPS 203 Algorithm 14 re-expands with the same byte order as KeyGen, , and then uses algebraically (line 19 of Algorithm 14 writes explicitly). The flagship code in solutions/ch11-mlkem/ exposes a transpose=True flag on sample_matrix_ntt that swaps the two suffix bytes to and materializes the transposed view directly. The row-wise product inside Encrypt therefore evaluates to without an explicit transpose operation. The byte-order swap is an implementation convenience, not a different FIPS matrix definition.
The encryption ephemeral randomness is sampled from the coin seed . The discipline uses with nonces for . It uses with nonces for . The scalar uses with nonce . The NTT brings into transform space. The ciphertext parts are
where is the dot product in the module and reads the bits of into a polynomial with coefficients in . Here maps and at .
The ciphertext is , total length . At ML-KEM-768 the two parts are bytes.
The derandomization from the coin seed means the same message under the same public key with the same coin seed produces the same ciphertext. K-PKE.Encrypt is therefore a deterministic function of its three inputs, which is essential for the FO re-encryption check inside Decaps to work.
K-PKE.Decrypt
Section titled “K-PKE.Decrypt”K-PKE.Decrypt takes and and recovers the message. The ciphertext splits into (the compressed ) and (the compressed ). Decompression undoes the rounding to produce approximations and in with residual error bounded by the compression noise formula from the preliminaries.
The decryption identity is the secret-cancellation from Chapter 10 with the extra compression and Module-LWE noise terms accounted for:
where is decoded from (FIPS 203 Algorithm 15 line 6: the decoder brings into the NTT domain with ntt, runs multiply_ntts slot-wise, sums, and returns to the coefficient domain with inverse_ntt). The recovered message is , which reads for coefficients closer to and for coefficients closer to .
A tiny Module-LWE round-trip at and demonstrates the secret-cancellation identity in the module setting without invoking the full ML-KEM NTT machinery. The parameters are Chapter 10’s flat-Regev toy at but with a module rank instead of a secret dimension . Each ring element is a single integer in , because at the ring is just .
import numpy as np
q = 97k = 2
rng = np.random.default_rng(seed=0)
# Module-Regev at k = 2, n = 1: secret and error live in Z_q^k,# matrix lives in Z_q^{k x k}, public vector is a Z_q^k column.s = rng.integers(-1, 2, size=k, dtype=np.int64) % qA = rng.integers(0, q, size=(k, k), dtype=np.int64)e = rng.integers(-1, 2, size=k, dtype=np.int64) % qt = (A @ s + e) % q
# Encryption: r, e1 in Z_q^k, e2 scalar. mu is the message bit.mu = 1r = rng.integers(-1, 2, size=k, dtype=np.int64) % qe1 = rng.integers(-1, 2, size=k, dtype=np.int64) % qe2 = int(rng.integers(-1, 2, dtype=np.int64)) % qu = (A.T @ r + e1) % qv = int((t @ r + e2 + ((q + 1) // 2) * mu) % q)
# Decryption: w = v - s^T u. Round to nearer of 0 or (q+1)//2 using# the same decode formula as Chapter 10 (midpoint -> 0).w = (v - int(s @ u)) % qhalf_q = q // 2decoded = ((2 * w + half_q) // q) % 2print("(q + 1) // 2 =", (q + 1) // 2)print("w =", w)print("decoded mu =", decoded, "expected =", mu)# ==> (q + 1) // 2 = 49# ==> w = 49# ==> decoded mu = 1 expected = 1The encoding multiplier is to mirror FIPS 203’s round-half-up convention at the prime modulus , rather than the symmetric . The decrypted value lands exactly on , so the decoder formula returns the expected bit . The noise at this tiny instance happens to cancel perfectly at seed . Other seeds give non-zero values inside the decoding half-width . The toy omits CBD noise, compression, and the NTT. Only the module structure and the secret-cancellation identity are preserved. The real ML-KEM-768 case is the same identity at with CBD noise and compression, and the FIPS 203 noise budget absorbs both.
The Fujisaki-Okamoto wrapper
Section titled “The Fujisaki-Okamoto wrapper”The K-PKE scheme above is IND-CPA, not IND-CCA2. If an attacker were given a useful decryption oracle on malformed ciphertexts, decoder-boundary behavior could leak information about the secret . The exact attack and query complexity depend on the chosen-ciphertext construction, which Chapter 13 treats in detail. FIPS 203 is explicit that K-PKE is not IND-CCA2-secure, must not be used standalone, and is approved only as a component inside ML-KEM (National Institute of Standards and Technology, 2024). The Fujisaki-Okamoto-style transform is designed to remove this useful oracle signal by making the encryptor’s randomness a deterministic function of the message and the public key. On decapsulation the decapsulator re-runs the encryptor and verifies the ciphertext against the re-encryption (Fujisaki & Okamoto, 1999; Hofheinz et al., 2017).
ML-KEM.KeyGen internally takes two -byte seeds, and . The seed feeds K-PKE.KeyGen, and is the implicit-rejection seed, stored inside for use only by the decapsulator. The decapsulation key is . The hash is precomputed so decapsulation does not rehash the public key on every call, and the rejection seed rides along.
ML-KEM.Encaps takes the public key and a -byte message . The “internal” form with an explicit is the one that matches NIST test vectors; the external form draws uniformly at random. Encaps computes , runs K-PKE.Encrypt with the derandomized coin seed , and returns . The shared secret is a hash of the message and the public-key commitment. The coin seed is the other half of the same hash.
ML-KEM.Decaps takes and . It runs K-PKE.Decrypt to recover a candidate message , then recomputes . It re-runs K-PKE.Encrypt to produce and compares against . On a match the shared secret is . On a mismatch the shared secret is the pseudorandom rejection value , where truncated to bytes. The rejection branch returns the same for the same pair. Flipping any byte of gives a different . An adversary tampering with the ciphertext cannot extract information through this oracle, because the returned is independent of the secret except through the hash.
m to c. Decaps recovers m', re-runs the pipeline to get c', and returns K' on match or the pseudorandom rejection value J(z || c) on mismatch.The diagram makes the FO wrapper shape visible. The decapsulator runs K-PKE.Encrypt a second time, which is the costly operation. The equality check at the end is cheap. The rejection branch has no algebraic dependence on the K-PKE secret . That structural property is what the FO-style wrapper relies on to argue that no useful decryption-oracle signal leaks under the ROM (or QROM) idealization of , , and .
A mock demonstration of the re-encryption check, using stand-in K-PKE functions, illustrates the structure without pulling the full package.
import hashlib
def H(data): return hashlib.sha3_256(data).digest()
def G(data): digest = hashlib.sha3_512(data).digest() return digest[:32], digest[32:]
def J(data): return hashlib.shake_256(data).digest(32)
# Mock K-PKE: encryption prepends m to a deterministic tag derived# from (ek, r); decryption reads m from the first 32 bytes. This is# not the real Module-Regev construction — it exists only to exercise# the FO wrapper's re-encryption check at the byte level. What# matters for the mock: it round-trips on honest inputs and diverges# when any byte of the ciphertext is flipped.def mock_kpke_encrypt(ek, m, r): tag = hashlib.sha3_256(ek + r).digest() return m + tag
def mock_kpke_decrypt(dk_pke, c): return c[:32]
ek = b"public key bytes".ljust(32, b"\x00")m = b"message_of_exactly_32_bytes_okay"z = b"implicit_rejection_seed_32_bytes"dk_pke = b"stub dk_pke, unused by the mock" + b"\x00"
# Encapsulate.K, r = G(m + H(ek))c = mock_kpke_encrypt(ek, m, r)
# Decapsulate: recover m', recompute (K', r'), re-encrypt, compare.m_prime = mock_kpke_decrypt(dk_pke, c)K_prime, r_prime = G(m_prime + H(ek))c_prime = mock_kpke_encrypt(ek, m_prime, r_prime)if c == c_prime: K_out = K_primeelse: K_out = J(z + c)
# Honest path: K matches K_prime.print("honest decapsulation matches encapsulation K =", K_out == K)
# Tampered path: flip a byte of c and observe the rejection branch.c_tampered = bytes([c[0] ^ 0xFF]) + c[1:]m_prime_bad = mock_kpke_decrypt(dk_pke, c_tampered)K_prime_bad, r_prime_bad = G(m_prime_bad + H(ek))c_reenc = mock_kpke_encrypt(ek, m_prime_bad, r_prime_bad)if c_tampered == c_reenc: K_bad = K_prime_badelse: K_bad = J(z + c_tampered)print("tampered decapsulation returns J(z || c) =", K_bad == J(z + c_tampered))print("tampered K != honest K =", K_bad != K)# ==> honest decapsulation matches encapsulation K = True# ==> tampered decapsulation returns J(z || c) = True# ==> tampered K != honest K = TrueThe mock exercises the control flow only. The real K-PKE.Encrypt is the Module-Regev construction built in the preceding sub-sections. What matters is the wrapper shape: the decapsulator always runs K-PKE.Decrypt, always recomputes , always re-encrypts, and always compares. There is no early return on a decryption-failure signal. An adversary timing the decapsulator sees the same work regardless of whether the ciphertext was tampered or honest, up to the data-dependent branches inside the mock encryptor. FIPS 203 §6.3 specifies that decrypt, recompute, re-encrypt, compare sequence as ML-KEM.Decaps_internal, Algorithm 18 (National Institute of Standards and Technology, 2024). The production form is in the ch11-mlkem package under solutions/, lifted to the full K-PKE over Module-LWE.
The Python if c == c_prime: branch in the mock is pedagogical. A real ML-KEM decapsulator must perform the ciphertext comparison and the shared-secret selection in constant time. FIPS 203 §6.3 calls the implicit-rejection flag a secret piece of intermediate data that must be destroyed before decapsulation returns. It must therefore not leak through timing, branch, or memory-access behavior either.
Input checks before Encaps and Decaps. FIPS 203 §7.2 and §7.3 require an implementation to validate inputs before running the security-sensitive path, and name five checks between them (National Institute of Standards and Technology, 2024).
| FIPS 203 check | Where | What it tests |
|---|---|---|
| Type check | Encaps | is exactly bytes |
| Modulus check | Encaps | survives a then round trip unchanged |
| Ciphertext type check | Decaps | is exactly bytes |
| Decapsulation key type check | Decaps | is exactly bytes |
| Hash check | Decaps | the hash stored in equals a fresh of the embedded beside it |
The round-trip equality is the actual modulus check because always reduces each 12-bit chunk modulo , so a malformed chunk encoding a value outside does not survive re-encoding. Failed checks return an error before the secret-dependent code path runs. FIPS 203 draws one operational distinction: the encapsulation-key and decapsulation-key checks need not be repeated on every operation if assurance of key validity has been obtained elsewhere, but the ciphertext check must be performed on every execution of ML-KEM.Decaps.
The ch11-mlkem package under solutions/ implements the §6 internal algorithms, which are the interfaces the ACVP vectors address, so it asserts the three length checks at the top of ml_kem_encaps_internal and ml_kem_decaps_internal and implements neither the modulus check nor the hash check. That split follows the standard: FIPS 203 attaches input checking to ML-KEM.Encaps and ML-KEM.Decaps in §7, which wrap the internal algorithms rather than living inside them. A production KEM has to add the other two.
Cryptanalysis and known attacks
Section titled “Cryptanalysis and known attacks”Chapter 13 handles the full lattice cryptanalysis. FIPS 203 §8 states that ML-KEM-512, ML-KEM-768, and ML-KEM-1024 are claimed to be in NIST security categories 1, 3, and 5 respectively (National Institute of Standards and Technology, 2024). Table 2 itself gives the parameter tuples and required RBG strength. The category mapping appears in the surrounding §8 text.
Those categories come from NIST’s PQC evaluation framework, which rates a scheme by the resources an attack needs relative to reference attacks on generic primitives rather than by a single bit-security number. For the parameter sets used by ML-KEM, categories 1, 3, and 5 correspond to reference security levels based on the cost of key recovery against AES-128, AES-192, and AES-256 respectively; categories 2 and 4 use hash-collision reference problems. The CRYSTALS-Kyber submission package priced the tuples with the core-SVP methodology, over the attack families the Albrecht-Player-Scott survey catalogues (Albrecht et al., 2015; Avanzi et al., 2021, sec. 5.1), and Chapter 13 re-runs that estimate against the ML-KEM parameters and reads its cost estimates against the published category claims, under a stated attack and cost model, rather than deriving the table from them.
Against K-PKE alone (with no FO wrapper), an adversary with a decryption oracle recovers information about the secret vector by probing decoder decisions near the noise-budget boundary. The structural shape of the attack: pick a ciphertext whose decryption value hugs a decoder boundary. Observe whether the decoded bit is what the attacker expected, and deduce information about one coordinate of per query. The exact query complexity depends on the attack model and ciphertext construction. Chapter 13 walks one such construction in full against a small-secret variant of the Chapter 10 flat Regev PKE and notes what changes for K-PKE’s centered-binomial secret. The point here is that K-PKE is IND-CPA, not IND-CCA2, and needs wrapping.
The FO-style wrapper is designed to remove the useful decryption-oracle signal by re-encrypting and implicitly rejecting malformed ciphertexts. A tampered ciphertext returns the pseudorandom rejection value , which depends on (secret but fixed per key) and (public) but has no algebraic dependence on the K-PKE secret . An adversary who flips bits and observes learns a random function of and nothing about in the random oracle model.
The Hofheinz-Hövelmanns-Kiltz IND-CCA2 theorem (Hofheinz et al., 2017) makes this rigorous: it requires K-PKE to be OW-CPA and -correct, and concludes IND-CCA2 in the random oracle model with an explicit reduction loss. The rigidity the second half of the transform relies on (decrypting a ciphertext and re-encrypting the result gives back the same ciphertext or rejects) is not assumed of K-PKE. The theorem proves it of the derandomized scheme in the middle of the transform, which encrypts under coins and re-encrypts on decryption. The security statement is reduction-based and depends on the underlying Module-LWE assumption, the ROM (or QROM, for the quantum-attacker refinement) idealization of , , and , and the small decapsulation-failure probability from FIPS 203 §3.2 Table 1. Constant-time implementation discipline at the decapsulator is also required. ML-KEM is therefore “believed to satisfy IND-CCA2” under these assumptions rather than unconditionally (National Institute of Standards and Technology, 2024).
Tradeoffs inside Part II
Section titled “Tradeoffs inside Part II”ML-KEM’s ring and modulus are fixed across all three parameter sets. The primary security lever is the module rank , with smaller parameter-set adjustments to and, for ML-KEM-1024, the ciphertext compression widths. The alternatives fall along two axes: stay with lattices but change the ring structure, or leave lattices entirely.
- ML-KEM versus flat Regev (Chapter 10): flat Regev is conceptually simpler and has no NTT, but its public key scales as bytes. A Chapter-10 Regev instance matching ML-KEM-768’s security would need an enormous and would have public keys in the megabyte range. Chapter 13’s tradeoffs draw the same comparison, without fixing a parameter set for it. ML-KEM’s ring structure cuts this to roughly kilobytes by amortizing coefficients across each matrix entry.
- ML-KEM versus a Ring-LWE KEM (Chapter 9’s Ring-LWE setting, whose encryption descendant Chapter 10 builds): a Ring-LWE KEM at would be even smaller than ML-KEM-768, but Module-LWE’s rank parameter provides a cleaner security lever. Scaling security in Ring-LWE means enlarging , which changes the ring, the NTT, and every other primitive. Scaling Module-LWE means raising , which keeps the ring fixed and just adds more matrix rows and more PRF calls. That is what the parameter table above shows: the three sets share the ring, the NTT, and the hash and XOF layer, and differ in plus two compensating adjustments. ML-KEM-512’s buys back noise that the smaller rank gives up; ML-KEM-1024’s wider holds below against the larger noise sums (National Institute of Standards and Technology, 2024).
- ML-KEM versus non-lattice KEMs (Part IV): code-based KEMs rely on different hardness assumptions and trade different costs against bandwidth. HQC (Chapter 21) is the only other post-quantum KEM NIST has selected at Category 3, in the Round 4 KEM selection of March 2025 (National Institute of Standards and Technology, 2025), with the final standard expected in 2027 per NIST’s announcement of that selection (National Institute of Standards and Technology, 2025b). It rests on the syndrome-decoding problem rather than Module-LWE (Gaborit et al., 2025). ML-KEM-768 at -byte and -byte ciphertext (Table 3 in National Institute of Standards and Technology, 2024) is currently the only FIPS-standardized post-quantum KEM at Category 3. HQC-3, the current Category-3 profile in the August 2025 HQC specification, lists a -byte encapsulation key and an -byte ciphertext, both several times larger than ML-KEM-768.
SLH-DSA (Chapter 17), ML-DSA (Chapter 12), and SQIsign (Chapter 23) are signature schemes, not KEM alternatives. The comparison there is by primitive role rather than by ciphertext size, and each of those chapters discusses its own bandwidth tradeoffs. FO-style transforms are common across the lattice KEMs in the standardization pool, but the exact hash schedule, rejection behavior, and byte layout differ by scheme. ML-KEM’s particular FO-style hash schedule and implicit-rejection behavior are design choices, not consequences forced by Module-LWE alone (Hofheinz et al., 2017). Saber, FrodoKEM, NTRU, and ML-KEM therefore produce different ciphertext bytes even when their wrapper logic follows the same broad re-encrypt-and-check pattern. Chapter 13 attacks exactly the Module-LWE instance ML-KEM uses.
Chapter 12 is the immediate next step. ML-DSA keeps this chapter’s module-lattice setting and changes the goal from key encapsulation to signing, and that changes almost everything downstream. It rests on Module-SIS for unforgeability as well as Module-LWE for key recovery, and it picks a modulus admitting the full -point NTT rather than this chapter’s partial one. Rejection sampling, not a Fujisaki-Okamoto wrapper, is what makes its output safe to publish.
Exercises
Section titled “Exercises”-
Compression noise at . Run a numpy sweep over and verify . Compute the closed-form upper bound at and , then sweep all at each width to compare the bound against the actual maximum symmetric error. The bound is at but the sweep maximum is ; at both equal . Explain why the closed form is an upper bound rather than always the exact maximum.
-
CBD versus uniform noise (stress test). Replace
sample_poly_cbd(eta, seed, nonce)in thech11-mlkempackage undersolutions/with a uniform sampler over . The replacement takes the same three arguments and returns the same shape: a degree-255 polynomial whose coefficients are drawn from the given support. The FIPS-203 decapsulation-failure rates ( for ML-KEM-768) are too small to observe in any feasible Monte Carlo. Artificially lower the noise budget first: drop from to and raise from to , leaving , then run the K-PKE round-trip times under each sampler and compare the failure counts. Lower the budget much further than that and the comparison stops working, because both samplers fail on essentially every trial. Explain why the uniform distribution’s heavier tail produces failures that the centered binomial’s tighter tail suppresses at the same support width. -
Tampered ciphertext decapsulation. Run ML-KEM-768 key generation at the NIST tcId seed, encapsulate under an arbitrary to produce honest , flip one byte of to produce , and decapsulate . Confirm that the returned shared secret equals exactly, not the honest . Repeat with a different byte flip and observe a different rejection value. Explain which property of the hash makes this enough to block a decryption oracle.
-
The matrix transpose trick. FIPS 203 Algorithm 14 specifies in K-PKE.Encrypt with the same byte order as K-PKE.KeyGen, and then uses algebraically on line 19. Read
sample_matrix_nttin thech11-mlkempackage undersolutions/and verify the two paths agree. Calling it withtranspose=True(which swaps the two suffix bytes to ) and then computing should give the same product as calling it withtranspose=Falseand then computing . Write a small pytest that asserts the two approaches give identical tensors. The implementation equivalence is the point. FIPS itself stays in the original orientation and applies the transpose algebraically.
Worked solutions and editorial notes for these exercises are in Appendix D, Chapter 11. A separate track, for rebuilding rather than reading: the package exercises/ch11-mlkem has every function the chapter teaches replaced by a stub. Run PQC_IMPL=exercises pytest tests/ch11 to grade your version against the suite that proves the reference one.
References
Section titled “References”Last updated: