Appendix D: Solutions for Chapter 12
These are worked solutions to the exercises in Chapter 12: ML-DSA (FIPS 204) from scratch. Compute-and-derive exercises get a worked answer; the more open-ended ones get an editorial note pointing at the reasoning rather than a single number.
The fuller versions of these routines are in the mldsa package under solutions/ch12-mldsa. From a clone of the companion repository, pytest tests/ch12 runs its suite. Appendix C has the setup.
Exercise 1
Section titled “Exercise 1”The expected number of trips through the abort loop is the reciprocal of the joint acceptance probability, and the two rejection tests are independent enough to multiply. Each coefficient of is a mask coefficient (uniform over values) plus a fixed secret-dependent shift, and it is accepted when it lands in the smaller range of values, so the per-coefficient acceptance probability is about . Over all coefficients the whole vector passes with probability roughly , and the low-part test on passes with probability roughly . At ML-DSA-65 those two factors are and , so the joint acceptance is and the expected repetition count is . FIPS 204 Table 1 gives the expected repetition count for ML-DSA-65 as . The in the same row is ML-DSA-44 and the is ML-DSA-87 (National Institute of Standards and Technology, 2024). The estimate matches the table, and that agreement is not confirmation, because both come from the same incomplete count. Algorithm 7 aborts in two places, not one. Line 23 runs the two tests above; line 28 rejects again, after the hint is built, when or the hint weight exceeds . Table 1’s figures come from equation 5 of the Dilithium round-3 submission, which prices line 23 only (Bai et al., 2021). NIST’s potential-updates sheet for FIPS 204, last revised 31 July 2026, records the omission: the repetition numbers are not quite accurate, as a result of the overall failure rate for signing being affected by line 28 of Algorithm 7, which had not been taken into account prior. The corrected values are , , and , and Table 3’s minimum allowable loop-iteration limit for internal signing moves with them, from to . Those are potential corrections rather than an official change to the standard. Read as the value of the two-test model and as the value of the loop this package actually runs. The ratio says line 28 throws away about of the attempts line 23 accepted. That share is largest at ML-DSA-44, near , because its is the narrowest of the three windows and is correspondingly the tightest.
Signing distinct messages under one key with the deterministic and averaging the traced iteration count gives a mean of , which is inside sampling error of both the two-test estimate and the corrected figure. The shape of that sample carries more than its mean. The median is and the longest attempt run is . Iteration counts are geometric, so the mean sits above the median and the tail is long, and a few hundred messages is not enough to pin the mean down: a -message batch from the same key averaged . Report the spread alongside the mean, or a sampling wobble reads as a disagreement with the standard.
Raising makes the first factor closer to , so fewer attempts are rejected and signing is faster, but it also widens the range each coefficient of can take, so packs at more bits per coefficient and the signature grows. ML-DSA-65 sits at , which packs at bits per coefficient and holds the mean repetition count low enough that signing is fast in practice. The parameter is a direct time-versus-size lever, and FIPS 204 fixed it where the signature stays small without the loop running long.
Exercise 2
Section titled “Exercise 2”The four scalar cores are the ones the chapter prints and the ch12-mldsa package under solutions/ ships. The tests in tests/ch12/test_mldsa_rounding.py pin them. The interesting part is the edge. Inside the window , adding to moves the Decompose high part by at most one step, because is smaller than the window width that separates adjacent high values, so a single bit (“did the high part change, and the sign of says in which direction”) is complete. One step past the window, at , the high part still moves by at most one step, but the direction can flip. A coefficient whose low part is small and positive sits just above its window center. A negative of magnitude can push it down across the lower boundary, the opposite of the direction the sign of predicts. UseHint reads the positive and nudges the high part up by one, while the true high part moved down by one, so the recovered value is off by two. That two-unit gap, not a two-window jump, is what the single bit cannot represent.
A concrete counterexample at ML-DSA-65’s :
Q = 8380417
def mod_pm(r, alpha): m = r % alpha return m - alpha if m > alpha // 2 else m
def decompose(r, gamma2): r %= Q r0 = mod_pm(r, 2 * gamma2) if r - r0 == Q - 1: return 0, r0 - 1 return (r - r0) // (2 * gamma2), r0
def high_bits(r, gamma2): return decompose(r, gamma2)[0]
def make_hint(z, r, gamma2): return 1 if high_bits(r, gamma2) != high_bits((r + z) % Q, gamma2) else 0
def use_hint(h, r, gamma2): m = (Q - 1) // (2 * gamma2) r1, r0 = decompose(r, gamma2) if h == 1: return (r1 + 1) % m if r0 > 0 else (r1 - 1) % m return r1
gamma_2 = (Q - 1) // 32 # 261888r, z = 3142657, -(gamma_2 + 1) # |z| = gamma_2 + 1, one past the windowprint("|z| =", abs(z), "= gamma_2 + 1 :", abs(z) == gamma_2 + 1)print("UseHint(MakeHint(z, r), r) =", use_hint(make_hint(z, r, gamma_2), r, gamma_2))print("HighBits(r + z) =", high_bits((r + z) % Q, gamma_2))# ==> |z| = 261889 = gamma_2 + 1 : True# ==> UseHint(MakeHint(z, r), r) = 7# ==> HighBits(r + z) = 5The recovered high value is where the true one is , off by two. The true high part moved down one window ( to ), but UseHint read the positive of the original and nudged up one window (to ). The coefficient crossed a single boundary, in the direction the sign of did not anticipate. This is why Sign’s second rejection test enforces . The hint corrects the perturbation , so that term has to stay strictly inside the window for the one-bit hint to be exact. An attempt where it does not is thrown away and resampled.
Exercise 3
Section titled “Exercise 3”Each tamper is caught by a different check, which is the point of the exercise. Corrupting a hint byte so a poly’s set positions are no longer strictly increasing is caught structurally: HintBitUnpack returns during signature decoding, and Verify returns false on the h is None guard before it computes anything. Flipping a byte inside the packed response is caught one of two ways. If the flip pushes some coefficient past , the norm test rejects it. If the coefficient stays in range, the altered changes the recomputed , so the recomputed high bits and the recomputed challenge no longer match . Flipping a byte of the challenge hash makes SampleInBall produce a different challenge , which changes and therefore , so the comparison fails.
No single check catches all three because they guard different layers. HintBitUnpack guards the wire format, rejecting a hint that is not a well-formed sparse encoding. The norm bound guards the size of the response, rejecting a that is too large to have come from an honest signer. The challenge recomputation guards the Fiat-Shamir binding, rejecting any signature whose response, hint, and challenge are not mutually consistent under the hash. A forger has to defeat all three at once, and the three are checked against independent properties of the signature.
Exercise 4
Section titled “Exercise 4”The challenge is , and w1Encode packs each high-bit coefficient of at bits: for ML-DSA-65 and for ML-DSA-44. Changing the ML-DSA-65 width from to makes w1Encode emit a different, longer byte string for the same . Both results follow from where that string is used.
The round trip still verifies because Sign and Verify call the same w1Encode. Widening it in one place widens it on both sides, so the two hash the same bytes, derive the same , and the comparison holds. Nothing else in the scheme reads the width: the norm bound on , the hint decoding, and the recomputation of are all unaffected. A signer and verifier that agree on the wrong width agree, and the widened pair is a self-consistent signature scheme. What it is not is ML-DSA, and it interoperates with nothing that packs at bits.
The second case is that mismatch made explicit. A signer that packs at bits against a verifier that still packs at , or the reverse, hash different inputs into , so they derive different challenge hashes, and the comparison fails on every signature. Only one side has to change for this: interoperability with the standardized encoding is lost the moment the two widths differ.
The lesson is that the byte layout is inside the security contract, not a free implementation choice. The Fiat-Shamir challenge commits to the exact encoded bytes of the commitment, so any two implementations that hope to interoperate must serialize identically down to the bit width. This is the same reason FIPS 204 fixes every packing width in the §7.2 encoders (Algorithms 22 through 28) rather than leaving it to the implementer: a width mismatch is not a cosmetic difference, it is a different signature scheme that happens to share the same key. The w1_bits helper in the ch12-mldsa package carries its own test for exactly this reason, because getting it wrong corrupts silently rather than raising an error.