Skip to main content

logicaffeine_system/
mlkem.rs

1//! ML-KEM-768 (FIPS-203) keygen / encapsulation / decapsulation as a Rust composition of the
2//! verified native kernels in [`crate::ntt`] (NTT, base-multiply, CBD, compress, byte-encode,
3//! 4-way AVX2 matrix expansion) and [`crate::keccak`] (SHA3-256/512, SHAKE256). This is the exact
4//! same primitive set the `assets/std/crypto.lg` Logos ML-KEM orchestrates and that the
5//! `logicaffeine-tests` AOT gates prove bit-exact vs the FIPS-203 reference — here orchestrated in
6//! Rust so the post-quantum channel (`logicaffeine_compile::concurrency::channel`) can run the
7//! handshake. Coefficients ride the fast `Word16` carrier throughout.
8
9use crate::keccak::{sha3_256_bytes, sha3_512_bytes, shake256_bytes};
10use crate::ntt::{
11    mlkem_base_mul_w16, mlkem_byte_decode_w16, mlkem_byte_encode_w16, mlkem_cbd2_w16,
12    mlkem_compress_w16, mlkem_decompress_w16, mlkem_inv_ntt_w16, mlkem_ntt_w16,
13    mlkem_sample_matrix_w16, mlkem_to_mont_w16,
14};
15use logicaffeine_base::Word16;
16
17const K: usize = 3; // ML-KEM-768 module rank
18const N: usize = 256; // polynomial degree
19const Q: u32 = 3329;
20const DU: usize = 10; // ciphertext u compression
21const DV: usize = 4; //  ciphertext v compression
22const POLY_BYTES: usize = 384; // ByteEncode_12 of one 256-coefficient polynomial
23
24/// `ek` = K·384 + 32 (encapsulation / public key).
25pub const EK_BYTES: usize = POLY_BYTES * K + 32;
26/// `dk` = dk_pke ‖ ek ‖ H(ek) ‖ z (decapsulation / secret key).
27pub const DK_BYTES: usize = POLY_BYTES * K + EK_BYTES + 32 + 32;
28/// `ct` = K·(32·du) + 32·dv (ciphertext).
29pub const CT_BYTES: usize = K * 32 * DU + 32 * DV;
30/// Shared secret length.
31pub const SS_BYTES: usize = 32;
32
33/// G = SHA3-512, split into (ρ, σ) or (K, r) 32-byte halves.
34fn g(input: &[u8]) -> ([u8; 32], [u8; 32]) {
35    let h = sha3_512_bytes(input);
36    let mut a = [0u8; 32];
37    let mut b = [0u8; 32];
38    a.copy_from_slice(&h[..32]);
39    b.copy_from_slice(&h[32..]);
40    (a, b)
41}
42
43/// One CBD_2 noise polynomial, raw (no NTT): PRF_η2(seed, nonce) = CBD_2(SHAKE256(seed ‖ nonce, 128)).
44fn noise_raw(seed: &[u8; 32], nonce: u8) -> Vec<Word16> {
45    let mut pin = seed.to_vec();
46    pin.push(nonce);
47    mlkem_cbd2_w16(&shake256_bytes(&pin, 128))
48}
49
50/// Sample `count` CBD_2 noise polynomials with nonces `base..base+count`, batching the SHAKE256 PRF
51/// four independent streams per 4-way AVX2 Keccak permutation (portable scalar fallback). Returns raw
52/// polys — the caller NTTs the subset that needs it (s/e/r get NTT; e1/e2 do not). This is the win
53/// that closes the keygen/decaps gap vs the hand-tuned reference libs: K-PKE keygen samples 6 noise
54/// polys and encrypt (the FO re-encryption inside decaps) samples 7 — otherwise 6–7 serial Keccak
55/// permutations; batched, that is ⌈count/4⌉ four-way permutations plus a short scalar tail.
56fn noise_batch_raw(seed: &[u8; 32], base: u8, count: usize) -> Vec<Vec<Word16>> {
57    let mut raw: Vec<Vec<Word16>> = Vec::with_capacity(count);
58    #[cfg(target_arch = "x86_64")]
59    {
60        if std::is_x86_feature_detected!("avx2") {
61            let mut i = 0;
62            while i + 4 <= count {
63                let ins: [[u8; 33]; 4] = std::array::from_fn(|l| {
64                    let mut a = [0u8; 33];
65                    a[..32].copy_from_slice(seed);
66                    a[32] = base + (i + l) as u8;
67                    a
68                });
69                let refs: [&[u8]; 4] = [&ins[0], &ins[1], &ins[2], &ins[3]];
70                let outs = unsafe { crate::keccak::shake256_x4_128(&refs) };
71                for o in &outs {
72                    raw.push(mlkem_cbd2_w16(o));
73                }
74                i += 4;
75            }
76            for j in i..count {
77                raw.push(noise_raw(seed, base + j as u8));
78            }
79            return raw;
80        }
81    }
82    for j in 0..count {
83        raw.push(noise_raw(seed, base + j as u8));
84    }
85    raw
86}
87
88/// Coefficient-wise `(a + b) mod q`. Operands are reduced (∈ [0, q)), so `a + b ∈ [0, 2q)` and the
89/// reduction is a single conditional subtract — no integer division. Written to auto-vectorize 16-wide
90/// (`vpaddw` + `vpcmpgtw`/`vpsubw` blend) rather than 256 scalar `idiv`s (the keygen/decaps hot path).
91#[inline]
92fn addq(a: &[Word16], b: &[Word16]) -> Vec<Word16> {
93    let q = Q as u16;
94    (0..N)
95        .map(|c| {
96            let s = a[c].0 + b[c].0; // ≤ 2·3328 = 6656, no u16 overflow
97            Word16(if s >= q { s - q } else { s })
98        })
99        .collect()
100}
101
102/// K-PKE.KeyGen(d) → (ek_pke = ByteEncode₁₂(t̂) ‖ ρ, dk_pke = ByteEncode₁₂(ŝ)).
103fn kpke_keygen(d: &[u8; 32]) -> (Vec<u8>, Vec<u8>) {
104    let mut gin = d.to_vec();
105    gin.push(K as u8);
106    let (rho, sigma) = g(&gin);
107    let matrix = mlkem_sample_matrix_w16(&rho); // Â[r][c] at slot (r·K+c)·N
108    // s (nonces 0..K) and e (nonces K..2K) — all NTT'd — in one 4-way-batched CBD noise pass.
109    let se = noise_batch_raw(&sigma, 0, 2 * K);
110    let s_hat: Vec<Vec<Word16>> = se[0..K].iter().map(|p| mlkem_ntt_w16(p)).collect();
111    let e_hat: Vec<Vec<Word16>> = se[K..2 * K].iter().map(|p| mlkem_ntt_w16(p)).collect();
112
113    let mut ek = Vec::with_capacity(EK_BYTES);
114    for i in 0..K {
115        let mut acc = vec![Word16(0); N];
116        for j in 0..K {
117            let e = (j * K + i) * N; // keygen uses Âᵀ: a_hat[i][j] = Â[j][i]
118            acc = addq(&acc, &mlkem_base_mul_w16(&matrix[e..e + N], &s_hat[j]));
119        }
120        let ti = addq(&mlkem_to_mont_w16(&acc), &e_hat[i]);
121        ek.extend(mlkem_byte_encode_w16(&ti, 12));
122    }
123    ek.extend_from_slice(&rho);
124
125    let mut dk_pke = Vec::with_capacity(POLY_BYTES * K);
126    for poly in &s_hat {
127        dk_pke.extend(mlkem_byte_encode_w16(poly, 12));
128    }
129    (ek, dk_pke)
130}
131
132/// K-PKE.Encrypt(ek, m, r) → c = ByteEncode_du(Compress_du(u)) ‖ ByteEncode_dv(Compress_dv(v)).
133fn kpke_encrypt(ek: &[u8], m: &[u8; 32], r: &[u8; 32]) -> Vec<u8> {
134    let t_hat: Vec<Vec<Word16>> =
135        (0..K).map(|i| mlkem_byte_decode_w16(&ek[POLY_BYTES * i..POLY_BYTES * (i + 1)], 12)).collect();
136    let rho = &ek[POLY_BYTES * K..POLY_BYTES * K + 32];
137    let matrix = mlkem_sample_matrix_w16(rho); // Â[i][j] at slot (i·K+j)·N
138
139    // r (nonces 0..K, NTT'd) + e1 (nonces K..2K, raw) + e2 (nonce 2K, raw) in one 4-way-batched pass.
140    let all = noise_batch_raw(r, 0, 2 * K + 1);
141    let r_hat: Vec<Vec<Word16>> = all[0..K].iter().map(|p| mlkem_ntt_w16(p)).collect();
142    let e1: Vec<Vec<Word16>> = all[K..2 * K].to_vec();
143    let e2 = all[2 * K].clone();
144
145    let mut c = Vec::with_capacity(CT_BYTES);
146    for i in 0..K {
147        let mut acc = vec![Word16(0); N];
148        for j in 0..K {
149            let e = (i * K + j) * N;
150            acc = addq(&acc, &mlkem_base_mul_w16(&matrix[e..e + N], &r_hat[j]));
151        }
152        let ui = addq(&mlkem_inv_ntt_w16(&acc), &e1[i]);
153        c.extend(mlkem_byte_encode_w16(&mlkem_compress_w16(&ui, DU), DU));
154    }
155    let mu = mlkem_decompress_w16(&mlkem_byte_decode_w16(m, 1), 1);
156    let mut acc = vec![Word16(0); N];
157    for i in 0..K {
158        acc = addq(&acc, &mlkem_base_mul_w16(&t_hat[i], &r_hat[i]));
159    }
160    let v = addq(&addq(&mlkem_inv_ntt_w16(&acc), &e2), &mu);
161    c.extend(mlkem_byte_encode_w16(&mlkem_compress_w16(&v, DV), DV));
162    c
163}
164
165/// K-PKE.Decrypt(dk_pke, c) → m = ByteEncode₁(Compress₁(v − NTT⁻¹(ŝᵀ ∘ NTT(u)))).
166fn kpke_decrypt(dk_pke: &[u8], c: &[u8]) -> [u8; 32] {
167    let s_hat: Vec<Vec<Word16>> = (0..K)
168        .map(|i| mlkem_byte_decode_w16(&dk_pke[POLY_BYTES * i..POLY_BYTES * (i + 1)], 12))
169        .collect();
170    let u: Vec<Vec<Word16>> = (0..K)
171        .map(|i| {
172            let bytes = &c[32 * DU * i..32 * DU * (i + 1)];
173            mlkem_decompress_w16(&mlkem_byte_decode_w16(bytes, DU), DU)
174        })
175        .collect();
176    let v = mlkem_decompress_w16(&mlkem_byte_decode_w16(&c[32 * DU * K..], DV), DV);
177
178    let mut acc = vec![Word16(0); N];
179    for i in 0..K {
180        acc = addq(&acc, &mlkem_base_mul_w16(&s_hat[i], &mlkem_ntt_w16(&u[i])));
181    }
182    let inv = mlkem_inv_ntt_w16(&acc);
183    // v, inv ∈ [0, q) ⇒ v + q − inv ∈ (0, 2q): one conditional subtract, not a division.
184    let q = Q as u16;
185    let w: Vec<Word16> = (0..N)
186        .map(|c| {
187            let s = v[c].0 + q - inv[c].0;
188            Word16(if s >= q { s - q } else { s })
189        })
190        .collect();
191    let mut m = [0u8; 32];
192    m.copy_from_slice(&mlkem_byte_encode_w16(&mlkem_compress_w16(&w, 1), 1));
193    m
194}
195
196/// ML-KEM.KeyGen(d, z) → (ek, dk) where dk = dk_pke ‖ ek ‖ H(ek) ‖ z.
197pub fn keygen(d: &[u8; 32], z: &[u8; 32]) -> (Vec<u8>, Vec<u8>) {
198    let (ek, dk_pke) = kpke_keygen(d);
199    let mut dk = dk_pke;
200    dk.extend_from_slice(&ek);
201    dk.extend_from_slice(&sha3_256_bytes(&ek));
202    dk.extend_from_slice(z);
203    (ek, dk)
204}
205
206/// ML-KEM.Encaps(ek, m) → (ciphertext, shared secret). `m` is the 32-byte message randomness.
207pub fn encaps(ek: &[u8], m: &[u8; 32]) -> (Vec<u8>, [u8; 32]) {
208    let h = sha3_256_bytes(ek);
209    let (kk, r) = g(&[m.as_slice(), &h].concat());
210    (kpke_encrypt(ek, m, &r), kk)
211}
212
213/// ML-KEM.Decaps(dk, c) → shared secret, with the Fujisaki-Okamoto implicit reject.
214pub fn decaps(dk: &[u8], c: &[u8]) -> [u8; 32] {
215    let dk_pke = &dk[0..POLY_BYTES * K];
216    let ek = &dk[POLY_BYTES * K..POLY_BYTES * K + EK_BYTES];
217    let h = &dk[POLY_BYTES * K + EK_BYTES..POLY_BYTES * K + EK_BYTES + 32];
218    let z = &dk[POLY_BYTES * K + EK_BYTES + 32..POLY_BYTES * K + EK_BYTES + 64];
219
220    let m_prime = kpke_decrypt(dk_pke, c);
221    let (k_prime, r_prime) = g(&[m_prime.as_slice(), h].concat());
222    let mut k_bar = [0u8; 32];
223    k_bar.copy_from_slice(&shake256_bytes(&[z, c].concat(), 32));
224
225    if c == kpke_encrypt(ek, &m_prime, &r_prime).as_slice() {
226        k_prime
227    } else {
228        k_bar
229    }
230}
231
232// ── Logos-facing wrappers (Seq of Int bytes 0..255) — the natives crypto.lg's handshake calls ────
233
234fn bytes(s: &[i64]) -> Vec<u8> {
235    s.iter().map(|&x| x.rem_euclid(256) as u8).collect()
236}
237fn seq(v: &[u8]) -> logicaffeine_data::LogosSeq<i64> {
238    logicaffeine_data::LogosSeq::from_vec(v.iter().map(|&b| b as i64).collect())
239}
240fn seed32(s: &[i64]) -> [u8; 32] {
241    let mut a = [0u8; 32];
242    for (b, &x) in a.iter_mut().zip(s) {
243        *b = x.rem_euclid(256) as u8;
244    }
245    a
246}
247
248/// `mlkemNoiseBatch(seed, base, count)` → `count·256` raw CBD_2 Word16 coefficients (nonces
249/// `base..base+count`, no NTT), the flat concatenation of `count` noise polynomials. The fast
250/// primitive the Logos `mlkem768Keygen`/`mlkemEncrypt` call once instead of `count` scalar
251/// `mlkemPrfNoise`s — one 4-way SHAKE256 permutation covers four PRF streams (see [`noise_batch_raw`]).
252pub fn mlkem_noise_batch_from_int(seed: &[i64], base: i64, count: i64) -> logicaffeine_data::LogosSeq<Word16> {
253    let s = seed32(seed);
254    let n = count.max(0) as usize;
255    let polys = noise_batch_raw(&s, base.max(0) as u8, n);
256    let mut out: Vec<Word16> = Vec::with_capacity(n * N);
257    for p in &polys {
258        out.extend_from_slice(p);
259    }
260    logicaffeine_data::LogosSeq::from_vec(out)
261}
262
263/// `mlkemKeypair(d, z)` → ek(1184) ‖ dk(2400).
264pub fn mlkem_keypair_seq(d: &[i64], z: &[i64]) -> logicaffeine_data::LogosSeq<i64> {
265    let (ek, dk) = keygen(&seed32(d), &seed32(z));
266    let mut out = ek;
267    out.extend(dk);
268    seq(&out)
269}
270/// `mlkemEncapsKem(ek, m)` → ciphertext(1088) ‖ shared_secret(32).
271pub fn mlkem_encaps_seq(ek: &[i64], m: &[i64]) -> logicaffeine_data::LogosSeq<i64> {
272    let (ct, ss) = encaps(&bytes(ek), &seed32(m));
273    let mut out = ct;
274    out.extend_from_slice(&ss);
275    seq(&out)
276}
277/// `mlkemDecapsKem(dk, ct)` → shared_secret(32).
278pub fn mlkem_decaps_seq(dk: &[i64], ct: &[i64]) -> logicaffeine_data::LogosSeq<i64> {
279    seq(&decaps(&bytes(dk), &bytes(ct)))
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn keygen_encaps_decaps_round_trips() {
288        // Deterministic seeds — full handshake: keygen, encaps to ek, decaps recovers the secret.
289        let d = [0x11u8; 32];
290        let z = [0x22u8; 32];
291        let m = [0x33u8; 32];
292        let (ek, dk) = keygen(&d, &z);
293        assert_eq!(ek.len(), EK_BYTES, "ek = 1184 bytes");
294        assert_eq!(dk.len(), DK_BYTES, "dk = 2400 bytes");
295
296        let (ct, ss_a) = encaps(&ek, &m);
297        assert_eq!(ct.len(), CT_BYTES, "ct = 1088 bytes");
298        let ss_b = decaps(&dk, &ct);
299        assert_eq!(ss_a, ss_b, "decaps must recover the encaps shared secret");
300
301        // A tampered ciphertext implicit-rejects to a DIFFERENT (z-derived) secret, never the real one.
302        let mut bad = ct.clone();
303        bad[0] ^= 1;
304        assert_ne!(decaps(&dk, &bad), ss_a, "tampered ct ⇒ implicit reject");
305    }
306
307    #[test]
308    fn distinct_keypairs_give_distinct_secrets() {
309        let m = [0x55u8; 32];
310        let (ek1, _) = keygen(&[1u8; 32], &[2u8; 32]);
311        let (ek2, _) = keygen(&[3u8; 32], &[4u8; 32]);
312        assert_ne!(ek1, ek2, "distinct seeds ⇒ distinct public keys");
313        assert_ne!(encaps(&ek1, &m).1, encaps(&ek2, &m).1, "distinct keys ⇒ distinct shared secrets");
314    }
315
316    /// Per-phase profiler for keygen + decaps — locates the cost vs libcrux (run with
317    /// `-C target-cpu=native --ignored --nocapture`). No kernel/oracle deps → builds fast.
318    #[test]
319    #[ignore = "profiler — cargo test -p logicaffeine-system profile_mlkem -- --ignored --nocapture"]
320    fn profile_mlkem_keygen_decaps_phases() {
321        use std::hint::black_box;
322        use std::time::Instant;
323        fn t<R, F: FnMut() -> R>(iters: usize, mut f: F) -> f64 {
324            for _ in 0..iters / 5 + 1 {
325                std::hint::black_box(f());
326            }
327            let s = Instant::now();
328            for _ in 0..iters {
329                std::hint::black_box(f());
330            }
331            s.elapsed().as_nanos() as f64 / iters as f64
332        }
333        const IT: usize = 4000;
334        let d = [0x11u8; 32];
335        let z = [0x22u8; 32];
336        let m = [0x33u8; 32];
337        let (ek, dk) = keygen(&d, &z);
338        let (ct, _) = encaps(&ek, &m);
339
340        let mut gin = d.to_vec();
341        gin.push(K as u8);
342        let (rho, sigma) = g(&gin);
343
344        // keygen phases
345        let g_ns = t(IT, || black_box(g(black_box(&gin))));
346        let expand_ns = t(IT, || black_box(mlkem_sample_matrix_w16(black_box(&rho))));
347        let noise_ns = t(IT, || black_box(noise_batch_raw(black_box(&sigma), 0, 2 * K)));
348        let noise_ntt_ns = t(IT, || {
349            let se = noise_batch_raw(&sigma, 0, 2 * K);
350            black_box(se.iter().map(|p| mlkem_ntt_w16(p)).collect::<Vec<_>>())
351        });
352        let one_enc12_ns = t(IT, || {
353            black_box(mlkem_byte_encode_w16(black_box(&vec![Word16(1234); N]), 12))
354        });
355        let keygen_ns = t(IT, || black_box(keygen(black_box(&d), black_box(&z))));
356
357        // decaps phases
358        let dk_pke = &dk[0..POLY_BYTES * K];
359        let decrypt_ns = t(IT, || black_box(kpke_decrypt(black_box(dk_pke), black_box(&ct))));
360        let encrypt_ns = t(IT, || black_box(kpke_encrypt(black_box(&ek), black_box(&m), black_box(&[7u8; 32]))));
361        let decaps_ns = t(IT, || black_box(decaps(black_box(&dk), black_box(&ct))));
362        let encaps_ns = t(IT, || black_box(encaps(black_box(&ek), black_box(&m))));
363
364        println!("\n=== ML-KEM-768 phase profile (ns/op, IT={IT}, native) ===");
365        println!("  G(SHA3-512)         {g_ns:>8.0}");
366        println!("  ExpandA (matrix)    {expand_ns:>8.0}   ← 4-way SHAKE128 rejection");
367        println!("  noise CBD (raw)     {noise_ns:>8.0}   ← 4-way SHAKE256 (batched)");
368        println!("  noise CBD + NTT     {noise_ntt_ns:>8.0}");
369        println!("  ByteEncode12 (×1)   {one_enc12_ns:>8.0}   (keygen does ×{K}, dk ×{K})");
370        println!("  -------------------------------------------");
371        println!("  KEYGEN full         {keygen_ns:>8.0}   (libcrux 16658)");
372        println!("  kpke_decrypt        {decrypt_ns:>8.0}");
373        println!("  kpke_encrypt        {encrypt_ns:>8.0}   ← re-encrypt (ExpandA+noise+compress)");
374        println!("  ENCAPS full         {encaps_ns:>8.0}   (libcrux 28479)");
375        println!("  DECAPS full         {decaps_ns:>8.0}   (libcrux 21351) = decrypt + re-encrypt");
376
377        // ── ExpandA internal split: pure Keccak permutation vs the rest (rejection/pack) ──
378        let a16: Vec<Word16> = (0..256).map(|i| Word16((i * 7 % Q as usize) as u16)).collect();
379        let b16: Vec<Word16> = (0..256).map(|i| Word16((i * 13 % Q as usize) as u16)).collect();
380        let basemul_ns = t(IT, || mlkem_base_mul_w16(black_box(&a16), black_box(&b16)));
381        #[cfg(target_arch = "x86_64")]
382        {
383            if std::is_x86_feature_detected!("avx2") {
384                use std::arch::x86_64::*;
385                let mut kst = [unsafe { _mm256_setzero_si256() }; 25];
386                let kperm_ns = t(IT * 4, || {
387                    unsafe { crate::keccak::keccak_f1600_x4(&mut kst) };
388                    kst[0]
389                });
390                println!("  -------------------------------------------");
391                println!("  keccak_f1600_x4 (1 perm)  {kperm_ns:>8.2}   ← ExpandA ≈ 6 of these (2×4-way) + 3 scalar");
392                println!("  keccak est. in ExpandA    {:>8.0}   (6 × 4-way perm)", 6.0 * kperm_ns);
393            }
394        }
395        println!("  base_mul (1 poly, scalar) {basemul_ns:>8.2}   ← ×9 keygen, ×15 decaps");
396    }
397}