From 47294613a4025d922d2a3df99069296b22a5e08d Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 23 Oct 2026 20:12:33 +0200 Subject: [PATCH 1/6] mlkem: divide by q with a multiply and a shift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compression and every reduction modulo q divided secret values by the constant 3329 with `/` and `%`. A hardware divide's latency can depend on its operands, which is the KyberSlash class (Bernstein et al., IACR ePrint 2024/1049); whether one is emitted was left to LLVM. At a00a7ff0f it emits none: no DIV/IDIV (x86-64) or UDIV/SDIV (AArch64) appears in any ML-KEM or Keccak function of sshserver for either architecture, of the x86-64 crypto_constant_time test binary, or of the crate's rlib in [profile.toyos] for either guest triple. LLVM had already lowered each to a multiply-high. `divide` makes that lowering the source's: n·⌈2^33/q⌉ >> 33, exact for n < 2^33/623, which covers q² and every compressed value. It is tested over that whole range, and the existing exhaustive reduction and compression tests hold. Co-Authored-By: Claude Opus 5.5 --- lib/crypto/src/mlkem.rs | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/lib/crypto/src/mlkem.rs b/lib/crypto/src/mlkem.rs index f6cdb91a653..a694fd9e2a9 100644 --- a/lib/crypto/src/mlkem.rs +++ b/lib/crypto/src/mlkem.rs @@ -1,7 +1,6 @@ //! FIPS 203 ML-KEM-768, for TLS and SSH hybrid exchange. Secret polynomial -//! operations, CBD sampling, hashes and re-encryption have fixed schedules. -//! Division is by compile-time constants; deployed code must be checked to -//! ensure fixed-latency lowering. Matrix rejection sampling depends only on the +//! operations, CBD sampling, hashes and re-encryption have fixed schedules, +//! and no secret value is divided. Matrix rejection sampling depends only on the //! public seed. Public-key decoding rejects coefficients >=3329. Decapsulation //! implements masked implicit rejection, with no validity bit or error oracle. //! Secret keys can only originate from the caller's entropy source, so external @@ -23,14 +22,26 @@ impl Drop for Poly { core::sync::atomic::compiler_fence(core::sync::atomic::Ordering::SeqCst) } } +/// ⌊n / q⌋ for n < 2^33 / 623, which holds every value reduced or compressed +/// here, by a multiply and shift: a divide's latency can depend on its +/// operands (KyberSlash, Bernstein et al., IACR ePrint 2024/1049). With +/// M = ⌈2^33 / q⌉, n·M / 2^33 = n / q + 623·n / (q·2^33), and the excess is +/// below 1/q, too small to carry the quotient past the next integer. +fn divide(n: u32) -> u32 { + const M: u64 = (1u64 << 33).div_ceil(Q as u64); + ((u64::from(n) * M) >> 33) as u32 +} +fn reduce(n: u32) -> u16 { + (n - divide(n) * Q) as u16 +} fn add(a: u16, b: u16) -> u16 { - ((u32::from(a) + u32::from(b)) % Q) as u16 + reduce(u32::from(a) + u32::from(b)) } fn sub(a: u16, b: u16) -> u16 { - ((u32::from(a) + Q - u32::from(b)) % Q) as u16 + reduce(u32::from(a) + Q - u32::from(b)) } fn mul(a: u16, b: u16) -> u16 { - ((u32::from(a) * u32::from(b)) % Q) as u16 + reduce(u32::from(a) * u32::from(b)) } fn power(mut base: u16, mut exponent: u32) -> u16 { let mut result = 1; @@ -131,7 +142,7 @@ impl Poly { } fn compress(mut self, d: usize) -> Self { for word in &mut self.0 { - *word = ((((u32::from(*word) << d) + Q / 2) / Q) & ((1 << d) - 1)) as u16 + *word = (divide((u32::from(*word) << d) + Q / 2) & ((1 << d) - 1)) as u16 } self } @@ -150,7 +161,7 @@ fn noise(seed: &[u8; 32], nonce: u8) -> Poly { let bits = (bytes.0[i / 2] >> (4 * (i % 2))) & 15; let a = u32::from(bits & 1) + u32::from((bits >> 1) & 1); let b = u32::from((bits >> 2) & 1) + u32::from((bits >> 3) & 1); - out.0[i] = ((Q + a - b) % Q) as u16 + out.0[i] = reduce(Q + a - b) } out } @@ -333,6 +344,16 @@ mod tests { .map(|i| u8::from_str_radix(&s[2 * i..2 * i + 2], 16).unwrap()) .collect() } + #[test] + #[cfg_attr(miri, ignore = "13.8 M quotients")] + fn division_by_q_is_exact_over_its_whole_input_range() { + let end = ((1u64 << 33) / 623) as u32; + assert!(u64::from(end) > u64::from(Q) * u64::from(Q)); + for n in 0..end { + assert_eq!(divide(n), n / Q, "{n}"); + } + } + /// Every pair of reduced coefficients, against 64-bit integers. #[test] #[cfg_attr(miri, ignore = "3329^2 pairs")] From feff54e3e21f87d28193399a7156d1fb1dbb21d4 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 23 Oct 2026 20:13:59 +0200 Subject: [PATCH 2/6] =?UTF-8?q?mlkem:=20=CE=B6=20and=20=CE=B3=20from=20tab?= =?UTF-8?q?les=20made=20at=20compile=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every NTT butterfly group and every base multiplication raised 17 to its exponent at run time, about 21,000 serial modular multiplications per key generation. FIPS 203 Appendix A's two 128-entry tables, ζ^BitRev7(i) and γ_i = ζ^(2·BitRev7(i)+1), are now `const`s computed by a `const fn`. Their indices are loop counters. The NIST, Wycheproof and RustCrypto differential vectors cover every entry. Co-Authored-By: Claude Opus 5.5 --- lib/crypto/src/mlkem.rs | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/lib/crypto/src/mlkem.rs b/lib/crypto/src/mlkem.rs index a694fd9e2a9..91e5478f72e 100644 --- a/lib/crypto/src/mlkem.rs +++ b/lib/crypto/src/mlkem.rs @@ -43,20 +43,27 @@ fn sub(a: u16, b: u16) -> u16 { fn mul(a: u16, b: u16) -> u16 { reduce(u32::from(a) * u32::from(b)) } -fn power(mut base: u16, mut exponent: u32) -> u16 { - let mut result = 1; - while exponent != 0 { - if exponent & 1 == 1 { - result = mul(result, base) +/// ζ^BitRev7(i) and, for `odd`, γ_i = ζ^(2·BitRev7(i)+1), with ζ = 17: FIPS 203 +/// Appendix A's two tables. +const fn powers(odd: bool) -> [u16; 128] { + let mut out = [0; 128]; + let mut i = 0; + while i < 128 { + let reversed = ((i as u8).reverse_bits() >> 1) as u32; + let exponent = if odd { 2 * reversed + 1 } else { reversed }; + let mut power = 1; + let mut k = 0; + while k < exponent { + power = power * 17 % Q; + k += 1 } - base = mul(base, base); - exponent >>= 1 + out[i] = power as u16; + i += 1 } - result -} -fn zeta(i: usize) -> u16 { - power(17, (i as u8).reverse_bits() as u32 >> 1) + out } +const ZETAS: [u16; 128] = powers(false); +const GAMMAS: [u16; 128] = powers(true); impl Poly { fn zero() -> Self { Self([0; 256]) @@ -66,7 +73,7 @@ impl Poly { let mut len = 128; while len >= 2 { for start in (0..256).step_by(2 * len) { - let z = zeta(k); + let z = ZETAS[k]; k += 1; for i in start..start + len { let t = mul(z, self.0[i + len]); @@ -83,7 +90,7 @@ impl Poly { let mut len = 2; while len <= 128 { for start in (0..256).step_by(2 * len) { - let z = zeta(k); + let z = ZETAS[k]; k -= 1; for i in start..start + len { let t = self.0[i]; @@ -101,7 +108,7 @@ impl Poly { fn product(&self, q: &Self) -> Self { let mut out = Self::zero(); for i in 0..128 { - let gamma = power(17, 2 * ((i as u8).reverse_bits() as u32 >> 1) + 1); + let gamma = GAMMAS[i]; out.0[2 * i] = add( mul(self.0[2 * i], q.0[2 * i]), mul(gamma, mul(self.0[2 * i + 1], q.0[2 * i + 1])), From ac50d907d8cf016cb1c4406b47aada3ff39ca8fb Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 23 Oct 2026 20:16:07 +0200 Subject: [PATCH 3/6] keccak: an unrolled round, lanes absorbed and squeezed whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The permutation indexed its lanes through `% 5` loops and wiped three workspace arrays, 35 words, after every call. Its loops now have constant bounds, which LLVM unrolls into a single round (one backward branch, the round loop, on both guest triples in [profile.toyos]). θ works in place and the arrays are ordinary locals: the crate's erasure contract covers the sponge's state, which is still wiped when the sponge drops, and not arithmetic intermediates. Absorbing and squeezing move a lane at a time wherever the position is on a lane boundary, and a byte at a time otherwise. Every rate is a multiple of eight. Matrix sampling squeezes whole 168-byte blocks and parses them, rather than three bytes per call. The new test checks that any split of the input into parts, and of the output into reads, gives the one-piece answer, which covers both paths at every alignment. Co-Authored-By: Claude Opus 5.5 --- lib/crypto/src/keccak.rs | 134 +++++++++++++++++++++++++-------------- lib/crypto/src/mlkem.rs | 20 +++--- 2 files changed, 99 insertions(+), 55 deletions(-) diff --git a/lib/crypto/src/keccak.rs b/lib/crypto/src/keccak.rs index f88c32385cd..95b4c652af7 100644 --- a/lib/crypto/src/keccak.rs +++ b/lib/crypto/src/keccak.rs @@ -1,6 +1,6 @@ -//! FIPS 202 sponge used only by ML-KEM. Absorption and permutation have fixed -//! schedules for public lengths. Rate/domain separation are internal constants. -//! State and explicit permutation workspaces are erased, including on failure. +//! FIPS 202 sponge used only by ML-KEM. Its schedule depends only on the rate +//! and the input and output lengths, which are public; the domain and rate are +//! internal constants. The state is erased when the sponge drops. use crate::secret::wipe64; const RC: [u64; 24] = [ 1, @@ -28,7 +28,8 @@ const RC: [u64; 24] = [ 0x80000001, 0x8000000080008008, ]; -const ROT: [u32; 25] = [ +/// ρ's rotation of lane x + 5y (FIPS 202 §3.2.2). +const RHO: [u32; 25] = [ 0, 1, 62, 28, 27, 36, 44, 6, 55, 20, 3, 10, 43, 25, 39, 41, 45, 15, 21, 8, 18, 2, 61, 56, 14, ]; pub(crate) struct Sponge { @@ -49,61 +50,80 @@ impl Sponge { rate, position: 0, }; - for p in parts { - for &b in *p { - s.state[s.position / 8] ^= u64::from(b) << (8 * (s.position % 8)); - s.position += 1; - if s.position == rate { - s.permute(); - s.position = 0 - } - } + for part in parts { + s.absorb(part) } - s.state[s.position / 8] ^= u64::from(domain) << (8 * (s.position % 8)); - let last = rate - 1; - s.state[last / 8] ^= 0x80u64 << (8 * (last % 8)); - s.permute(); + s.xor_byte(s.position, domain); + s.xor_byte(rate - 1, 0x80); + permute(&mut s.state); s.position = 0; s } - fn permute(&mut self) { - let mut c = [0; 5]; - let mut d = [0; 5]; - let mut b = [0; 25]; - for rc in RC { - for x in 0..5 { - c[x] = (0..5).fold(0, |c, y| c ^ self.state[x + 5 * y]) - } - for x in 0..5 { - d[x] = c[(x + 4) % 5] ^ c[(x + 1) % 5].rotate_left(1) - } - for y in 0..5 { - for x in 0..5 { - let i = x + 5 * y; - b[y + 5 * ((2 * x + 3 * y) % 5)] = (self.state[i] ^ d[x]).rotate_left(ROT[i]); + fn xor_byte(&mut self, at: usize, byte: u8) { + self.state[at / 8] ^= u64::from(byte) << (8 * (at % 8)) + } + /// A whole lane at a time where the position is on one; every rate is. + fn absorb(&mut self, mut data: &[u8]) { + while !data.is_empty() { + let at = self.position; + let step = match data.first_chunk::<8>() { + Some(word) if at.is_multiple_of(8) => { + self.state[at / 8] ^= u64::from_le_bytes(*word); + 8 } - } - for y in 0..5 { - for x in 0..5 { - self.state[x + 5 * y] = - b[x + 5 * y] ^ (!b[(x + 1) % 5 + 5 * y] & b[(x + 2) % 5 + 5 * y]); + _ => { + self.xor_byte(at, data[0]); + 1 } + }; + data = &data[step..]; + self.position += step; + if self.position == self.rate { + permute(&mut self.state); + self.position = 0 } - self.state[0] ^= rc; } - wipe64(&mut c); - wipe64(&mut d); - wipe64(&mut b) } - pub(crate) fn read(&mut self, output: &mut [u8]) { - for byte in output { + pub(crate) fn read(&mut self, mut output: &mut [u8]) { + while !output.is_empty() { if self.position == self.rate { - self.permute(); + permute(&mut self.state); self.position = 0 } - *byte = (self.state[self.position / 8] >> (8 * (self.position % 8))) as u8; - self.position += 1 + let at = self.position; + let step = if at.is_multiple_of(8) && output.len() >= 8 { 8 } else { 1 }; + let lane = self.state[at / 8].to_le_bytes(); + let (head, rest) = core::mem::take(&mut output).split_at_mut(step); + head.copy_from_slice(&lane[at % 8..at % 8 + step]); + output = rest; + self.position += step + } + } +} +/// Keccak-f[1600] (FIPS 202 §3.3), lane x + 5y at index x + 5y. Each loop but +/// the rounds' has constant bounds, for the compiler to unroll. +fn permute(a: &mut [u64; 25]) { + for &rc in &RC { + let c: [u64; 5] = core::array::from_fn(|x| a[x] ^ a[x + 5] ^ a[x + 10] ^ a[x + 15] ^ a[x + 20]); + for x in 0..5 { + let d = c[(x + 4) % 5] ^ c[(x + 1) % 5].rotate_left(1); + for y in 0..5 { + a[x + 5 * y] ^= d + } + } + // ρ and π: lane (x, y), rotated, moves to (y, 2x + 3y). + let mut b = [0; 25]; + for y in 0..5 { + for x in 0..5 { + b[y + 5 * ((2 * x + 3 * y) % 5)] = a[x + 5 * y].rotate_left(RHO[x + 5 * y]) + } } + for y in 0..5 { + for x in 0..5 { + a[x + 5 * y] = b[x + 5 * y] ^ (!b[(x + 1) % 5 + 5 * y] & b[(x + 2) % 5 + 5 * y]) + } + } + a[0] ^= rc } } pub(crate) fn h(parts: &[&[u8]]) -> [u8; 32] { @@ -135,4 +155,26 @@ mod tests { ] ); } + + /// The lane-wise and byte-wise paths agree: any split of the input into + /// parts, and of the output into reads, gives the one-piece answer. + #[test] + fn splits_do_not_change_the_output() { + let data: [u8; 400] = core::array::from_fn(|i| (i * 7 + 3) as u8); + for rate in [72, 136, 168] { + let mut want = [0; 400]; + Sponge::new(rate, 31, &[&data]).read(&mut want); + for cut in [1, 3, 8, 9, 15, 64, 71, 135, 167, 169, 399] { + let (a, b) = data.split_at(cut); + let mut got = [0; 400]; + let mut sponge = Sponge::new(rate, 31, &[a, b]); + let (head, tail) = got.split_at_mut(cut); + for piece in head.chunks_mut(5) { + sponge.read(piece) + } + sponge.read(tail); + assert_eq!(got, want, "rate {rate}, cut {cut}"); + } + } + } } diff --git a/lib/crypto/src/mlkem.rs b/lib/crypto/src/mlkem.rs index 91e5478f72e..8d0efc9ef64 100644 --- a/lib/crypto/src/mlkem.rs +++ b/lib/crypto/src/mlkem.rs @@ -176,16 +176,18 @@ fn sample(rho: &[u8; 32], row: usize, col: usize) -> Poly { let mut xof = Sponge::new(168, 31, &[rho, &[col as u8, row as u8]]); let mut out = Poly::zero(); let mut pos = 0; + let mut block = [0; 168]; while pos < 256 { - let mut b = [0; 3]; - xof.read(&mut b); - for v in [ - u16::from(b[0]) | ((u16::from(b[1]) & 15) << 8), - (u16::from(b[1]) >> 4) | (u16::from(b[2]) << 4), - ] { - if u32::from(v) < Q && pos < 256 { - out.0[pos] = v; - pos += 1 + xof.read(&mut block); + for b in block.as_chunks::<3>().0 { + for v in [ + u16::from(b[0]) | ((u16::from(b[1]) & 15) << 8), + (u16::from(b[1]) >> 4) | (u16::from(b[2]) << 4), + ] { + if u32::from(v) < Q && pos < 256 { + out.0[pos] = v; + pos += 1 + } } } } From 6398da41fd38729f27c852634bdee8154d3f52f5 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 23 Oct 2026 20:16:54 +0200 Subject: [PATCH 4/6] mlkem: ByteEncode and ByteDecode eight coefficients at a time Encoding and decoding moved one bit per inner iteration, d per coefficient. Eight d-bit coefficients are d bytes for every d, so each eight are now packed into or unpacked from one 128-bit word, with d a const parameter. The indices and shifts are public. Co-Authored-By: Claude Opus 5.5 --- lib/crypto/src/mlkem.rs | 51 ++++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/lib/crypto/src/mlkem.rs b/lib/crypto/src/mlkem.rs index 8d0efc9ef64..c369dd25a2e 100644 --- a/lib/crypto/src/mlkem.rs +++ b/lib/crypto/src/mlkem.rs @@ -126,23 +126,28 @@ impl Poly { fn minus(&self, b: &Self) -> Self { Self(core::array::from_fn(|i| sub(self.0[i], b.0[i]))) } - fn encode(&self, d: usize, output: &mut [u8]) { - assert_eq!(output.len(), 32 * d); - output.fill(0); - for i in 0..256 { - for bit in 0..d { - let at = i * d + bit; - output[at / 8] |= (((self.0[i] >> bit) & 1) as u8) << (at % 8) + /// ByteEncode_D (FIPS 203 Algorithm 5) of coefficients below 2^D: each + /// eight fill D bytes. + fn encode(&self, output: &mut [u8]) { + assert_eq!(output.len(), 32 * D); + for (coefficients, bytes) in self.0.as_chunks::<8>().0.iter().zip(output.as_chunks_mut::().0) { + let mut word = 0u128; + for (i, &c) in coefficients.iter().enumerate() { + word |= u128::from(c) << (D * i) } + bytes.copy_from_slice(&word.to_le_bytes()[..D]) } } - fn decode(d: usize, input: &[u8]) -> Self { - assert_eq!(input.len(), 32 * d); + /// ByteDecode_D (Algorithm 6), whose coefficients are below 2^D. + fn decode(input: &[u8]) -> Self { + assert_eq!(input.len(), 32 * D); let mut out = Self::zero(); - for i in 0..256 { - for bit in 0..d { - let at = i * d + bit; - out.0[i] |= u16::from((input[at / 8] >> (at % 8)) & 1) << bit + for (coefficients, bytes) in out.0.as_chunks_mut::<8>().0.iter_mut().zip(input.as_chunks::().0) { + let mut word = [0; 16]; + word[..D].copy_from_slice(bytes); + let word = u128::from_le_bytes(word); + for (i, c) in coefficients.iter_mut().enumerate() { + *c = (word >> (D * i)) as u16 & ((1 << D) - 1) } } out @@ -214,7 +219,7 @@ impl EncapsulationKey { return Err(Error::Length); } for chunk in input[..1152].as_chunks::<384>().0 { - let p = Poly::decode(12, chunk); + let p = Poly::decode::<12>(chunk); if p.0.iter().any(|&x| u32::from(x) >= Q) { return Err(Error::InvalidKey); } @@ -252,17 +257,17 @@ impl EncapsulationKey { sum.inverse() .plus(&noise(coins, (i + 3) as u8)) .compress(10) - .encode(10, &mut out[i * 320..i * 320 + 320]); + .encode::<10>(&mut out[i * 320..i * 320 + 320]); } let mut sum = Poly::zero(); for (j, yp) in y.iter().enumerate() { - sum = sum.plus(&Poly::decode(12, &self.bytes[j * 384..j * 384 + 384]).product(yp)) + sum = sum.plus(&Poly::decode::<12>(&self.bytes[j * 384..j * 384 + 384]).product(yp)) } sum.inverse() .plus(&noise(coins, 6)) - .plus(&Poly::decode(1, message).decompress(1)) + .plus(&Poly::decode::<1>(message).decompress(1)) .compress(4) - .encode(4, &mut out[960..]); + .encode::<4>(&mut out[960..]); out } } @@ -280,7 +285,7 @@ impl DecapsulationKey { for (j, s) in secret.iter().enumerate() { sum = sum.plus(&sample(rho, i, j).product(s)) } - sum.encode(12, &mut bytes[i * 384..i * 384 + 384]) + sum.encode::<12>(&mut bytes[i * 384..i * 384 + 384]) } bytes[1152..].copy_from_slice(rho); let hash = keccak::h(&[&bytes]); @@ -304,18 +309,18 @@ impl DecapsulationKey { for (i, s) in self.secret.iter().enumerate() { sum = sum.plus( &s.product( - &Poly::decode(10, &ciphertext[i * 320..i * 320 + 320]) + &Poly::decode::<10>(&ciphertext[i * 320..i * 320 + 320]) .decompress(10) .ntt(), ), ) } let mut message = Secret([0; 32]); - Poly::decode(4, &ciphertext[960..]) + Poly::decode::<4>(&ciphertext[960..]) .decompress(4) .minus(&sum.inverse()) .compress(1) - .encode(1, &mut message.0); + .encode::<1>(&mut message.0); let kr = Secret(keccak::g(&[&message.0, &self.hash])); let rejected = Secret(keccak::j(&[&self.rejection.0, ciphertext])); let mut expected = self @@ -428,7 +433,7 @@ mod tests { let expected = hex(test["dk"].as_str().unwrap()); let mut actual = Secret([0; 2400]); for (i, p) in key.secret.iter().enumerate() { - p.encode(12, &mut actual.0[384 * i..384 * i + 384]) + p.encode::<12>(&mut actual.0[384 * i..384 * i + 384]) } actual.0[1152..2336].copy_from_slice(key.public.as_bytes()); actual.0[2336..2368].copy_from_slice(&key.hash); From 76bf8b41835102235cb6fc5ea1faf2c21388b3ef Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 23 Oct 2026 20:20:33 +0200 Subject: [PATCH 5/6] mlkem: signed 16-bit coefficients, reduced only where a bound needs it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every addition, subtraction and product was reduced to [0, q) at once. Coefficients are now signed 16-bit representatives (Seiler, IACR ePrint 2018/039), with Montgomery reduction for products and Barrett reduction where a bound requires it: - The NTT's butterflies add without reducing. Each layer adds less than q to a magnitude, so inputs below q stay below 8q < 2^15. One Barrett pass ends the transform. - `dot` replaces `product` and `plus` for the matrix-vector and inner products. Each coefficient pair's three products accumulate in 32 bits, below 4.5q² < q·2^15, and are reduced once. The twiddle and γ tables carry the Montgomery factor R = 2^16 mod q, so a product by them is exact. - The inverse NTT removes `dot`'s factor 2^-16 in its final scale, R²/128 (Algorithm 10's 128⁻¹ with that removal), and key generation removes it by a product with R². - CBD noise is a − b directly, without an offset of q. - Encoding to 12 bits and compression take the canonical representative, by Barrett reduction and a sign mask, without a branch. - The transforms walk each layer's blocks with `chunks_exact_mut` over a power-of-two length. `step_by` over a length read from an array had LLVM divide 256 by it for the trip count. That value is public, but with it gone no ML-KEM or Keccak function contains a divide. Each reduction is tested over its whole input range. The forward and inverse transform and `dot` are held to the NTT's definition, x mod (X² − γ_i), at the edges of their stated input bounds. Co-Authored-By: Claude Opus 5.5 --- lib/crypto/src/mlkem.rs | 347 ++++++++++++++++++++++++++-------------- 1 file changed, 224 insertions(+), 123 deletions(-) diff --git a/lib/crypto/src/mlkem.rs b/lib/crypto/src/mlkem.rs index c369dd25a2e..98631ac2bdc 100644 --- a/lib/crypto/src/mlkem.rs +++ b/lib/crypto/src/mlkem.rs @@ -5,14 +5,28 @@ //! implements masked implicit rejection, with no validity bit or error oracle. //! Secret keys can only originate from the caller's entropy source, so external //! expanded decapsulation-key validation is outside this API. +//! +//! Coefficients are signed 16-bit representatives, reduced only where a bound +//! requires it (Seiler, IACR ePrint 2018/039). The reductions, transforms and +//! products state their input bounds; additions are checked against overflow. use crate::{ Error, Random, ct, keccak::{self, Sponge}, secret::{Secret, wipe}, }; -const Q: u32 = 3329; -struct Poly([u16; 256]); +const Q: i16 = 3329; +/// q⁻¹ mod 2^16. +const Q_INVERSE: i16 = -3327; +const _: () = assert!(Q.wrapping_mul(Q_INVERSE) == 1); +/// 2^16 mod q, the Montgomery factor. +const R: i32 = (1 << 16) % Q as i32; +/// R² mod q: a Montgomery product by it removes one factor 2^-16. +const R_SQUARED: i16 = (R * R % Q as i32) as i16; +/// R²/128 mod q (3303 is 128⁻¹), Algorithm 10's final scale with that removal. +const INVERSE_SCALE: i16 = (R * R % Q as i32 * 3303 % Q as i32) as i16; +const _: () = assert!(128 * 3303 % Q as i32 == 1); +struct Poly([i16; 256]); impl Drop for Poly { fn drop(&mut self) { for word in &mut self.0 { @@ -22,123 +36,139 @@ impl Drop for Poly { core::sync::atomic::compiler_fence(core::sync::atomic::Ordering::SeqCst) } } -/// ⌊n / q⌋ for n < 2^33 / 623, which holds every value reduced or compressed -/// here, by a multiply and shift: a divide's latency can depend on its -/// operands (KyberSlash, Bernstein et al., IACR ePrint 2024/1049). With +/// ⌊n / q⌋ for n < 2^33 / 623, which holds every compressed value, by a +/// multiply and shift: a divide's latency can depend on its operands +/// (KyberSlash, Bernstein et al., IACR ePrint 2024/1049). With /// M = ⌈2^33 / q⌉, n·M / 2^33 = n / q + 623·n / (q·2^33), and the excess is /// below 1/q, too small to carry the quotient past the next integer. fn divide(n: u32) -> u32 { const M: u64 = (1u64 << 33).div_ceil(Q as u64); ((u64::from(n) * M) >> 33) as u32 } -fn reduce(n: u32) -> u16 { - (n - divide(n) * Q) as u16 +/// a·2^-16 mod q, in (-q, q), for |a| < q·2^15 (Montgomery reduction, in +/// Seiler's signed form): a − t·q is a multiple of 2^16 for t = a·q⁻¹ mod 2^16. +fn montgomery(a: i32) -> i16 { + let t = (a as i16).wrapping_mul(Q_INVERSE); + ((a - i32::from(t) * i32::from(Q)) >> 16) as i16 } -fn add(a: u16, b: u16) -> u16 { - reduce(u32::from(a) + u32::from(b)) +/// a·b·2^-16 mod q, in (-q, q), for |a·b| < q·2^15. +fn multiply(a: i16, b: i16) -> i16 { + montgomery(i32::from(a) * i32::from(b)) } -fn sub(a: u16, b: u16) -> u16 { - reduce(u32::from(a) + Q - u32::from(b)) +/// a mod q, in [-(q-1)/2, (q-1)/2], for any a: a less q times the nearest +/// integer to a·⌊2^26/q⌉/2^26 (Barrett reduction). +fn barrett(a: i16) -> i16 { + const V: i32 = ((1 << 26) + Q as i32 / 2) / Q as i32; + let t = (V * i32::from(a) + (1 << 25)) >> 26; + (i32::from(a) - t * i32::from(Q)) as i16 } -fn mul(a: u16, b: u16) -> u16 { - reduce(u32::from(a) * u32::from(b)) +/// a mod q, in [0, q). +fn canonical(a: i16) -> i16 { + let r = barrett(a); + r + ((r >> 15) & Q) } /// ζ^BitRev7(i) and, for `odd`, γ_i = ζ^(2·BitRev7(i)+1), with ζ = 17: FIPS 203 -/// Appendix A's two tables. -const fn powers(odd: bool) -> [u16; 128] { +/// Appendix A's two tables, each entry times R, which a Montgomery product +/// by it cancels. +const fn powers(odd: bool) -> [i16; 128] { let mut out = [0; 128]; let mut i = 0; while i < 128 { let reversed = ((i as u8).reverse_bits() >> 1) as u32; let exponent = if odd { 2 * reversed + 1 } else { reversed }; - let mut power = 1; + let mut power = R; let mut k = 0; while k < exponent { - power = power * 17 % Q; + power = power * 17 % Q as i32; k += 1 } - out[i] = power as u16; + out[i] = power as i16; i += 1 } out } -const ZETAS: [u16; 128] = powers(false); -const GAMMAS: [u16; 128] = powers(true); +const ZETAS: [i16; 128] = powers(false); +const GAMMAS: [i16; 128] = powers(true); impl Poly { fn zero() -> Self { Self([0; 256]) } + /// Algorithm 9, for coefficients below q in magnitude. A layer adds less + /// than q to a magnitude, so seven stay below 8q < 2^15. The result is + /// Barrett-reduced. fn ntt(mut self) -> Self { - let mut k = 1; - let mut len = 128; - while len >= 2 { - for start in (0..256).step_by(2 * len) { - let z = ZETAS[k]; - k += 1; - for i in start..start + len { - let t = mul(z, self.0[i + len]); - self.0[i + len] = sub(self.0[i], t); - self.0[i] = add(self.0[i], t) + let mut zetas = ZETAS[1..].iter(); + for layer in (1..8).rev() { + let len = 1 << layer; + for (block, &z) in self.0.chunks_exact_mut(2 * len).zip(&mut zetas) { + let (low, high) = block.split_at_mut(len); + for (a, b) in low.iter_mut().zip(high) { + let t = multiply(z, *b); + *b = *a - t; + *a += t } } - len /= 2 + } + for c in &mut self.0 { + *c = barrett(*c) } self } + /// Algorithm 10 of [`dot`]'s output, whose factor 2^-16 the final scale + /// removes. Coefficients enter and stay below q in magnitude. fn inverse(mut self) -> Self { - let mut k = 127; - let mut len = 2; - while len <= 128 { - for start in (0..256).step_by(2 * len) { - let z = ZETAS[k]; - k -= 1; - for i in start..start + len { - let t = self.0[i]; - self.0[i] = add(t, self.0[i + len]); - self.0[i + len] = mul(z, sub(self.0[i + len], t)) + let mut zetas = ZETAS[1..].iter().rev(); + for layer in 1..8 { + let len = 1 << layer; + for (block, &z) in self.0.chunks_exact_mut(2 * len).zip(&mut zetas) { + let (low, high) = block.split_at_mut(len); + for (a, b) in low.iter_mut().zip(high) { + let t = *a; + *a = barrett(t + *b); + *b = multiply(z, *b - t) } } - len *= 2 } - for word in &mut self.0 { - *word = mul(*word, 3303) + self.scale(INVERSE_SCALE) + } + /// Each coefficient times factor·2^-16, in (-q, q). + fn scale(mut self, factor: i16) -> Self { + for c in &mut self.0 { + *c = multiply(*c, factor) } self } - fn product(&self, q: &Self) -> Self { - let mut out = Self::zero(); - for i in 0..128 { - let gamma = GAMMAS[i]; - out.0[2 * i] = add( - mul(self.0[2 * i], q.0[2 * i]), - mul(gamma, mul(self.0[2 * i + 1], q.0[2 * i + 1])), - ); - out.0[2 * i + 1] = add( - mul(self.0[2 * i], q.0[2 * i + 1]), - mul(self.0[2 * i + 1], q.0[2 * i]), - ); + fn plus(mut self, b: &Self) -> Self { + for (c, b) in self.0.iter_mut().zip(&b.0) { + *c += b } - out + self } - fn plus(&self, b: &Self) -> Self { - Self(core::array::from_fn(|i| add(self.0[i], b.0[i]))) + fn minus(mut self, b: &Self) -> Self { + for (c, b) in self.0.iter_mut().zip(&b.0) { + *c -= b + } + self } - fn minus(&self, b: &Self) -> Self { - Self(core::array::from_fn(|i| sub(self.0[i], b.0[i]))) + fn canonical(mut self) -> Self { + for c in &mut self.0 { + *c = canonical(*c) + } + self } - /// ByteEncode_D (FIPS 203 Algorithm 5) of coefficients below 2^D: each + /// ByteEncode_D (FIPS 203 Algorithm 5) of coefficients in [0, 2^D): each /// eight fill D bytes. fn encode(&self, output: &mut [u8]) { assert_eq!(output.len(), 32 * D); for (coefficients, bytes) in self.0.as_chunks::<8>().0.iter().zip(output.as_chunks_mut::().0) { let mut word = 0u128; for (i, &c) in coefficients.iter().enumerate() { - word |= u128::from(c) << (D * i) + word |= u128::from(c as u16) << (D * i) } bytes.copy_from_slice(&word.to_le_bytes()[..D]) } } - /// ByteDecode_D (Algorithm 6), whose coefficients are below 2^D. + /// ByteDecode_D (Algorithm 6), whose coefficients are in [0, 2^D). fn decode(input: &[u8]) -> Self { assert_eq!(input.len(), 32 * D); let mut out = Self::zero(); @@ -147,36 +177,60 @@ impl Poly { word[..D].copy_from_slice(bytes); let word = u128::from_le_bytes(word); for (i, c) in coefficients.iter_mut().enumerate() { - *c = (word >> (D * i)) as u16 & ((1 << D) - 1) + *c = (word >> (D * i)) as i16 & ((1 << D) - 1) } } out } + /// Compress_d of any representatives, into [0, 2^d). fn compress(mut self, d: usize) -> Self { - for word in &mut self.0 { - *word = (divide((u32::from(*word) << d) + Q / 2) & ((1 << d) - 1)) as u16 + for c in &mut self.0 { + let x = canonical(*c) as u32; + *c = (divide((x << d) + Q as u32 / 2) & ((1 << d) - 1)) as i16 } self } + /// Decompress_d of [0, 2^d), into [0, q). fn decompress(mut self, d: usize) -> Self { - for word in &mut self.0 { - *word = ((Q * u32::from(*word) + (1 << (d - 1))) >> d) as u16 + for c in &mut self.0 { + *c = ((Q as u32 * *c as u32 + (1 << (d - 1))) >> d) as i16 } self } } +/// Σ a_j·b_j in the NTT domain (Algorithms 11 and 12), times 2^-16, in +/// (-q, q). Each a_j's coefficients are below q in magnitude and each b_j's +/// at most (q-1)/2, so a pair's three products sum below 4.5q² < q·2^15 and +/// are reduced once. +fn dot(a: &[Poly; 3], b: &[Poly; 3]) -> Poly { + let mut out = Poly::zero(); + for (i, &gamma) in GAMMAS.iter().enumerate() { + let (mut even, mut odd) = (0, 0); + for (a, b) in a.iter().zip(b) { + let (a0, a1) = (i32::from(a.0[2 * i]), i32::from(a.0[2 * i + 1])); + let (b0, b1) = (i32::from(b.0[2 * i]), i32::from(b.0[2 * i + 1])); + even += a0 * b0 + i32::from(montgomery(a1 * b1)) * i32::from(gamma); + odd += a0 * b1 + a1 * b0 + } + out.0[2 * i] = montgomery(even); + out.0[2 * i + 1] = montgomery(odd) + } + out +} +/// SamplePolyCBD_2 (Algorithm 8), in [-2, 2]. fn noise(seed: &[u8; 32], nonce: u8) -> Poly { let mut bytes = Secret([0; 128]); Sponge::new(136, 31, &[seed, &[nonce]]).read(&mut bytes.0); let mut out = Poly::zero(); for i in 0..256 { let bits = (bytes.0[i / 2] >> (4 * (i % 2))) & 15; - let a = u32::from(bits & 1) + u32::from((bits >> 1) & 1); - let b = u32::from((bits >> 2) & 1) + u32::from((bits >> 3) & 1); - out.0[i] = reduce(Q + a - b) + let a = (bits & 1) + ((bits >> 1) & 1); + let b = ((bits >> 2) & 1) + ((bits >> 3) & 1); + out.0[i] = i16::from(a) - i16::from(b) } out } +/// SampleNTT (Algorithm 7), in [0, q). fn sample(rho: &[u8; 32], row: usize, col: usize) -> Poly { let mut xof = Sponge::new(168, 31, &[rho, &[col as u8, row as u8]]); let mut out = Poly::zero(); @@ -186,10 +240,10 @@ fn sample(rho: &[u8; 32], row: usize, col: usize) -> Poly { xof.read(&mut block); for b in block.as_chunks::<3>().0 { for v in [ - u16::from(b[0]) | ((u16::from(b[1]) & 15) << 8), - (u16::from(b[1]) >> 4) | (u16::from(b[2]) << 4), + i16::from(b[0]) | ((i16::from(b[1]) & 15) << 8), + (i16::from(b[1]) >> 4) | (i16::from(b[2]) << 4), ] { - if u32::from(v) < Q && pos < 256 { + if v < Q && pos < 256 { out.0[pos] = v; pos += 1 } @@ -202,6 +256,7 @@ pub struct EncapsulationKey { bytes: [u8; 1184], } pub struct DecapsulationKey { + /// ŝ, Barrett-reduced. secret: [Poly; 3], public: EncapsulationKey, hash: [u8; 32], @@ -220,7 +275,7 @@ impl EncapsulationKey { } for chunk in input[..1152].as_chunks::<384>().0 { let p = Poly::decode::<12>(chunk); - if p.0.iter().any(|&x| u32::from(x) >= Q) { + if p.0.iter().any(|&x| x >= Q) { return Err(Error::InvalidKey); } } @@ -250,20 +305,16 @@ impl EncapsulationKey { let y: [Poly; 3] = core::array::from_fn(|i| noise(coins, i as u8).ntt()); let mut out = [0; 1088]; for i in 0..3 { - let mut sum = Poly::zero(); - for (j, yp) in y.iter().enumerate() { - sum = sum.plus(&sample(rho, j, i).product(yp)) - } - sum.inverse() + let column: [Poly; 3] = core::array::from_fn(|j| sample(rho, j, i)); + dot(&column, &y) + .inverse() .plus(&noise(coins, (i + 3) as u8)) .compress(10) .encode::<10>(&mut out[i * 320..i * 320 + 320]); } - let mut sum = Poly::zero(); - for (j, yp) in y.iter().enumerate() { - sum = sum.plus(&Poly::decode::<12>(&self.bytes[j * 384..j * 384 + 384]).product(yp)) - } - sum.inverse() + let t: [Poly; 3] = core::array::from_fn(|j| Poly::decode::<12>(&self.bytes[j * 384..j * 384 + 384])); + dot(&t, &y) + .inverse() .plus(&noise(coins, 6)) .plus(&Poly::decode::<1>(message).decompress(1)) .compress(4) @@ -281,11 +332,12 @@ impl DecapsulationKey { let secret: [Poly; 3] = core::array::from_fn(|i| noise(sigma, i as u8).ntt()); let mut bytes = [0; 1184]; for i in 0..3 { - let mut sum = noise(sigma, (i + 3) as u8).ntt(); - for (j, s) in secret.iter().enumerate() { - sum = sum.plus(&sample(rho, i, j).product(s)) - } - sum.encode::<12>(&mut bytes[i * 384..i * 384 + 384]) + let row: [Poly; 3] = core::array::from_fn(|j| sample(rho, i, j)); + dot(&row, &secret) + .scale(R_SQUARED) + .plus(&noise(sigma, (i + 3) as u8).ntt()) + .canonical() + .encode::<12>(&mut bytes[i * 384..i * 384 + 384]) } bytes[1152..].copy_from_slice(rho); let hash = keccak::h(&[&bytes]); @@ -305,20 +357,12 @@ impl DecapsulationKey { if ciphertext.len() != 1088 { return Err(Error::Length); } - let mut sum = Poly::zero(); - for (i, s) in self.secret.iter().enumerate() { - sum = sum.plus( - &s.product( - &Poly::decode::<10>(&ciphertext[i * 320..i * 320 + 320]) - .decompress(10) - .ntt(), - ), - ) - } + let u: [Poly; 3] = + core::array::from_fn(|i| Poly::decode::<10>(&ciphertext[i * 320..i * 320 + 320]).decompress(10).ntt()); let mut message = Secret([0; 32]); Poly::decode::<4>(&ciphertext[960..]) .decompress(4) - .minus(&sum.inverse()) + .minus(&dot(&self.secret, &u).inverse()) .compress(1) .encode::<1>(&mut message.0); let kr = Secret(keccak::g(&[&message.0, &self.hash])); @@ -361,23 +405,79 @@ mod tests { #[test] #[cfg_attr(miri, ignore = "13.8 M quotients")] fn division_by_q_is_exact_over_its_whole_input_range() { - let end = ((1u64 << 33) / 623) as u32; - assert!(u64::from(end) > u64::from(Q) * u64::from(Q)); - for n in 0..end { - assert_eq!(divide(n), n / Q, "{n}"); + for n in 0..((1u64 << 33) / 623) as u32 { + assert_eq!(divide(n), n / Q as u32, "{n}"); } } - /// Every pair of reduced coefficients, against 64-bit integers. + /// Each reduction over its whole input range, against 64-bit integers. #[test] - #[cfg_attr(miri, ignore = "3329^2 pairs")] - fn reduction_modulo_q_is_exact_over_its_whole_input_range() { - for a in 0..Q as u16 { - for b in 0..Q as u16 { - let (x, y, q) = (u64::from(a), u64::from(b), u64::from(Q)); - assert_eq!(u64::from(add(a, b)), (x + y) % q, "{a} + {b}"); - assert_eq!(u64::from(sub(a, b)), (x + q - y) % q, "{a} - {b}"); - assert_eq!(u64::from(mul(a, b)), x * y % q, "{a} * {b}"); + #[cfg_attr(miri, ignore = "2^28 reductions")] + fn reductions_are_exact_over_their_whole_input_ranges() { + let q = i64::from(Q); + for a in i16::MIN..=i16::MAX { + let (r, c) = (barrett(a), canonical(a)); + assert!((i64::from(r) - i64::from(a)) % q == 0 && 2 * r.abs() < Q, "barrett({a}) = {r}"); + assert_eq!(i64::from(c), i64::from(a).rem_euclid(q), "canonical({a})"); + } + let bound = i32::from(Q) << 15; + for a in -bound + 1..bound { + let r = montgomery(a); + assert!((i64::from(r) * 65536 - i64::from(a)) % q == 0 && r.abs() < Q, "montgomery({a}) = {r}"); + } + } + + /// x mod (X² − γ_i) for each i, the NTT's definition (FIPS 203 §4.3), with + /// γ_i = 17^(2·BitRev7(i)+1), in plain 64-bit arithmetic. + fn ntt_by_definition(x: &[i16; 256]) -> [i64; 256] { + let q = i64::from(Q); + let mut out = [0; 256]; + for i in 0..128 { + let gamma = (0..2 * ((i as u8).reverse_bits() >> 1) as u32 + 1).fold(1, |p, _| p * 17 % q); + let mut power = 1; + for m in 0..128 { + out[2 * i] = (out[2 * i] + i64::from(x[2 * m]) * power).rem_euclid(q); + out[2 * i + 1] = (out[2 * i + 1] + i64::from(x[2 * m + 1]) * power).rem_euclid(q); + power = power * gamma % q + } + } + out + } + + /// The lazily reduced transforms at the edges of their stated input + /// bounds, where an overflow would be, against the definition. + #[test] + fn the_ntt_domain_holds_its_bounds_and_the_definition() { + let q = i64::from(Q); + let r = (1i64 << 16) % q; + let patterns: [fn(usize, i16) -> i16; 4] = + [|_, m| m, |_, m| -m, |i, m| if i % 2 == 0 { m } else { -m }, |i, m| (i as i16 * 97 % (2 * m + 1)) - m]; + for pattern in patterns { + let wide = Poly(core::array::from_fn(|i| pattern(i, Q - 1))); + let narrow = Poly(core::array::from_fn(|i| pattern(i, (Q - 1) / 2))); + + let want = ntt_by_definition(&wide.0); + let ntt = Poly(wide.0).ntt(); + for (i, &c) in ntt.0.iter().enumerate() { + assert!(2 * c.abs() < Q && i64::from(canonical(c)) == want[i], "ntt[{i}] = {c}"); + } + + let inverse = Poly(wide.0).inverse(); + let back = ntt_by_definition(&inverse.0); + for (i, &c) in inverse.0.iter().enumerate() { + assert!(c.abs() < Q && back[i] == (i64::from(wide.0[i]) * r).rem_euclid(q), "inverse[{i}] = {c}"); + } + + let (a, b) = ([Poly(wide.0), Poly(wide.0), Poly(wide.0)], [Poly(narrow.0), Poly(narrow.0), Poly(narrow.0)]); + let product = dot(&a, &b); + for i in 0..128 { + let gamma = (0..2 * ((i as u8).reverse_bits() >> 1) as u32 + 1).fold(1, |p, _| p * 17 % q); + let (a0, a1) = (i64::from(wide.0[2 * i]), i64::from(wide.0[2 * i + 1])); + let (b0, b1) = (i64::from(narrow.0[2 * i]), i64::from(narrow.0[2 * i + 1])); + for (k, want) in [3 * (a0 * b0 + a1 * b1 % q * gamma), 3 * (a0 * b1 + a1 * b0)].into_iter().enumerate() { + let c = product.0[2 * i + k]; + assert!(c.abs() < Q && (i64::from(c) * 65536 - want) % q == 0, "dot[{}] = {c}", 2 * i + k); + } } } } @@ -389,25 +489,26 @@ mod tests { fn compression_rounds_as_fips_203_for_every_input() { for d in [1, 4, 10] { let mut x = Poly::zero(); - for start in (0..Q as u16).step_by(256) { + for start in (0..Q).step_by(256) { for (i, word) in x.0.iter_mut().enumerate() { - *word = (start + i as u16).min(Q as u16 - 1); + *word = (start + i as i16).min(Q - 1); } let compressed = Poly(x.0).compress(d); for (i, &value) in x.0.iter().enumerate() { - let want = ((u64::from(value) << (d + 1)) + u64::from(Q)) / (2 * u64::from(Q)) % (1 << d); - assert_eq!(u64::from(compressed.0[i]), want, "Compress_{d}({value})"); + let want = ((value as u64) << (d + 1)) + Q as u64; + let want = want / (2 * Q as u64) % (1 << d); + assert_eq!(compressed.0[i] as u64, want, "Compress_{d}({value})"); } } let mut y = Poly::zero(); for start in (0..1u32 << d).step_by(256) { for (i, word) in y.0.iter_mut().enumerate() { - *word = (start + i as u32).min((1 << d) - 1) as u16; + *word = (start + i as u32).min((1 << d) - 1) as i16; } let decompressed = Poly(y.0).decompress(d); for (i, &value) in y.0.iter().enumerate() { - let want = (2 * u64::from(Q) * u64::from(value) + (1 << d)) >> (d + 1); - assert_eq!(u64::from(decompressed.0[i]), want, "Decompress_{d}({value})"); + let want = (2 * Q as u64 * value as u64 + (1 << d)) >> (d + 1); + assert_eq!(decompressed.0[i] as u64, want, "Decompress_{d}({value})"); } } } @@ -433,7 +534,7 @@ mod tests { let expected = hex(test["dk"].as_str().unwrap()); let mut actual = Secret([0; 2400]); for (i, p) in key.secret.iter().enumerate() { - p.encode::<12>(&mut actual.0[384 * i..384 * i + 384]) + Poly(p.0).canonical().encode::<12>(&mut actual.0[384 * i..384 * i + 384]) } actual.0[1152..2336].copy_from_slice(key.public.as_bytes()); actual.0[2336..2368].copy_from_slice(&key.hash); From 1d3d91b4e81bbe81fecd6a6f2ca9366849771ceb Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 23 Oct 2026 21:38:00 +0200 Subject: [PATCH 6/6] mlkem: answer review r1 of #1051 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module header no longer says additions are checked against overflow: that holds only where overflow checks are on, and [profile.release] has none. The contract is the stated bounds. The division test now includes the last value of its stated range, ⌊2^33/623⌋. Co-Authored-By: Claude Opus 5.5 --- lib/crypto/src/mlkem.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/crypto/src/mlkem.rs b/lib/crypto/src/mlkem.rs index 98631ac2bdc..f82611a9f60 100644 --- a/lib/crypto/src/mlkem.rs +++ b/lib/crypto/src/mlkem.rs @@ -8,7 +8,7 @@ //! //! Coefficients are signed 16-bit representatives, reduced only where a bound //! requires it (Seiler, IACR ePrint 2018/039). The reductions, transforms and -//! products state their input bounds; additions are checked against overflow. +//! products state their input bounds. use crate::{ Error, Random, ct, @@ -405,7 +405,7 @@ mod tests { #[test] #[cfg_attr(miri, ignore = "13.8 M quotients")] fn division_by_q_is_exact_over_its_whole_input_range() { - for n in 0..((1u64 << 33) / 623) as u32 { + for n in 0..=((1u64 << 33) / 623) as u32 { assert_eq!(divide(n), n / Q as u32, "{n}"); } }