diff --git a/Cargo.lock b/Cargo.lock index 9ef32e3..17a0710 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,12 +14,39 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.3" @@ -29,6 +56,44 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "ctutils" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2376ffd6f96d8a9db59875b4b6b0237d6900b5baf64279714e05493ce64828a" +dependencies = [ + "cmov", +] + +[[package]] +name = "der" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "crypto-common", +] + [[package]] name = "flate2" version = "1.1.10" @@ -57,6 +122,16 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "ctutils", + "typenum", +] + [[package]] name = "indoc" version = "2.0.7" @@ -72,6 +147,26 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common", + "rand_core 0.10.1", +] + [[package]] name = "libc" version = "0.2.182" @@ -103,12 +198,57 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ml-kem" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9ba01ebcf90f6aff8e9b9f55bb69b19e6250e4ade5787d0040eaa27dd226238" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "pkcs8", + "rand_core 0.10.1", + "sha3", + "shake", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -138,9 +278,10 @@ name = "pykyber" version = "1.0.5" dependencies = [ "flate2", + "ml-kem", "pyo3", "rand", - "rand_core", + "rand_core 0.6.4", "serde", "serde_json", "zeroize", @@ -224,7 +365,7 @@ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -234,7 +375,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -246,6 +387,12 @@ dependencies = [ "getrandom", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rustversion" version = "1.0.22" @@ -295,12 +442,50 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest", + "keccak", + "sponge-cursor", +] + +[[package]] +name = "shake" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" +dependencies = [ + "digest", + "keccak", + "sponge-cursor", +] + [[package]] name = "simd-adler32" version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "spki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "syn" version = "2.0.117" @@ -329,6 +514,12 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index 20fdce9..b908696 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,9 @@ acvp = [] [dev-dependencies] flate2 = "1" +# Independent FIPS 203 implementation for the differential test only (never a +# runtime dependency). `hazmat` exposes encapsulation with a caller-chosen m. +ml-kem = { version = "=0.3.3", features = ["hazmat"] } rand = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src/mlkem/differential_tests.rs b/src/mlkem/differential_tests.rs new file mode 100644 index 0000000..a7b4824 --- /dev/null +++ b/src/mlkem/differential_tests.rs @@ -0,0 +1,221 @@ +//! Differential test against RustCrypto `ml-kem` 0.3.3 (a dev-dependency, FIPS +//! 203 final, written independently of this crate; no code is shared). +//! +//! Random inputs come from a fixed-seed generator, so a failure reproduces and +//! the case number in the message says which input it was. For every parameter +//! set, per round: +//! - KeyGen from a seed `d || z`: same `ek`, same expanded `dk`. +//! - Encaps with a given `m`: same `c` and `K`. +//! - Decaps of the valid ciphertext, of one with a flipped bit and of a random +//! one: same `K` (the last two are the implicit-rejection path). +//! - Decaps with a `dk` whose `dk_PKE` or `z` bytes were changed. +//! - The key checks: a random byte of `ek` or `dk` is replaced and both +//! implementations must agree on whether the key is accepted (section 7.2 +//! modulus check, section 7.3 hash check), plus a coefficient forced into +//! q..2^12-1. + +use super::check::{check_dk, check_ek}; +use super::kem::acvp; +use super::params::{MlKem1024, MlKem512, MlKem768, ParamSet}; +use crate::kyber::params::{KYBER_N, KYBER_Q, KYBER_SYMBYTES}; +use ml_kem::array::Array; +use ml_kem::kem::{Decapsulate, KeyExport}; +#[allow(deprecated)] +use ml_kem::ExpandedKeyEncoding; +use ml_kem::{FromSeed, TryKeyInit}; + +/// Rounds per parameter set; each round draws fresh seeds, `m` and mutations. +const ROUNDS: usize = 200; +/// Seed of the generator below. +const PRNG_SEED: u64 = 0x2545_f491_4f6c_dd1d; +/// Bits in the 12-bit coefficient encoding of the keys (ByteEncode12). +const COEFFICIENT_BITS: u32 = 12; +const SEED_BYTES: usize = KYBER_SYMBYTES; +const BYTE_BITS: usize = u8::BITS as usize; + +/// Shifts of Marsaglia's xorshift64 as used in xorshift64* (Vigna). +const XORSHIFT_RIGHT_1: u32 = 12; +const XORSHIFT_LEFT: u32 = 25; +const XORSHIFT_RIGHT_2: u32 = 27; +/// Output multiplier of xorshift64* (Vigna). +const OUTPUT_MULTIPLIER: u64 = 0x2545_f491_4f6c_dd1d; + +/// xorshift64*, enough for reproducible test inputs. +struct Prng(u64); + +impl Prng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 >> XORSHIFT_RIGHT_1; + self.0 ^= self.0 << XORSHIFT_LEFT; + self.0 ^= self.0 >> XORSHIFT_RIGHT_2; + self.0.wrapping_mul(OUTPUT_MULTIPLIER) + } + + fn below(&mut self, bound: usize) -> usize { + (self.next() >> u32::BITS) as usize % bound + } + + fn bytes(&mut self) -> [u8; N] { + let mut out = [0u8; N]; + out.iter_mut() + .for_each(|b| *b = (self.next() >> u32::BITS) as u8); + out + } + + fn vec(&mut self, len: usize) -> Vec { + (0..len).map(|_| (self.next() >> u32::BITS) as u8).collect() + } +} + +/// Sets the 12-bit coefficient number `index` of an encoded polynomial vector. +fn set_coefficient(encoded: &mut [u8], index: usize, value: u16) { + let first_bit = index * COEFFICIENT_BITS as usize; + for bit in 0..COEFFICIENT_BITS as usize { + let (byte, shift) = ((first_bit + bit) / BYTE_BITS, (first_bit + bit) % BYTE_BITS); + encoded[byte] = (encoded[byte] & !(1 << shift)) | ((((value >> bit) & 1) as u8) << shift); + } +} + +macro_rules! differential_test { + ($name:ident, $params:ident, $module:ident, $reference:ident) => { + #[test] + fn $name() { + use ml_kem::$module::{Ciphertext, DecapsulationKey, EncapsulationKey}; + const K: usize = $params::K; + let mut rng = Prng(PRNG_SEED); + // dk = dk_PKE || ek || H(ek) || z (Algorithm 16 step 3), and ek = t || rho. + let t_bytes = ParamSet::::DK_PKE_BYTES; + let z_start = ParamSet::::DK_BYTES - SEED_BYTES; + + for round in 0..ROUNDS { + let (d, z, m) = ( + rng.bytes::(), + rng.bytes::(), + rng.bytes::(), + ); + + // KeyGen. + let seed = Array::try_from([&d[..], &z[..]].concat().as_slice()).unwrap(); + let (ref_dk, ref_ek) = ml_kem::$reference::from_seed(&seed); + let (ek, dk) = acvp::keygen_internal::(&d, &z); + assert_eq!( + ek.as_slice(), + ref_ek.to_bytes().as_slice(), + "round {round}: ek" + ); + #[allow(deprecated)] + let ref_expanded = ref_dk.to_expanded_bytes(); + assert_eq!(dk.as_slice(), ref_expanded.as_slice(), "round {round}: dk"); + + // Encaps. + let (k, c) = acvp::encaps_internal::(&ek, &m).unwrap(); + let (ref_c, ref_k) = ref_ek.encapsulate_deterministic(&Array::from(m)); + assert_eq!(c.as_slice(), ref_c.as_slice(), "round {round}: c"); + assert_eq!(k.as_slice(), ref_k.as_slice(), "round {round}: K"); + + // Decaps: valid, one flipped bit, random. + let mut flipped = c.clone(); + flipped[rng.below(c.len())] ^= 1 << rng.below(BYTE_BITS); + for (label, input) in [ + ("valid", c.clone()), + ("flipped", flipped), + ("random", rng.vec(c.len())), + ] { + let ours = acvp::decaps_internal::(&dk, &input).unwrap(); + let theirs = + ref_dk.decapsulate(&Ciphertext::try_from(input.as_slice()).unwrap()); + assert_eq!( + ours.as_slice(), + theirs.as_slice(), + "round {round}: decaps of {label}" + ); + assert_eq!( + ours.as_slice() == k.as_slice(), + label == "valid", + "round {round}: {label}" + ); + } + + // Decaps with a changed dk_PKE or z (both pass the section 7.3 checks). + let mut changed = dk.to_vec(); + let position = [rng.below(t_bytes), z_start + rng.below(SEED_BYTES)][rng.below(2)]; + changed[position] ^= 1 << rng.below(BYTE_BITS); + #[allow(deprecated)] + let ref_changed = DecapsulationKey::from_expanded_bytes( + &Array::try_from(changed.as_slice()).unwrap(), + ); + let ref_changed = + ref_changed.expect("hash check passes when only dk_PKE or z changes"); + check_dk::(&changed).unwrap(); + let ours = acvp::decaps_internal::(&changed, &c).unwrap(); + let theirs = ref_changed.decapsulate(&ref_c); + assert_eq!( + ours.as_slice(), + theirs.as_slice(), + "round {round}: decaps, changed dk byte {position}" + ); + + // Key checks: both must give the same verdict on a random byte change. + let mut bad_ek = ek.clone(); + bad_ek[rng.below(ek.len())] = rng.bytes::<1>()[0]; + let ours = check_ek::(&bad_ek).is_ok(); + let theirs = EncapsulationKey::new_from_slice(&bad_ek).is_ok(); + assert_eq!(ours, theirs, "round {round}: ek check after a random byte"); + + let mut overflow_ek = ek.clone(); + let value = KYBER_Q + rng.below((1 << COEFFICIENT_BITS) - KYBER_Q); + set_coefficient( + &mut overflow_ek[..t_bytes], + rng.below(K * KYBER_N), + value as u16, + ); + assert!( + check_ek::(&overflow_ek).is_err(), + "round {round}: coefficient {value}" + ); + assert!( + EncapsulationKey::new_from_slice(&overflow_ek).is_err(), + "round {round}: reference, {value}" + ); + + let mut bad_dk = dk.to_vec(); + let position = t_bytes + rng.below(z_start - t_bytes); + bad_dk[position] = + bad_dk[position].wrapping_add(1 + rng.below(u8::MAX as usize) as u8); + let ours = check_dk::(&bad_dk).is_ok(); + #[allow(deprecated)] + let theirs = DecapsulationKey::from_expanded_bytes( + &Array::try_from(bad_dk.as_slice()).unwrap(), + ) + .is_ok(); + assert_eq!( + ours, theirs, + "round {round}: dk check after a change at byte {position}" + ); + assert!( + !ours, + "round {round}: a change inside ek or H(ek) must fail the hash check" + ); + } + } + }; +} + +differential_test!( + ml_kem_512_matches_the_reference, + MlKem512, + ml_kem_512, + MlKem512 +); +differential_test!( + ml_kem_768_matches_the_reference, + MlKem768, + ml_kem_768, + MlKem768 +); +differential_test!( + ml_kem_1024_matches_the_reference, + MlKem1024, + ml_kem_1024, + MlKem1024 +); diff --git a/src/mlkem/mod.rs b/src/mlkem/mod.rs index 49a2086..ab92f1b 100644 --- a/src/mlkem/mod.rs +++ b/src/mlkem/mod.rs @@ -94,6 +94,8 @@ mod cctv_tests; pub mod check; pub mod compress; pub mod ct; +#[cfg(test)] +mod differential_tests; pub mod kem; pub mod kpke; pub mod params;