Appendix D: Solutions for Chapter 11
This page collects solutions and editorial notes for the exercises in Chapter 11: ML-KEM (FIPS 203) from scratch. Compute and derivation exercises have worked solutions; open-ended exercises have an editorial note describing what a strong answer addresses.
The fuller versions of these routines are in the mlkem package under solutions/ch11-mlkem. From a clone of the companion repository, pytest tests/ch11 runs its suite. Appendix C has the setup.
Exercise 1
Section titled “Exercise 1”The compression operation followed by the matching decompression introduces rounding error bounded by . With :
- : .
- : .
- : .
ML-KEM-768 picks , so the compression error per coefficient is at most 2 in and at most 105 in . The asymmetry is deliberate, and the reason is where each error lands in the decryption identity rather than how many coefficients it covers. The term reaches the decoder bare, so 105 against a decoding half-width of is cheap. The term reaches it as , a signed sum of products with secret coefficients, so it has to start far smaller.
Sweeping every alongside the closed form shows the two agree at and but not at .
import math
q = 3329
def compress(x, d): return ((x * 2 ** d) + q // 2) // q % 2 ** d
def decompress(y, d): return ((q * y) + 2 ** (d - 1)) // 2 ** d
def sym(z): z %= q return z - q if z > q // 2 else z
for d in (10, 11, 4): bound = math.ceil(q / 2 ** (d + 1)) worst = max(abs(sym(decompress(compress(x, d), d) - x)) for x in range(q)) print(d, bound, worst)# ==> 10 2 2# ==> 11 1 1# ==> 4 105 104The closed form is an upper bound rather than the exact maximum because the decompression points are themselves integers. rounds to the nearest integer, so consecutive points sit or apart rather than exactly apart. Since is an integer too, the worst round-trip error is for the widest gap , while the closed form is . The two agree exactly when is even. At the gaps are 208 and 209, giving against a bound of 105; at they are 3 and 4, giving , which is the bound. The same arithmetic at gives gaps 1664 and 1665 and a true maximum of 832, which is the chapter’s decoding half-width.
Exercise 2
Section titled “Exercise 2”The centered binomial at produces values in with the binomial probabilities , so has probability and each have probability . The uniform distribution over the same range has every value at probability . The decryption-failure analysis depends on the variance and tail of the noise after polynomial multiplication. CBD has variance at ; uniform on has variance , twice as large.
The replacement sampler is a few lines, and the variance ratio is the number to have in hand before running anything.
import numpy as np
Q = 3329
def sample_poly_uniform(eta, seed, nonce): """Drop-in for sample_poly_cbd: flat on {-eta, ..., eta}.""" rng = np.random.default_rng(int.from_bytes(seed[:8], "little") ^ nonce) return rng.integers(-eta, eta + 1, size=256, dtype=np.int64) % Q
def cbd_variance(eta): return eta / 2
def uniform_variance(eta): return ((2 * eta + 1) ** 2 - 1) / 12
for eta in (2, 3): print(eta, cbd_variance(eta), uniform_variance(eta))
f = sample_poly_uniform(3, b"\x00" * 32, 0)sym = [int(x) - Q if int(x) > Q // 2 else int(x) for x in f]print(sorted(set(sym)))# ==> 2 1.0 2.0# ==> 3 1.5 4.0# ==> [-3, -2, -1, 0, 1, 2, 3]Set and , leaving everything else at ML-KEM-768. Over K-PKE round trips, the centered binomial fails 0 times and the uniform sampler fails 2,840, a rate of . The budget still absorbs completely at these widths, so the whole difference is attributable to the distribution rather than to the narrowed budget.
The usable window is narrower than it looks. At the real neither sampler fails once in trials, and at both fail on essentially every trial, because the budget is gone outright and the distribution no longer matters. The comparison only exists in between.
The mechanism is the heavier tail. At the centered binomial puts on each of where uniform puts , about nine times as much. The terms do not all scale alike. Each coefficient of is a signed sum of products of two resampled values, and for independent centered factors the variance of a product is the product of the variances, so that term grows by . The term pairs an factor with an factor and grows by . The bare doubles. The compression term , which at carries about of the centered-binomial total, grows by exactly because only is resampled, and does not change. Weighted together, the standard deviation of the decoder input grows from about to about , a factor of , against a fixed decoding half-width of . Under a normal approximation that moves the per-coefficient overflow probability from about to about , and spread over 256 coordinates the second figure is a failure rate near , which is what the measured reflects.
Exercise 3
Section titled “Exercise 3”Decapsulation of a tampered ciphertext fails the FO re-encryption check, so the implicit-rejection branch fires and the returned shared secret is the rejection value , where is the per-key rejection seed stored in . The argument to is the ciphertext as submitted, not the re-encryption the decapsulator computed and rejected. FIPS 203 Algorithm 18 fixes at step 7, before the comparison. Each distinct gives a distinct as far as any attacker can tell, since finding two ciphertexts with the same rejection value is a collision on .
The property that blocks the decryption oracle is a different one. is indistinguishable from a random function to anyone who does not hold , so the returned value has no algebraic dependence on the K-PKE secret . An attacker who submits malformed ciphertexts therefore learns a random function of their own inputs, and nothing about whether the malformation hit the decoding band, the re-encryption check, or anything else.
Exercise 4
Section titled “Exercise 4”The two approaches are equivalent by the definition of matrix transpose: indexing the transposed matrix at returns the same value as indexing the original at . The transpose=True flag in sample_matrix_ntt is purely an indexing convention: it produces a matrix whose entry is what the non-transposed call would produce at , derived from the same XOF stream. The pytest is a one-liner that builds both and asserts elementwise equality of the resulting u_hat tensors after the inner-product loop.
The exercise reinforces that FIPS 203 samples one matrix from the public seed and uses it in two orientations. K-PKE.KeyGen takes it un-transposed, as at Algorithm 13 line 18. K-PKE.Encrypt takes it transposed, as at Algorithm 14 line 19. That pairing is what makes reduce to plus small terms, which is plus small terms, which is what cancels against . K-PKE.Decrypt (Algorithm 15) touches no matrix at all. The only matrix work inside Decaps happens in its re-encryption, and that is K-PKE.Encrypt, so it is transposed too.