Skip to content

Chapter 12: ML-DSA (FIPS 204) from scratch

ML-DSA can be read as a Schnorr identification scheme (Chapter 33) moved to the Module-LWE setting from Chapter 9, compiled into a signature by the Fiat-Shamir transform, and made safe to publish by rejection sampling (Fiat & Shamir, 1987; National Institute of Standards and Technology, 2024; Schnorr, 1991). It standardizes the CRYSTALS-Dilithium scheme (Ducas et al., 2018). The secret is a short module vector s1\mathbf{s}_1; the public key is a Module-LWE sample t=As1+s2\mathbf{t} = \mathbf{A}\mathbf{s}_1 + \mathbf{s}_2. A signature proves knowledge of s1\mathbf{s}_1 by answering a challenge cc with the response z=y+cs1\mathbf{z} = \mathbf{y} + c\mathbf{s}_1, where y\mathbf{y} is a fresh random mask.

The response would leak s1\mathbf{s}_1 if published directly, because its distribution depends on the secret. Lyubashevsky’s fix is to reject any z\mathbf{z} that falls outside a fixed box and restart with a fresh mask, so the distribution of every z\mathbf{z} that is actually output is independent of s1\mathbf{s}_1 (Lyubashevsky, 2009, 2012). That rejection step is the “with aborts” in Fiat-Shamir with aborts.

The chapter builds the whole construction at the ML-DSA-65 parameter set specified in FIPS 204 (National Institute of Standards and Technology, 2024), and its output matches the NIST ACVP test vectors byte-for-byte. That second claim is an implementation result rather than anything the standard reports: tests/ch12/test_vectors.py checks it at all three parameter sets against fixtures vendored from NIST’s ACVP-Server repository. Where ML-KEM (Chapter 11) rests on Module-LWE alone, ML-DSA rests on two assumptions: Module-LWE protects the secret key, and Module-SIS protects against forgery. Chapter 13 builds the lattice cryptanalysis behind the first of those two, working the primal and dual attacks on Module-LWE and the block-size-to-category estimator against ML-KEM’s instances. The same machinery prices the Module-LWE half of this chapter’s table.

The public key is a single Module-LWE sample. KeyGen draws a short secret s1Rq\mathbf{s}_1 \in R_q^\ell and a short error s2Rqk\mathbf{s}_2 \in R_q^k, expands a public matrix ARqk×\mathbf{A} \in R_q^{k \times \ell} from a seed, and publishes t=As1+s2\mathbf{t} = \mathbf{A}\mathbf{s}_1 + \mathbf{s}_2. Recovering s1\mathbf{s}_1 from (A,t)(\mathbf{A}, \mathbf{t}) is the Module-LWE problem from Chapter 9 (Langlois & Stehlé, 2015). So far this is the ML-KEM key equation with the roles renamed.

The signature is an identification proof turned non-interactive. The signer commits to a fresh mask y\mathbf{y} by publishing the high bits of w=Ay\mathbf{w} = \mathbf{A}\mathbf{y}, derives a challenge cc by hashing the commitment together with the message (the Fiat-Shamir step), and responds with z=y+cs1\mathbf{z} = \mathbf{y} + c\mathbf{s}_1. A verifier who knows t\mathbf{t} can check the response: Azct=Ay+cAs1c(As1+s2)=wcs2\mathbf{A}\mathbf{z} - c\mathbf{t} = \mathbf{A}\mathbf{y} + c\mathbf{A}\mathbf{s}_1 - c(\mathbf{A}\mathbf{s}_1 + \mathbf{s}_2) = \mathbf{w} - c\mathbf{s}_2, which has the same high bits as w\mathbf{w} when cs2c\mathbf{s}_2 is small. Matching high bits recomputes the same challenge, and the proof closes.

Two problems stand between that sketch and a secure signature, and the ML-DSA machinery exists to solve them.

First, z=y+cs1\mathbf{z} = \mathbf{y} + c\mathbf{s}_1 leaks the secret. Over a prime-order group, Schnorr hides the secret by drawing the mask uniformly modulo the group order, so the response is uniform regardless of the secret (Schnorr, 1991). A lattice has no such uniform mask: y\mathbf{y} is drawn from a bounded box, and y+cs1\mathbf{y} + c\mathbf{s}_1 is a shifted box whose position depends on cs1c\mathbf{s}_1. Rejection sampling removes the dependence. The signer draws y\mathbf{y} uniformly from (γ1,γ1](-\gamma_1, \gamma_1], computes z\mathbf{z}, and outputs it only when every coefficient lands in the smaller box (γ1+β,γ1β)(-\gamma_1 + \beta, \gamma_1 - \beta), where β\beta bounds the infinity norm of cs1c\mathbf{s}_1. Inside that smaller box the distribution of z\mathbf{z} is exactly uniform and carries no information about s1\mathbf{s}_1 (Lyubashevsky, 2012). A response outside the box is discarded and the signer restarts with a fresh mask. The number of restarts is public; the discarded values are secret.

Second, publishing t\mathbf{t} in full makes the public key large, so ML-DSA drops the low d=13d = 13 bits of every coefficient and ships only the top part t1\mathbf{t}_1. That truncation breaks the clean check Azct\mathbf{A}\mathbf{z} - c\mathbf{t}, because the verifier now knows only t1=(tt0)/2d\mathbf{t}_1 = (\mathbf{t} - \mathbf{t}_0)/2^d, not t\mathbf{t}. The signer repairs the gap with a one-bit-per-coefficient hint that tells the verifier how the missing low part t0\mathbf{t}_0 shifts the high bits of the recomputed commitment. The hint is the MakeHint / UseHint mechanism, and it is what most distinguishes ML-DSA’s algebra from ML-KEM’s.

The rest of the chapter builds these pieces: the rounding and hint algebra, the samplers that draw A\mathbf{A}, s1\mathbf{s}_1, s2\mathbf{s}_2, y\mathbf{y}, and cc, the full NTT, and the KeyGen, Sign, and Verify assembly. Forgery without the secret means producing a short z\mathbf{z} and a matching cc that satisfy the verification equation, which the scheme’s analysis states as SelfTargetMSIS, a Module-SIS-shaped problem over the lattice defined by A\mathbf{A} that also involves the hash function (Ducas et al., 2018, sec. 4.1; National Institute of Standards and Technology, 2024, sec. 3.2). The two assumptions are separate: Module-LWE hides the key, Module-SIS blocks the forgery.

A concrete ML-DSA-65 parameter and seed derivation

Section titled “A concrete ML-DSA-65 parameter and seed derivation”

FIPS 204 fixes three parameter sets over one ring. The ring constants (n,q,d)=(256,8380417,13)(n, q, d) = (256, 8380417, 13) and ζ=1753\zeta = 1753 are the same in all three. What varies is the module shape and the bounds that ride on it (National Institute of Standards and Technology, 2024).

ParameterML-DSA-44ML-DSA-65ML-DSA-87
(k,)(k, \ell), dimensions of A\mathbf{A}(4,4)(4,4)(6,5)(6,5)(8,7)(8,7)
η\eta, secret coefficient bound224422
τ\tau, nonzero coefficients in cc393949496060
γ1\gamma_1, mask bound2172^{17}2192^{19}2192^{19}
γ2\gamma_2, low-order window(q1)/88(q{-}1)/88(q1)/32(q{-}1)/32(q1)/32(q{-}1)/32
ω\omega, hint budget808055557575
λ\lambda, collision strength of c~\tilde c128128192192256256
β=τη\beta = \tau\eta, box margin7878196196120120
Public key, bytes131213121952195225922592
Secret key, bytes256025604032403248964896
Signature, bytes242024203309330946274627
Claimed NIST category235

This chapter builds the middle set throughout. Every byte length in the table is fixed by the parameter set rather than chosen. The block below computes all three of ML-DSA-65’s from the packing widths that FIPS 204 §7.2 gives its encoders, and reproduces that column’s three byte-size entries exactly.

