Skip to main content

logicaffeine_system/
mldsa.rs

1//! ML-DSA-65 (FIPS-204 / Dilithium) runtime kernels — the post-quantum SIGNATURE scheme that
2//! complements ML-KEM-768 in the channel. This module builds bottom-up: the length-256 NTT over
3//! the Dilithium prime `q = 8380417` (a COMPLETE transform, unlike Kyber's incomplete one — `q ≡ 1
4//! (mod 512)`, so `X²⁵⁶+1` splits fully), with signed Montgomery reduction. Validated against the
5//! schoolbook negacyclic convolution.
6
7use std::sync::OnceLock;
8
9pub(crate) const Q: i32 = 8_380_417;
10const QINV: i32 = 58_728_449; // q⁻¹ mod 2³² (signed)
11const ZETA: i64 = 1753; // a primitive 512th root of unity mod q
12/// 2³² mod q — the Montgomery factor R.
13const MONT: i64 = 4_193_792;
14const N: usize = 256;
15
16/// Signed Montgomery reduction: for `|a| ≤ q·2³¹`, returns `a·2⁻³² mod q` in `(−q, q)`.
17#[inline]
18pub(crate) fn montgomery_reduce(a: i64) -> i32 {
19    let t = (a as i32).wrapping_mul(QINV) as i64;
20    ((a - t * Q as i64) >> 32) as i32
21}
22
23/// The Dilithium twiddle table: `zetas[i] = ζ^brv₈(i) · R mod q`, reduced into `(−q/2, q/2]`.
24fn zetas() -> &'static [i32; 256] {
25    static ZETAS: OnceLock<[i32; 256]> = OnceLock::new();
26    ZETAS.get_or_init(|| {
27        let mut pow = [1i64; 256];
28        for i in 1..256 {
29            pow[i] = pow[i - 1] * ZETA % Q as i64;
30        }
31        let mut z = [0i32; 256];
32        for (i, slot) in z.iter_mut().enumerate() {
33            let br = (i as u8).reverse_bits() as usize;
34            let mut v = pow[br] * MONT % Q as i64;
35            if v > Q as i64 / 2 {
36                v -= Q as i64;
37            }
38            *slot = v as i32;
39        }
40        z
41    })
42}
43
44/// Forward NTT in place (Dilithium reference): `a` normal domain → NTT domain. Kept scalar/
45/// register-resident — measured A/B shows 8-wide butterflies lose here (small-group per-zeta setup
46/// overhead + the scalar is already register-tight); the win is in the full-poly ops (inverse
47/// F-scaling, pointwise), not the butterflies.
48pub(crate) fn ntt(a: &mut [i32; N]) {
49    let zetas = zetas();
50    let mut k = 0usize;
51    let mut len = 128usize;
52    while len > 0 {
53        let mut start = 0usize;
54        while start < N {
55            k += 1;
56            let zeta = zetas[k] as i64;
57            for j in start..start + len {
58                let t = montgomery_reduce(zeta * a[j + len] as i64);
59                a[j + len] = a[j] - t;
60                a[j] = a[j] + t;
61            }
62            start += 2 * len;
63        }
64        len >>= 1;
65    }
66}
67
68const INVNTT_F: i64 = 41_978; // mont²/256 mod q
69
70/// Inverse NTT with the `tomont` scaling (Dilithium `invntt_tomont`): NTT domain → normal domain,
71/// folding in the final `f = mont²/256` factor. Scalar — LLVM auto-vectorizes the butterfly and the
72/// final scaling in release; hand-written AVX2 measured slower.
73pub(crate) fn invntt_tomont(a: &mut [i32; N]) {
74    let zetas = zetas();
75    let mut k = 256usize;
76    let mut len = 1usize;
77    while len < N {
78        let mut start = 0usize;
79        while start < N {
80            k -= 1;
81            let zeta = -(zetas[k] as i64);
82            for j in start..start + len {
83                let t = a[j];
84                a[j] = t + a[j + len];
85                a[j + len] = t - a[j + len];
86                a[j + len] = montgomery_reduce(zeta * a[j + len] as i64);
87            }
88            start += 2 * len;
89        }
90        len <<= 1;
91    }
92    for x in a.iter_mut() {
93        *x = montgomery_reduce(INVNTT_F * *x as i64);
94    }
95}
96
97/// Pointwise product in the NTT domain, with Montgomery reduction (`poly_pointwise_montgomery`).
98pub(crate) fn pointwise_montgomery(a: &[i32; N], b: &[i32; N]) -> [i32; N] {
99    // Scalar — LLVM auto-vectorizes this element-wise `montgomery_reduce` loop in release; measured A/B
100    // showed hand-written AVX2 intrinsics ~3× SLOWER than the auto-vectorized scalar. Trust the compiler
101    // for simple full-poly loops; hand-vectorize only irreducible data-parallel restructuring (expand_a).
102    std::array::from_fn(|i| montgomery_reduce(a[i] as i64 * b[i] as i64))
103}
104
105/// Reduce a coefficient into the canonical `[0, q)` representative.
106#[inline]
107pub(crate) fn freeze(a: i32) -> i32 {
108    ((a % Q) + Q) % Q
109}
110
111// ── ML-DSA-65 (Dilithium3) parameters ──────────────────────────────────────────────────────────
112pub(crate) const D: i32 = 13; // Power2Round split
113pub(crate) const GAMMA1: i32 = 1 << 19; // 524288 — ExpandMask range
114pub(crate) const GAMMA2: i32 = (Q - 1) / 32; // 261888 — Decompose interval (α/2)
115
116// ── Rounding & hints (FIPS-204 §7.4) ───────────────────────────────────────────────────────────
117
118/// Power2Round: split `r ∈ [0,q)` as `r = r1·2ᵈ + r0` with `r0 ∈ (−2ᵈ⁻¹, 2ᵈ⁻¹]`. Returns `(r1, r0)`.
119pub(crate) fn power2round(r: i32) -> (i32, i32) {
120    let r = freeze(r);
121    let r1 = (r + (1 << (D - 1)) - 1) >> D;
122    let r0 = r - (r1 << D);
123    (r1, r0)
124}
125
126/// Decompose `r ∈ [0,q)` as `r = r1·2γ2 + r0` with `r0 ∈ (−γ2, γ2]` (Dilithium's γ2 = (q−1)/32 path).
127/// Returns `(r1, r0)`; `r1 ∈ [0, 16)`.
128pub(crate) fn decompose(r: i32) -> (i32, i32) {
129    let r = freeze(r);
130    let mut a1 = (r + 127) >> 7;
131    a1 = (a1 * 1025 + (1 << 21)) >> 22;
132    a1 &= 15;
133    let mut a0 = r - a1 * 2 * GAMMA2;
134    a0 -= (((Q - 1) / 2 - a0) >> 31) & Q;
135    (a1, a0)
136}
137
138/// HighBits / LowBits — the two halves of [`decompose`].
139pub(crate) fn highbits(r: i32) -> i32 {
140    decompose(r).0
141}
142pub(crate) fn lowbits(r: i32) -> i32 {
143    decompose(r).1
144}
145
146/// MakeHint(z, r): 1 iff adding `z` carries `r` across a Decompose boundary (so HighBits changes).
147pub(crate) fn make_hint(z: i32, r: i32) -> u8 {
148    (highbits(r) != highbits(r + z)) as u8
149}
150
151/// UseHint(h, r): recover `HighBits(r + z)` from `r` and the hint bit `h` (FIPS-204 §7.4).
152pub(crate) fn use_hint(h: u8, r: i32) -> i32 {
153    const M: i32 = 16; // (q-1)/(2·γ2)
154    let (r1, r0) = decompose(r);
155    if h == 0 {
156        r1
157    } else if r0 > 0 {
158        (r1 + 1) % M
159    } else {
160        (r1 - 1 + M) % M
161    }
162}
163
164// ── ML-DSA-65 dimensions ───────────────────────────────────────────────────────────────────────
165pub(crate) const MK: usize = 6; // rows (k)
166pub(crate) const ML: usize = 5; // cols (l)
167pub(crate) const ETA: i32 = 4;
168pub(crate) const TAU: usize = 49;
169
170type Poly = [i32; N];
171
172// ── Sampling (FIPS-204 §7.3) ───────────────────────────────────────────────────────────────────
173
174/// RejNTTPoly / ExpandA element: uniform NTT-domain polynomial from SHAKE128(seed), rejection on
175/// 23-bit reads `< q`. `seed = ρ ‖ col ‖ row` (34 bytes).
176fn rej_ntt_poly(seed: &[u8]) -> Poly {
177    let mut st = crate::keccak::shake_absorb(seed, 168);
178    let mut buf = [0u8; 168];
179    crate::keccak::shake_squeeze_block(&st, &mut buf, 168);
180    let mut pos = 0;
181    let mut a = [0i32; N];
182    let mut ctr = 0;
183    while ctr < N {
184        if pos + 3 > 168 {
185            crate::keccak::keccak_permute(&mut st);
186            crate::keccak::shake_squeeze_block(&st, &mut buf, 168);
187            pos = 0;
188        }
189        let d = (buf[pos] as u32) | ((buf[pos + 1] as u32) << 8) | (((buf[pos + 2] as u32) & 0x7f) << 16);
190        pos += 3;
191        if d < Q as u32 {
192            a[ctr] = d as i32;
193            ctr += 1;
194        }
195    }
196    a
197}
198
199/// RejBoundedPoly / ExpandS element (η=4): coefficients in `[−η, η]` from SHAKE256(seed), each
200/// nibble `< 9` mapped to `η − nibble`. `seed = ρ' ‖ nonce(2)` (66 bytes).
201fn rej_bounded_poly(seed: &[u8]) -> Poly {
202    let mut st = crate::keccak::shake_absorb(seed, 136);
203    let mut buf = [0u8; 136];
204    crate::keccak::shake_squeeze_block(&st, &mut buf, 136);
205    let mut pos = 0;
206    let mut a = [0i32; N];
207    let mut ctr = 0;
208    while ctr < N {
209        if pos >= 136 {
210            crate::keccak::keccak_permute(&mut st);
211            crate::keccak::shake_squeeze_block(&st, &mut buf, 136);
212            pos = 0;
213        }
214        let b = buf[pos];
215        pos += 1;
216        let t0 = (b & 15) as i32;
217        let t1 = (b >> 4) as i32;
218        if t0 < 9 {
219            a[ctr] = ETA - t0;
220            ctr += 1;
221        }
222        if t1 < 9 && ctr < N {
223            a[ctr] = ETA - t1;
224            ctr += 1;
225        }
226    }
227    a
228}
229
230/// ExpandA → the k×l matrix Â, each element a uniform NTT-domain polynomial keyed by (row, col).
231/// ExpandA — the 6×5 public matrix  in NTT domain. Each entry Â[r][c] = RejNTTPoly(ρ‖c‖r) via
232/// SHAKE128 rejection. AVX2 batches four entries per `keccak_f1600_x4` permutation (bit-identical to
233/// the scalar path — `keccak_f1600_x4` is a verified 4× lane replica); scalar fallback otherwise.
234/// This is ~68% of `verify` (30 SHAKE128 streams), so vectorizing it is the dominant verify win.
235pub(crate) fn expand_a(rho: &[u8]) -> Vec<Vec<Poly>> {
236    #[cfg(target_arch = "x86_64")]
237    {
238        if std::is_x86_feature_detected!("avx2") {
239            const ENTRIES: usize = MK * ML;
240            let coords: [(usize, usize); ENTRIES] = std::array::from_fn(|i| (i / ML, i % ML));
241            let mut flat = vec![[0i32; N]; ENTRIES];
242            let mut e = 0;
243            while e + 4 <= ENTRIES {
244                let blocks: [[u8; 168]; 4] = std::array::from_fn(|l| {
245                    let (r, c) = coords[e + l];
246                    mldsa_expand_xof_block(rho, c as u8, r as u8)
247                });
248                let mut accs: [Vec<i32>; 4] = std::array::from_fn(|_| Vec::with_capacity(256));
249                let mut st = unsafe { crate::keccak::shake128_x4_absorb_once(&blocks) };
250                loop {
251                    let outb = unsafe { crate::keccak::shake128_x4_squeeze_block(&st) };
252                    let mut done = true;
253                    for (l, acc) in accs.iter_mut().enumerate() {
254                        if acc.len() < N {
255                            mldsa_reject_ntt_block(&outb[l], acc);
256                        }
257                        if acc.len() < N {
258                            done = false;
259                        }
260                    }
261                    if done {
262                        break;
263                    }
264                    unsafe { crate::keccak::keccak_f1600_x4(&mut st) };
265                }
266                for (l, acc) in accs.iter().enumerate() {
267                    let (r, c) = coords[e + l];
268                    flat[r * ML + c].copy_from_slice(&acc[..N]);
269                }
270                e += 4;
271            }
272            while e < ENTRIES {
273                let (r, c) = coords[e];
274                let mut seed = rho[..32].to_vec();
275                seed.push(c as u8);
276                seed.push(r as u8);
277                flat[r * ML + c] = rej_ntt_poly(&seed);
278                e += 1;
279            }
280            return (0..MK).map(|r| (0..ML).map(|c| flat[r * ML + c]).collect()).collect();
281        }
282    }
283    expand_a_scalar(rho)
284}
285
286fn expand_a_scalar(rho: &[u8]) -> Vec<Vec<Poly>> {
287    (0..MK)
288        .map(|r| {
289            (0..ML)
290                .map(|s| {
291                    let mut seed = rho.to_vec();
292                    seed.push(s as u8);
293                    seed.push(r as u8);
294                    rej_ntt_poly(&seed)
295                })
296                .collect()
297        })
298        .collect()
299}
300
301/// The padded 168-byte SHAKE128 absorb block for Â[r][c]: `ρ‖c‖r`, the `0x1f` domain delimiter, and
302/// the `0x80` final bit — matching what `shake_absorb(ρ‖c‖r, 168)` produces on the scalar path.
303#[cfg(target_arch = "x86_64")]
304#[inline]
305fn mldsa_expand_xof_block(rho: &[u8], s: u8, r: u8) -> [u8; 168] {
306    let mut blk = [0u8; 168];
307    blk[..32].copy_from_slice(&rho[..32]);
308    blk[32] = s;
309    blk[33] = r;
310    blk[34] = 0x1f;
311    blk[167] |= 0x80;
312    blk
313}
314
315/// RejNTTPoly over one 168-byte SHAKE128 block: 56 non-straddling 3-byte groups (168 = 56·3) parsed
316/// as 23-bit integers, keeping those `< q`, appended to `out` (capped at 256).
317#[cfg(target_arch = "x86_64")]
318#[inline]
319fn mldsa_reject_ntt_block(buf: &[u8; 168], out: &mut Vec<i32>) {
320    let q = Q as u32;
321    let mut k = 0;
322    while k + 3 <= 168 && out.len() < N {
323        let d = (buf[k] as u32) | ((buf[k + 1] as u32) << 8) | (((buf[k + 2] as u32) & 0x7f) << 16);
324        k += 3;
325        if d < q {
326            out.push(d as i32);
327        }
328    }
329}
330
331// ── Bit-packing (FIPS-204 §7.1, Dilithium layouts) ─────────────────────────────────────────────
332
333/// Pack `t1` (10-bit coefficients): 4 coeffs → 5 bytes. 320 bytes/poly.
334fn pack_t1(t1: &Poly) -> Vec<u8> {
335    let mut r = vec![0u8; N / 4 * 5];
336    for i in 0..N / 4 {
337        let a = [t1[4 * i], t1[4 * i + 1], t1[4 * i + 2], t1[4 * i + 3]];
338        r[5 * i] = a[0] as u8;
339        r[5 * i + 1] = ((a[0] >> 8) | (a[1] << 2)) as u8;
340        r[5 * i + 2] = ((a[1] >> 6) | (a[2] << 4)) as u8;
341        r[5 * i + 3] = ((a[2] >> 4) | (a[3] << 6)) as u8;
342        r[5 * i + 4] = (a[3] >> 2) as u8;
343    }
344    r
345}
346
347/// Pack η=4 coefficients (`η − coeff` in `[0,8]`, 4 bits): 2 coeffs → 1 byte. 128 bytes/poly.
348fn pack_eta(p: &Poly) -> Vec<u8> {
349    let mut r = vec![0u8; N / 2];
350    for i in 0..N / 2 {
351        let t0 = (ETA - p[2 * i]) as u8;
352        let t1 = (ETA - p[2 * i + 1]) as u8;
353        r[i] = t0 | (t1 << 4);
354    }
355    r
356}
357
358/// Pack `t0` (13-bit signed, `2¹² − coeff`): 8 coeffs → 13 bytes. 416 bytes/poly.
359fn pack_t0(p: &Poly) -> Vec<u8> {
360    let mut r = vec![0u8; N / 8 * 13];
361    for i in 0..N / 8 {
362        let mut t = [0u32; 8];
363        for j in 0..8 {
364            t[j] = ((1 << (D - 1)) - p[8 * i + j]) as u32;
365        }
366        let o = 13 * i;
367        r[o] = t[0] as u8;
368        r[o + 1] = ((t[0] >> 8) | (t[1] << 5)) as u8;
369        r[o + 2] = (t[1] >> 3) as u8;
370        r[o + 3] = ((t[1] >> 11) | (t[2] << 2)) as u8;
371        r[o + 4] = ((t[2] >> 6) | (t[3] << 7)) as u8;
372        r[o + 5] = (t[3] >> 1) as u8;
373        r[o + 6] = ((t[3] >> 9) | (t[4] << 4)) as u8;
374        r[o + 7] = (t[4] >> 4) as u8;
375        r[o + 8] = ((t[4] >> 12) | (t[5] << 1)) as u8;
376        r[o + 9] = ((t[5] >> 7) | (t[6] << 6)) as u8;
377        r[o + 10] = (t[6] >> 2) as u8;
378        r[o + 11] = ((t[6] >> 10) | (t[7] << 3)) as u8;
379        r[o + 12] = (t[7] >> 5) as u8;
380    }
381    r
382}
383
384// ── ML-DSA-65 KeyGen (FIPS-204 §6.1) ───────────────────────────────────────────────────────────
385
386/// `pk` = ρ ‖ pack(t1).
387pub const PK_BYTES: usize = 32 + MK * 320;
388/// `sk` = ρ ‖ K ‖ tr ‖ pack(s1) ‖ pack(s2) ‖ pack(t0).
389pub const SK_BYTES: usize = 32 + 32 + 64 + ML * 128 + MK * 128 + MK * 416;
390
391/// ML-DSA.KeyGen_internal(ξ) → (pk, sk), deterministic in the 32-byte seed ξ.
392pub fn keygen(seed: &[u8; 32]) -> (Vec<u8>, Vec<u8>) {
393    let mut h_in = seed.to_vec();
394    h_in.push(MK as u8);
395    h_in.push(ML as u8);
396    let hash = crate::keccak::shake256_bytes(&h_in, 128);
397    let rho = &hash[0..32];
398    let rho_prime = &hash[32..96];
399    let k_key = &hash[96..128];
400
401    let a_hat = expand_a(rho);
402    let s1: Vec<Poly> = (0..ML)
403        .map(|i| {
404            let mut seed = rho_prime.to_vec();
405            seed.extend_from_slice(&(i as u16).to_le_bytes());
406            rej_bounded_poly(&seed)
407        })
408        .collect();
409    let s2: Vec<Poly> = (0..MK)
410        .map(|i| {
411            let mut seed = rho_prime.to_vec();
412            seed.extend_from_slice(&((ML + i) as u16).to_le_bytes());
413            rej_bounded_poly(&seed)
414        })
415        .collect();
416
417    let s1_hat: Vec<Poly> = s1.iter().map(|p| { let mut q = *p; ntt(&mut q); q }).collect();
418    let mut t1 = vec![[0i32; N]; MK];
419    let mut t0 = vec![[0i32; N]; MK];
420    for i in 0..MK {
421        let mut acc = [0i32; N];
422        for j in 0..ML {
423            let prod = pointwise_montgomery(&a_hat[i][j], &s1_hat[j]);
424            for c in 0..N {
425                acc[c] += prod[c];
426            }
427        }
428        invntt_tomont(&mut acc);
429        for c in 0..N {
430            let t = freeze(acc[c] + s2[i][c]);
431            let (r1, r0) = power2round(t);
432            t1[i][c] = r1;
433            t0[i][c] = r0;
434        }
435    }
436
437    let mut pk = rho.to_vec();
438    for poly in &t1 {
439        pk.extend(pack_t1(poly));
440    }
441    let tr = crate::keccak::shake256_bytes(&pk, 64);
442
443    let mut sk = rho.to_vec();
444    sk.extend_from_slice(k_key);
445    sk.extend_from_slice(&tr);
446    for poly in &s1 {
447        sk.extend(pack_eta(poly));
448    }
449    for poly in &s2 {
450        sk.extend(pack_eta(poly));
451    }
452    for poly in &t0 {
453        sk.extend(pack_t0(poly));
454    }
455    (pk, sk)
456}
457
458// ── ML-DSA-65 sign/verify parameters ───────────────────────────────────────────────────────────
459pub(crate) const BETA: i32 = (TAU as i32) * ETA; // 196
460pub(crate) const OMEGA: usize = 55;
461const CTILDE: usize = 48; // λ/4
462/// ML-DSA-65 signature length.
463pub const SIG_BYTES: usize = CTILDE + ML * 640 + OMEGA + MK;
464
465/// Map `a` into the signed representative `(−q/2, q/2]` and return its magnitude basis for ‖·‖∞.
466#[inline]
467fn to_signed(a: i32) -> i32 {
468    let r = freeze(a);
469    if r > Q / 2 {
470        r - Q
471    } else {
472        r
473    }
474}
475fn inf_norm(p: &Poly) -> i32 {
476    p.iter().map(|&c| to_signed(c).abs()).max().unwrap()
477}
478
479#[inline]
480fn nttp(p: &Poly) -> Poly {
481    let mut q = *p;
482    ntt(&mut q);
483    q
484}
485
486// ── Unpacking (sk/pk/sig decode) ───────────────────────────────────────────────────────────────
487
488fn unpack_eta(b: &[u8]) -> Poly {
489    std::array::from_fn(|i| {
490        let byte = b[i / 2];
491        let nib = if i % 2 == 0 { byte & 15 } else { byte >> 4 };
492        ETA - nib as i32
493    })
494}
495
496fn unpack_t0(b: &[u8]) -> Poly {
497    let mut r = [0i32; N];
498    for i in 0..N / 8 {
499        let a: Vec<u32> = b[13 * i..13 * i + 13].iter().map(|&x| x as u32).collect();
500        let mut t = [0u32; 8];
501        t[0] = a[0] | (a[1] << 8);
502        t[1] = (a[1] >> 5) | (a[2] << 3) | (a[3] << 11);
503        t[2] = (a[3] >> 2) | (a[4] << 6);
504        t[3] = (a[4] >> 7) | (a[5] << 1) | (a[6] << 9);
505        t[4] = (a[6] >> 4) | (a[7] << 4) | (a[8] << 12);
506        t[5] = (a[8] >> 1) | (a[9] << 7);
507        t[6] = (a[9] >> 6) | (a[10] << 2) | (a[11] << 10);
508        t[7] = (a[11] >> 3) | (a[12] << 5);
509        for j in 0..8 {
510            r[8 * i + j] = (1 << (D - 1)) - (t[j] & 0x1fff) as i32;
511        }
512    }
513    r
514}
515
516fn unpack_t1(b: &[u8]) -> Poly {
517    let mut r = [0i32; N];
518    for i in 0..N / 4 {
519        let a: Vec<u32> = b[5 * i..5 * i + 5].iter().map(|&x| x as u32).collect();
520        r[4 * i] = ((a[0] | (a[1] << 8)) & 0x3ff) as i32;
521        r[4 * i + 1] = (((a[1] >> 2) | (a[2] << 6)) & 0x3ff) as i32;
522        r[4 * i + 2] = (((a[2] >> 4) | (a[3] << 4)) & 0x3ff) as i32;
523        r[4 * i + 3] = (((a[3] >> 6) | (a[4] << 2)) & 0x3ff) as i32;
524    }
525    r
526}
527
528fn unpack_z(b: &[u8]) -> Poly {
529    let mut r = [0i32; N];
530    for i in 0..N / 2 {
531        let a: Vec<u32> = b[5 * i..5 * i + 5].iter().map(|&x| x as u32).collect();
532        let z0 = (a[0] | (a[1] << 8) | (a[2] << 16)) & 0xfffff;
533        let z1 = ((a[2] >> 4) | (a[3] << 4) | (a[4] << 12)) & 0xfffff;
534        r[2 * i] = GAMMA1 - z0 as i32;
535        r[2 * i + 1] = GAMMA1 - z1 as i32;
536    }
537    r
538}
539
540fn unpack_hint(b: &[u8]) -> Option<Vec<Poly>> {
541    let mut h = vec![[0i32; N]; MK];
542    let mut k = 0usize;
543    for i in 0..MK {
544        let cnt = b[OMEGA + i] as usize;
545        if cnt < k || cnt > OMEGA {
546            return None;
547        }
548        for j in k..cnt {
549            if j > k && b[j] <= b[j - 1] {
550                return None; // indices must be strictly increasing within a poly
551            }
552            h[i][b[j] as usize] = 1;
553        }
554        k = cnt;
555    }
556    if b[k..OMEGA].iter().any(|&x| x != 0) {
557        return None; // padding must be zero
558    }
559    Some(h)
560}
561
562// ── sig packing ────────────────────────────────────────────────────────────────────────────────
563
564fn pack_z(p: &Poly) -> Vec<u8> {
565    let mut r = vec![0u8; N / 2 * 5];
566    for i in 0..N / 2 {
567        let t0 = (GAMMA1 - to_signed(p[2 * i])) as u32;
568        let t1 = (GAMMA1 - to_signed(p[2 * i + 1])) as u32;
569        r[5 * i] = t0 as u8;
570        r[5 * i + 1] = (t0 >> 8) as u8;
571        r[5 * i + 2] = ((t0 >> 16) | (t1 << 4)) as u8;
572        r[5 * i + 3] = (t1 >> 4) as u8;
573        r[5 * i + 4] = (t1 >> 12) as u8;
574    }
575    r
576}
577
578fn pack_w1(p: &Poly) -> Vec<u8> {
579    (0..N / 2).map(|i| (p[2 * i] | (p[2 * i + 1] << 4)) as u8).collect()
580}
581
582fn pack_hint(h: &[Poly]) -> Vec<u8> {
583    let mut r = vec![0u8; OMEGA + MK];
584    let mut k = 0usize;
585    for (i, poly) in h.iter().enumerate() {
586        for (j, &v) in poly.iter().enumerate() {
587            if v != 0 {
588                r[k] = j as u8;
589                k += 1;
590            }
591        }
592        r[OMEGA + i] = k as u8;
593    }
594    r
595}
596
597// ── ExpandMask + SampleInBall ──────────────────────────────────────────────────────────────────
598
599fn expand_mask(rho: &[u8], kappa: u16) -> Poly {
600    let mut seed = rho.to_vec();
601    seed.extend_from_slice(&kappa.to_le_bytes());
602    let buf = crate::keccak::shake256_bytes(&seed, N / 2 * 5);
603    let mut a = [0i32; N];
604    for i in 0..N / 2 {
605        let o = 5 * i;
606        let z0 = (buf[o] as u32) | ((buf[o + 1] as u32) << 8) | (((buf[o + 2] as u32) & 0xf) << 16);
607        let z1 = ((buf[o + 2] as u32) >> 4) | ((buf[o + 3] as u32) << 4) | ((buf[o + 4] as u32) << 12);
608        a[2 * i] = GAMMA1 - z0 as i32;
609        a[2 * i + 1] = GAMMA1 - z1 as i32;
610    }
611    a
612}
613
614fn sample_in_ball(seed: &[u8]) -> Poly {
615    let mut st = crate::keccak::shake_absorb(seed, 136);
616    let mut buf = [0u8; 136];
617    crate::keccak::shake_squeeze_block(&st, &mut buf, 136);
618    let signs = u64::from_le_bytes(buf[0..8].try_into().unwrap());
619    let mut pos = 8;
620    let mut c = [0i32; N];
621    let mut sign_idx = 0;
622    for i in (N - TAU)..N {
623        let j = loop {
624            if pos >= 136 {
625                crate::keccak::keccak_permute(&mut st);
626                crate::keccak::shake_squeeze_block(&st, &mut buf, 136);
627                pos = 0;
628            }
629            let candidate = buf[pos] as usize;
630            pos += 1;
631            if candidate <= i {
632                break candidate;
633            }
634        };
635        c[i] = c[j];
636        c[j] = 1 - 2 * (((signs >> sign_idx) & 1) as i32);
637        sign_idx += 1;
638    }
639    c
640}
641
642/// `M' = 0x00 ‖ |ctx| ‖ ctx ‖ M` (FIPS-204 pure, no pre-hash), then `μ = H(tr ‖ M', 64)`.
643fn compute_mu(tr: &[u8], m: &[u8], ctx: &[u8]) -> Vec<u8> {
644    let mut mp = vec![0u8, ctx.len() as u8];
645    mp.extend_from_slice(ctx);
646    mp.extend_from_slice(m);
647    let mut mu_in = tr.to_vec();
648    mu_in.extend_from_slice(&mp);
649    crate::keccak::shake256_bytes(&mu_in, 64)
650}
651
652// ── ML-DSA.Sign / Verify (FIPS-204 §6.2, §6.3) ─────────────────────────────────────────────────
653
654/// Deterministic ML-DSA-65 signature over `m` with context `ctx` (the `rnd = 0` variant).
655pub fn sign(sk: &[u8], m: &[u8], ctx: &[u8]) -> Vec<u8> {
656    let rho = &sk[0..32];
657    let k_key = &sk[32..64];
658    let tr = &sk[64..128];
659    let mut off = 128;
660    let mut take = |n: usize, sk: &[u8], off: &mut usize| {
661        let s = sk[*off..*off + n].to_vec();
662        *off += n;
663        s
664    };
665    let s1: Vec<Poly> = (0..ML).map(|_| unpack_eta(&take(128, sk, &mut off))).collect();
666    let s2: Vec<Poly> = (0..MK).map(|_| unpack_eta(&take(128, sk, &mut off))).collect();
667    let t0: Vec<Poly> = (0..MK).map(|_| unpack_t0(&take(416, sk, &mut off))).collect();
668
669    let a_hat = expand_a(rho);
670    let s1_hat: Vec<Poly> = s1.iter().map(nttp).collect();
671    let s2_hat: Vec<Poly> = s2.iter().map(nttp).collect();
672    let t0_hat: Vec<Poly> = t0.iter().map(nttp).collect();
673
674    let mu = compute_mu(tr, m, ctx);
675    let mut rp_in = k_key.to_vec();
676    rp_in.extend_from_slice(&[0u8; 32]); // rnd = 0 (deterministic)
677    rp_in.extend_from_slice(&mu);
678    let rho_pp = crate::keccak::shake256_bytes(&rp_in, 64);
679
680    let mut kappa = 0u16;
681    loop {
682        let y: Vec<Poly> = (0..ML).map(|i| expand_mask(&rho_pp, kappa + i as u16)).collect();
683        let y_hat: Vec<Poly> = y.iter().map(nttp).collect();
684        let mut w = vec![[0i32; N]; MK];
685        for i in 0..MK {
686            let mut acc = [0i32; N];
687            for j in 0..ML {
688                let prod = pointwise_montgomery(&a_hat[i][j], &y_hat[j]);
689                for c in 0..N {
690                    acc[c] += prod[c];
691                }
692            }
693            invntt_tomont(&mut acc);
694            w[i] = acc;
695        }
696        let w1: Vec<Poly> =
697            w.iter().map(|p| std::array::from_fn(|c| highbits(p[c]))).collect();
698
699        let mut ct_in = mu.clone();
700        for p in &w1 {
701            ct_in.extend(pack_w1(p));
702        }
703        let c_tilde = crate::keccak::shake256_bytes(&ct_in, CTILDE);
704        let c_hat = nttp(&sample_in_ball(&c_tilde));
705
706        let cs1: Vec<Poly> = s1_hat
707            .iter()
708            .map(|sh| {
709                let mut a = pointwise_montgomery(&c_hat, sh);
710                invntt_tomont(&mut a);
711                a
712            })
713            .collect();
714        let cs2: Vec<Poly> = s2_hat
715            .iter()
716            .map(|sh| {
717                let mut a = pointwise_montgomery(&c_hat, sh);
718                invntt_tomont(&mut a);
719                a
720            })
721            .collect();
722
723        let z: Vec<Poly> = (0..ML).map(|j| std::array::from_fn(|c| y[j][c] + cs1[j][c])).collect();
724        let r0: Vec<Poly> =
725            (0..MK).map(|i| std::array::from_fn(|c| lowbits(freeze(w[i][c] - cs2[i][c])))).collect();
726
727        if z.iter().any(|p| inf_norm(p) >= GAMMA1 - BETA)
728            || r0.iter().any(|p| inf_norm(p) >= GAMMA2 - BETA)
729        {
730            kappa += ML as u16;
731            continue;
732        }
733
734        let ct0: Vec<Poly> = t0_hat
735            .iter()
736            .map(|th| {
737                let mut a = pointwise_montgomery(&c_hat, th);
738                invntt_tomont(&mut a);
739                a
740            })
741            .collect();
742        let mut h = vec![[0i32; N]; MK];
743        let mut weight = 0i32;
744        for i in 0..MK {
745            for c in 0..N {
746                let r = w[i][c] - cs2[i][c] + ct0[i][c];
747                let bit = make_hint(-ct0[i][c], r) as i32;
748                h[i][c] = bit;
749                weight += bit;
750            }
751        }
752        if ct0.iter().any(|p| inf_norm(p) >= GAMMA2) || weight > OMEGA as i32 {
753            kappa += ML as u16;
754            continue;
755        }
756
757        let mut sig = c_tilde.clone();
758        for p in &z {
759            sig.extend(pack_z(p));
760        }
761        sig.extend(pack_hint(&h));
762        return sig;
763    }
764}
765
766/// Verify an ML-DSA-65 signature; `true` iff valid for `(pk, m, ctx)`.
767pub fn verify(pk: &[u8], m: &[u8], ctx: &[u8], sig: &[u8]) -> bool {
768    if sig.len() != SIG_BYTES || pk.len() != PK_BYTES {
769        return false;
770    }
771    let rho = &pk[0..32];
772    let t1: Vec<Poly> = (0..MK).map(|i| unpack_t1(&pk[32 + i * 320..32 + (i + 1) * 320])).collect();
773
774    let c_tilde = &sig[0..CTILDE];
775    let mut off = CTILDE;
776    let z: Vec<Poly> = (0..ML)
777        .map(|_| {
778            let p = unpack_z(&sig[off..off + 640]);
779            off += 640;
780            p
781        })
782        .collect();
783    let h = match unpack_hint(&sig[off..off + OMEGA + MK]) {
784        Some(h) => h,
785        None => return false,
786    };
787    if z.iter().any(|p| inf_norm(p) >= GAMMA1 - BETA) {
788        return false;
789    }
790
791    let a_hat = expand_a(rho);
792    let tr = crate::keccak::shake256_bytes(pk, 64);
793    let mu = compute_mu(&tr, m, ctx);
794    let c_hat = nttp(&sample_in_ball(c_tilde));
795    let z_hat: Vec<Poly> = z.iter().map(nttp).collect();
796
797    let mut w1 = vec![[0i32; N]; MK];
798    for i in 0..MK {
799        let mut acc = [0i32; N];
800        for j in 0..ML {
801            let prod = pointwise_montgomery(&a_hat[i][j], &z_hat[j]);
802            for c in 0..N {
803                acc[c] += prod[c];
804            }
805        }
806        let t1d_hat = nttp(&std::array::from_fn(|c| t1[i][c] << D));
807        let ct1 = pointwise_montgomery(&c_hat, &t1d_hat);
808        for c in 0..N {
809            acc[c] -= ct1[c];
810        }
811        invntt_tomont(&mut acc);
812        for c in 0..N {
813            w1[i][c] = use_hint(h[i][c] as u8, acc[c]);
814        }
815    }
816
817    let mut ct_in = mu;
818    for p in &w1 {
819        ct_in.extend(pack_w1(p));
820    }
821    c_tilde == crate::keccak::shake256_bytes(&ct_in, CTILDE).as_slice()
822}
823
824/// Dev-only profiler: break `verify`'s cost into components to locate the hot spot. Not shipped logic.
825pub fn bench_verify_breakdown(pk: &[u8], sig: &[u8]) {
826    use std::time::Instant;
827    let rho = &pk[0..32];
828    let c_tilde = &sig[0..CTILDE];
829    let iters = 2000u32;
830    macro_rules! t {
831        ($l:expr, $b:expr) => {{
832            for _ in 0..50 { std::hint::black_box($b); }
833            let s = Instant::now();
834            for _ in 0..iters { std::hint::black_box($b); }
835            eprintln!("  {:<22} {:>9.1} ns/op", $l, s.elapsed().as_nanos() as f64 / iters as f64);
836        }};
837    }
838    eprintln!("--- ML-DSA verify component breakdown ---");
839    t!("expand_a (30 poly)", expand_a(rho));
840    t!("sample_in_ball", sample_in_ball(c_tilde));
841    let c = sample_in_ball(c_tilde);
842    t!("nttp (1 poly)", nttp(&c));
843    t!("shake256(pk,64)", crate::keccak::shake256_bytes(pk, 64));
844}
845
846// ── Logos-facing wrappers (Seq of Int bytes 0..255) — the natives crypto.lg's handshake calls ────
847
848fn bytes(s: &[i64]) -> Vec<u8> {
849    s.iter().map(|&x| x.rem_euclid(256) as u8).collect()
850}
851fn seq(v: &[u8]) -> logicaffeine_data::LogosSeq<i64> {
852    logicaffeine_data::LogosSeq::from_vec(v.iter().map(|&b| b as i64).collect())
853}
854
855/// `mldsaKeypair(seed)` → pk(1952) ‖ sk(4032).
856pub fn mldsa_keypair_seq(seed: &[i64]) -> logicaffeine_data::LogosSeq<i64> {
857    let mut s = [0u8; 32];
858    for (b, &x) in s.iter_mut().zip(seed) {
859        *b = x.rem_euclid(256) as u8;
860    }
861    let (pk, sk) = keygen(&s);
862    let mut out = pk;
863    out.extend(sk);
864    seq(&out)
865}
866/// `mldsaSign(sk, msg, ctx)` → signature(3309).
867pub fn mldsa_sign_seq(sk: &[i64], msg: &[i64], ctx: &[i64]) -> logicaffeine_data::LogosSeq<i64> {
868    seq(&sign(&bytes(sk), &bytes(msg), &bytes(ctx)))
869}
870/// `mldsaVerify(pk, msg, ctx, sig)` → 1 if valid, else 0.
871pub fn mldsa_verify_seq(pk: &[i64], msg: &[i64], ctx: &[i64], sig: &[i64]) -> i64 {
872    verify(&bytes(pk), &bytes(msg), &bytes(ctx), &bytes(sig)) as i64
873}
874
875#[cfg(test)]
876mod tests {
877    use super::*;
878
879    /// Schoolbook negacyclic convolution mod (X²⁵⁶ + 1) over ℤ_q, the gold-standard reference.
880    fn schoolbook(a: &[i32; N], b: &[i32; N]) -> [i32; N] {
881        let mut c = [0i64; N];
882        for i in 0..N {
883            for j in 0..N {
884                let prod = a[i] as i64 * b[j] as i64;
885                if i + j < N {
886                    c[i + j] += prod;
887                } else {
888                    c[i + j - N] -= prod;
889                }
890            }
891        }
892        std::array::from_fn(|i| ((c[i] % Q as i64 + Q as i64) % Q as i64) as i32)
893    }
894
895    fn rand_coeff(s: &mut u64) -> i32 {
896        *s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
897        ((*s >> 33) % Q as u64) as i32
898    }
899    fn rand_poly(s: &mut u64) -> [i32; N] {
900        std::array::from_fn(|_| rand_coeff(s))
901    }
902
903    #[test]
904    fn ntt_multiply_matches_schoolbook_convolution() {
905        let mut s = 0xD15A_C0DEu64;
906        for _ in 0..50 {
907            let a = rand_poly(&mut s);
908            let b = rand_poly(&mut s);
909            let mut na = a;
910            let mut nb = b;
911            ntt(&mut na);
912            ntt(&mut nb);
913            let mut nc = pointwise_montgomery(&na, &nb);
914            invntt_tomont(&mut nc);
915            let got: [i32; N] = std::array::from_fn(|i| freeze(nc[i]));
916            assert_eq!(got, schoolbook(&a, &b), "NTT product must equal the schoolbook convolution");
917        }
918    }
919
920    #[test]
921    fn power2round_reconstructs() {
922        let mut s = 0xABCD_1234u64;
923        for _ in 0..100000 {
924            let r = rand_coeff(&mut s);
925            let (r1, r0) = power2round(r);
926            assert!(r0 > -(1 << (D - 1)) && r0 <= (1 << (D - 1)), "r0 ∈ (−2¹², 2¹²]");
927            assert_eq!(r1 * (1 << D) + r0, r, "r = r1·2ᵈ + r0");
928        }
929    }
930
931    #[test]
932    fn decompose_reconstructs_and_bounds() {
933        let mut s = 0x5678_9ABCu64;
934        for _ in 0..100000 {
935            let r = rand_coeff(&mut s);
936            let (r1, r0) = decompose(r);
937            assert!(r0 > -GAMMA2 && r0 <= GAMMA2, "r0 ∈ (−γ2, γ2]: got {r0}");
938            assert!((0..16).contains(&r1), "r1 ∈ [0,16): got {r1}");
939            // r1·2γ2 + r0 ≡ r (mod q)
940            assert_eq!(freeze(r1 * 2 * GAMMA2 + r0), r, "r1·2γ2 + r0 = r (mod q)");
941        }
942    }
943
944    #[test]
945    fn use_hint_inverts_make_hint() {
946        // FIPS-204: UseHint(MakeHint(z, r), r) = HighBits(r + z) for |z| ≤ γ2.
947        let mut s = 0xFEED_FACEu64;
948        for _ in 0..100000 {
949            let r = rand_coeff(&mut s);
950            s = s.wrapping_mul(6364136223846793005).wrapping_add(1);
951            let z = ((s >> 40) as i32 % (2 * GAMMA2 + 1)) - GAMMA2; // |z| ≤ γ2
952            let h = make_hint(z, r);
953            assert_eq!(use_hint(h, r), highbits(freeze(r + z)), "UseHint∘MakeHint = HighBits(r+z)");
954        }
955    }
956
957    #[test]
958    fn sign_verify_round_trips_and_rejects_tamper() {
959        let (pk, sk) = keygen(&[0x33; 32]);
960        let m = b"the quick brown fox jumps over the lazy dog";
961        let ctx = b"ctx";
962        let sig = sign(&sk, m, ctx);
963        assert_eq!(sig.len(), SIG_BYTES, "ML-DSA-65 sig = 3309 bytes");
964        assert!(verify(&pk, m, ctx, &sig), "a fresh signature verifies");
965        // Tampered message, tampered signature, and wrong context all reject.
966        assert!(!verify(&pk, b"different message", ctx, &sig), "wrong message rejects");
967        assert!(!verify(&pk, m, b"other", &sig), "wrong context rejects");
968        let mut bad = sig.clone();
969        bad[100] ^= 1;
970        assert!(!verify(&pk, m, ctx, &bad), "tampered signature rejects");
971        // A signature under a different key rejects.
972        let (pk2, _) = keygen(&[0x99; 32]);
973        assert!(!verify(&pk2, m, ctx, &sig), "wrong public key rejects");
974    }
975
976    #[test]
977    fn keygen_sizes_and_determinism() {
978        let (pk, sk) = keygen(&[0x11; 32]);
979        assert_eq!(pk.len(), PK_BYTES, "ML-DSA-65 pk = 1952 bytes");
980        assert_eq!(sk.len(), SK_BYTES, "ML-DSA-65 sk = 4032 bytes");
981        assert_eq!(keygen(&[0x11; 32]).0, pk, "keygen is deterministic in the seed");
982        assert_ne!(keygen(&[0x22; 32]).0, pk, "distinct seeds ⇒ distinct keys");
983    }
984
985    #[test]
986    fn ntt_round_trip_is_identity() {
987        // invntt_tomont(ntt(a)) = a·(2³²) — multiply by the Montgomery one (ntt of the unit) recovers a.
988        let mut s = 0x1234_5678u64;
989        let a = rand_poly(&mut s);
990        // Multiply a by the constant polynomial 1 via NTT (1 in normal domain).
991        let mut one = [0i32; N];
992        one[0] = 1;
993        let mut na = a;
994        let mut n1 = one;
995        ntt(&mut na);
996        ntt(&mut n1);
997        let mut prod = pointwise_montgomery(&na, &n1);
998        invntt_tomont(&mut prod);
999        let got: [i32; N] = std::array::from_fn(|i| freeze(prod[i]));
1000        let want: [i32; N] = std::array::from_fn(|i| freeze(a[i]));
1001        assert_eq!(got, want, "a · 1 through the NTT must recover a");
1002    }
1003}