- extensions/pq-signatures.md: the suite (id-MLDSA65-Ed25519-SHA512 of draft-ietf-lamps-pq-composite-sigs-19, ML-DSA-65 of FIPS 204 beside Ed25519) and why -65 and why the composite; the hybrid members; signing and verifying in WAI's order and what stripping does; the require-hybrid policy; key logs (hybrid entries and roots, the hybrid commitment, anchors that require hybrid, confirmation step 4a), group receipts (the payload's third version), claims and the shared check for wai:<domain>\x01 || JCS records; security considerations; the reasons; sizes and costs measured on the build host; the corpus. - jwp-receipts: pointers at section 2.3, 2.4.1, 2.4.3, 2.7.1, 3, 3.1, 3.3, 7.2, 7.4 and 7.7; SPEC section 5 and section 7 Signatures paragraphs; CHANGELOG. - pq-conformance/pq_verify.py and _mldsa.py: ML-DSA-65 verification written from FIPS 204 in pure Python (all 90 ACVP vectors), every case, the sizes, section 8's table, 26 mutated cases refused, and every one of 14 checks caught when dropped. README: the corpus rules and the provenance of the vendored vectors, with the notice their licence asks kept intact. - the suite registered as a wire identifier; registry export regenerated. - CI: the reference's tests, the corpus's cross-platform regeneration, both verifiers, and a diff of their verdicts. Co-Authored-By: Claude Opus 5.5 <[email protected]>
314 lines
9 KiB
Python
314 lines
9 KiB
Python
"""ML-DSA verification (FIPS 204), written from the standard's text with Python's integers and
|
|
nothing beyond the standard library: SHAKE128 and SHAKE256 are hashlib's (FIPS 202).
|
|
|
|
It verifies; it does not sign. It shares no code with wai-rs, which signs and verifies with a
|
|
separate implementation. Every algorithm is named by its number in FIPS 204:
|
|
|
|
- `verify(pk, m, sig, ctx)` Algorithm 3, ML-DSA.Verify, with the context string `ctx`;
|
|
- `verify_internal(pk, mp, sig)` Algorithm 8, ML-DSA.Verify_internal, over M' as bytes;
|
|
- `verify_mu(pk, mu, sig)` Algorithm 8 from line 8, over a message representative μ
|
|
computed elsewhere (the "external μ" interface).
|
|
|
|
Parameters are ML-DSA-65's (§4, Table 1); `PARAMS` holds the three sets so a test can name
|
|
another, and a key or signature of another length is refused (§3.6.2).
|
|
|
|
A verification takes a few milliseconds: Python's integers, no vectorisation. The matrix Â
|
|
(Algorithm 32) is cached per public key, as §3.6.3 allows.
|
|
"""
|
|
import hashlib
|
|
|
|
Q = 8380417
|
|
D = 13
|
|
N = 256
|
|
ZETA = 1753
|
|
|
|
# FIPS 204 §4, Table 1.
|
|
PARAMS = {
|
|
"ML-DSA-44": dict(k=4, l=4, eta=2, tau=39, lam=128, gamma1=1 << 17, gamma2=(Q - 1) // 88, omega=80),
|
|
"ML-DSA-65": dict(k=6, l=5, eta=4, tau=49, lam=192, gamma1=1 << 19, gamma2=(Q - 1) // 32, omega=55),
|
|
"ML-DSA-87": dict(k=8, l=7, eta=2, tau=60, lam=256, gamma1=1 << 19, gamma2=(Q - 1) // 32, omega=75),
|
|
}
|
|
for _p in PARAMS.values():
|
|
_p["beta"] = _p["tau"] * _p["eta"]
|
|
|
|
|
|
def bitlen(x):
|
|
return x.bit_length()
|
|
|
|
|
|
def pk_len(p):
|
|
return 32 + 32 * p["k"] * (bitlen(Q - 1) - D)
|
|
|
|
|
|
def sig_len(p):
|
|
return p["lam"] // 4 + p["l"] * 32 * (1 + bitlen(p["gamma1"] - 1)) + p["omega"] + p["k"]
|
|
|
|
|
|
def H(data, n):
|
|
"""SHAKE256 to n bytes (§3.7)."""
|
|
return hashlib.shake_256(data).digest(n)
|
|
|
|
|
|
class _XOF:
|
|
"""Incremental squeezing (§3.7, SP 800-185's API): each call returns the next bytes of the
|
|
one output stream. hashlib's `digest(n)` gives a prefix of that stream, so the stream is
|
|
read again, longer, whenever more is needed."""
|
|
|
|
def __init__(self, ctor, data):
|
|
self.ctor, self.data, self.buf, self.at = ctor, data, b"", 0
|
|
|
|
def squeeze(self, n):
|
|
while self.at + n > len(self.buf):
|
|
self.buf = self.ctor(self.data).digest(max(2 * len(self.buf), 1024))
|
|
out = self.buf[self.at:self.at + n]
|
|
self.at += n
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------------------- §7.1, §7.2: encodings
|
|
|
|
def _bits(data):
|
|
"""BytesToBits (Algorithm 13), as one little-endian integer."""
|
|
return int.from_bytes(data, "little")
|
|
|
|
|
|
def simple_bit_unpack(v, b):
|
|
"""Algorithm 18: 256 coefficients of bitlen(b) bits each."""
|
|
c = bitlen(b)
|
|
z = _bits(v)
|
|
mask = (1 << c) - 1
|
|
return [(z >> (i * c)) & mask for i in range(N)]
|
|
|
|
|
|
def bit_unpack(v, a, b):
|
|
"""Algorithm 19: coefficients b - x for x of bitlen(a + b) bits."""
|
|
c = bitlen(a + b)
|
|
z = _bits(v)
|
|
mask = (1 << c) - 1
|
|
return [b - ((z >> (i * c)) & mask) for i in range(N)]
|
|
|
|
|
|
def hint_bit_unpack(y, p):
|
|
"""Algorithm 21; None for ⊥."""
|
|
k, omega = p["k"], p["omega"]
|
|
h = [[0] * N for _ in range(k)]
|
|
index = 0
|
|
for i in range(k):
|
|
if y[omega + i] < index or y[omega + i] > omega:
|
|
return None
|
|
first = index
|
|
while index < y[omega + i]:
|
|
if index > first and y[index - 1] >= y[index]:
|
|
return None
|
|
h[i][y[index]] = 1
|
|
index += 1
|
|
for i in range(index, omega):
|
|
if y[i] != 0:
|
|
return None
|
|
return h
|
|
|
|
|
|
def pk_decode(pk, p):
|
|
"""Algorithm 23."""
|
|
rho = pk[:32]
|
|
w = 32 * (bitlen(Q - 1) - D)
|
|
t1 = [simple_bit_unpack(pk[32 + i * w:32 + (i + 1) * w], (1 << (bitlen(Q - 1) - D)) - 1) for i in range(p["k"])]
|
|
return rho, t1
|
|
|
|
|
|
def sig_decode(sig, p):
|
|
"""Algorithm 27; h is None for ⊥."""
|
|
lam4 = p["lam"] // 4
|
|
c_tilde = sig[:lam4]
|
|
w = 32 * (1 + bitlen(p["gamma1"] - 1))
|
|
z = [bit_unpack(sig[lam4 + i * w:lam4 + (i + 1) * w], p["gamma1"] - 1, p["gamma1"]) for i in range(p["l"])]
|
|
h = hint_bit_unpack(sig[lam4 + p["l"] * w:], p)
|
|
return c_tilde, z, h
|
|
|
|
|
|
def simple_bit_pack(w, b):
|
|
"""Algorithm 16."""
|
|
c = bitlen(b)
|
|
z = 0
|
|
for i, x in enumerate(w):
|
|
z |= x << (i * c)
|
|
return z.to_bytes(32 * c, "little")
|
|
|
|
|
|
def w1_encode(w1, p):
|
|
"""Algorithm 28."""
|
|
b = (Q - 1) // (2 * p["gamma2"]) - 1
|
|
return b"".join(simple_bit_pack(w, b) for w in w1)
|
|
|
|
|
|
# ---------------------------------------------------------------- §7.3: sampling
|
|
|
|
def sample_in_ball(rho, p):
|
|
"""Algorithm 29."""
|
|
tau = p["tau"]
|
|
c = [0] * N
|
|
x = _XOF(hashlib.shake_256, rho)
|
|
s = x.squeeze(8)
|
|
h = int.from_bytes(s, "little")
|
|
for i in range(N - tau, N):
|
|
j = x.squeeze(1)[0]
|
|
while j > i:
|
|
j = x.squeeze(1)[0]
|
|
c[i] = c[j]
|
|
c[j] = -1 if (h >> (i + tau - N)) & 1 else 1
|
|
return c
|
|
|
|
|
|
def rej_ntt_poly(rho):
|
|
"""Algorithm 30, with CoeffFromThreeBytes (Algorithm 14)."""
|
|
x = _XOF(hashlib.shake_128, rho)
|
|
a = []
|
|
while len(a) < N:
|
|
s = x.squeeze(3)
|
|
z = (s[2] & 0x7F) << 16 | s[1] << 8 | s[0]
|
|
if z < Q:
|
|
a.append(z)
|
|
return a
|
|
|
|
|
|
def expand_a(rho, p):
|
|
"""Algorithm 32."""
|
|
return [[rej_ntt_poly(rho + bytes([s, r])) for s in range(p["l"])] for r in range(p["k"])]
|
|
|
|
|
|
# ---------------------------------------------------------------- §7.4: hints
|
|
|
|
def decompose(r, p):
|
|
"""Algorithm 36."""
|
|
g2 = 2 * p["gamma2"]
|
|
rp = r % Q
|
|
r0 = rp % g2
|
|
if r0 > g2 // 2:
|
|
r0 -= g2
|
|
if rp - r0 == Q - 1:
|
|
return 0, r0 - 1
|
|
return (rp - r0) // g2, r0
|
|
|
|
|
|
def use_hint(h, r, p):
|
|
"""Algorithm 40."""
|
|
m = (Q - 1) // (2 * p["gamma2"])
|
|
r1, r0 = decompose(r, p)
|
|
if h == 1 and r0 > 0:
|
|
return (r1 + 1) % m
|
|
if h == 1 and r0 <= 0:
|
|
return (r1 - 1) % m
|
|
return r1
|
|
|
|
|
|
# ---------------------------------------------------------------- §7.5, §7.6: the NTT
|
|
|
|
def _bitrev8(m):
|
|
"""Algorithm 43."""
|
|
return int(format(m, "08b")[::-1], 2)
|
|
|
|
|
|
ZETAS = [pow(ZETA, _bitrev8(k), Q) for k in range(N)] # zetas[k] = ζ^BitRev8(k) mod q (§7.5)
|
|
|
|
|
|
def ntt(w):
|
|
"""Algorithm 41."""
|
|
a = list(w)
|
|
m, ln = 0, 128
|
|
while ln >= 1:
|
|
start = 0
|
|
while start < N:
|
|
m += 1
|
|
z = ZETAS[m]
|
|
for j in range(start, start + ln):
|
|
t = z * a[j + ln] % Q
|
|
a[j + ln] = (a[j] - t) % Q
|
|
a[j] = (a[j] + t) % Q
|
|
start += 2 * ln
|
|
ln //= 2
|
|
return a
|
|
|
|
|
|
def ntt_inv(a):
|
|
"""Algorithm 42."""
|
|
w = list(a)
|
|
m, ln = 256, 1
|
|
while ln < N:
|
|
start = 0
|
|
while start < N:
|
|
m -= 1
|
|
z = -ZETAS[m] % Q
|
|
for j in range(start, start + ln):
|
|
t = w[j]
|
|
w[j] = (t + w[j + ln]) % Q
|
|
w[j + ln] = (t - w[j + ln]) % Q
|
|
w[j + ln] = z * w[j + ln] % Q
|
|
start += 2 * ln
|
|
ln *= 2
|
|
f = 8347681 # 256^-1 mod q
|
|
return [f * x % Q for x in w]
|
|
|
|
|
|
# ---------------------------------------------------------------- §5.3, §6.3: verification
|
|
|
|
_A_CACHE = {}
|
|
|
|
|
|
def _matrix(pk, rho, p):
|
|
key = (bytes(pk), p["k"], p["l"])
|
|
a = _A_CACHE.get(key)
|
|
if a is None:
|
|
a = _A_CACHE[key] = expand_a(rho, p)
|
|
return a
|
|
|
|
|
|
def _inf_norm(v):
|
|
"""‖·‖∞ of coefficients in mod± representation (§2.3)."""
|
|
m = 0
|
|
for poly in v:
|
|
for x in poly:
|
|
x %= Q
|
|
if x > Q // 2:
|
|
x -= Q
|
|
m = max(m, abs(x))
|
|
return m
|
|
|
|
|
|
def verify_mu(pk, mu, sig, name="ML-DSA-65"):
|
|
"""Algorithm 8 with μ given (lines 6 and 7 done by the caller)."""
|
|
p = PARAMS[name]
|
|
if len(pk) != pk_len(p) or len(sig) != sig_len(p) or len(mu) != 64:
|
|
return False
|
|
rho, t1 = pk_decode(pk, p)
|
|
c_tilde, z, h = sig_decode(sig, p)
|
|
if h is None:
|
|
return False
|
|
a_hat = _matrix(pk, rho, p)
|
|
c_hat = ntt(sample_in_ball(c_tilde, p))
|
|
z_hat = [ntt(zi) for zi in z]
|
|
t1_hat = [ntt([x << D for x in ti]) for ti in t1]
|
|
w1 = []
|
|
for r in range(p["k"]):
|
|
acc = [0] * N
|
|
for s in range(p["l"]):
|
|
ar, zs = a_hat[r][s], z_hat[s]
|
|
for j in range(N):
|
|
acc[j] += ar[j] * zs[j]
|
|
ct = t1_hat[r]
|
|
w_approx = ntt_inv([(acc[j] - c_hat[j] * ct[j]) % Q for j in range(N)])
|
|
w1.append([use_hint(h[r][j], w_approx[j], p) for j in range(N)])
|
|
c_tilde2 = H(mu + w1_encode(w1, p), p["lam"] // 4)
|
|
return _inf_norm(z) < p["gamma1"] - p["beta"] and c_tilde == c_tilde2
|
|
|
|
|
|
def verify_internal(pk, mp, sig, name="ML-DSA-65"):
|
|
"""Algorithm 8: tr = H(pk, 64), μ = H(tr ‖ M', 64), then the rest."""
|
|
tr = H(bytes(pk), 64)
|
|
return verify_mu(pk, H(tr + bytes(mp), 64), sig, name)
|
|
|
|
|
|
def verify(pk, m, sig, ctx=b"", name="ML-DSA-65"):
|
|
"""Algorithm 3: M' = 0 ‖ |ctx| ‖ ctx ‖ M."""
|
|
if len(ctx) > 255:
|
|
return False
|
|
return verify_internal(pk, bytes([0, len(ctx)]) + bytes(ctx) + bytes(m), sig, name)
|