A concrete instantiation at NIST ACVP key-generation test case 2626 fixes the seed and reproduces the exact byte lengths. This block derives the three sub-seeds (ρ,ρ,K)(\rho, \rho', K) that KeyGen splits out of the master seed; it does not run a full key generation, which the construction below builds up to.

import hashlib
# ML-DSA-65 parameters (FIPS 204 Table 1; n is the ring degree, from Section 2.4.1).
n, q, d = 256, 8380417, 13
k, l, eta = 6, 5, 4
gamma_1, omega, lam = 1 << 19, 55, 192
# Bit widths that drive the packed lengths (FIPS 204 Section 7.2 encoders).
t1_bits = (q - 1).bit_length() - d # 23 - 13 = 10
eta_bits = (2 * eta).bit_length() # bitlen(8) = 4
gamma1_bits = (2 * gamma_1 - 1).bit_length() # 20
# Derived byte lengths (FIPS 204 Table 2), computed rather than hard-coded.
c_tilde_len = lam // 4
pk_len = 32 + 32 * t1_bits * k
sk_len = 32 + 32 + 64 + 32 * eta_bits * (k + l) + 32 * d * k
sig_len = c_tilde_len + 32 * gamma1_bits * l + omega + k
# NIST ACVP ML-DSA-65 keyGen test case tcId = 26 seed.
xi = bytes.fromhex(
"A991FD42B071D49C48AE3E75C647459E0DAAD1E1BA356A04801912D3294BCFF8"
)
# The KeyGen seed expansion: H(xi || k || l) split into (rho, rho', K).
raw = hashlib.shake_256(xi + bytes([k]) + bytes([l])).digest(128)
rho, rho_prime, K = raw[:32], raw[32:96], raw[96:128]
print("pk_len =", pk_len)
print("sk_len =", sk_len)
print("sig_len =", sig_len)
print("c_tilde_len =", c_tilde_len)
print("rho[:8] =", rho[:8].hex())
print("rho'[:8] =", rho_prime[:8].hex())
print("K[:8] =", K[:8].hex())
# ==> pk_len = 1952
# ==> sk_len = 4032
# ==> sig_len = 3309
# ==> c_tilde_len = 48
# ==> rho[:8] = 36db0b5dce98bd19
# ==> rho'[:8] = 3a443ee0b259e6e5
# ==> K[:8] = 33824e8fa472bead

Every Python block this chapter prints is also a standalone file in the companion repository, under chapter-code/ch12/, one file per block. Appendix C covers the clone and the environment they run on.

The key generator takes the 3232-byte seed ξ\xi and applies H=SHAKE-256H = \text{SHAKE-256} to the concatenation ξk\xi \mathbin\Vert k \mathbin\Vert \ell, squeezing 128128 bytes. The single bytes kk and \ell are a domain separator equal to the module dimensions. FIPS 204 added them in the final standard, so a key generated under the earlier public draft differs (National Institute of Standards and Technology, 2024). The first 3232 bytes become the matrix seed ρ\rho, the next 6464 bytes the noise seed ρ\rho', and the last 3232 bytes the signing seed KK. The seed ρ\rho becomes the first 3232 bytes of the public key verbatim, so the derived ρ\rho here matches the leading bytes of the ACVP public key for this test case. KeyGen and the sign and verify paths are checked against the NIST vectors by pytest tests/ch12/test_vectors.py. The fixtures live at tests/ch12/vectors/ml_dsa_{44,65,87}_acvp.json, vendored from the NIST ACVP-Server repository with the source commit pinned in each file.

Six objects recur: the ring RqR_q and its full NTT, the Module-LWE and Module-SIS problems, Power2Round for public-key compression, Decompose for the high-bit commitment, the hint identity that ties MakeHint to UseHint, and the norm bounds that drive rejection.

The ring RqR_q at (n,q)=(256,8380417)(n, q) = (256, 8380417). The ring is Rq=Z8380417[x]/(x256+1)R_q = \mathbb{Z}_{8380417}[x]/(x^{256}+1). The modulus q=8380417=223213+1q = 8380417 = 2^{23} - 2^{13} + 1 is prime (National Institute of Standards and Technology, 2024). Unlike ML-KEM’s q=3329q = 3329, this qq satisfies q1(mod2n)q \equiv 1 \pmod{2n}: q1=8380416=81921023q - 1 = 8380416 = 8192 \cdot 1023 is divisible by 2n=5122n = 512, so Zq\mathbb{Z}_q has a primitive 512512-th root of unity, and FIPS 204 fixes ζ=1753\zeta = 1753 as the one ML-DSA uses. The polynomial x256+1x^{256} + 1 therefore factors into 256256 distinct linear terms over Zq\mathbb{Z}_q,

x256+1=i=0255(xζ2BitRev8(i)+1)(modq),x^{256} + 1 = \prod_{i=0}^{255} \bigl(x - \zeta^{2\,\text{BitRev}_8(i) + 1}\bigr) \pmod q,

so the NTT is a full transform: a polynomial maps to 256256 independent scalars, and the ring product becomes coefficient-wise multiplication. This is the structural difference from ML-KEM, whose qq admits only a primitive 256256-th root and leaves the ring split into 128128 quadratic factors that need a base-case multiply. ML-DSA’s larger qq buys the simpler transform.

Module-LWE and Module-SIS. Chapter 9 introduced Module-LWE as the problem between Ring-LWE and flat LWE (Langlois & Stehlé, 2015). ML-DSA’s key equation is a Module-LWE sample at dimensions (k,)(k, \ell): the secret s1Rq\mathbf{s}_1 \in R_q^\ell and error s2Rqk\mathbf{s}_2 \in R_q^k are short, ARqk×\mathbf{A} \in R_q^{k \times \ell} is uniform, and the public value is t=As1+s2\mathbf{t} = \mathbf{A}\mathbf{s}_1 + \mathbf{s}_2. Recovering the secret is Module-LWE.

Forgery is the dual problem. A forger who does not know s1\mathbf{s}_1 must produce a short z\mathbf{z} and a challenge cc satisfying the verification relation, which amounts to finding a short nonzero solution to a homogeneous linear system over RqR_q built from A\mathbf{A} and t\mathbf{t}. Finding short solutions to such a system is a Module-SIS (Short Integer Solution) problem, whose worst-case hardness traces to Ajtai’s reduction for the unstructured case and to the module-lattice reductions of Langlois and Stehlé for the structured one (Ajtai, 1996; Langlois & Stehlé, 2015). The two assumption families are independent, and ML-DSA needs both. The three parameter sets scale both problems by the module dimensions (k,)(k, \ell) while the ring (n,q)(n, q) stays fixed.

Power2Round: dropping the low bits of the public key. Power2Round splits a coefficient rZqr \in \mathbb{Z}_q into a high part and a low part around a power of two (Algorithm 35 in National Institute of Standards and Technology, 2024). Writing r0=rmod±2dr_0 = r \bmod^\pm 2^d for the centered residue in (2d1,2d1](-2^{d-1}, 2^{d-1}], it returns (r1,r0)(r_1, r_0) with r=r12d+r0r = r_1 \cdot 2^d + r_0. Applied coefficient-wise to t\mathbf{t}, it produces t1\mathbf{t}_1 (shipped in the public key) and t0\mathbf{t}_0 (kept in the secret key). At d=13d = 13 the public key stores log2qd=2313=10\lceil \log_2 q \rceil - d = 23 - 13 = 10 bits per coefficient instead of 2323, which is where most of the public key’s size reduction comes from. The centered residue mod±\bmod^\pm is the reduction with representatives in (α/2,α/2](-\alpha/2, \alpha/2] rather than [0,α)[0, \alpha). It is the norm-minimizing representative and the one every ML-DSA bound is stated against.

Decompose and the high-bit commitment. Decompose is the same split around a different modulus 2γ22\gamma_2 (Algorithm 36 in National Institute of Standards and Technology, 2024). It returns (r1,r0)(r_1, r_0) with r=r12γ2+r0r = r_1 \cdot 2\gamma_2 + r_0 and r0=rmod±2γ2r_0 = r \bmod^\pm 2\gamma_2, except at the top boundary rr0=q1r - r_0 = q - 1, where it sets r1=0r_1 = 0 and r0=1r_0 \mathrel{-}= 1 so that r1r_1 stays in {0,,(q1)/(2γ2)1}\{0, \ldots, (q-1)/(2\gamma_2) - 1\}. HighBits\text{HighBits} and LowBits\text{LowBits} are the two outputs. The signer commits to HighBits(w)\text{HighBits}(\mathbf{w}) of the mask product w=Ay\mathbf{w} = \mathbf{A}\mathbf{y}. The window γ2\gamma_2 is chosen so the high part takes few enough values to hash compactly. At ML-DSA-65, (q1)/(2γ2)=16(q-1)/(2\gamma_2) = 16, so each high coefficient is one of 1616 values and packs into 44 bits.

The hint identity. The verifier recomputes an approximation to w\mathbf{w} but is off by the truncation term ct0c\mathbf{t}_0, which it cannot compute because the public key does not carry t0\mathbf{t}_0. FIPS 204 §6.1 is explicit that dropping those bits is a performance optimization rather than a security one, and that the low-order bits can be reconstructed from a small number of signatures and need not be regarded as secret (National Institute of Standards and Technology, 2024). MakeHint records, one bit per coefficient, whether adding a perturbation zz changes the high bits of rr; UseHint applies that bit to recover the corrected high bits (Algorithms 39 and 40 in National Institute of Standards and Technology, 2024). The governing identity is

UseHint(MakeHint(z,r),r)=HighBits(r+z)whenever zγ2.\text{UseHint}\bigl(\text{MakeHint}(z, r),\, r\bigr) = \text{HighBits}(r + z) \quad \text{whenever } \|z\|_\infty \le \gamma_2.

The bound is why a single bit suffices: within the window, adding zz can move the high part by at most ±1\pm 1, so one bit of “did the high part change, and in which direction” is complete information, with the direction read from the sign of r0r_0. Sign enforces ct0<γ2\|c\mathbf{t}_0\|_\infty < \gamma_2 by rejection so this bound always holds on the term the hint corrects. The identity is verified over 20,00020{,}000 random pairs in the rounding code block below and pinned by tests/ch12/test_mldsa_rounding.py.

Norm bounds and rejection. ML-DSA works with the infinity norm \|\cdot\|_\infty on centered representatives. The challenge cc has exactly τ\tau coefficients equal to ±1\pm 1 and the rest zero, so cs1τη=β\|c\mathbf{s}_1\|_\infty \le \tau \eta = \beta (each output coefficient is a signed sum of at most τ\tau secret coefficients, each bounded by η\eta). This β=τη\beta = \tau\eta is the box margin. The signer accepts z\mathbf{z} only when z<γ1β\|\mathbf{z}\|_\infty < \gamma_1 - \beta, so the shifted box y+cs1\mathbf{y} + c\mathbf{s}_1 stays inside (γ1,γ1](-\gamma_1, \gamma_1] regardless of the secret. It accepts only when r0<γ2β\|\mathbf{r}_0\|_\infty < \gamma_2 - \beta, so the low part of wcs2\mathbf{w} - c\mathbf{s}_2 cannot cross a high-bit boundary. At ML-DSA-65, β=494=196\beta = 49 \cdot 4 = 196. Those two rejection tests are the abort condition, and the expected number of restarts follows from the fraction of the box they cut off (Ducas et al., 2018; Lyubashevsky, 2012).

The construction walks from the byte-level primitives outward to KeyGen, Sign, and Verify. The inline blocks use numpy and hashlib only so they run standalone. The full implementation is at solutions/ch12-mldsa/. The final assembly block imports that package to run an end-to-end signature, because the KeyGen, Sign, and Verify routines are too large to reproduce inline in full.

Every polynomial ML-DSA puts on the wire is a little-endian bit-packing of a length-256256 coefficient vector at a field-specific width (National Institute of Standards and Technology, 2024, sec. 7.1). Not everything on the wire is a polynomial. The object encoders build a public key as the raw seed ρ\rho followed by the packed t1t_1. A signature is the raw challenge bytes c~\tilde{c}, then the packed zz, then a hint encoding (National Institute of Standards and Technology, 2024, sec. 7.2). The hint is the exception on the polynomial side too, since HintBitPack\text{HintBitPack} writes the positions of the nonzero coefficients rather than a fixed-width field for every coefficient. Two primitives do the packing. SimpleBitPack(w,b)\text{SimpleBitPack}(w, b) packs unsigned coefficients in [0,b][0, b] using bitlen(b)\text{bitlen}(b) bits each. BitPack(w,a,b)\text{BitPack}(w, a, b) packs signed coefficients in [a,b][-a, b] by storing the unsigned value bwib - w_i in bitlen(a+b)\text{bitlen}(a + b) bits. Both lay coefficient ii into bit positions [iwidth,(i+1)width)[i \cdot \text{width}, (i+1) \cdot \text{width}) of one big integer and emit it with int.to_bytes(..., "little"), the same idiom the ML-KEM serializer used at d=12d = 12.

import numpy as np
def bitlen(m):
return m.bit_length()
def bit_pack(w, a, b):
width = bitlen(a + b)
mask = (1 << width) - 1
big = 0
for i in range(256):
big |= ((b - int(w[i])) & mask) << (i * width)
return big.to_bytes(32 * width, "little")
def bit_unpack(v, a, b):
width = bitlen(a + b)
big = int.from_bytes(v, "little")
mask = (1 << width) - 1
return np.array([b - ((big >> (i * width)) & mask) for i in range(256)],
dtype=np.int64)
# The response z packs at (a, b) = (gamma_1 - 1, gamma_1) for ML-DSA-65,
# so each coefficient uses bitlen(2*gamma_1 - 1) = 20 bits.
gamma_1 = 1 << 19
rng = np.random.default_rng(seed=20260723)
z = rng.integers(-(gamma_1 - 1), gamma_1 + 1, size=256, dtype=np.int64)
packed = bit_pack(z, gamma_1 - 1, gamma_1)
recovered = bit_unpack(packed, gamma_1 - 1, gamma_1)
print("bits per coefficient =", bitlen(2 * gamma_1 - 1))
print("packed length =", len(packed))
print("round trip equal =", bool(np.array_equal(recovered, z)))
# ==> bits per coefficient = 20
# ==> packed length = 640
# ==> round trip equal = True

The stored value bwib - w_i maps the signed range [a,b][-a, b] onto the unsigned range [0,a+b][0, a+b] so the packer never has to encode a sign bit, and bit_unpack inverts it by subtracting from bb. The signature’s response vector z\mathbf{z} has \ell polynomials at 2020 bits each, giving 5640=32005 \cdot 640 = 3200 bytes, the bulk of the 33093309-byte signature. The hint has its own sparse format, HintBitPack, which lists the set positions poly by poly followed by kk cumulative end markers. Its decoder is the one serializer that can reject, returning FIPS 204’s \bot when the positions are not strictly increasing, an end marker is out of range, or an unused slot is nonzero. That rejection is what makes a tampered-hint signature fail verification, and it is exercised by tests/ch12/test_mldsa_encode.py.

ML-DSA uses only SHAKE and does not need ML-KEM’s zoo of HH, GG, PRF, XOF, and JJ (National Institute of Standards and Technology, 2024, sec. 3.7). SHAKE-256 is the general hash HH, called with whatever output length a step needs, and SHAKE-128 is the extendable output used to grow the public matrix. Every derivation is one call to HH on a domain-separated byte string: the KeyGen seed split, the public-key transcript tr=H(pk,64)\text{tr} = H(\text{pk}, 64), the message representative μ=H(trM,64)\mu = H(\text{tr} \mathbin\Vert M', 64), the per-signature mask seed ρ=H(Krndμ,64)\rho'' = H(K \mathbin\Vert \text{rnd} \mathbin\Vert \mu, 64), and the challenge hash c~=H(μw1Encode(w1),λ/4)\tilde c = H(\mu \mathbin\Vert \text{w1Encode}(\mathbf{w}_1), \lambda/4).

import hashlib
def H(data, outlen):
return hashlib.shake_256(data).digest(outlen)
def integer_to_bytes(x, length):
return x.to_bytes(length, "little")
# ML-DSA-65 module shape.
k, l = 6, 5
# KeyGen seed split: (rho, rho', K) = H(xi || k || l, 128).
xi = bytes.fromhex(
"A991FD42B071D49C48AE3E75C647459E0DAAD1E1BA356A04801912D3294BCFF8"
)
raw = H(xi + integer_to_bytes(k, 1) + integer_to_bytes(l, 1), 128)
rho, rho_prime, K = raw[:32], raw[32:96], raw[96:128]
# The public-key transcript tr = H(pk, 64), the message representative
# mu = H(tr || M', 64), and the per-signature mask seed rho'' = H(K || rnd || mu, 64).
pk_stub = bytes(1952) # length of a real ML-DSA-65 pk
tr = H(pk_stub, 64)
m_prime = integer_to_bytes(0, 1) + integer_to_bytes(0, 1) + b"sign me"
mu = H(tr + m_prime, 64)
rho_dprime = H(K + bytes(32) + mu, 64) # rnd = 0^32 is the deterministic variant
print("H is SHAKE256 :", H(b"", 32) == hashlib.shake_256(b"").digest(32))
print("len(rho, rho', K) =", (len(rho), len(rho_prime), len(K)))
print("len(tr, mu, rho'') =", (len(tr), len(mu), len(rho_dprime)))
print("mu[:8] =", mu[:8].hex())
# ==> H is SHAKE256 : True
# ==> len(rho, rho', K) = (32, 64, 32)
# ==> len(tr, mu, rho'') = (64, 64, 64)
# ==> mu[:8] = 5cc3785bc60dd808

The message actually signed is μ\mu, not the raw message MM. The internal message MM' is MM framed with a context string as M=0x00ctxctxMM' = 0x00 \mathbin\Vert |\text{ctx}| \mathbin\Vert \text{ctx} \mathbin\Vert M. Here the context is empty, so MM' is two zero bytes followed by the message. The randomizer rnd\text{rnd} is 3232 bytes: random in the hedged variant, all zero in the deterministic one. Hedged is FIPS 204’s default, and it protects against fault attacks that resign the same message to compare outputs. The deterministic variant is the permitted alternative for a signer with no fresh randomness at signing time (National Institute of Standards and Technology, 2024). The ACVP vectors pin the deterministic form, so this chapter signs with rnd=032\text{rnd} = 0^{32} throughout.

Sampling: SampleInBall, ExpandA, ExpandS, ExpandMask

Section titled “Sampling: SampleInBall, ExpandA, ExpandS, ExpandMask”

Every structured value in ML-DSA is squeezed from a SHAKE stream and filtered (National Institute of Standards and Technology, 2024, sec. 7.3). Four samplers appear. ExpandA (SHAKE-128) fills the matrix A^\hat{\mathbf{A}} directly in the NTT domain, rejecting any three-byte read that decodes to a value q\ge q. ExpandS (SHAKE-256) fills the short secret vectors, rejecting half-bytes outside the small window that maps into [η,η][-\eta, \eta]. ExpandMask does not reject; it bit-unpacks a fixed number of bytes into the mask range (γ1,γ1](-\gamma_1, \gamma_1]. SampleInBall builds the challenge cc: exactly τ\tau coefficients set to ±1\pm 1 and the rest zero, placed by a Fisher-Yates-style swap driven by the stream, with the signs taken from the first eight squeezed bytes.

import hashlib
import numpy as np
def sample_in_ball(rho, tau):
xof = hashlib.shake_256(rho)
need = 8
stream = xof.digest(need)
signs = int.from_bytes(stream[:8], "little")
c = np.zeros(256, dtype=np.int64)
pos = 8
for i in range(256 - tau, 256):
while True:
if pos >= len(stream):
need += 168
stream = xof.digest(need)
j = stream[pos]
pos += 1
if j <= i:
break
c[i] = c[j]
bit = (signs >> (i + tau - 256)) & 1
c[j] = -1 if bit else 1
return c
# ML-DSA-65: tau = 49 nonzero coefficients, challenge seed c-tilde is 48 bytes.
tau = 49
c = sample_in_ball(bytes(range(48)), tau)
print("challenge length =", int(c.shape[0]))
print("nonzero coefficients =", int(np.count_nonzero(c)))
print("values are +-1 only =", set(int(x) for x in c) == {-1, 0, 1})
print("sum of coefficients =", int(c.sum()))
# ==> challenge length = 256
# ==> nonzero coefficients = 49
# ==> values are +-1 only = True
# ==> sum of coefficients = 3

The loop runs ii from 256τ256 - \tau to 255255. At each step it reads bytes until it finds a position jij \le i, moves whatever sits at jj up to position ii (position ii is still zero, since every earlier step wrote only positions no larger than its own smaller ii), and writes a fresh ±1\pm 1 at jj. Each iteration adds exactly one nonzero coefficient, whatever the stream contains: if jj already held a ±1\pm 1 it is carried to ii and jj is re-signed, and if jj was zero it becomes the new ±1\pm 1. So the construction guarantees exactly τ\tau nonzero coefficients. The signed sum is data-dependent (here 33, meaning 2626 of the 4949 are +1+1). What SampleInBall fixes is the count and the magnitude, not the sum. A challenge with τ\tau nonzero ±1\pm 1 coefficients has infinity norm 11 and one-norm τ\tau, which is what bounds cs1c\mathbf{s}_1 and cs2c\mathbf{s}_2 by β=τη\beta = \tau\eta.

Because q1(mod2n)q \equiv 1 \pmod{2n}, ML-DSA’s NTT is the full negacyclic transform, not ML-KEM’s partial one (National Institute of Standards and Technology, 2024, sec. 7.5). The forward transform is the decimation-in-time (Cooley-Tukey) butterfly with bit-reversed twiddle factors zetas[k]=ζBitRev8(k)modq\text{zetas}[k] = \zeta^{\text{BitRev}_8(k)} \bmod q; the inverse is the Gentleman-Sande butterfly with negated twiddles, followed by one scaling by n1=8347681modqn^{-1} = 8347681 \bmod q. Multiplication in the NTT domain is plain coefficient-wise, with no base-case multiply, because every factor of x256+1x^{256}+1 is linear.

import numpy as np
Q = 8380417
N = 256
ZETA = 1753
N_INV = pow(N, -1, Q)
def bit_rev_8(k):
r = 0
for _ in range(8):
r = (r << 1) | (k & 1)
k >>= 1
return r
ZETAS = [pow(ZETA, bit_rev_8(k), Q) for k in range(256)]
def ntt(w):
w_hat = (np.asarray(w, dtype=np.int64) % Q).copy()
m = 0
length = 128
while length >= 1:
start = 0
while start < N:
m += 1
zeta = ZETAS[m]
for j in range(start, start + length):
t = (zeta * int(w_hat[j + length])) % Q
w_hat[j + length] = (int(w_hat[j]) - t) % Q
w_hat[j] = (int(w_hat[j]) + t) % Q
start += 2 * length
length //= 2
return w_hat
def ntt_inverse(w_hat):
w = (np.asarray(w_hat, dtype=np.int64) % Q).copy()
m = 256
length = 1
while length < N:
start = 0
while start < N:
m -= 1
zeta = (-ZETAS[m]) % Q
for j in range(start, start + length):
t = int(w[j])
w[j] = (t + int(w[j + length])) % Q
w[j + length] = (zeta * (t - int(w[j + length]))) % Q
start += 2 * length
length *= 2
return np.array([(N_INV * int(w[j])) % Q for j in range(N)], dtype=np.int64)
def multiply_ntts(a_hat, b_hat):
return np.array([(int(a_hat[i]) * int(b_hat[i])) % Q for i in range(N)],
dtype=np.int64)
def schoolbook(a, b):
out = [0] * N
for i in range(N):
ai = int(a[i])
for j in range(N):
k = i + j
prod = ai * int(b[j])
if k < N:
out[k] = (out[k] + prod) % Q
else: # X^256 = -1: wrap with a sign flip
out[k - N] = (out[k - N] - prod) % Q
return np.array(out, dtype=np.int64)
rng = np.random.default_rng(seed=20260723)
f = rng.integers(0, Q, size=N, dtype=np.int64)
g = rng.integers(0, Q, size=N, dtype=np.int64)
prod_ntt = ntt_inverse(multiply_ntts(ntt(f), ntt(g)))
prod_school = schoolbook(f, g)
print("N_INV =", N_INV)
print("ZETAS[1] =", ZETAS[1])
print("NTT product equals schoolbook =", bool(np.array_equal(prod_ntt, prod_school)))
# ==> N_INV = 8347681
# ==> ZETAS[1] = 4808194
# ==> NTT product equals schoolbook = True

The forward NTT, the inverse NTT, and the coefficient-wise multiply are correct by construction, and the schoolbook cross-check is the independent computation path: a tautological check like ntt(f) == ntt(f) would catch nothing. The value ZETAS[1]=4808194\text{ZETAS}[1] = 4808194 is the first twiddle consumed by the butterfly and is pinned in tests/ch12/test_mldsa_ntt.py against the FIPS 204 table, alongside ZETAS[128]=ζ=1753\text{ZETAS}[128] = \zeta = 1753. Both are landmarks rather than a complete test: a wrong root changes them, and so does any bit-reversal error reaching index 1 or 128, but an error confined to other positions leaves both standing. The scaling factor n1=8347681n^{-1} = 8347681 is applied once at the end of the inverse transform, where ML-KEM’s partial NTT scaled by 1281128^{-1} instead because it split the ring into 128128 factors.

The rounding operators are defined coefficient-wise on integers in [0,q)[0, q) (Algorithms 35 to 40 in National Institute of Standards and Technology, 2024). Power2Round drops the low dd bits; Decompose splits around 2γ22\gamma_2; MakeHint and UseHint carry the one-bit correction. The block below defines all four scalar cores, checks that Power2Round reconstructs its input, and checks the hint identity over 20,00020{,}000 random pairs.

import numpy as np
Q = 8380417
D = 13
def mod_pm(r, alpha):
m = r % alpha
return m - alpha if m > alpha // 2 else m
def power2round(r):
r %= Q
r0 = mod_pm(r, 1 << D)
return (r - r0) >> D, r0
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 # ML-DSA-65/87 low-order window
# Power2Round splits a public coefficient into a top part and the dropped d bits.
t = 4211255
t1, t0 = power2round(t)
print("power2round(t) = (t1, t0) =", (t1, t0))
print("t1 * 2^d + t0 == t :", t1 * (1 << D) + t0 == t)
# The hint lets a verifier that knows r and the hint recover HighBits(r + z)
# whenever the perturbation z stays within the low-order window gamma_2.
# UseHint decomposes r itself and branches on the sign of the low part.
rng = np.random.default_rng(seed=7)
ok = True
for _ in range(20000):
r = int(rng.integers(0, Q))
z = int(rng.integers(-gamma_2, gamma_2 + 1))
if use_hint(make_hint(z, r, gamma_2), r, gamma_2) != high_bits((r + z) % Q, gamma_2):
ok = False
break
print("UseHint(MakeHint(z, r), r) == HighBits(r + z) for |z| <= gamma_2 :", ok)
# ==> power2round(t) = (t1, t0) = (514, 567)
# ==> t1 * 2^d + t0 == t : True
# ==> UseHint(MakeHint(z, r), r) == HighBits(r + z) for |z| <= gamma_2 : True

The reconstruction t12d+t0=5148192+567=4211255t_1 \cdot 2^d + t_0 = 514 \cdot 8192 + 567 = 4211255 recovers the input exactly, which is the invariant Power2Round guarantees. The hint identity holds for every one of the 20,00020{,}000 pairs because the perturbation stays inside the window γ2\gamma_2. The exercises ask what happens at z=γ2+1\|z\|_\infty = \gamma_2 + 1, where the single bit is no longer enough. UseHint reads the direction of the correction from the sign of the low part r0r_0: a positive r0r_0 means the coefficient sits just below a boundary and adding zz pushed it up, so the high part increments, and a nonpositive r0r_0 means it decrements. The modulus m=(q1)/(2γ2)m = (q-1)/(2\gamma_2) wraps the high part cyclically so the boundary case at the top of the range stays consistent with Decompose’s boundary rule.

KeyGen expands the seed, draws the secret, forms the Module-LWE sample, and truncates it. From ξ\xi it derives (ρ,ρ,K)(\rho, \rho', K), expands A^\hat{\mathbf{A}} from ρ\rho directly in the NTT domain, and draws (s1,s2)(\mathbf{s}_1, \mathbf{s}_2) from ρ\rho' with ExpandS. It computes t=As1+s2\mathbf{t} = \mathbf{A}\mathbf{s}_1 + \mathbf{s}_2 (the matrix product runs in the NTT domain and comes back), applies Power2Round to get (t1,t0)(\mathbf{t}_1, \mathbf{t}_0), and packs pk=ρSimpleBitPack(t1)\text{pk} = \rho \mathbin\Vert \text{SimpleBitPack}(\mathbf{t}_1) and sk=ρKtrBitPack(s1)BitPack(s2)BitPack(t0)\text{sk} = \rho \mathbin\Vert K \mathbin\Vert \text{tr} \mathbin\Vert \text{BitPack}(\mathbf{s}_1) \mathbin\Vert \text{BitPack}(\mathbf{s}_2) \mathbin\Vert \text{BitPack}(\mathbf{t}_0), where tr=H(pk,64)\text{tr} = H(\text{pk}, 64).

The key equation is the only new algebra, and it is easiest to see at ring degree n=1n = 1, where each ring element is a single integer and the module becomes ordinary matrix arithmetic over Zq\mathbb{Z}_q. The block below runs t=As1+s2\mathbf{t} = \mathbf{A}\mathbf{s}_1 + \mathbf{s}_2 at (k,)=(2,2)(k, \ell) = (2, 2) and truncates with the real d=13d = 13, using the same qq and Power2Round as the full scheme.

import numpy as np
Q = 8380417
D = 13
def mod_pm(r, alpha):
m = r % alpha
return m - alpha if m > alpha // 2 else m
def power2round(r):
r %= Q
r0 = mod_pm(r, 1 << D)
return (r - r0) >> D, r0
# The ML-DSA key equation t = A s1 + s2, shown at ring degree n = 1 so each
# ring element is a single integer in Z_q. Module shape (k, l) = (2, 2), secret
# coefficients drawn from [-eta, eta] with eta = 4. The real scheme runs the
# same equation over degree-255 polynomials with A expanded from rho.
rng = np.random.default_rng(seed=204)
A = rng.integers(0, Q, size=(2, 2), dtype=np.int64)
s1 = rng.integers(-4, 5, size=2, dtype=np.int64)
s2 = rng.integers(-4, 5, size=2, dtype=np.int64)
t = (A @ s1 + s2) % Q
# Power2Round drops the low d = 13 bits of t; the public key ships t1 only.
t1 = np.empty(2, dtype=np.int64)
t0 = np.empty(2, dtype=np.int64)
for i in range(2):
t1[i], t0[i] = power2round(int(t[i]))
print("t =", [int(x) for x in t])
print("t1 =", [int(x) for x in t1])
print("reconstructs t :",
all(int(t1[i]) * (1 << D) + int(t0[i]) == int(t[i]) for i in range(2)))
print("t0 within (-2^12, 2^12] :",
bool(np.all((t0 > -(1 << (D - 1))) & (t0 <= (1 << (D - 1))))))
# ==> t = [2613718, 1033595]
# ==> t1 = [319, 126]
# ==> reconstructs t : True
# ==> t0 within (-2^12, 2^12] : True

The public key ships t1\mathbf{t}_1 (here [319,126][319, 126]) and keeps t0\mathbf{t}_0 in the secret key. A verifier reconstructs t12d\mathbf{t}_1 \cdot 2^d, which differs from the true t\mathbf{t} by the discarded t0\mathbf{t}_0, bounded by 2d1=40962^{d-1} = 4096 in each coefficient. That gap is exactly what the hint corrects during verification. Because the low bits are the least significant, dropping them costs the public key nothing in security: the Dilithium security analysis assumes the public key is the full t\mathbf{t} rather than the truncated t1\mathbf{t}_1, so hiding t0\mathbf{t}_0 is not what protects the key (Ducas et al., 2018). FIPS 204 keeps t0\mathbf{t}_0 in the secret key only so the signer can compute the hint, not because it is secret in the Module-LWE sense (National Institute of Standards and Technology, 2024).

Sign is the Fiat-Shamir-with-aborts loop. It decodes the secret key, computes μ=H(trM,64)\mu = H(\text{tr} \mathbin\Vert M', 64) and the mask seed ρ=H(Krndμ,64)\rho'' = H(K \mathbin\Vert \text{rnd} \mathbin\Vert \mu, 64), and then loops. Each iteration:

  1. Expands a fresh mask yRq\mathbf{y} \in R_q^\ell from ρ\rho'' and a counter κ\kappa, and advances κ\kappa by \ell so the next attempt reads a fresh block of sub-seeds.
  2. Computes the commitment w=Ay\mathbf{w} = \mathbf{A}\mathbf{y} and its high bits w1=HighBits(w)\mathbf{w}_1 = \text{HighBits}(\mathbf{w}).
  3. Derives the challenge c~=H(μw1Encode(w1),λ/4)\tilde c = H(\mu \mathbin\Vert \text{w1Encode}(\mathbf{w}_1), \lambda/4) and c=SampleInBall(c~)c = \text{SampleInBall}(\tilde c).
  4. Forms the response z=y+cs1\mathbf{z} = \mathbf{y} + c\mathbf{s}_1 and the low part r0=LowBits(wcs2)\mathbf{r}_0 = \text{LowBits}(\mathbf{w} - c\mathbf{s}_2).
  5. Rejects and restarts if zγ1β\|\mathbf{z}\|_\infty \ge \gamma_1 - \beta or r0γ2β\|\mathbf{r}_0\|_\infty \ge \gamma_2 - \beta.
  6. Builds the hint h=MakeHint(ct0,wcs2+ct0)\mathbf{h} = \text{MakeHint}(-c\mathbf{t}_0,\, \mathbf{w} - c\mathbf{s}_2 + c\mathbf{t}_0) and rejects if ct0γ2\|c\mathbf{t}_0\|_\infty \ge \gamma_2 or the hint has more than ω\omega set bits.
  7. Otherwise outputs σ=(c~,z,h)\sigma = (\tilde c, \mathbf{z}, \mathbf{h}).

The two rejection tests in step 5 are the whole point of the “with aborts” design. The first keeps z\mathbf{z} inside the box where its distribution is independent of s1\mathbf{s}_1; the second keeps the low part of wcs2\mathbf{w} - c\mathbf{s}_2 far enough from a boundary that its high bits equal those of w\mathbf{w}. Step 6’s tests keep the hint correctable by a single bit and within its sparse budget ω\omega. Every rejected attempt is discarded in full: no partial state carries to the next iteration except the advancing counter κ\kappa, which only reads fresh randomness.

ML-DSA Sign abort loop. A vertical pipeline for one signing attempt. It starts at "sample mask y from rho'' and kappa", then computes w = A y, then w1 = HighBits(w), then the challenge hash c-tilde = H(mu, w1Encode(w1)) and c = SampleInBall(c-tilde), then the response z = y + c s1 together with the low part r0 = LowBits(w minus c s2). The first rejection gate tests whether the infinity norm of z is at least gamma1 minus beta, or the infinity norm of r0 is at least gamma2 minus beta; if either holds, the attempt is rejected and control loops back to the top with kappa increased by ell. Otherwise it computes the hint h = MakeHint of minus c t0. The second rejection gate tests whether the infinity norm of c t0 is at least gamma2, or the hint weight exceeds omega; if either holds, control loops back to the top. Otherwise the attempt succeeds and emits the signature sigma = (c-tilde, z, h). ML-DSA Sign: Fiat-Shamir with aborts sample mask y from rho'', kappa w = A y ; w1 = HighBits(w) c-tilde = H(mu, w1Encode(w1)) ; c = SampleInBall z = y + c s1 r0 = LowBits(w - c s2) ||z|| >= gamma1 - beta or ||r0|| >= gamma2 - beta ? h = MakeHint(-c t0, w - c s2 + c t0) ||c t0|| >= gamma2 or weight(h) > omega ? emit sigma = (c-tilde, z, h) no no reject reject dashed red: reject and restart with kappa += ell
Figure 12.1. The ML-DSA sign loop, showing the two rejection gates that make Fiat-Shamir "with aborts". One attempt samples a fresh mask y, commits to HighBits(A y), derives the challenge c by hashing the commitment and message, and forms the response z = y + c s1. The first gate rejects when z or the low part r0 leaves its safe box; the second rejects when the hint term c t0 is too large or the hint exceeds its budget omega. A rejected attempt restarts with a fresh mask; an accepted one emits the signature.

The diagram makes the loop structure visible. The two gates sit at different points because they guard different things. The first gate guards two properties at once: its bound on z\mathbf{z} protects zero-knowledge by rejecting any response whose distribution would depend on s1\mathbf{s}_1, and its bound on r0\mathbf{r}_0 protects correctness by keeping the low part of wcs2\mathbf{w} - c\mathbf{s}_2 clear of a high-bit boundary. The expected number of trips through the loop is small and public. What must never leak is anything about the attempts that were thrown away.

Verify: recomputing the commitment through UseHint

Section titled “Verify: recomputing the commitment through UseHint”

Verify decodes (c~,z,h)(\tilde c, \mathbf{z}, \mathbf{h}), rejecting immediately if the hint decode returns \bot. It recomputes the approximate commitment

wapprox=Azct12d,\mathbf{w}'_{\text{approx}} = \mathbf{A}\mathbf{z} - c \cdot \mathbf{t}_1 \cdot 2^d,

which expands to wcs2+ct0\mathbf{w} - c\mathbf{s}_2 + c\mathbf{t}_0: substituting Az=w+cAs1=w+c(ts2)\mathbf{A}\mathbf{z} = \mathbf{w} + c\mathbf{A}\mathbf{s}_1 = \mathbf{w} + c(\mathbf{t} - \mathbf{s}_2) and ct12d=c(tt0)c\mathbf{t}_1 2^d = c(\mathbf{t} - \mathbf{t}_0) cancels the ctc\mathbf{t} terms. Applying w1=UseHint(h,wapprox)\mathbf{w}'_1 = \text{UseHint}(\mathbf{h}, \mathbf{w}'_{\text{approx}}) uses the hint bit to undo the ct0c\mathbf{t}_0 perturbation, recovering HighBits(wcs2)\text{HighBits}(\mathbf{w} - c\mathbf{s}_2), which equals w1\mathbf{w}_1 because the signer rejected any attempt whose r0\mathbf{r}_0 was too large. Verify then recomputes c~=H(μw1Encode(w1),λ/4)\tilde c' = H(\mu \mathbin\Vert \text{w1Encode}(\mathbf{w}'_1), \lambda/4) and accepts iff z<γ1β\|\mathbf{z}\|_\infty < \gamma_1 - \beta and c~=c~\tilde c' = \tilde c.

The end-to-end block imports the from-scratch package to run KeyGen, Sign, and Verify at ML-DSA-65 and to confirm that a one-byte tamper on the signature is rejected. It uses the ACVP tcId=26 seed for the key and signs with the deterministic rnd=032\text{rnd} = 0^{32}.

import sys
sys.path.insert(0, "solutions/ch12-mldsa/src")
from mldsa.params import ML_DSA_65
from mldsa.ml_dsa import (
ml_dsa_keygen_internal,
ml_dsa_sign_internal,
ml_dsa_verify_internal,
)
# The from-scratch package assembles the primitives above into the three
# operations. KeyGen expands a 32-byte seed; Sign runs the abort loop; Verify
# recomputes the commitment through UseHint. These are the internal (explicit
# seed and rnd) variants the ACVP vectors drive.
xi = bytes.fromhex(
"A991FD42B071D49C48AE3E75C647459E0DAAD1E1BA356A04801912D3294BCFF8"
)
pk, sk = ml_dsa_keygen_internal(ML_DSA_65, xi)
# M' is the internal message: 0x00 || len(ctx) || ctx || message, empty context.
m_prime = bytes([0, 0]) + b"the abort loop terminates"
sigma = ml_dsa_sign_internal(ML_DSA_65, sk, m_prime, bytes(32))
good = ml_dsa_verify_internal(ML_DSA_65, pk, m_prime, sigma)
tampered = bytes([sigma[0] ^ 0x01]) + sigma[1:]
bad = ml_dsa_verify_internal(ML_DSA_65, pk, m_prime, tampered)
print("pk, sk, sig lengths =", (len(pk), len(sk), len(sigma)))
print("honest signature verifies =", good)
print("tampered signature verifies =", bad)
# ==> pk, sk, sig lengths = (1952, 4032, 3309)
# ==> honest signature verifies = True
# ==> tampered signature verifies = False

The tampered signature flips a byte of c~\tilde c, so the verifier samples a different challenge cc, recomputes a different w1\mathbf{w}'_1, and finds c~c~\tilde c' \ne \tilde c. Tampering with the response z\mathbf{z} instead trips the norm check or the challenge recomputation; tampering with the hint trips either the HintBitUnpack \bot path or the recomputed high bits. Each of those rejection paths is exercised by the per-parameter-set tamper tests in tests/ch12/test_mldsa_sign.py across all three sets. Those are selected byte flips, wrong messages and wrong keys, so they show that the displayed tampering is rejected rather than that no modification of any signature can verify. That stronger statement is a property of the construction and its stated assumptions, not something a finite test set establishes. The reference is toy code: it compares c~\tilde c with a plain ==. A deployed signer must run every secret-dependent step in constant time, a point the cryptanalysis section returns to. In the ordinary case every verification input is public and the comparison processes nothing confidential. FIPS 204 §3.6.3 names the message, the signature and the public key as inputs that some applications need to keep confidential, and what it requires is that potentially sensitive intermediate data be destroyed once it is no longer needed (National Institute of Standards and Technology, 2024). Constant-time comparison is this book’s implementation advice for that case. The standard does not use the phrase anywhere.

ML-DSA claims NIST security categories 2, 3, and 5 for ML-DSA-44, ML-DSA-65, and ML-DSA-87 (National Institute of Standards and Technology, 2024). Those categories rate a scheme by the resources an attack needs relative to reference attacks on generic primitives, with categories 2 and 4 using hash-collision reference problems and categories 1, 3, and 5 using key recovery against AES. Two separate attacks set the parameters, one per assumption.

Key recovery is Module-LWE. An attacker who could recover s1\mathbf{s}_1 from (A,t)(\mathbf{A}, \mathbf{t}) would hold the signing key. The cost is the cost of solving the Module-LWE instance, which the CRYSTALS-Dilithium design paper prices with the same core-SVP methodology that sets the ML-KEM parameters (Ducas et al., 2018, sec. 4.3). Chapter 13 builds that estimator and runs it end to end, on ML-KEM’s Module-LWE instances first and then on ML-DSA’s, from the Kannan embedding through the core-SVP cost model to the NIST category floors. The short version is that the module dimensions (k,)(k, \ell) are the security lever, exactly as the module rank was for ML-KEM.

Forgery is Module-SIS. A forger without the key must exhibit a short z\mathbf{z} and a challenge cc satisfying the verification relation. Because z\mathbf{z} is norm-bounded by γ1β\gamma_1 - \beta and cc is a sparse ±1\pm 1 vector, a forgery is a short solution to a homogeneous module-lattice system, and its cost is the cost of the corresponding SelfTargetMSIS instance, which is at least as hard as Module-SIS in the classical random-oracle model, by a non-tight reduction (Ducas et al., 2018, sec. 4). The hint budget ω\omega and the bound β\beta enter here: they cap how much slack a forger has in the last coefficients, and FIPS 204 sets them so the SIS instance stays as hard as the target category. This is why ML-DSA needs two assumptions where ML-KEM needed one. The proof leans on both, through different doors: key recovery is a Module-LWE instance, and forgery reduces to SelfTargetMSIS, which the concrete estimates price as a Module-SIS instance. Losing either assumption removes the corresponding guarantee.

The rejection loop is the implementation’s most sensitive point. The number of iterations is public and leaks nothing, because the acceptance region does not depend on the secret (Lyubashevsky, 2012). The danger is the rejected values. Each discarded z\mathbf{z} and each intermediate cs1c\mathbf{s}_1, cs2c\mathbf{s}_2, ct0c\mathbf{t}_0 depends on the secret, so a side channel that leaks whether a particular coefficient was near a rejection boundary, or leaks the timing of the norm checks, hands an attacker a noisy view of the secret.

FIPS 204 §3.6.3 requires that implementations destroy any potentially sensitive intermediate data as soon as it is no longer needed, and adds that in certain situations, deterministic signing among them, additional care must be taken against side-channel and fault attacks (National Institute of Standards and Technology, 2024). Constant-time execution of the secret-dependent steps (the norm comparisons on z\mathbf{z}, r0\mathbf{r}_0 and ct0c\mathbf{t}_0, the hint construction, and every arithmetic step that touches s1\mathbf{s}_1, s2\mathbf{s}_2, t0\mathbf{t}_0 or the mask y\mathbf{y}) is the accepted way to meet that. The deterministic variant this chapter signs with is the one that carries the caveat: §3.4 says it should not be used on platforms where side-channel attacks are a concern and cannot otherwise be mitigated.

The Fiat-Shamir transform’s security in the quantum random-oracle model, needed to claim EUF-CMA (Goldwasser et al., 1988) against a quantum adversary rather than only a classical one, rests on the QROM analyses of the transform (Don et al., 2019). As with ML-KEM, the security statement is reduction-based: it assumes Module-LWE and SelfTargetMSIS hardness, idealizes SHAKE as a random oracle, and requires constant-time implementation, so ML-DSA is believed to be EUF-CMA-secure under these assumptions rather than unconditionally.

ML-DSA and ML-KEM share a ring family, a serialization style, and the Module-LWE assumption, and diverge on almost everything else. The comparison is by primitive role.

  • ML-DSA versus ML-KEM (Chapter 11): ML-KEM is a key-encapsulation mechanism wrapped in the Fujisaki-Okamoto transform to reach IND-CCA2 from one assumption; ML-DSA is a signature built from Fiat-Shamir with aborts on two assumptions. ML-KEM’s q=3329q = 3329 forces a partial NTT with a base-case multiply; ML-DSA’s q=8380417q = 8380417 admits a full NTT with pointwise multiply, at the cost of larger coefficients. ML-KEM runs in fixed time; ML-DSA’s signer runs a variable number of abort iterations, so signing time is data-dependent even though the acceptance region is not. ML-DSA’s objects are larger: a 19521952-byte public key and 33093309-byte signature at category 3, against ML-KEM-768’s 11841184-byte encapsulation key and 10881088-byte ciphertext, because a signature must carry a full masked response vector while a KEM ciphertext carries only a compressed sample.
  • ML-DSA versus SLH-DSA (Chapter 17): SLH-DSA is the hash-based signature standardized in FIPS 205, and it rests on no structured-lattice assumption at all, only on the security of its hash functions. That makes SLH-DSA the conservative choice against a future break of Module-LWE or Module-SIS, at the cost of much larger signatures (roughly 88 to 5050 kilobytes across its parameter sets, against ML-DSA-65’s 3,3093{,}309 bytes) and slower signing. NIST presents ML-DSA as its primary signature standard and SLH-DSA as a backup resting on a different mathematical approach, to be reached for if ML-DSA proves vulnerable (National Institute of Standards and Technology, 2024b). That is an intended role and not a measurement. The announcement makes no claim about what deployments actually use, and neither does this book. The size and speed figures above are what a deployment weighs when the lattice assumptions are acceptable to it (National Institute of Standards and Technology, 2024a, 2024b).
  • The abort loop is unique to the lattice Fiat-Shamir family. Neither ML-KEM’s FO wrapper nor SLH-DSA’s hash tree has anything like it, because neither needs to hide a secret-dependent response inside a bounded box. The rejection step is the price ML-DSA pays for building a signature from Module-LWE rather than from a hash tree or a KEM.

Chapter 13 closes Part II by putting a price on the Module-LWE assumption that this chapter and Chapter 11 both rest on. It builds a core-SVP estimator in Python, runs the primal and dual attacks through it, and turns a BKZ block size into a bit count under a stated cost model, which it then reads against the published category claims rather than assigning a category itself. It works that machinery against ML-KEM’s instances and then against ML-DSA’s. Its estimator prices Module-LWE only, and the Module-SIS row it reports is the Dilithium submission’s own, so the parameter table above stays taken as given on the forgery side.

  1. Counting abort iterations. Read the traced signer _sign_internal_traced in the ch12-mldsa package under solutions/, which returns the number of rejection-loop iterations alongside the signature. Sign a few hundred distinct messages at ML-DSA-65 with the deterministic rnd=032\text{rnd} = 0^{32}, record the iteration count for each, and report the mean. Compare it to the two-test prediction, the reciprocal of the product of the two acceptance probabilities: the probability that z<γ1β\|\mathbf{z}\|_\infty < \gamma_1 - \beta and the probability that r0<γ2β\|\mathbf{r}_0\|_\infty < \gamma_2 - \beta. That prediction prices one of Algorithm 7’s two abort points. Identify the other, say which direction it moves the count, and state whether the difference is large enough to see in your sample. Explain why raising γ1\gamma_1 lowers the expected iteration count but enlarges the signature, and why ML-DSA-65 sits where it does on that tradeoff.

  2. The hint window, and its edge. The block above checks the hint identity over 20,00020{,}000 pairs drawn from inside the window, and it never fails. Go one step outside it. Using power2round, decompose, make_hint, and use_hint in the ch12-mldsa package under solutions/, find a pair (r,z)(r, z) with z=γ2+1\|z\|_\infty = \gamma_2 + 1 for which UseHint(MakeHint(z,r),r)HighBits(r+z)\text{UseHint}(\text{MakeHint}(z, r), r) \ne \text{HighBits}(r + z). Report how far apart the two values are, and explain why the single-bit hint is exactly enough inside the window zγ2\|z\|_\infty \le \gamma_2 but not one step past it. The direction UseHint infers from the sign of r0r_0 is where the argument turns. Tie this back to the second rejection test in Sign, which enforces ct0<γ2\|c\mathbf{t}_0\|_\infty < \gamma_2 on the term the hint has to correct.

  3. Which check catches which tamper. Using the ch12-mldsa package under solutions/, sign a message at ML-DSA-65, then produce three tampered signatures: one that flips a byte inside the packed response z\mathbf{z}, one that corrupts a hint byte so a poly’s positions are no longer strictly increasing, and one that flips a byte of the challenge hash c~\tilde c. For each, run verification and identify which of the three verifier checks rejects it: the norm bound on z\mathbf{z}, the HintBitUnpack \bot path, or the recomputed-challenge comparison c~=c~\tilde c' = \tilde c. Explain why no single check catches all three.

  4. The commitment binds the encoding width. In the ch12-mldsa package under solutions/, w1_encode packs each high-bit coefficient of w1\mathbf{w}_1 at the width (q1)/(2γ2)1(q-1)/(2\gamma_2) - 1 implies: 44 bits for ML-DSA-65, 66 for ML-DSA-44. Widen the ML-DSA-65 packing to 66 bits inside w1_encode and rerun a sign-then-verify round trip. It still verifies. Now check a signature produced by an unmodified signer against the widened verifier. It fails. Explain both results. The width feeds into c~=H(μw1Encode(w1),λ/4)\tilde c = H(\mu \mathbin\Vert \text{w1Encode}(\mathbf{w}_1), \lambda/4), so a signer and verifier that agree on the wrong width still agree. A signer and verifier that disagree hash different byte strings for the same w1\mathbf{w}_1, and the recomputed challenge no longer matches. Nothing in the scheme checks the width; it is only agreed. This is why the byte layer is part of the security contract, not just an encoding detail.

Worked solutions and editorial notes for these exercises are in Appendix D, Chapter 12. A separate track, for rebuilding rather than reading: the package exercises/ch12-mldsa has every function the chapter teaches replaced by a stub. Run PQC_IMPL=exercises pytest tests/ch12 to grade your version against the suite that proves the reference one.

Ajtai, M. (1996). Generating hard instances of lattice problems (extended abstract). Proceedings of the 28th Annual ACM Symposium on Theory of Computing (STOC), 99–108. https://doi.org/10.1145/237814.237838
Don, J., Fehr, S., Majenz, C., & Schaffner, C. (2019). Security of the Fiat-Shamir Transformation in the Quantum Random-Oracle Model. Advances in Cryptology — CRYPTO 2019. https://doi.org/10.1007/978-3-030-26951-7_13
Ducas, L., Kiltz, E., Lepoint, T., Lyubashevsky, V., Schwabe, P., Seiler, G., & Stehlé, D. (2018). CRYSTALS-Dilithium: A Lattice-Based Digital Signature Scheme. IACR Transactions on Cryptographic Hardware and Embedded Systems, 2018(1), 238–268. https://doi.org/10.13154/tches.v2018.i1.238-268
Fiat, A., & Shamir, A. (1987). How To Prove Yourself: Practical Solutions to Identification and Signature Problems. In A. M. Odlyzko (Ed.), Advances in Cryptology — CRYPTO ’86 (pp. 186–194). Springer. https://doi.org/10.1007/3-540-47721-7_12
Goldwasser, S., Micali, S., & Rivest, R. L. (1988). A digital signature scheme secure against adaptive chosen-message attacks. SIAM Journal on Computing, 17(2), 281–308. https://doi.org/10.1137/0217017
Langlois, A., & Stehlé, D. (2015). Worst-case to average-case reductions for module lattices. Designs, Codes and Cryptography, 75(3), 565–599. https://doi.org/10.1007/s10623-014-9938-4
Lyubashevsky, V. (2009). Fiat-Shamir with Aborts: Applications to Lattice and Factoring-Based Signatures. In M. Matsui (Ed.), Advances in Cryptology – ASIACRYPT 2009 (Vol. 5912, pp. 598–616). Springer. https://doi.org/10.1007/978-3-642-10366-7_35
Lyubashevsky, V. (2012). Lattice Signatures without Trapdoors. In D. Pointcheval & T. Johansson (Eds.), Advances in Cryptology – EUROCRYPT 2012 (Vol. 7237, pp. 738–755). Springer. https://doi.org/10.1007/978-3-642-29011-4_43
National Institute of Standards and Technology. (2024a). FIPS 204: Module-Lattice-Based Digital Signature Standard. Federal Information Processing Standards Publication. https://doi.org/10.6028/NIST.FIPS.204
National Institute of Standards and Technology. (2024b). FIPS 205: Stateless Hash-Based Digital Signature Standard. Federal Information Processing Standards Publication. https://doi.org/10.6028/NIST.FIPS.205
National Institute of Standards and Technology. (2024c). NIST Releases First 3 Finalized Post-Quantum Encryption Standards. NIST news release. https://www.nist.gov/news-events/news/2024/08/nist-releases-first-3-finalized-post-quantum-encryption-standards
Schnorr, C. P. (1991). Efficient Signature Generation by Smart Cards. Journal of Cryptology, 4(3), 161–174. https://doi.org/10.1007/BF00196725

Last updated: