From 7a4a891755638e01f807811e44cb0cc818764bec Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 09:54:41 +1100 Subject: [PATCH 01/35] fix(bit2): refuse malformed lengths in the legacy raw comparator The legacy wire has no header, so compare_raw_slices infers the block count from the length. It subtracted the nonce size without checking for it (two empty slices panicked on underflow) and divided without checking the remainder (a partial block sliced past the end). Found by the new bit8_parse fuzz target on its first run. Lengths that do not fit n*(1 + 16 + 32) + 16 now return None, as the v2 comparators do. --- packages/ore-rs/src/scheme/bit2.rs | 36 +++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/ore-rs/src/scheme/bit2.rs b/packages/ore-rs/src/scheme/bit2.rs index a0419f3d..a1117b0d 100644 --- a/packages/ore-rs/src/scheme/bit2.rs +++ b/packages/ore-rs/src/scheme/bit2.rs @@ -234,9 +234,17 @@ impl OreCipher for OreAes128 { let left_size = Self::LeftBlockType::BLOCK_SIZE; let right_size = Self::RightBlockType::BLOCK_SIZE; - // TODO: This calculation slows things down a bit - maybe store the number of blocks in the - // first byte? - let num_blocks = (a.len() - NONCE_SIZE) / (left_size + right_size + 1); + // The legacy wire has no header: the block count is implied by the + // length, `n·(1 + left + right) + nonce`. Anything that does not fit + // that shape exactly (too short for a nonce, a partial block, or no + // blocks at all) is not a ciphertext of this scheme, and the slicing + // below would panic on it. + let body = a.len().checked_sub(NONCE_SIZE)?; + let per_block = left_size + right_size + 1; + if body == 0 || body % per_block != 0 { + return None; + } + let num_blocks = body / per_block; let mut is_equal = Choice::from(1); let mut l: u64 = 0; // Unequal block @@ -368,6 +376,28 @@ mod tests { OreCipher::init(&k1, &k2).unwrap() } + /// The legacy wire has no header, so the comparator infers the block + /// count from the length. Lengths that do not fit `n·(1 + 16 + 32) + 16` + /// must be refused, not indexed: two empty slices used to underflow the + /// nonce subtraction, and a partial block to slice past the end. + #[test] + fn compare_raw_slices_refuses_malformed_lengths() { + let ore = init_ore(); + let good = 42u64.encrypt(&ore).unwrap().to_bytes(); + assert!(Ore::compare_raw_slices(&good, &good).is_some()); + + assert_eq!(Ore::compare_raw_slices(&[], &[]), None); + let nonce_only = vec![0u8; NONCE_SIZE]; + assert_eq!(Ore::compare_raw_slices(&nonce_only, &nonce_only), None); + let short = vec![0u8; NONCE_SIZE - 1]; + assert_eq!(Ore::compare_raw_slices(&short, &short), None); + let partial = &good[..good.len() - 1]; + assert_eq!(Ore::compare_raw_slices(partial, partial), None); + let mut longer = good.clone(); + longer.push(0); + assert_eq!(Ore::compare_raw_slices(&longer, &longer), None); + } + quickcheck! { fn compare_u64(x: u64, y: u64) -> bool { let ore = init_ore(); From b0fe75ea0c0134dd0f208eac1b339f27be29dab8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 09:54:41 +1100 Subject: [PATCH 02/35] test(fuzz): libFuzzer targets for the ciphertext parsers and comparators A detached cargo-fuzz crate (packages/ore-rs/fuzz) with four targets: chained_parse, chained_compare, bit6_parse and bit8_parse. Each asserts that parsing or comparing arbitrary bytes never panics and that an accepted ciphertext re-encodes to the same bytes. Ciphertexts are read back from storage, so these are untrusted-input paths. scripts/fuzz.sh builds and runs them; the corpora found in the first runs are committed as seeds. --- .gitignore | 8 + packages/ore-rs/fuzz/Cargo.lock | 436 ++++++++++++++++++ packages/ore-rs/fuzz/Cargo.toml | 52 +++ .../000d7507a7b6bed879041f4ce25a3d529e28e623 | Bin 0 -> 5 bytes .../00ce8985418ed391c629aa567ef15cbb260732e7 | Bin 0 -> 171 bytes .../12aebf75e86d5d4f05aa3aad23349ec2aeef98fd | Bin 0 -> 107 bytes .../157f50e1321d659080887d9fd370c1dc589b6e72 | Bin 0 -> 9 bytes .../183665a90573ea56efa1cc2cb3ef78006a36b2b7 | Bin 0 -> 264 bytes .../23aa46d9452afbbd7e3ab119be67326c9a0fd4f6 | Bin 0 -> 11 bytes .../2e6844ff250c6e5a1c3913796828e70c8b5a5366 | Bin 0 -> 8 bytes .../3085b983de17cea555ce3567bcb6df1b1cf24fae | Bin 0 -> 11 bytes .../30b556270801fd397cd4a9592a6326488af77be4 | Bin 0 -> 9 bytes .../3562e03960b028eff5d572b72e290ec9d0a657fe | Bin 0 -> 192 bytes .../3659dc5c4e97fa96417cc67ad753ef2fce37a59b | Bin 0 -> 192 bytes .../3f7be39df0bb715a7b4745d57e4b79fc7c6aecd0 | Bin 0 -> 107 bytes .../49c99d11dada646352a48cd45da81108d28ccd0e | Bin 0 -> 15 bytes .../4fa94c6fd9ba98f8939114f8660ec114a5d8e556 | Bin 0 -> 107 bytes .../500083da360656944aa064d1b2c3892f5ce76927 | 1 + .../50414e57b481be32d7a5a35052d511a1c468fa42 | Bin 0 -> 257 bytes .../505b7ce5f3630a15f521fc2a6e3419f52e9101f0 | Bin 0 -> 171 bytes .../54b20345484cd24ae7186b035119e50496531cb4 | Bin 0 -> 5 bytes .../5e6c1c9650f82fb531e908b5c5fddf2d68719c3a | Bin 0 -> 107 bytes .../5f16e95069a76408a8fe334e71e2178bd8c20875 | Bin 0 -> 13 bytes .../66a0ba18cdc3f9e4134daa8651a55e6da00e5492 | Bin 0 -> 192 bytes .../6fdcd6e786ddeaa27a88dd9bd01eea8899efc2a9 | Bin 0 -> 107 bytes .../6fee54294ecbd170c1d99d69cbe0748dd3e689ec | Bin 0 -> 312 bytes .../773ba1a9c4879736ff180d3eb548f429842cc5c9 | Bin 0 -> 5 bytes .../786a871ce842feefd10f6b4fc075b4a8d8fad8e7 | Bin 0 -> 192 bytes .../7d75cc8bc1fd82131e2230305a01f8436f68f5b9 | Bin 0 -> 107 bytes .../84ef5b36e3cdd63709b71371113fa57ab95d9e01 | Bin 0 -> 171 bytes .../855155dccb9ab04e6954ed4e2711e244baa6ba14 | Bin 0 -> 258 bytes .../874cada632e8aa3982ff17f30dbdbe6a8d74eed4 | Bin 0 -> 192 bytes .../89096f0b81069aba25c5349783bb4861c03c20c2 | 2 + .../90899bbb8bc55398246823622d09833d1d9d9499 | Bin 0 -> 8 bytes .../97aa93acfca61e454d4cf767d59ee8891b6264aa | Bin 0 -> 192 bytes .../99f7fab666c9333055210ee362273969764f029f | Bin 0 -> 192 bytes .../9cc6b74a646822c92ea1fd2e5443a489704fbb21 | Bin 0 -> 5 bytes .../a0b29d245b7b93f3ba2a10cf9786969a880f749a | Bin 0 -> 192 bytes .../a23de43548e425170ab14d347c9abe7db9d572eb | Bin 0 -> 192 bytes .../a3cad4149521dd2f7ef2b1e98df8f15a214f8c6a | Bin 0 -> 107 bytes .../a820b93c65d2350da0135e0006c226e58c33399b | Bin 0 -> 192 bytes .../ac99fd37c875754cdece6c8b0f88ba3bc83f8e74 | Bin 0 -> 9 bytes .../b634594820aa893076dc03c130b7d019576d1243 | Bin 0 -> 257 bytes .../c5b6ccdb4df2eba10378960b334b0406d63f63ce | Bin 0 -> 13 bytes .../c967cd940ba7cd1d6901e03f848e643a7d601c87 | Bin 0 -> 9 bytes .../ca5b02785cca7a3641a216d93ddaf6b96804b2a4 | Bin 0 -> 5 bytes .../cea59c2bd0bb96386146a3776b40f9b8cd7b497d | Bin 0 -> 256 bytes .../d913fde08ecaff77eb3349757c5eff50d0a590ae | Bin 0 -> 192 bytes .../dac13f6b4c0db911dc5ba50a183408924bfe8519 | Bin 0 -> 9 bytes .../e0ed746a4207aadce54ee1ee5716e9299839f263 | Bin 0 -> 192 bytes .../eb53cb42f3a3bbf0fd55906fd4eaa73dbf6c8491 | Bin 0 -> 312 bytes .../f92c5d64634b29aa544bef2f7c2ba26abe059d09 | Bin 0 -> 107 bytes .../1a1169b85b4da2d2e7d6a40d0a92ea304871c898 | 2 + .../1bc7c470785e9b20b5e6b476a2cab9876a16117b | 2 + .../1d2945904afe31ca4e11beace7fa80b26ea7d76d | Bin 0 -> 229 bytes .../4ec9aa556c92fb471e8a47817b673a9a3d1f278f | 3 + .../57b635ecfa2d621d1059ddf560e330fcef2e1ab4 | 2 + .../5b55ec37559bb110228a6591713076b7fa2ce5e8 | 1 + .../5d3671d6fd79c556777376b9db9eeb1238286cbe | 3 + .../79a4d9f793276a51378df429e0ebf837c8aadc07 | 3 + .../867f9d66dcbce6ae39342dd2fc04ac219dbb2a16 | 3 + .../bfcf553891e3ba2c34e5e63225b51bc0a0332eb2 | Bin 0 -> 229 bytes .../c6b504a181054b9c9bff07e9461d1a6ea2dafc1b | 2 + .../c77f05e9e9648c99adb66235e1b87715da99309f | 2 + .../ccaa9768cc85a13e46df3dea0e02952e010b3852 | Bin 0 -> 258 bytes .../e7dd041512c660ccd0f4f63daf0414497b8b7be2 | 3 + .../eaf3021dbdc5feeaba63175d7006d9523d8fc465 | 4 + .../f11684a34515cd7d5c708ae44def7a4163aa1074 | 2 + .../f683fb5ae87e6044c802b30a3fb58c0bdcb350be | 3 + .../1498b91d9601baa5b4a8506dcf35f35084aefeb8 | Bin 0 -> 100 bytes .../1a162e129193f8179d677a82bd7f25c830e292b5 | Bin 0 -> 50 bytes .../1b77c4fe26629c6716dbd50cc7a3a549e68fd9e1 | Bin 0 -> 257 bytes .../1fd2e9fcf01c1736b15e7226e7676b19c04616c1 | 3 + .../245f6e3cdedbf18b1a839a9c43c109b1711c9d50 | 4 + .../2886832a394afa27969905c40503dbd60834e1f6 | Bin 0 -> 75 bytes .../4766af07390d4fb62682fd6658fdf3caaeab97f2 | 1 + .../50c99221f6bbd7f615f47fde80c726514578aa06 | Bin 0 -> 9 bytes .../66a01f825f6546b851c597318bd307a73a089fe5 | Bin 0 -> 125 bytes .../69db7413a72a01817a55236ccc943dae5b75a382 | Bin 0 -> 11 bytes .../6f1e8d7fa8c2dc39d5df3248c0fce695b170fb5e | 1 + .../712e27f64b93cf7fd2b7c09e7354a3fcf22d4158 | 2 + .../749e715bf58ab2b3a134353ef1f123611c5ec337 | 1 + .../8643cda4e2e84d8255f9decd445a05f22a821793 | Bin 0 -> 50 bytes .../ae0b7e44403af4ef918f37a4a254c47bc69195b1 | 2 + .../b9d1f333f27c1376371782661a6ff0301522fed7 | Bin 0 -> 100 bytes .../ba921fc92e3bdb9757825fc618ba41f3741fefa8 | Bin 0 -> 25 bytes .../bb3953c2f738264b2d17d9d8f1301c1a3567443a | Bin 0 -> 9 bytes .../c190bddafec30d2016a482c9ae390ac59e1ab36b | 1 + .../c7c8ad4d6eb862e28ce0b192f58d80bfc1dda323 | Bin 0 -> 9 bytes .../d304cab0bc67acafc9ff86ef1000551d731e8521 | Bin 0 -> 256 bytes .../d70a31566afd8362d9fc4d9f221dced7c77277aa | Bin 0 -> 258 bytes .../d870ec0cc1724a182044c97ba3c7870a2f28da8e | 2 + .../de6b6d078e2249a744cb5e103557d22cd9c75dd9 | 2 + .../f4855d9e4dd28214677e20d52b5969d88be8c692 | 1 + .../11efd772c1b4e6f667ecbfeb4873ff777f3c2a09 | Bin 0 -> 4 bytes .../2ed8c13d341e8e12aa6f6320c11259b87fcffdae | Bin 0 -> 45 bytes .../4450d4ef86aa3af4aa06080ba8b724ee1617de07 | Bin 0 -> 70 bytes .../4a7ac40e72557bbb47625347bc253bf59592ebcc | Bin 0 -> 8 bytes .../5a0d51d6406dff707363da7baed6cbf85c862965 | Bin 0 -> 45 bytes .../5d18fd9726255aef7acb14cacb6c1f74ab748661 | Bin 0 -> 120 bytes .../6dc20172dc2ab8b09c45e12ab5d5836223566e5e | Bin 0 -> 20 bytes .../a4fe776497787732b7e01a7e5c051ed91f6bb8b7 | Bin 0 -> 8 bytes .../ae3597212aeaf69e8c6932e9a26d9aeeea3c80a6 | Bin 0 -> 95 bytes .../b72e19e1a60e66a97d4f55e4ef410a84c1d06c26 | Bin 0 -> 4 bytes .../d623c63a4d62802dd790ca8c575034d350d2e4d4 | Bin 0 -> 270 bytes .../d663b1e3643fe6e5e851067d5eabc6aee524e64f | Bin 0 -> 70 bytes .../d9e816fb2b510edc9434e954a29a1f7a9bfab1b7 | 2 + .../ea6969cd6fdea04b397aec0b3be7dfc7ef7c14cc | Bin 0 -> 195 bytes .../ore-rs/fuzz/fuzz_targets/bit6_parse.rs | 34 ++ .../ore-rs/fuzz/fuzz_targets/bit8_parse.rs | 34 ++ .../fuzz/fuzz_targets/chained_compare.rs | 17 + .../ore-rs/fuzz/fuzz_targets/chained_parse.rs | 18 + scripts/fuzz.sh | 27 ++ 113 files changed, 686 insertions(+) create mode 100644 packages/ore-rs/fuzz/Cargo.lock create mode 100644 packages/ore-rs/fuzz/Cargo.toml create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/000d7507a7b6bed879041f4ce25a3d529e28e623 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/00ce8985418ed391c629aa567ef15cbb260732e7 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/12aebf75e86d5d4f05aa3aad23349ec2aeef98fd create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/157f50e1321d659080887d9fd370c1dc589b6e72 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/183665a90573ea56efa1cc2cb3ef78006a36b2b7 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/23aa46d9452afbbd7e3ab119be67326c9a0fd4f6 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/2e6844ff250c6e5a1c3913796828e70c8b5a5366 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/3085b983de17cea555ce3567bcb6df1b1cf24fae create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/30b556270801fd397cd4a9592a6326488af77be4 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/3562e03960b028eff5d572b72e290ec9d0a657fe create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/3659dc5c4e97fa96417cc67ad753ef2fce37a59b create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/3f7be39df0bb715a7b4745d57e4b79fc7c6aecd0 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/49c99d11dada646352a48cd45da81108d28ccd0e create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/4fa94c6fd9ba98f8939114f8660ec114a5d8e556 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/500083da360656944aa064d1b2c3892f5ce76927 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/50414e57b481be32d7a5a35052d511a1c468fa42 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/505b7ce5f3630a15f521fc2a6e3419f52e9101f0 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/54b20345484cd24ae7186b035119e50496531cb4 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/5e6c1c9650f82fb531e908b5c5fddf2d68719c3a create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/5f16e95069a76408a8fe334e71e2178bd8c20875 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/66a0ba18cdc3f9e4134daa8651a55e6da00e5492 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/6fdcd6e786ddeaa27a88dd9bd01eea8899efc2a9 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/6fee54294ecbd170c1d99d69cbe0748dd3e689ec create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/773ba1a9c4879736ff180d3eb548f429842cc5c9 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/786a871ce842feefd10f6b4fc075b4a8d8fad8e7 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/7d75cc8bc1fd82131e2230305a01f8436f68f5b9 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/84ef5b36e3cdd63709b71371113fa57ab95d9e01 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/855155dccb9ab04e6954ed4e2711e244baa6ba14 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/874cada632e8aa3982ff17f30dbdbe6a8d74eed4 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/89096f0b81069aba25c5349783bb4861c03c20c2 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/90899bbb8bc55398246823622d09833d1d9d9499 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/97aa93acfca61e454d4cf767d59ee8891b6264aa create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/99f7fab666c9333055210ee362273969764f029f create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/9cc6b74a646822c92ea1fd2e5443a489704fbb21 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/a0b29d245b7b93f3ba2a10cf9786969a880f749a create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/a23de43548e425170ab14d347c9abe7db9d572eb create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/a3cad4149521dd2f7ef2b1e98df8f15a214f8c6a create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/a820b93c65d2350da0135e0006c226e58c33399b create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/ac99fd37c875754cdece6c8b0f88ba3bc83f8e74 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/b634594820aa893076dc03c130b7d019576d1243 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/c5b6ccdb4df2eba10378960b334b0406d63f63ce create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/c967cd940ba7cd1d6901e03f848e643a7d601c87 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/ca5b02785cca7a3641a216d93ddaf6b96804b2a4 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/cea59c2bd0bb96386146a3776b40f9b8cd7b497d create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/d913fde08ecaff77eb3349757c5eff50d0a590ae create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/dac13f6b4c0db911dc5ba50a183408924bfe8519 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/e0ed746a4207aadce54ee1ee5716e9299839f263 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/eb53cb42f3a3bbf0fd55906fd4eaa73dbf6c8491 create mode 100644 packages/ore-rs/fuzz/corpus/bit6_parse/f92c5d64634b29aa544bef2f7c2ba26abe059d09 create mode 100644 packages/ore-rs/fuzz/corpus/bit8_parse/1a1169b85b4da2d2e7d6a40d0a92ea304871c898 create mode 100644 packages/ore-rs/fuzz/corpus/bit8_parse/1bc7c470785e9b20b5e6b476a2cab9876a16117b create mode 100644 packages/ore-rs/fuzz/corpus/bit8_parse/1d2945904afe31ca4e11beace7fa80b26ea7d76d create mode 100644 packages/ore-rs/fuzz/corpus/bit8_parse/4ec9aa556c92fb471e8a47817b673a9a3d1f278f create mode 100644 packages/ore-rs/fuzz/corpus/bit8_parse/57b635ecfa2d621d1059ddf560e330fcef2e1ab4 create mode 100644 packages/ore-rs/fuzz/corpus/bit8_parse/5b55ec37559bb110228a6591713076b7fa2ce5e8 create mode 100644 packages/ore-rs/fuzz/corpus/bit8_parse/5d3671d6fd79c556777376b9db9eeb1238286cbe create mode 100644 packages/ore-rs/fuzz/corpus/bit8_parse/79a4d9f793276a51378df429e0ebf837c8aadc07 create mode 100644 packages/ore-rs/fuzz/corpus/bit8_parse/867f9d66dcbce6ae39342dd2fc04ac219dbb2a16 create mode 100644 packages/ore-rs/fuzz/corpus/bit8_parse/bfcf553891e3ba2c34e5e63225b51bc0a0332eb2 create mode 100644 packages/ore-rs/fuzz/corpus/bit8_parse/c6b504a181054b9c9bff07e9461d1a6ea2dafc1b create mode 100644 packages/ore-rs/fuzz/corpus/bit8_parse/c77f05e9e9648c99adb66235e1b87715da99309f create mode 100644 packages/ore-rs/fuzz/corpus/bit8_parse/ccaa9768cc85a13e46df3dea0e02952e010b3852 create mode 100644 packages/ore-rs/fuzz/corpus/bit8_parse/e7dd041512c660ccd0f4f63daf0414497b8b7be2 create mode 100644 packages/ore-rs/fuzz/corpus/bit8_parse/eaf3021dbdc5feeaba63175d7006d9523d8fc465 create mode 100644 packages/ore-rs/fuzz/corpus/bit8_parse/f11684a34515cd7d5c708ae44def7a4163aa1074 create mode 100644 packages/ore-rs/fuzz/corpus/bit8_parse/f683fb5ae87e6044c802b30a3fb58c0bdcb350be create mode 100644 packages/ore-rs/fuzz/corpus/chained_compare/1498b91d9601baa5b4a8506dcf35f35084aefeb8 create mode 100644 packages/ore-rs/fuzz/corpus/chained_compare/1a162e129193f8179d677a82bd7f25c830e292b5 create mode 100644 packages/ore-rs/fuzz/corpus/chained_compare/1b77c4fe26629c6716dbd50cc7a3a549e68fd9e1 create mode 100644 packages/ore-rs/fuzz/corpus/chained_compare/1fd2e9fcf01c1736b15e7226e7676b19c04616c1 create mode 100644 packages/ore-rs/fuzz/corpus/chained_compare/245f6e3cdedbf18b1a839a9c43c109b1711c9d50 create mode 100644 packages/ore-rs/fuzz/corpus/chained_compare/2886832a394afa27969905c40503dbd60834e1f6 create mode 100644 packages/ore-rs/fuzz/corpus/chained_compare/4766af07390d4fb62682fd6658fdf3caaeab97f2 create mode 100644 packages/ore-rs/fuzz/corpus/chained_compare/50c99221f6bbd7f615f47fde80c726514578aa06 create mode 100644 packages/ore-rs/fuzz/corpus/chained_compare/66a01f825f6546b851c597318bd307a73a089fe5 create mode 100644 packages/ore-rs/fuzz/corpus/chained_compare/69db7413a72a01817a55236ccc943dae5b75a382 create mode 100644 packages/ore-rs/fuzz/corpus/chained_compare/6f1e8d7fa8c2dc39d5df3248c0fce695b170fb5e create mode 100644 packages/ore-rs/fuzz/corpus/chained_compare/712e27f64b93cf7fd2b7c09e7354a3fcf22d4158 create mode 100644 packages/ore-rs/fuzz/corpus/chained_compare/749e715bf58ab2b3a134353ef1f123611c5ec337 create mode 100644 packages/ore-rs/fuzz/corpus/chained_compare/8643cda4e2e84d8255f9decd445a05f22a821793 create mode 100644 packages/ore-rs/fuzz/corpus/chained_compare/ae0b7e44403af4ef918f37a4a254c47bc69195b1 create mode 100644 packages/ore-rs/fuzz/corpus/chained_compare/b9d1f333f27c1376371782661a6ff0301522fed7 create mode 100644 packages/ore-rs/fuzz/corpus/chained_compare/ba921fc92e3bdb9757825fc618ba41f3741fefa8 create mode 100644 packages/ore-rs/fuzz/corpus/chained_compare/bb3953c2f738264b2d17d9d8f1301c1a3567443a create mode 100644 packages/ore-rs/fuzz/corpus/chained_compare/c190bddafec30d2016a482c9ae390ac59e1ab36b create mode 100644 packages/ore-rs/fuzz/corpus/chained_compare/c7c8ad4d6eb862e28ce0b192f58d80bfc1dda323 create mode 100644 packages/ore-rs/fuzz/corpus/chained_compare/d304cab0bc67acafc9ff86ef1000551d731e8521 create mode 100644 packages/ore-rs/fuzz/corpus/chained_compare/d70a31566afd8362d9fc4d9f221dced7c77277aa create mode 100644 packages/ore-rs/fuzz/corpus/chained_compare/d870ec0cc1724a182044c97ba3c7870a2f28da8e create mode 100644 packages/ore-rs/fuzz/corpus/chained_compare/de6b6d078e2249a744cb5e103557d22cd9c75dd9 create mode 100644 packages/ore-rs/fuzz/corpus/chained_compare/f4855d9e4dd28214677e20d52b5969d88be8c692 create mode 100644 packages/ore-rs/fuzz/corpus/chained_parse/11efd772c1b4e6f667ecbfeb4873ff777f3c2a09 create mode 100644 packages/ore-rs/fuzz/corpus/chained_parse/2ed8c13d341e8e12aa6f6320c11259b87fcffdae create mode 100644 packages/ore-rs/fuzz/corpus/chained_parse/4450d4ef86aa3af4aa06080ba8b724ee1617de07 create mode 100644 packages/ore-rs/fuzz/corpus/chained_parse/4a7ac40e72557bbb47625347bc253bf59592ebcc create mode 100644 packages/ore-rs/fuzz/corpus/chained_parse/5a0d51d6406dff707363da7baed6cbf85c862965 create mode 100644 packages/ore-rs/fuzz/corpus/chained_parse/5d18fd9726255aef7acb14cacb6c1f74ab748661 create mode 100644 packages/ore-rs/fuzz/corpus/chained_parse/6dc20172dc2ab8b09c45e12ab5d5836223566e5e create mode 100644 packages/ore-rs/fuzz/corpus/chained_parse/a4fe776497787732b7e01a7e5c051ed91f6bb8b7 create mode 100644 packages/ore-rs/fuzz/corpus/chained_parse/ae3597212aeaf69e8c6932e9a26d9aeeea3c80a6 create mode 100644 packages/ore-rs/fuzz/corpus/chained_parse/b72e19e1a60e66a97d4f55e4ef410a84c1d06c26 create mode 100644 packages/ore-rs/fuzz/corpus/chained_parse/d623c63a4d62802dd790ca8c575034d350d2e4d4 create mode 100644 packages/ore-rs/fuzz/corpus/chained_parse/d663b1e3643fe6e5e851067d5eabc6aee524e64f create mode 100644 packages/ore-rs/fuzz/corpus/chained_parse/d9e816fb2b510edc9434e954a29a1f7a9bfab1b7 create mode 100644 packages/ore-rs/fuzz/corpus/chained_parse/ea6969cd6fdea04b397aec0b3be7dfc7ef7c14cc create mode 100644 packages/ore-rs/fuzz/fuzz_targets/bit6_parse.rs create mode 100644 packages/ore-rs/fuzz/fuzz_targets/bit8_parse.rs create mode 100644 packages/ore-rs/fuzz/fuzz_targets/chained_compare.rs create mode 100644 packages/ore-rs/fuzz/fuzz_targets/chained_parse.rs create mode 100755 scripts/fuzz.sh diff --git a/.gitignore b/.gitignore index b1b15cef..660d1999 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,11 @@ .DS_Store .env *.pem + +# Detached verification crates (packages/ct-taint, packages/ct-dudect, +# packages/ore-rs/fuzz) build into their own target dirs; the taint harness +# also builds into target-linux from a container on a macOS host. libFuzzer +# crash artefacts are per-run output, not seeds (the seeds are in corpus/). +target/ +packages/ct-taint/target-linux/ +packages/ore-rs/fuzz/artifacts/ diff --git a/packages/ore-rs/fuzz/Cargo.lock b/packages/ore-rs/fuzz/Cargo.lock new file mode 100644 index 00000000..71157491 --- /dev/null +++ b/packages/ore-rs/fuzz/Cargo.lock @@ -0,0 +1,436 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", + "zeroize", +] + +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "block-modes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2211b0817f061502a8dd9f11a37e879e79763e3c698d2418cf824d8cb2f21e" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "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 = "orderable-bytes" +version = "0.1.1" + +[[package]] +name = "ore-rs" +version = "0.8.3" +dependencies = [ + "aes", + "block-modes", + "byteorder", + "hex", + "num", + "orderable-bytes", + "rand", + "rand_chacha", + "subtle-ng", + "thiserror", + "zeroize", +] + +[[package]] +name = "ore-rs-fuzz" +version = "0.0.0" +dependencies = [ + "libfuzzer-sys", + "ore-rs", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "subtle-ng" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "734676eb262c623cec13c3155096e08d1f8f29adce39ba17948b18dad1e54142" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/packages/ore-rs/fuzz/Cargo.toml b/packages/ore-rs/fuzz/Cargo.toml new file mode 100644 index 00000000..f48077d0 --- /dev/null +++ b/packages/ore-rs/fuzz/Cargo.toml @@ -0,0 +1,52 @@ +# Fuzz crate for ore-rs's ciphertext parsers and raw-byte comparators. +# +# This is a DETACHED crate: the `[workspace]` table at the bottom makes it its +# own workspace root, so the libfuzzer-sys dependency and the nightly-only +# build never touch the main workspace. It is not a member of the root +# `Cargo.toml`. Run with `cargo +nightly fuzz run ` from this +# directory, or via `scripts/fuzz.sh`. +[package] +name = "ore-rs-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" + +[dependencies.ore-rs] +path = ".." + +[[bin]] +name = "chained_parse" +path = "fuzz_targets/chained_parse.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "chained_compare" +path = "fuzz_targets/chained_compare.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "bit6_parse" +path = "fuzz_targets/bit6_parse.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "bit8_parse" +path = "fuzz_targets/bit8_parse.rs" +test = false +doc = false +bench = false + +[workspace] +resolver = "2" diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/000d7507a7b6bed879041f4ce25a3d529e28e623 b/packages/ore-rs/fuzz/corpus/bit6_parse/000d7507a7b6bed879041f4ce25a3d529e28e623 new file mode 100644 index 0000000000000000000000000000000000000000..5ea0e75f8e18fbcae2933cbe3a2954fc555220b6 GIT binary patch literal 5 McmZQ#V&L`y004IYS^xk5 literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/00ce8985418ed391c629aa567ef15cbb260732e7 b/packages/ore-rs/fuzz/corpus/bit6_parse/00ce8985418ed391c629aa567ef15cbb260732e7 new file mode 100644 index 0000000000000000000000000000000000000000..42ab5f5dd2b6b778db1e8472fba6532dc0441bd1 GIT binary patch literal 171 zcmZQ#Vqjz7vX*6FU{YXUV2FwW5(z*I0uLD&45PqcT2vGxP=E=j@;?(30|!Xf3Pb<_ h2gBk2xBxpu2?Me`149M~pok&MBiK;QVC5NEs{r&rRKEZK literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/12aebf75e86d5d4f05aa3aad23349ec2aeef98fd b/packages/ore-rs/fuzz/corpus/bit6_parse/12aebf75e86d5d4f05aa3aad23349ec2aeef98fd new file mode 100644 index 0000000000000000000000000000000000000000..1dbd9403771a3247c1d3bcee014e4fb85eef8d22 GIT binary patch literal 107 zcmZQ#Vqjwcf`+Il5CsMgL1YvHFmW<5F>qP`2Lc9Jh>`{)nEEINMk$0=1_rQFU5F|M N4hWr*k-?CW1pt2{4=4Zt literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/157f50e1321d659080887d9fd370c1dc589b6e72 b/packages/ore-rs/fuzz/corpus/bit6_parse/157f50e1321d659080887d9fd370c1dc589b6e72 new file mode 100644 index 0000000000000000000000000000000000000000..bc51a3af72bd3efd3b1c90824bd6bad1baa87de4 GIT binary patch literal 9 NcmZQzF#Zn$VgMCz1w{Y= literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/183665a90573ea56efa1cc2cb3ef78006a36b2b7 b/packages/ore-rs/fuzz/corpus/bit6_parse/183665a90573ea56efa1cc2cb3ef78006a36b2b7 new file mode 100644 index 0000000000000000000000000000000000000000..b2df4ff9e3b70faf3b7ff4480787520c5e909bed GIT binary patch literal 264 zcmZQz_y++DC;%#mN-;3}b3_%PnD-wU1pZ`ZP)J~4Ko%p3O}cYHp8o%z`@IbS*fZzB literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/23aa46d9452afbbd7e3ab119be67326c9a0fd4f6 b/packages/ore-rs/fuzz/corpus/bit6_parse/23aa46d9452afbbd7e3ab119be67326c9a0fd4f6 new file mode 100644 index 0000000000000000000000000000000000000000..1008a1f6d298319984dd4da6d96a36e0a7d4762d GIT binary patch literal 11 QcmZQz0D=Gi8Q$9f01F%gDgXcg literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/2e6844ff250c6e5a1c3913796828e70c8b5a5366 b/packages/ore-rs/fuzz/corpus/bit6_parse/2e6844ff250c6e5a1c3913796828e70c8b5a5366 new file mode 100644 index 0000000000000000000000000000000000000000..e20621b460ffa9cd98afd35e81b61dc4857dff54 GIT binary patch literal 8 PcmZSLWMByV$*c|l1hWBv literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/3085b983de17cea555ce3567bcb6df1b1cf24fae b/packages/ore-rs/fuzz/corpus/bit6_parse/3085b983de17cea555ce3567bcb6df1b1cf24fae new file mode 100644 index 0000000000000000000000000000000000000000..592f06ce8e8c81336967a86698d2b4652dbe110a GIT binary patch literal 11 PcmZQz_|MS503vJv5xN6A literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/30b556270801fd397cd4a9592a6326488af77be4 b/packages/ore-rs/fuzz/corpus/bit6_parse/30b556270801fd397cd4a9592a6326488af77be4 new file mode 100644 index 0000000000000000000000000000000000000000..7c75e7bb4ab36a0231f6a27c5eee1a26513e4afa GIT binary patch literal 9 OcmZQ#U|?VZVle;!I{+I1 literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/3562e03960b028eff5d572b72e290ec9d0a657fe b/packages/ore-rs/fuzz/corpus/bit6_parse/3562e03960b028eff5d572b72e290ec9d0a657fe new file mode 100644 index 0000000000000000000000000000000000000000..e9b5fdd081bc2835decbcddcf7f255adc324d16d GIT binary patch literal 192 lcmZQ#V&KjI0|ugi15v8b6g^~Mh$0NoG{LwK*Wn=d0|09~9CiQz literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/3659dc5c4e97fa96417cc67ad753ef2fce37a59b b/packages/ore-rs/fuzz/corpus/bit6_parse/3659dc5c4e97fa96417cc67ad753ef2fce37a59b new file mode 100644 index 0000000000000000000000000000000000000000..d36e17b41c26ed7bfc24a8bb6399a7cad872a03f GIT binary patch literal 192 zcmZQ#V&K-$&Hw@i1_l-&hJ%b#AkkgOKv^HgyG;y$DMqE$LEQfkuxrR literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/3f7be39df0bb715a7b4745d57e4b79fc7c6aecd0 b/packages/ore-rs/fuzz/corpus/bit6_parse/3f7be39df0bb715a7b4745d57e4b79fc7c6aecd0 new file mode 100644 index 0000000000000000000000000000000000000000..7899b4f91b9be312d7e2ca0b7691910bf672eb81 GIT binary patch literal 107 gcmZQ#VqjzV|9>3=qf}HB0|anD=?oA!KrN6303ftHzyJUM literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/49c99d11dada646352a48cd45da81108d28ccd0e b/packages/ore-rs/fuzz/corpus/bit6_parse/49c99d11dada646352a48cd45da81108d28ccd0e new file mode 100644 index 0000000000000000000000000000000000000000..ea69dc3855f460e4279996272f9fcc4dbb335bb1 GIT binary patch literal 15 RcmYdH1OWyHCRYyeyMKLe})iN+jMX?}B0hwSu RU?B&FV2D&kMg~Jh7669)4>$k- literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/500083da360656944aa064d1b2c3892f5ce76927 b/packages/ore-rs/fuzz/corpus/bit6_parse/500083da360656944aa064d1b2c3892f5ce76927 new file mode 100644 index 00000000..0a88a742 --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit6_parse/500083da360656944aa064d1b2c3892f5ce76927 @@ -0,0 +1 @@ +ÿÿ;;ÿÿÿÿÉ \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/50414e57b481be32d7a5a35052d511a1c468fa42 b/packages/ore-rs/fuzz/corpus/bit6_parse/50414e57b481be32d7a5a35052d511a1c468fa42 new file mode 100644 index 0000000000000000000000000000000000000000..0ecbc2e35281d730f0c497a36c2caa93a8251c50 GIT binary patch literal 257 vcmZQz_zwgO?->{v{xdNABLqN-DFnZ<=x1Os#v+O-BnB1o#FQdK1ngM=J;d{6 literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/505b7ce5f3630a15f521fc2a6e3419f52e9101f0 b/packages/ore-rs/fuzz/corpus/bit6_parse/505b7ce5f3630a15f521fc2a6e3419f52e9101f0 new file mode 100644 index 0000000000000000000000000000000000000000..19055f9894e7157b0906d10900cfeb171de299b6 GIT binary patch literal 171 zcmZQ#Vqjz7vSxq)ZiechD2Avg2FCv=03_>>k&(dw5rwJ-Qd}_ge^J$QLAZ=ym5j|Bn9*ObkE}6$K)p;2{IU)+jg-jAG*a4+LOIu4<4t10w^YGLjCE J7*=H&Spa=R8cF~F literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/6fee54294ecbd170c1d99d69cbe0748dd3e689ec b/packages/ore-rs/fuzz/corpus/bit6_parse/6fee54294ecbd170c1d99d69cbe0748dd3e689ec new file mode 100644 index 0000000000000000000000000000000000000000..d853e81e76c8671b48abb9b5a164df310d82fcea GIT binary patch literal 312 zcmZQ#V&KjI0VXB}HU=QbU|?Vb(F}~rU;FK&AS;Wcf~z_zDkzG9kwFTqj|rw5tQ@M2 N143tHWH4l80RRz$4gdfE literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/84ef5b36e3cdd63709b71371113fa57ab95d9e01 b/packages/ore-rs/fuzz/corpus/bit6_parse/84ef5b36e3cdd63709b71371113fa57ab95d9e01 new file mode 100644 index 0000000000000000000000000000000000000000..bc4ef80343adc6c6580737ddc00ddeda62458ffa GIT binary patch literal 171 zcmZQ#Vqjz7vR)?yp@X6rqM{i7|7QRLMuY!203%QyhZGj!gIL6%c0@%*F>x|6WdPj( cQp6w&vN$6nI0Nhe1_rKbpo%C4Mux0a0OzD;#{d8T literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/855155dccb9ab04e6954ed4e2711e244baa6ba14 b/packages/ore-rs/fuzz/corpus/bit6_parse/855155dccb9ab04e6954ed4e2711e244baa6ba14 new file mode 100644 index 0000000000000000000000000000000000000000..9942c82aec7fcefc3124025e1a8d661892d41e27 GIT binary patch literal 258 zcmZQzVE6|COq~CLfB^)!s-a>qIVcyE!mATiDNbJCPi6*%1h`3<>OgLS0=Ohh`NeLBZ1p}A{fJGi4i9>WVfOUhM1JMfR0-0H0?tTFGWHVg= literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/89096f0b81069aba25c5349783bb4861c03c20c2 b/packages/ore-rs/fuzz/corpus/bit6_parse/89096f0b81069aba25c5349783bb4861c03c20c2 new file mode 100644 index 00000000..2aa437d0 --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit6_parse/89096f0b81069aba25c5349783bb4861c03c20c2 @@ -0,0 +1,2 @@ + +ÿ \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/90899bbb8bc55398246823622d09833d1d9d9499 b/packages/ore-rs/fuzz/corpus/bit6_parse/90899bbb8bc55398246823622d09833d1d9d9499 new file mode 100644 index 0000000000000000000000000000000000000000..f030786842e9d8f51d638ffcfdde41563902e68d GIT binary patch literal 8 LcmZQz0E71c0QUg! literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/97aa93acfca61e454d4cf767d59ee8891b6264aa b/packages/ore-rs/fuzz/corpus/bit6_parse/97aa93acfca61e454d4cf767d59ee8891b6264aa new file mode 100644 index 0000000000000000000000000000000000000000..d6025142a0402128a521075f7289ba47923b82a6 GIT binary patch literal 192 zcmZQ#V&LXvU|{(FpMhZm!$*chFkoOX{tpBUOh6fQ@Q@)2C^RDqD*zdV3?4Etz|}Ev bg3Sa;b5(;x;R+EfMv$%eK?Ve5fvNofx@adS literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/99f7fab666c9333055210ee362273969764f029f b/packages/ore-rs/fuzz/corpus/bit6_parse/99f7fab666c9333055210ee362273969764f029f new file mode 100644 index 0000000000000000000000000000000000000000..23b1f01bf5d23b341c34c6538794d26015f6c1d8 GIT binary patch literal 192 scmZQ#V&LXvfP!x@25#B`w-iK%35$Y<3=C0(0TzuQp$zOG3oN`J09uY42><{9 literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/9cc6b74a646822c92ea1fd2e5443a489704fbb21 b/packages/ore-rs/fuzz/corpus/bit6_parse/9cc6b74a646822c92ea1fd2e5443a489704fbb21 new file mode 100644 index 0000000000000000000000000000000000000000..5d1a5f238a7de67a6083c0e72ec5df7257b1d6e8 GIT binary patch literal 5 McmdO7U|@I;00F@P82|tP literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/a0b29d245b7b93f3ba2a10cf9786969a880f749a b/packages/ore-rs/fuzz/corpus/bit6_parse/a0b29d245b7b93f3ba2a10cf9786969a880f749a new file mode 100644 index 0000000000000000000000000000000000000000..8e7c1cf82c69dbf6b439a7c15b3afedac547ba7c GIT binary patch literal 192 zcmZQ#V&K-u009OxVEq68|ET}}#Ta6t5`{v{xdNABLqN-DFnZ<=x1Os#v+O-B!($Oz6jW_0DkB58~^|S literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/d913fde08ecaff77eb3349757c5eff50d0a590ae b/packages/ore-rs/fuzz/corpus/bit6_parse/d913fde08ecaff77eb3349757c5eff50d0a590ae new file mode 100644 index 0000000000000000000000000000000000000000..c470a2043d43f0dd762f8c8630829a83af2de811 GIT binary patch literal 192 zcmZQ#V&LXv00ScihK~#kiD1CMVEi8l7?{8^RUq;q14C5Qj3^Yq3F3j3pb}uU2$e=4 f<|PO}@Fz2aLISFC!aSH+|Nlc+85vm_Ag~_*kX0uT literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/dac13f6b4c0db911dc5ba50a183408924bfe8519 b/packages/ore-rs/fuzz/corpus/bit6_parse/dac13f6b4c0db911dc5ba50a183408924bfe8519 new file mode 100644 index 0000000000000000000000000000000000000000..81edacecf52fde5e6c35ddcf5491291cdc762c2f GIT binary patch literal 9 OcmZQ#Vqjnb;wAt9zW|E> literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/e0ed746a4207aadce54ee1ee5716e9299839f263 b/packages/ore-rs/fuzz/corpus/bit6_parse/e0ed746a4207aadce54ee1ee5716e9299839f263 new file mode 100644 index 0000000000000000000000000000000000000000..4db899a86bff460a25267fd5085acf5b214ce87c GIT binary patch literal 192 zcmZQ#V&LXv0E7Sk85q8SSy4CvP}%{k1UJEiA!Ym@2wi% u82;PfR8<0XBUnKeBhYOOj8ah;M1%;o*Ofz~4WAfMWl_A~3^%G%bur LvK7JXK!73u_2{Lv literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/4ec9aa556c92fb471e8a47817b673a9a3d1f278f b/packages/ore-rs/fuzz/corpus/bit8_parse/4ec9aa556c92fb471e8a47817b673a9a3d1f278f new file mode 100644 index 00000000..1c81276a --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit8_parse/4ec9aa556c92fb471e8a47817b673a9a3d1f278f @@ -0,0 +1,3 @@ + +«««««««««««««««]««« +«µ \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/57b635ecfa2d621d1059ddf560e330fcef2e1ab4 b/packages/ore-rs/fuzz/corpus/bit8_parse/57b635ecfa2d621d1059ddf560e330fcef2e1ab4 new file mode 100644 index 00000000..0b4d2e3b --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit8_parse/57b635ecfa2d621d1059ddf560e330fcef2e1ab4 @@ -0,0 +1,2 @@ + +&ââââââââââââââââââââââââââââââââââââòââââââââââwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwââââàâââââââôââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/5b55ec37559bb110228a6591713076b7fa2ce5e8 b/packages/ore-rs/fuzz/corpus/bit8_parse/5b55ec37559bb110228a6591713076b7fa2ce5e8 new file mode 100644 index 00000000..5e2ed41f --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit8_parse/5b55ec37559bb110228a6591713076b7fa2ce5e8 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/5d3671d6fd79c556777376b9db9eeb1238286cbe b/packages/ore-rs/fuzz/corpus/bit8_parse/5d3671d6fd79c556777376b9db9eeb1238286cbe new file mode 100644 index 00000000..a74b3e2e --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit8_parse/5d3671d6fd79c556777376b9db9eeb1238286cbe @@ -0,0 +1,3 @@ + +WWWWWWWWWWWWÿÿÿÿWW¦WWWWWWWWââââââââââââââââââââââââââââââââW +WWWWWWWWWWWWÿÿÿÿWW¦WWWüüüüüüüüüüüüüüüüüüüüüüüüüüüˆˆˆˆˆˆˆˆˆˆˆA \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/79a4d9f793276a51378df429e0ebf837c8aadc07 b/packages/ore-rs/fuzz/corpus/bit8_parse/79a4d9f793276a51378df429e0ebf837c8aadc07 new file mode 100644 index 00000000..1e793758 --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit8_parse/79a4d9f793276a51378df429e0ebf837c8aadc07 @@ -0,0 +1,3 @@ + +&âââââââââââââââââââââââââââââââââKâââòââââââââââwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww +WWWWWWWWWWWWÿÿÿÿWW¦WWWWWWWWââââââââââââââââââââââââââââââââWWWW©¨§úÿÿÿWWüüüüüüüüüüüüýüüüüüüüüüüüüüüüüüüüüüüüüüüüüüˆˆˆˆˆˆˆˆˆˆˆˆˆwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwâ∈ââˆâˆâˆâ \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/867f9d66dcbce6ae39342dd2fc04ac219dbb2a16 b/packages/ore-rs/fuzz/corpus/bit8_parse/867f9d66dcbce6ae39342dd2fc04ac219dbb2a16 new file mode 100644 index 00000000..3800a21c --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit8_parse/867f9d66dcbce6ae39342dd2fc04ac219dbb2a16 @@ -0,0 +1,3 @@ + +«W«««««««««««««««]««« +«W \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/bfcf553891e3ba2c34e5e63225b51bc0a0332eb2 b/packages/ore-rs/fuzz/corpus/bit8_parse/bfcf553891e3ba2c34e5e63225b51bc0a0332eb2 new file mode 100644 index 0000000000000000000000000000000000000000..0ed8f212d267744139d7de423614aad3982e4b95 GIT binary patch literal 229 zcmd;*mX;O|M*{yD85kCYFN29ZLI+$J8vg(PAC5-@ngQW>4ETc^bO7x@mi_zZ576HK M$U>;>4g@Fy0JqniOaK4? literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/c6b504a181054b9c9bff07e9461d1a6ea2dafc1b b/packages/ore-rs/fuzz/corpus/bit8_parse/c6b504a181054b9c9bff07e9461d1a6ea2dafc1b new file mode 100644 index 00000000..039e75de --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit8_parse/c6b504a181054b9c9bff07e9461d1a6ea2dafc1b @@ -0,0 +1,2 @@ + +WWWWWWWWWWWWÿÿÿÿWW¦WWWWWWWWââââââââââââââââââââââââââââââââWWWW©¨§úÿÿÿWWüüüüüüüüüüüüýüüüüüüüüüüüüüüüüüüüüüüüüüüüüüˆˆˆˆˆˆˆˆˆˆˆA \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/c77f05e9e9648c99adb66235e1b87715da99309f b/packages/ore-rs/fuzz/corpus/bit8_parse/c77f05e9e9648c99adb66235e1b87715da99309f new file mode 100644 index 00000000..ddda8a2c --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit8_parse/c77f05e9e9648c99adb66235e1b87715da99309f @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/ccaa9768cc85a13e46df3dea0e02952e010b3852 b/packages/ore-rs/fuzz/corpus/bit8_parse/ccaa9768cc85a13e46df3dea0e02952e010b3852 new file mode 100644 index 0000000000000000000000000000000000000000..26ea6a2743af3c77251c0e3004cc26bd53ac5273 GIT binary patch literal 258 zcmd;5dxQg&mtSFEU?`^=;NnsPnz>% literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/e7dd041512c660ccd0f4f63daf0414497b8b7be2 b/packages/ore-rs/fuzz/corpus/bit8_parse/e7dd041512c660ccd0f4f63daf0414497b8b7be2 new file mode 100644 index 00000000..6c997655 --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit8_parse/e7dd041512c660ccd0f4f63daf0414497b8b7be2 @@ -0,0 +1,3 @@ + + +&ââââââââââââââââââââââââââââââââââââòâââwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwââââàâââââââwwwwâââââââââââââââââââââ \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/eaf3021dbdc5feeaba63175d7006d9523d8fc465 b/packages/ore-rs/fuzz/corpus/bit8_parse/eaf3021dbdc5feeaba63175d7006d9523d8fc465 new file mode 100644 index 00000000..c12424f3 --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit8_parse/eaf3021dbdc5feeaba63175d7006d9523d8fc465 @@ -0,0 +1,4 @@ + +&ââââââââââââââââââââââââââââââââââââââââââòâââwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwââââââââââââââââââââ + +&ââââââââââââââââââââââââââââââââââââòâââwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwâââââââwwwwwwwwwwwwwwwwwwwwwwwâââòââââââââââwwwwwwwwwwwwwwwwwwwwwwwwwww \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/f11684a34515cd7d5c708ae44def7a4163aa1074 b/packages/ore-rs/fuzz/corpus/bit8_parse/f11684a34515cd7d5c708ae44def7a4163aa1074 new file mode 100644 index 00000000..14bdae15 --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit8_parse/f11684a34515cd7d5c708ae44def7a4163aa1074 @@ -0,0 +1,2 @@ + +&ââ=âââââââââââââââââââââââ~âââââââââââââââââââââÚÚÚââÚÚÚÚÚÚÚÚÚÚÚÚÚÚÚÚÚÚØ \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/f683fb5ae87e6044c802b30a3fb58c0bdcb350be b/packages/ore-rs/fuzz/corpus/bit8_parse/f683fb5ae87e6044c802b30a3fb58c0bdcb350be new file mode 100644 index 00000000..bb11cc55 --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit8_parse/f683fb5ae87e6044c802b30a3fb58c0bdcb350be @@ -0,0 +1,3 @@ + +ÒÒÒÒÒÒÒÒÒÒÒ« +ÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒÒ \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/1498b91d9601baa5b4a8506dcf35f35084aefeb8 b/packages/ore-rs/fuzz/corpus/chained_compare/1498b91d9601baa5b4a8506dcf35f35084aefeb8 new file mode 100644 index 0000000000000000000000000000000000000000..32a12c7cc5bcc0c0e2f51e5c825bcc5e27e1ee80 GIT binary patch literal 100 zcmZQ#W?*1qW?*JubYK911~ADm?f-UTrpX{F7Ki|XWOVos24Il}h&UeQ|BYDyxhW3% literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/1a162e129193f8179d677a82bd7f25c830e292b5 b/packages/ore-rs/fuzz/corpus/chained_compare/1a162e129193f8179d677a82bd7f25c830e292b5 new file mode 100644 index 0000000000000000000000000000000000000000..66f1d7e5db11000dbc82cf46ed5862a22beac84e GIT binary patch literal 50 hcmZQ#W?*1qW?*z+KmybL8#4)m#95F8!OZ{0EC59-184vM literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/1b77c4fe26629c6716dbd50cc7a3a549e68fd9e1 b/packages/ore-rs/fuzz/corpus/chained_compare/1b77c4fe26629c6716dbd50cc7a3a549e68fd9e1 new file mode 100644 index 0000000000000000000000000000000000000000..1aa79e4814626df890f2de4793be35ab4d527376 GIT binary patch literal 257 ocmdn;pFV&K#hTYxfe}_wA_cji>SD2~2iuP11_m%NXJA+W0Aq#eumAu6 literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/1fd2e9fcf01c1736b15e7226e7676b19c04616c1 b/packages/ore-rs/fuzz/corpus/chained_compare/1fd2e9fcf01c1736b15e7226e7676b19c04616c1 new file mode 100644 index 00000000..9d711897 --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/chained_compare/1fd2e9fcf01c1736b15e7226e7676b19c04616c1 @@ -0,0 +1,3 @@ + ++ ++ diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/245f6e3cdedbf18b1a839a9c43c109b1711c9d50 b/packages/ore-rs/fuzz/corpus/chained_compare/245f6e3cdedbf18b1a839a9c43c109b1711c9d50 new file mode 100644 index 00000000..4da3d060 --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/chained_compare/245f6e3cdedbf18b1a839a9c43c109b1711c9d50 @@ -0,0 +1,4 @@ +½ëëëÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ +ÿÿÿÿÿÿÿÿÿÿÿÿÿëëëëëëëëëëëëëëëëëëëëëë2ëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëëë +ëëëëëëë]ëëëëëëëëëëëëëëëëëëëëëÿÿÿÿÿ +ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ+ \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/2886832a394afa27969905c40503dbd60834e1f6 b/packages/ore-rs/fuzz/corpus/chained_compare/2886832a394afa27969905c40503dbd60834e1f6 new file mode 100644 index 0000000000000000000000000000000000000000..41ea429389ddbd7d5b0f22aeebb85e0f9649d363 GIT binary patch literal 75 ocmZQ#W?*1q1|bk;bYMUN)BYPX34_F0kOZ+X|JO3G{5NI+0C|A~@&Et; literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/4766af07390d4fb62682fd6658fdf3caaeab97f2 b/packages/ore-rs/fuzz/corpus/chained_compare/4766af07390d4fb62682fd6658fdf3caaeab97f2 new file mode 100644 index 00000000..ea417bad --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/chained_compare/4766af07390d4fb62682fd6658fdf3caaeab97f2 @@ -0,0 +1 @@ +ÿÿ,ÿÿ, \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/50c99221f6bbd7f615f47fde80c726514578aa06 b/packages/ore-rs/fuzz/corpus/chained_compare/50c99221f6bbd7f615f47fde80c726514578aa06 new file mode 100644 index 0000000000000000000000000000000000000000..2101cd33fdcb33312c8cc32cd1454144ce68c2ef GIT binary patch literal 9 OcmZQ#W?*0fVio`ZO#lx7 literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/66a01f825f6546b851c597318bd307a73a089fe5 b/packages/ore-rs/fuzz/corpus/chained_compare/66a01f825f6546b851c597318bd307a73a089fe5 new file mode 100644 index 0000000000000000000000000000000000000000..30ee0c0611ae82950b3d4e87c2b446bc852b2951 GIT binary patch literal 125 zcmZQ#W?*1qW?%skAPgiSpaDWn`@h|oX);KP1u6lj7#;rq2Xg)c0a&a7BJ+P5HXT4E M#vplwDgTXG0E3AS{Qv*} literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/69db7413a72a01817a55236ccc943dae5b75a382 b/packages/ore-rs/fuzz/corpus/chained_compare/69db7413a72a01817a55236ccc943dae5b75a382 new file mode 100644 index 0000000000000000000000000000000000000000..3ccd58984f28158500d6a117f128279b4b2d1a05 GIT binary patch literal 11 OcmdnO00!m^3=04WLjjBc literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/6f1e8d7fa8c2dc39d5df3248c0fce695b170fb5e b/packages/ore-rs/fuzz/corpus/chained_compare/6f1e8d7fa8c2dc39d5df3248c0fce695b170fb5e new file mode 100644 index 00000000..b42f82e6 --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/chained_compare/6f1e8d7fa8c2dc39d5df3248c0fce695b170fb5e @@ -0,0 +1 @@ +ÿ,ÿ3 \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/712e27f64b93cf7fd2b7c09e7354a3fcf22d4158 b/packages/ore-rs/fuzz/corpus/chained_compare/712e27f64b93cf7fd2b7c09e7354a3fcf22d4158 new file mode 100644 index 00000000..91348809 --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/chained_compare/712e27f64b93cf7fd2b7c09e7354a3fcf22d4158 @@ -0,0 +1,2 @@ + +ÿúÿÿÿÿÿÿÿ \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/749e715bf58ab2b3a134353ef1f123611c5ec337 b/packages/ore-rs/fuzz/corpus/chained_compare/749e715bf58ab2b3a134353ef1f123611c5ec337 new file mode 100644 index 00000000..06df6fda --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/chained_compare/749e715bf58ab2b3a134353ef1f123611c5ec337 @@ -0,0 +1 @@ +ÿ,ÿ,7 \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/8643cda4e2e84d8255f9decd445a05f22a821793 b/packages/ore-rs/fuzz/corpus/chained_compare/8643cda4e2e84d8255f9decd445a05f22a821793 new file mode 100644 index 0000000000000000000000000000000000000000..ad20df4cf4a5fe61da26924748010972df779e36 GIT binary patch literal 50 fcmZQ#W?*1qW?*DM0soDGVnA^g6j31OzcC8{C>;WV literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/ae0b7e44403af4ef918f37a4a254c47bc69195b1 b/packages/ore-rs/fuzz/corpus/chained_compare/ae0b7e44403af4ef918f37a4a254c47bc69195b1 new file mode 100644 index 00000000..af91ad9e --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/chained_compare/ae0b7e44403af4ef918f37a4a254c47bc69195b1 @@ -0,0 +1,2 @@ + +)))ÿúÿÿÿÿÿÿÿ \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/b9d1f333f27c1376371782661a6ff0301522fed7 b/packages/ore-rs/fuzz/corpus/chained_compare/b9d1f333f27c1376371782661a6ff0301522fed7 new file mode 100644 index 0000000000000000000000000000000000000000..9f425c71dd7e5bda86be8dc7d2c00c09b03af09c GIT binary patch literal 100 xcmZQ#W?*1qW&n~9&;X&P{oiiPG#Mnt0+oPMj1K?704&e|5&l07r}F>CEC8an4!HmT literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/ba921fc92e3bdb9757825fc618ba41f3741fefa8 b/packages/ore-rs/fuzz/corpus/chained_compare/ba921fc92e3bdb9757825fc618ba41f3741fefa8 new file mode 100644 index 0000000000000000000000000000000000000000..c20c5ff9bee4042b34132e7464fa7c01dd5f8ab7 GIT binary patch literal 25 ScmZQ#W?*0fVpQz>% literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/c190bddafec30d2016a482c9ae390ac59e1ab36b b/packages/ore-rs/fuzz/corpus/chained_compare/c190bddafec30d2016a482c9ae390ac59e1ab36b new file mode 100644 index 00000000..16473aad --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/chained_compare/c190bddafec30d2016a482c9ae390ac59e1ab36b @@ -0,0 +1 @@ +ÿÿÿÿÿ«( \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/c7c8ad4d6eb862e28ce0b192f58d80bfc1dda323 b/packages/ore-rs/fuzz/corpus/chained_compare/c7c8ad4d6eb862e28ce0b192f58d80bfc1dda323 new file mode 100644 index 0000000000000000000000000000000000000000..64d002d808e88b5cc22159b28bc2a1edb2ab7227 GIT binary patch literal 9 QcmdnX!l2E-!lK6l00>M0CjbBd literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/d304cab0bc67acafc9ff86ef1000551d731e8521 b/packages/ore-rs/fuzz/corpus/chained_compare/d304cab0bc67acafc9ff86ef1000551d731e8521 new file mode 100644 index 0000000000000000000000000000000000000000..7f7d6b1bb1046b9361d951b4e19a941560ac1fdc GIT binary patch literal 256 ocmdn;pDut4#g^Atfe}_wA_cji>SD2~2iuP11_m%NXJA+W0As=Eu>b%7 literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/d70a31566afd8362d9fc4d9f221dced7c77277aa b/packages/ore-rs/fuzz/corpus/chained_compare/d70a31566afd8362d9fc4d9f221dced7c77277aa new file mode 100644 index 0000000000000000000000000000000000000000..b6cb829247465c6cc445a20a22326429180635bc GIT binary patch literal 258 kcmdn{`t|Go0|tPW;{#k+BpFBsa6|t8umAs_3(d3I09vBP*#H0l literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/d870ec0cc1724a182044c97ba3c7870a2f28da8e b/packages/ore-rs/fuzz/corpus/chained_compare/d870ec0cc1724a182044c97ba3c7870a2f28da8e new file mode 100644 index 00000000..d1d1abf7 --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/chained_compare/d870ec0cc1724a182044c97ba3c7870a2f28da8e @@ -0,0 +1,2 @@ + ++ \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/de6b6d078e2249a744cb5e103557d22cd9c75dd9 b/packages/ore-rs/fuzz/corpus/chained_compare/de6b6d078e2249a744cb5e103557d22cd9c75dd9 new file mode 100644 index 00000000..18697ce1 --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/chained_compare/de6b6d078e2249a744cb5e103557d22cd9c75dd9 @@ -0,0 +1,2 @@ +½ÿÿÿÿÿ ++ \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/f4855d9e4dd28214677e20d52b5969d88be8c692 b/packages/ore-rs/fuzz/corpus/chained_compare/f4855d9e4dd28214677e20d52b5969d88be8c692 new file mode 100644 index 00000000..b111549e --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/chained_compare/f4855d9e4dd28214677e20d52b5969d88be8c692 @@ -0,0 +1 @@ +ÿ,#ÿ \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/chained_parse/11efd772c1b4e6f667ecbfeb4873ff777f3c2a09 b/packages/ore-rs/fuzz/corpus/chained_parse/11efd772c1b4e6f667ecbfeb4873ff777f3c2a09 new file mode 100644 index 0000000000000000000000000000000000000000..007ff77487a16c3cd06fcf4ee2d752da70939eea GIT binary patch literal 4 LcmZSLVqgFO04e|t literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/chained_parse/2ed8c13d341e8e12aa6f6320c11259b87fcffdae b/packages/ore-rs/fuzz/corpus/chained_parse/2ed8c13d341e8e12aa6f6320c11259b87fcffdae new file mode 100644 index 0000000000000000000000000000000000000000..047088a14f37e4a3803ea10c374effb36944b93d GIT binary patch literal 45 pcmZQ#W?*Ds;$i^d|NsAMfr$U_|NmzOu^G6|bN$x>3NV8S1^|LZ6p#P_ literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/chained_parse/4450d4ef86aa3af4aa06080ba8b724ee1617de07 b/packages/ore-rs/fuzz/corpus/chained_parse/4450d4ef86aa3af4aa06080ba8b724ee1617de07 new file mode 100644 index 0000000000000000000000000000000000000000..c7e7b78a32a6150c89cafe1676f011651a67d570 GIT binary patch literal 70 acmZQ#W?*tuAQ9LqFmW+30P%kaUrNsaM1vmjX literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/chained_parse/5a0d51d6406dff707363da7baed6cbf85c862965 b/packages/ore-rs/fuzz/corpus/chained_parse/5a0d51d6406dff707363da7baed6cbf85c862965 new file mode 100644 index 0000000000000000000000000000000000000000..e832e94d8d3e65e627a63e5d301d56a16aa5eb60 GIT binary patch literal 45 hcmZQ#W?*D^pnwK+ZL1iVxEMhA{eLiEW@6@I006c*3f=$! literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/chained_parse/5d18fd9726255aef7acb14cacb6c1f74ab748661 b/packages/ore-rs/fuzz/corpus/chained_parse/5d18fd9726255aef7acb14cacb6c1f74ab748661 new file mode 100644 index 0000000000000000000000000000000000000000..6d27bed89b2d6a3444e82b429bbc74d95b28bf85 GIT binary patch literal 120 scmZQ#W?*4}044?nY#;%fpaK@ryI2&$#B3FqxEL6K`2YX^;y}y{07HKh&j0`b literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/chained_parse/6dc20172dc2ab8b09c45e12ab5d5836223566e5e b/packages/ore-rs/fuzz/corpus/chained_parse/6dc20172dc2ab8b09c45e12ab5d5836223566e5e new file mode 100644 index 0000000000000000000000000000000000000000..db2866e11f323ead10db6e6e668ee03629635ab2 GIT binary patch literal 20 TcmZQ#W?*1o;sWFU5WoxoBzXrK literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/chained_parse/a4fe776497787732b7e01a7e5c051ed91f6bb8b7 b/packages/ore-rs/fuzz/corpus/chained_parse/a4fe776497787732b7e01a7e5c051ed91f6bb8b7 new file mode 100644 index 0000000000000000000000000000000000000000..68abf304489ad1fe495b22d6279f9fe0a8ef3f3f GIT binary patch literal 8 PcmZQ#;xaJcVqgFO0x$r1 literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/chained_parse/ae3597212aeaf69e8c6932e9a26d9aeeea3c80a6 b/packages/ore-rs/fuzz/corpus/chained_parse/ae3597212aeaf69e8c6932e9a26d9aeeea3c80a6 new file mode 100644 index 0000000000000000000000000000000000000000..84fe154efd624984b53f85c6077af7b8a8a45829 GIT binary patch literal 95 fcmZQ#W?*JeAP!8x1>o9k6_~gf7=ZXc1TX^t#Mv8) literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/chained_parse/b72e19e1a60e66a97d4f55e4ef410a84c1d06c26 b/packages/ore-rs/fuzz/corpus/chained_parse/b72e19e1a60e66a97d4f55e4ef410a84c1d06c26 new file mode 100644 index 0000000000000000000000000000000000000000..2e947f1bb93601ff9e4c617dd955625cb1872bdb GIT binary patch literal 4 LcmZQ#=3)Q<04V?v literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/chained_parse/d623c63a4d62802dd790ca8c575034d350d2e4d4 b/packages/ore-rs/fuzz/corpus/chained_parse/d623c63a4d62802dd790ca8c575034d350d2e4d4 new file mode 100644 index 0000000000000000000000000000000000000000..c3fe03d0456c1d8e54f45eac9bb5001dee12e82c GIT binary patch literal 270 wcmZQ#X5eB_zy{I?0TfMy6w_KB#W7&c1UQI=vmgw(T3ZDsE(Qi5{tp4n0EN9>v;Y7A literal 0 HcmV?d00001 diff --git a/packages/ore-rs/fuzz/corpus/chained_parse/d663b1e3643fe6e5e851067d5eabc6aee524e64f b/packages/ore-rs/fuzz/corpus/chained_parse/d663b1e3643fe6e5e851067d5eabc6aee524e64f new file mode 100644 index 0000000000000000000000000000000000000000..25e26eeb0cb96de47fd27e11c4b37fde32bc5e6c GIT binary patch literal 70 acmZQ#W?*7aAQ9LqFmW+30P%kaU(data: &[u8]) { + if let Ok(ct) = CipherText::::from_slice(data) { + assert_eq!( + ct.to_bytes(), + data, + "accepted full ciphertext did not round-trip" + ); + } + if let Ok(left) = Left::::from_slice(data) { + assert_eq!( + left.to_bytes(), + data, + "accepted left ciphertext did not round-trip" + ); + } +} + +fuzz_target!(|pair: (&[u8], &[u8])| { + let (a, b) = pair; + check::<11>(a); + check::<6>(a); + let _ = OreAes128Bit6ChaCha20::compare_raw_slices(a, b); + let _ = OreAes128Bit6ChaCha20::compare_raw_slices(b, a); +}); diff --git a/packages/ore-rs/fuzz/fuzz_targets/bit8_parse.rs b/packages/ore-rs/fuzz/fuzz_targets/bit8_parse.rs new file mode 100644 index 00000000..00c7d9c6 --- /dev/null +++ b/packages/ore-rs/fuzz/fuzz_targets/bit8_parse.rs @@ -0,0 +1,34 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use ore_rs::scheme::bit2::OreAes128ChaCha20; +use ore_rs::{CipherText, Left, OreCipher, OreOutput}; + +// The legacy 8-bit-block scheme, whose wire format is frozen and whose rows +// already sit in customer databases. Same contract as the v2 targets: +// never panic on untrusted bytes, accepted bytes round-trip, the raw +// comparator answers any pair. +fn check(data: &[u8]) { + if let Ok(ct) = CipherText::::from_slice(data) { + assert_eq!( + ct.to_bytes(), + data, + "accepted full ciphertext did not round-trip" + ); + } + if let Ok(left) = Left::::from_slice(data) { + assert_eq!( + left.to_bytes(), + data, + "accepted left ciphertext did not round-trip" + ); + } +} + +fuzz_target!(|pair: (&[u8], &[u8])| { + let (a, b) = pair; + check::<8>(a); + check::<4>(a); + let _ = OreAes128ChaCha20::compare_raw_slices(a, b); + let _ = OreAes128ChaCha20::compare_raw_slices(b, a); +}); diff --git a/packages/ore-rs/fuzz/fuzz_targets/chained_compare.rs b/packages/ore-rs/fuzz/fuzz_targets/chained_compare.rs new file mode 100644 index 00000000..9bcbd001 --- /dev/null +++ b/packages/ore-rs/fuzz/fuzz_targets/chained_compare.rs @@ -0,0 +1,17 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use ore_rs::scheme::chained::OreAes128Bit6ChainedChaCha20; + +// Both comparators take raw stored bytes and index into them by the block +// count each header declares. Arbitrary byte pairs must never panic: a +// malformed side is answered with `None`. No ordering property is asserted +// here, because antisymmetry only holds for genuine ciphertexts, not for +// arbitrary right halves. +fuzz_target!(|pair: (&[u8], &[u8])| { + let (a, b) = pair; + let _ = OreAes128Bit6ChainedChaCha20::compare_raw_slices(a, b); + let _ = OreAes128Bit6ChainedChaCha20::compare_raw_slices(b, a); + let _ = OreAes128Bit6ChainedChaCha20::compare_left_to_full(a, b); + let _ = OreAes128Bit6ChainedChaCha20::compare_left_to_full(b, a); +}); diff --git a/packages/ore-rs/fuzz/fuzz_targets/chained_parse.rs b/packages/ore-rs/fuzz/fuzz_targets/chained_parse.rs new file mode 100644 index 00000000..928745f3 --- /dev/null +++ b/packages/ore-rs/fuzz/fuzz_targets/chained_parse.rs @@ -0,0 +1,18 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use ore_rs::scheme::chained::VarCipherText; + +// Ciphertexts are read back from storage, so the parser sees untrusted bytes: +// it must never panic (the block count is read from the header and drives +// every slice index), and anything it accepts must re-encode to the same +// bytes, or the stored form and the parsed form would disagree. +fuzz_target!(|data: &[u8]| { + if let Ok(ct) = VarCipherText::from_slice(data) { + assert_eq!( + ct.to_bytes(), + data, + "accepted ciphertext did not round-trip" + ); + } +}); diff --git a/scripts/fuzz.sh b/scripts/fuzz.sh new file mode 100755 index 00000000..2ce2dba1 --- /dev/null +++ b/scripts/fuzz.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Build and run the ore-rs fuzz targets (packages/ore-rs/fuzz, a detached +# cargo-fuzz crate; needs the nightly toolchain and cargo-fuzz). +# +# scripts/fuzz.sh # every target, 60 s each +# scripts/fuzz.sh 300 # every target, 300 s each +# scripts/fuzz.sh 60 bit6_parse # one target +# +# The targets assert that the ciphertext parsers and raw comparators never +# panic on arbitrary bytes and that accepted bytes re-encode to themselves. +# A crash leaves its input under packages/ore-rs/fuzz/artifacts//. +set -euo pipefail + +seconds="${1:-60}" +cd "$(dirname "$0")/../packages/ore-rs/fuzz" + +if [ $# -ge 2 ]; then + targets=("$2") +else + targets=(chained_parse chained_compare bit6_parse bit8_parse) +fi + +cargo +nightly fuzz build +for target in "${targets[@]}"; do + echo "== $target (${seconds}s)" + cargo +nightly fuzz run "$target" -- -max_total_time="$seconds" +done From b3ecff2bde2c420e2472e0396943e80492408c6f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 09:54:42 +1100 Subject: [PATCH 03/35] test(ct-taint): Valgrind taint harness for the v2 encryptors ctgrind technique: packages/ct-taint marks the key and the plaintext as undefined memory and runs each v2 encryptor (Bit6 and chained, full and left-only); memcheck then reports every conditional jump and every memory address that depends on them. scripts/ct-taint.sh fails on any report not listed in valgrind.supp, whose two entries are the documented exceptions: the range check on the plaintext symbol in oblivious_lookup together with its callers' test of the Result, whose direction is the same for every in-domain symbol. Nothing else in the encryptors (PRP builder, decomposition, CMAC, hash, SIMD kernels, ct_select_byte, AES) reports. Removing the suppressions makes every mode fail. The Fisher-Yates swap at a keystream-derived index used to be a third entry; the oblivious PRP builder no longer uses a secret value as an address, so it is gone. --- packages/ct-taint/Cargo.lock | 582 ++++++++++++++++++++++++++++++++ packages/ct-taint/Cargo.toml | 36 ++ packages/ct-taint/src/main.rs | 143 ++++++++ packages/ct-taint/valgrind.supp | 22 ++ scripts/ct-taint.sh | 38 +++ 5 files changed, 821 insertions(+) create mode 100644 packages/ct-taint/Cargo.lock create mode 100644 packages/ct-taint/Cargo.toml create mode 100644 packages/ct-taint/src/main.rs create mode 100644 packages/ct-taint/valgrind.supp create mode 100755 scripts/ct-taint.sh diff --git a/packages/ct-taint/Cargo.lock b/packages/ct-taint/Cargo.lock new file mode 100644 index 00000000..e0bcd30f --- /dev/null +++ b/packages/ct-taint/Cargo.lock @@ -0,0 +1,582 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", + "zeroize", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bindgen" +version = "0.73.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "787ef8ef523575546b106a58213d6e6b06198a05c2f757258c68a74273670cfa" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 3.0.6", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-modes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2211b0817f061502a8dd9f11a37e879e79763e3c698d2418cf824d8cb2f21e" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crabgrind" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3f4bc9b199b6cfdf87ef7960ac7be99306a1ad4d6c442b1c3701cddb22ea47d" +dependencies = [ + "bindgen", + "cc", + "pkg-config", + "rustversion", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ct-taint" +version = "0.0.0" +dependencies = [ + "crabgrind", + "hex-literal", + "ore-rs", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "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 = "orderable-bytes" +version = "0.1.1" + +[[package]] +name = "ore-rs" +version = "0.8.3" +dependencies = [ + "aes", + "block-modes", + "byteorder", + "hex", + "num", + "orderable-bytes", + "rand", + "rand_chacha", + "subtle-ng", + "thiserror", + "zeroize", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn 3.0.6", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "subtle-ng" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "734676eb262c623cec13c3155096e08d1f8f29adce39ba17948b18dad1e54142" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/packages/ct-taint/Cargo.toml b/packages/ct-taint/Cargo.toml new file mode 100644 index 00000000..ba6ffa95 --- /dev/null +++ b/packages/ct-taint/Cargo.toml @@ -0,0 +1,36 @@ +# Valgrind taint harness for ore-rs's secret-dependent code paths. +# +# This is a DETACHED crate (own `[workspace]` table, not a member of the root +# workspace): it links Valgrind client requests through `crabgrind`, which the +# normal build and test of ore-rs must never depend on. Run it with +# `scripts/ct-taint.sh`, under `valgrind --tool=memcheck`; outside Valgrind the +# marks are no-ops and the binary just exercises the paths. +# +# The technique is ctgrind's: mark the key and the plaintext as *undefined* +# memory, run the operation, and let memcheck report every conditional jump +# and every memory address that depends on them. Each report is a +# secret-dependent branch or a secret-indexed access. The expected reports — +# the documented exceptions — live in `valgrind.supp`; anything else fails +# the run. +[package] +name = "ct-taint" +version = "0.0.0" +publish = false +edition = "2021" + +[dependencies] +crabgrind = "0.4" +hex-literal = "0.4" + +[dependencies.ore-rs] +path = "../ore-rs" + +# Frames must resolve for the suppressions to match by function name, so keep +# debug info in the optimised build. Optimisation is required: the question is +# whether the *shipped* code is branch-free, and the debug build is not the +# shipped code. +[profile.release] +debug = 1 + +[workspace] +resolver = "2" diff --git a/packages/ct-taint/src/main.rs b/packages/ct-taint/src/main.rs new file mode 100644 index 00000000..1475cfdb --- /dev/null +++ b/packages/ct-taint/src/main.rs @@ -0,0 +1,143 @@ +//! Valgrind taint harness for the v2 ORE encryptors (ctgrind technique). +//! +//! Each mode marks the key material and the plaintext as *undefined* memory +//! (Valgrind client request), runs one operation, marks the output defined, +//! and prints a checksum. Under `valgrind --tool=memcheck`, every conditional +//! jump and every memory address that depends on the undefined bytes is +//! reported: those are the secret-dependent branches and secret-indexed +//! accesses. The expected ones are listed, with their reasons, in +//! `valgrind.supp`; `scripts/ct-taint.sh` fails on anything else. +//! +//! Only the encryptors are tainted. The comparators take ciphertexts, which are +//! public, so there is nothing to mark on that path; the comparator's timing +//! behaviour on *equal-prefix versus differing* inputs is a statistical +//! question (dudect), not a taint one. +//! +//! Outside Valgrind the marks are no-ops and the modes just run. + +use std::env; +use std::process::exit; + +use crabgrind::memcheck::{mark_memory, MemState}; +use hex_literal::hex; +use ore_rs::scheme::bit2_w6::OreAes128Bit6ChaCha20; +use ore_rs::scheme::chained::{OreAes128Bit6Chained, OreAes128Bit6ChainedChaCha20}; +use ore_rs::{OreCipher, OreEncrypt, OreOutput}; + +/// Mark `bytes` undefined: from here on, anything computed from them is +/// tracked as tainted by memcheck. +fn taint(bytes: &mut [u8]) { + // `Err(NoValgrind)` means the binary is not running under Valgrind; the + // run is then a plain exercise of the path, which is still useful. + let _ = mark_memory(bytes.as_ptr() as *const _, bytes.len(), MemState::Undefined); +} + +/// Mark `bytes` defined again, so that using the *output* of an operation +/// (printing it, hashing it) is not itself reported. +fn untaint(bytes: &mut [u8]) { + let _ = mark_memory( + bytes.as_ptr() as *const _, + bytes.len(), + MemState::DefinedIfAddressable, + ); +} + +/// FNV-1a over the output, so the result is observably used and the +/// optimiser cannot drop the computation. +fn checksum(bytes: &[u8]) -> u64 { + bytes.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &b| { + (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3) + }) +} + +fn keys() -> ([u8; 16], [u8; 16]) { + ( + hex!("000102030405060708090a0b0c0d0e0f"), + hex!("d0d1d2d3d4d5d6d7d8d9dadbdcdddedf"), + ) +} + +fn bit6_full() -> Vec { + let (mut k1, mut k2) = keys(); + taint(&mut k1); + taint(&mut k2); + let ore: OreAes128Bit6ChaCha20 = OreCipher::init(&k1, &k2).expect("init"); + let mut x = 0x1234_5678_9abc_def0u64.to_le_bytes(); + taint(&mut x); + let x = u64::from_le_bytes(x); + let mut out = x.encrypt(&ore).expect("encrypt").to_bytes(); + untaint(&mut out); + out +} + +fn bit6_left() -> Vec { + let (mut k1, mut k2) = keys(); + taint(&mut k1); + taint(&mut k2); + let ore: OreAes128Bit6ChaCha20 = OreCipher::init(&k1, &k2).expect("init"); + let mut x = 0x1234_5678_9abc_def0u64.to_le_bytes(); + taint(&mut x); + let x = u64::from_le_bytes(x); + let mut out = x.encrypt_left(&ore).expect("encrypt_left").to_bytes(); + untaint(&mut out); + out +} + +/// The plaintext string, tainted. Built with `from_utf8_unchecked` because +/// `from_utf8`'s validation branches on every byte, which would report the +/// harness rather than the encryptor; the bytes are ASCII. +fn tainted_str(buf: &mut [u8; 17]) -> &str { + *buf = *b"alice@example.com"; + taint(buf); + // SAFETY: the buffer was just filled with ASCII. + unsafe { std::str::from_utf8_unchecked(buf) } +} + +fn chained_full() -> Vec { + let (mut k1, _) = keys(); + taint(&mut k1); + let ore: OreAes128Bit6ChainedChaCha20 = OreAes128Bit6Chained::init(&k1).expect("init"); + let mut buf = [0u8; 17]; + let s = tainted_str(&mut buf); + let mut out = ore.encrypt_str(s).expect("encrypt_str").to_bytes(); + untaint(&mut out); + out +} + +fn chained_left() -> Vec { + let (mut k1, _) = keys(); + taint(&mut k1); + let ore: OreAes128Bit6ChainedChaCha20 = OreAes128Bit6Chained::init(&k1).expect("init"); + let mut buf = [0u8; 17]; + let s = tainted_str(&mut buf); + let mut out = ore + .encrypt_left_str(s) + .expect("encrypt_left_str") + .to_bytes(); + untaint(&mut out); + out +} + +const MODES: &[(&str, fn() -> Vec)] = &[ + ("bit6-encrypt", bit6_full), + ("bit6-encrypt-left", bit6_left), + ("chained-encrypt", chained_full), + ("chained-encrypt-left", chained_left), +]; + +fn main() { + let mode = env::args().nth(1).unwrap_or_default(); + let Some((name, run)) = MODES.iter().find(|(n, _)| *n == mode) else { + eprintln!("usage: ct-taint \nmodes:"); + for (n, _) in MODES { + eprintln!(" {n}"); + } + exit(2); + }; + let out = run(); + println!( + "{name}: {} bytes, checksum {:016x}", + out.len(), + checksum(&out) + ); +} diff --git a/packages/ct-taint/valgrind.supp b/packages/ct-taint/valgrind.supp new file mode 100644 index 00000000..2c4039f3 --- /dev/null +++ b/packages/ct-taint/valgrind.supp @@ -0,0 +1,22 @@ +# Accepted memcheck reports for the taint harness. Each entry is a +# secret-dependent access or branch that the design documents and accepts; +# scripts/ct-taint.sh fails on anything not listed here. Add an entry only +# with a reason. Frame names are demangled Rust symbols, matched as globs. + +# `oblivious_lookup` rejects an out-of-range index before the constant-time +# scan, and its callers test the `Result`. The comparison is against the +# public table length, and every symbol the encryptors produce is in range, +# so the branch direction is the same for every input and carries no +# information; it is still a comparison on the plaintext symbol, which is +# why memcheck reports it. Removing it means an infallible `permute` over a +# typed in-domain symbol. +{ + oblivious-lookup-range-check + Memcheck:Cond + fun:*oblivious_lookup* +} +{ + oblivious-lookup-range-check-result + Memcheck:Cond + fun:*branch Date: Fri, 9 Oct 2026 09:54:42 +1100 Subject: [PATCH 04/35] test(ct-dudect): dudect timing benches for the comparators and encryptors A detached crate running Welch's t-test over interleaved input classes: the comparators on pairs differing in the first versus the last block, and the encryptors on constant versus random plaintexts, plus the PRP builder alone through a new ct-bench feature of ore-rs that exposes it for measurement. Results are recorded in the docs rather than gated in CI, because the test is too noisy on shared runners. The PRP builder is also timed through each named builder from one binary (the textbook reference builder, and the portable SWAR and byte-at-a-time oblivious builders), as fixed-A versus fixed-B pairs, so before and after compare like for like. Four isolation benches split the reference builder in two, the Fisher-Yates swap loop and the inverse fill, each next to a control doing the same work at public addresses; they locate the timing signal the oblivious builder removes. --- packages/ct-dudect/Cargo.lock | 631 ++++++++++++++++++ packages/ct-dudect/Cargo.toml | 25 + packages/ct-dudect/src/main.rs | 429 ++++++++++++ packages/ore-rs/Cargo.toml | 5 + packages/ore-rs/src/lib.rs | 119 ++++ packages/ore-rs/src/primitives/prp.rs | 19 + .../ore-rs/src/primitives/prp/oblivious.rs | 20 +- 7 files changed, 1239 insertions(+), 9 deletions(-) create mode 100644 packages/ct-dudect/Cargo.lock create mode 100644 packages/ct-dudect/Cargo.toml create mode 100644 packages/ct-dudect/src/main.rs diff --git a/packages/ct-dudect/Cargo.lock b/packages/ct-dudect/Cargo.lock new file mode 100644 index 00000000..45f634da --- /dev/null +++ b/packages/ct-dudect/Cargo.lock @@ -0,0 +1,631 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", + "zeroize", +] + +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-modes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2211b0817f061502a8dd9f11a37e879e79763e3c698d2418cf824d8cb2f21e" + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "2.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +dependencies = [ + "ansi_term", + "atty", + "bitflags 1.3.2", + "strsim", + "textwrap", + "unicode-width", + "vec_map", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ct-dudect" +version = "0.0.0" +dependencies = [ + "dudect-bencher", + "ore-rs", +] + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "dudect-bencher" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6174adfb35811a6845001876823965032406784c36e795edb84a74353455e1" +dependencies = [ + "clap", + "ctrlc", + "rand 0.10.3", + "rand_chacha 0.10.0", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core 0.10.1", +] + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "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 = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "orderable-bytes" +version = "0.1.1" + +[[package]] +name = "ore-rs" +version = "0.8.3" +dependencies = [ + "aes", + "block-modes", + "byteorder", + "hex", + "num", + "orderable-bytes", + "rand 0.8.8", + "rand_chacha 0.3.1", + "subtle-ng", + "thiserror", + "zeroize", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" +dependencies = [ + "ppv-lite86", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + +[[package]] +name = "subtle-ng" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "734676eb262c623cec13c3155096e08d1f8f29adce39ba17948b18dad1e54142" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/packages/ct-dudect/Cargo.toml b/packages/ct-dudect/Cargo.toml new file mode 100644 index 00000000..8f8f90d3 --- /dev/null +++ b/packages/ct-dudect/Cargo.toml @@ -0,0 +1,25 @@ +# dudect timing-leakage benches for the v2 ORE comparators and encryptors. +# +# DETACHED crate (own `[workspace]` table): it is run by hand on a quiet +# machine and its results are recorded in the docs, not gated in CI, because a +# t-test over wall-clock timings is too noisy on shared runners. Run with +# cargo run --release -- --continuous +# or a single bench by name; see src/main.rs for the classes each bench +# compares. +[package] +name = "ct-dudect" +version = "0.0.0" +publish = false +edition = "2021" + +[dependencies] +dudect-bencher = "0.7" + + +[dependencies.ore-rs] +path = "../ore-rs" +# `ct-bench` exposes the private PRP builder for the isolated bench. +features = ["ct-bench"] + +[workspace] +resolver = "2" diff --git a/packages/ct-dudect/src/main.rs b/packages/ct-dudect/src/main.rs new file mode 100644 index 00000000..63ed5ce3 --- /dev/null +++ b/packages/ct-dudect/src/main.rs @@ -0,0 +1,429 @@ +//! dudect timing-leakage benches for the v2 ORE comparators and encryptors. +//! +//! dudect feeds two input classes to the same function, interleaved, and +//! runs Welch's t-test on the timing distributions. `|t| < 5` after a few +//! million samples means no leakage was detected between the classes. +//! +//! The comparator benches ask the question the constant-time prefix scan is +//! meant to answer: does the time to compare two ciphertexts depend on +//! *where* they first differ? Class `Left` pairs differ in the first block; +//! class `Right` pairs share every block but the last. The encryptor benches +//! ask whether encryption time depends on the plaintext: `Left` is the +//! all-zero value, `Right` a random one. +//! +//! Run on a quiet machine: `cargo run --release -- --continuous` (all +//! benches, until interrupted) or `cargo run --release -- `. + +use dudect_bencher::rand::RngExt; +use dudect_bencher::{ctbench_main, BenchRng, Class, CtRunner}; +use ore_rs::ct_bench::{self, Line}; +use ore_rs::scheme::bit2_w6::OreAes128Bit6ChaCha20; +use ore_rs::scheme::chained::{OreAes128Bit6Chained, OreAes128Bit6ChainedChaCha20}; +use ore_rs::{OreCipher, OreEncrypt, OreOutput}; +use std::hint::black_box; + +const K1: [u8; 16] = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, +]; +const K2: [u8; 16] = [ + 0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xdb, 0xdc, 0xdd, 0xde, 0xdf, +]; + +/// Inputs per class, precomputed so the measured region is the call alone. +const POOL: usize = 512; +/// Measurements per invocation of a bench (dudect's `--continuous` loops). +const SAMPLES: usize = 100_000; + +/// With `CT_DUDECT_DIT=1`, set the ARMv8.4 DIT (data-independent timing) +/// bit for this thread before measuring. On Apple Silicon this also disables +/// the data memory-dependent prefetcher, whose behaviour depends on whether +/// loaded *values* look like pointers; a difference that disappears under +/// DIT is that microarchitectural effect, not a data-dependent code path. +fn data_independent_timing() { + if std::env::var_os("CT_DUDECT_DIT").is_none() { + return; + } + #[cfg(target_arch = "aarch64")] + // SAFETY: `msr DIT, #1` only sets the per-thread timing control bit; it + // touches no memory and has no other architectural effect. + unsafe { + std::arch::asm!("msr DIT, #1", options(nomem, nostack)); + } +} + +fn bit6() -> OreAes128Bit6ChaCha20 { + data_independent_timing(); + OreCipher::init(&K1, &K2).unwrap() +} + +fn chained() -> OreAes128Bit6ChainedChaCha20 { + data_independent_timing(); + OreAes128Bit6Chained::init(&K1).unwrap() +} + +/// Random class assignment, so the two classes interleave. +fn class(rng: &mut BenchRng) -> Class { + if rng.random::() { + Class::Left + } else { + Class::Right + } +} + +/// Bit6 u64 comparator: first block differs (`Left`) versus only the last +/// 6-bit block differs (`Right`). +fn bit6_compare_first_vs_last_block(runner: &mut CtRunner, rng: &mut BenchRng) { + let ore = bit6(); + let mut left = Vec::with_capacity(POOL); + let mut right = Vec::with_capacity(POOL); + for _ in 0..POOL { + let x: u64 = rng.random::(); + // Flip the top bit: block 0 differs. + let y0 = x ^ (1 << 63); + // Flip the bottom bit: only the last block differs. + let y1 = x ^ 1; + left.push(( + x.encrypt(&ore).unwrap().to_bytes(), + y0.encrypt(&ore).unwrap().to_bytes(), + )); + right.push(( + x.encrypt(&ore).unwrap().to_bytes(), + y1.encrypt(&ore).unwrap().to_bytes(), + )); + } + for _ in 0..SAMPLES { + let c = class(rng); + let pool = if matches!(c, Class::Left) { + &left + } else { + &right + }; + let (a, b) = &pool[rng.random_range(0..POOL)]; + runner.run_one(c, || { + black_box(OreAes128Bit6ChaCha20::compare_raw_slices( + black_box(a), + black_box(b), + )) + }); + } +} + +/// Chained string comparator over 17-character strings: first byte differs +/// (`Left`) versus only the last byte differs (`Right`). +fn chained_compare_first_vs_last_byte(runner: &mut CtRunner, rng: &mut BenchRng) { + let ore = chained(); + let mut left = Vec::with_capacity(POOL); + let mut right = Vec::with_capacity(POOL); + for _ in 0..POOL { + let mut s = [0u8; 17]; + for b in s.iter_mut() { + *b = rng.random_range(b'a'..=b'z'); + } + let mut first = s; + first[0] ^= 0x01; + let mut last = s; + last[16] ^= 0x01; + let enc = |bytes: &[u8]| { + ore.encrypt_str(std::str::from_utf8(bytes).unwrap()) + .unwrap() + .to_bytes() + }; + left.push((enc(&s), enc(&first))); + right.push((enc(&s), enc(&last))); + } + for _ in 0..SAMPLES { + let c = class(rng); + let pool = if matches!(c, Class::Left) { + &left + } else { + &right + }; + let (a, b) = &pool[rng.random_range(0..POOL)]; + runner.run_one(c, || { + black_box(OreAes128Bit6ChainedChaCha20::compare_raw_slices( + black_box(a), + black_box(b), + )) + }); + } +} + +/// Bit6 u64 encryption: all-zero plaintext (`Left`) versus random (`Right`). +/// Both classes draw from a precomputed pool, so the work between two +/// measurements is the same whichever class comes next. +fn bit6_encrypt_zero_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { + let ore = bit6(); + let left = vec![0u64; POOL]; + let right: Vec = (0..POOL).map(|_| rng.random::()).collect(); + for _ in 0..SAMPLES { + let c = class(rng); + let pool = if matches!(c, Class::Left) { + &left + } else { + &right + }; + let x = pool[rng.random_range(0..POOL)]; + runner.run_one(c, || black_box(black_box(x).encrypt(&ore).unwrap())); + } +} + +/// Bit6 u64 encryption: one fixed random-looking plaintext (`Left`) versus +/// fresh random ones (`Right`). Separates "the value is constant" from "the +/// value is zero": a constant with the shape of a random word isolates the +/// zero class's other property, that it never looks like a pointer. +fn bit6_encrypt_const_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { + let ore = bit6(); + let left = vec![0x1234_5678_9abc_def0u64; POOL]; + let right: Vec = (0..POOL).map(|_| rng.random::()).collect(); + for _ in 0..SAMPLES { + let c = class(rng); + let pool = if matches!(c, Class::Left) { + &left + } else { + &right + }; + let x = pool[rng.random_range(0..POOL)]; + runner.run_one(c, || black_box(black_box(x).encrypt(&ore).unwrap())); + } +} + +/// Bit6 u64 *left-only* encryption, constant versus random plaintext. The +/// left path is the prefix PRF, the PRP build and the oblivious `permute`; +/// no random-oracle keys, hashing or right blocks. +fn bit6_encrypt_left_const_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { + let ore = bit6(); + let left = vec![0x1234_5678_9abc_def0u64; POOL]; + let right: Vec = (0..POOL).map(|_| rng.random::()).collect(); + for _ in 0..SAMPLES { + let c = class(rng); + let pool = if matches!(c, Class::Left) { + &left + } else { + &right + }; + let x = pool[rng.random_range(0..POOL)]; + runner.run_one(c, || black_box(black_box(x).encrypt_left(&ore).unwrap())); + } +} + +/// The PRP builder alone (`LemireFyPrp::from_stream`, the oblivious builder +/// the schemes use, through the `ct-bench` hook): one fixed 512-byte draw +/// stream (`Left`) versus fresh random streams (`Right`). The draw stream +/// decides every Fisher–Yates swap, so this isolates the builder from +/// everything else in the encryptor. +fn prp_build_const_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { + let mut fixed = [0u8; 512]; + for b in fixed.iter_mut() { + *b = rng.random::(); + } + let left = vec![fixed; POOL]; + let right: Vec<[u8; 512]> = (0..POOL) + .map(|_| { + let mut s = [0u8; 512]; + for b in s.iter_mut() { + *b = rng.random::(); + } + s + }) + .collect(); + for _ in 0..SAMPLES { + let c = class(rng); + let pool = if matches!(c, Class::Left) { + &left + } else { + &right + }; + let s = &pool[rng.random_range(0..POOL)]; + runner.run_one(c, || { + black_box(ore_rs::ct_bench::lemire_fy_prp_from_stream(black_box(s))) + }); + } +} + +/// The PRP builder on two *different fixed* streams, `A` (`Left`) and `B` +/// (`Right`). Both classes repeat their input, so predictor training is the +/// same for each; a difference here means the time depends on *which* +/// permutation is built, not merely on whether the input repeats. +fn prp_build_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) { + let mut a = [0u8; 512]; + let mut b = [0u8; 512]; + for x in a.iter_mut().chain(b.iter_mut()) { + *x = rng.random::(); + } + for _ in 0..SAMPLES { + let c = class(rng); + let s = if matches!(c, Class::Left) { &a } else { &b }; + runner.run_one(c, || { + black_box(ore_rs::ct_bench::lemire_fy_prp_from_stream(black_box(s))) + }); + } +} + +/// Bit6 u64 left-only encryption of two different fixed plaintexts: the +/// same question as `prp_build_fixed_a_vs_fixed_b`, end to end. +fn bit6_encrypt_left_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) { + let ore = bit6(); + let a: u64 = rng.random(); + let b: u64 = rng.random(); + for _ in 0..SAMPLES { + let c = class(rng); + let x = if matches!(c, Class::Left) { a } else { b }; + runner.run_one(c, || black_box(black_box(x).encrypt_left(&ore).unwrap())); + } +} + +/// Chained encryption of 17 bytes: all-`a` plaintext (`Left`) versus random +/// lowercase (`Right`), both from precomputed pools. +fn chained_encrypt_fixed_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { + let ore = chained(); + let left: Vec = vec!["a".repeat(17); POOL]; + let right: Vec = (0..POOL) + .map(|_| { + (0..17) + .map(|_| char::from(rng.random_range(b'a'..=b'z'))) + .collect() + }) + .collect(); + for _ in 0..SAMPLES { + let c = class(rng); + let pool = if matches!(c, Class::Left) { + &left + } else { + &right + }; + let s = pool[rng.random_range(0..POOL)].as_str(); + runner.run_one(c, || black_box(ore.encrypt_str(black_box(s)).unwrap())); + } +} + +/// Two fixed random 512-byte draw streams for the isolation benches. +fn stream_pair(rng: &mut BenchRng) -> ([u8; 512], [u8; 512]) { + let mut a = [0u8; 512]; + let mut b = [0u8; 512]; + for x in a.iter_mut().chain(b.iter_mut()) { + *x = rng.random::(); + } + (a, b) +} + +/// Mechanism isolation, swap half: the Fisher–Yates swap loop alone, at +/// the secret draw-derived index, on two fixed streams. +fn iso_swap_secret_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) { + let (a, b) = stream_pair(rng); + for _ in 0..SAMPLES { + let c = class(rng); + let s = if matches!(c, Class::Left) { &a } else { &b }; + runner.run_one(c, || { + let mut out = Line([0u8; 64]); + ct_bench::fy_swaps_only(black_box(s), &mut out); + black_box(out.0[0]) + }); + } +} + +/// Control for the swap half: the same draws and swaps at a public index. +fn iso_swap_public_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) { + let (a, b) = stream_pair(rng); + for _ in 0..SAMPLES { + let c = class(rng); + let s = if matches!(c, Class::Left) { &a } else { &b }; + runner.run_one(c, || { + let mut out = Line([0u8; 64]); + ct_bench::fy_swaps_public(black_box(s), &mut out); + black_box(out.0[0]) + }); + } +} + +/// Mechanism isolation, inverse half: the inverse fill alone, from the two +/// permutations two fixed streams build (computed outside the measurement). +fn iso_fill_secret_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) { + let (a, b) = stream_pair(rng); + let (pa, pb) = (ct_bench::permutation_for(&a), ct_bench::permutation_for(&b)); + for _ in 0..SAMPLES { + let c = class(rng); + let p = if matches!(c, Class::Left) { &pa } else { &pb }; + runner.run_one(c, || { + let mut out = Line([0u8; 64]); + ct_bench::inverse_fill_only(black_box(p), &mut out); + black_box(out.0[0]) + }); + } +} + +/// Control for the inverse half: the same loads and stores at public offsets. +fn iso_fill_public_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) { + let (a, b) = stream_pair(rng); + let (pa, pb) = (ct_bench::permutation_for(&a), ct_bench::permutation_for(&b)); + for _ in 0..SAMPLES { + let c = class(rng); + let p = if matches!(c, Class::Left) { &pa } else { &pb }; + runner.run_one(c, || { + let mut out = Line([0u8; 64]); + ct_bench::inverse_fill_public(black_box(p), &mut out); + black_box(out.0[0]) + }); + } +} + +/// `prp_build_fixed_a_vs_fixed_b` through the reference builder (textbook +/// Fisher–Yates, secret-indexed), for before/after comparison in one binary. +fn prp_build_reference_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) { + let (a, b) = stream_pair(rng); + for _ in 0..SAMPLES { + let c = class(rng); + let s = if matches!(c, Class::Left) { &a } else { &b }; + runner.run_one(c, || { + black_box(ct_bench::lemire_fy_prp_from_stream_reference(black_box(s))) + }); + } +} + +/// `prp_build_fixed_a_vs_fixed_b` through the portable scalar oblivious +/// builder. +fn prp_build_scalar_obl_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) { + let (a, b) = stream_pair(rng); + for _ in 0..SAMPLES { + let c = class(rng); + let s = if matches!(c, Class::Left) { &a } else { &b }; + runner.run_one(c, || { + black_box(ct_bench::lemire_fy_prp_from_stream_scalar_oblivious( + black_box(s), + )) + }); + } +} + +/// `prp_build_fixed_a_vs_fixed_b` through the portable SWAR oblivious +/// builder (the fallback on targets with neither NEON nor SSSE3). +fn prp_build_swar_obl_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) { + let (a, b) = stream_pair(rng); + for _ in 0..SAMPLES { + let c = class(rng); + let s = if matches!(c, Class::Left) { &a } else { &b }; + runner.run_one(c, || { + black_box(ct_bench::lemire_fy_prp_from_stream_swar_oblivious( + black_box(s), + )) + }); + } +} + +ctbench_main!( + bit6_compare_first_vs_last_block, + chained_compare_first_vs_last_byte, + bit6_encrypt_zero_vs_random, + bit6_encrypt_const_vs_random, + bit6_encrypt_left_const_vs_random, + bit6_encrypt_left_fixed_a_vs_fixed_b, + prp_build_const_vs_random, + prp_build_fixed_a_vs_fixed_b, + chained_encrypt_fixed_vs_random, + iso_swap_secret_fixed_a_vs_fixed_b, + iso_swap_public_fixed_a_vs_fixed_b, + iso_fill_secret_fixed_a_vs_fixed_b, + iso_fill_public_fixed_a_vs_fixed_b, + prp_build_reference_fixed_a_vs_fixed_b, + prp_build_scalar_obl_fixed_a_vs_fixed_b, + prp_build_swar_obl_fixed_a_vs_fixed_b +); diff --git a/packages/ore-rs/Cargo.toml b/packages/ore-rs/Cargo.toml index c57ba418..1a58f2ff 100644 --- a/packages/ore-rs/Cargo.toml +++ b/packages/ore-rs/Cargo.toml @@ -31,6 +31,11 @@ chrono = ["dep:chrono", "orderable-bytes/chrono"] # order-preserving encoding fed into the existing fixed-N ORE machinery. # The encoder lives in the sibling `orderable-bytes` crate. decimal = ["dep:rust_decimal", "orderable-bytes/decimal"] +# Exposes `ct_bench`, timing-measurement entry points to otherwise private +# primitives, for the detached `packages/ct-dudect` crate. Not part of the +# public API: a feature rather than a `cfg` so it never trips +# `unexpected_cfgs`, and never enabled by a dependant. +ct-bench = [] [dependencies] aes = { version = "0.8.2", features = ["zeroize"] } diff --git a/packages/ore-rs/src/lib.rs b/packages/ore-rs/src/lib.rs index a9d86410..a67009df 100644 --- a/packages/ore-rs/src/lib.rs +++ b/packages/ore-rs/src/lib.rs @@ -144,6 +144,125 @@ mod encrypt; mod primitives; pub mod scheme; pub use crate::ciphertext::*; + +/// Timing-measurement entry points to private primitives, for the detached +/// `ct-dudect` crate. Enabled only by the `ct-bench` feature; not public API. +#[cfg(feature = "ct-bench")] +#[doc(hidden)] +pub mod ct_bench { + use crate::primitives::prp::{oblivious, LemireFyPrp}; + use crate::primitives::Prp; + + /// Build the 64-element Bit6 PRP from a 512-byte draw stream and return + /// one permuted value, so the construction is observably used. This is + /// the builder the schemes use (the target's oblivious builder). + pub fn lemire_fy_prp_from_stream(stream: &[u8; 512]) -> u8 { + LemireFyPrp::<64>::from_stream(stream) + .expect("512 bytes is a full stream") + .permute(0) + .expect("0 is in the domain") + } + + fn from_stream_with(stream: &[u8; 512], build: fn(&[u8], &mut [u8; 64], &mut [u8; 64])) -> u8 { + LemireFyPrp::<64>::from_stream_with(stream, build) + .expect("512 bytes is a full stream") + .permute(0) + .expect("0 is in the domain") + } + + /// As [`lemire_fy_prp_from_stream`], through the reference builder: + /// textbook Fisher–Yates with secret-indexed swaps and inverse fill. + /// Not used by any scheme; here for before/after timing. + pub fn lemire_fy_prp_from_stream_reference(stream: &[u8; 512]) -> u8 { + from_stream_with(stream, oblivious::reference::build) + } + + /// As [`lemire_fy_prp_from_stream`], through the byte-at-a-time + /// `subtle_ng` oblivious builder whatever the target. + pub fn lemire_fy_prp_from_stream_scalar_oblivious(stream: &[u8; 512]) -> u8 { + from_stream_with(stream, oblivious::scalar::build) + } + + /// As [`lemire_fy_prp_from_stream`], through the portable SWAR + /// oblivious builder whatever the target. + pub fn lemire_fy_prp_from_stream_swar_oblivious(stream: &[u8; 512]) -> u8 { + from_stream_with(stream, oblivious::swar::build) + } + + // ----------------------------------------------------------------- + // Mechanism isolation: the two halves of the reference builder, and a + // control for each with the same work at public addresses. Each keeps + // its table in one 64-byte-aligned line, as `LemireFyPrp<64>` does. + // ----------------------------------------------------------------- + + /// One 64-byte, line-aligned table. + #[repr(C, align(64))] + pub struct Line(pub [u8; 64]); + + /// Lemire draw for step `i`, reduced to `0..=i`, as the builders do. + #[inline(always)] + fn draw(stream: &[u8; 512], i: usize) -> usize { + let d = 63 - i; + let mut b = [0u8; 8]; + b.copy_from_slice(&stream[d * 8..d * 8 + 8]); + ((u64::from_le_bytes(b) as u128 * (i as u128 + 1)) >> 64) as usize + } + + /// The reference builder's Fisher–Yates swap loop alone (no inverse + /// fill): swaps at the secret draw-derived index. + #[inline(never)] + pub fn fy_swaps_only(stream: &[u8; 512], out: &mut Line) { + for (k, p) in out.0.iter_mut().enumerate() { + *p = k as u8; + } + for i in (1..64).rev() { + let j = draw(stream, i); + out.0.swap(i, j); + } + } + + /// Control for [`fy_swaps_only`]: the same draws and the same swaps, + /// but at the public index `i - 1` (the draw is computed and kept live, + /// then not used as an address). + #[inline(never)] + pub fn fy_swaps_public(stream: &[u8; 512], out: &mut Line) { + for (k, p) in out.0.iter_mut().enumerate() { + *p = k as u8; + } + for i in (1..64).rev() { + core::hint::black_box(draw(stream, i)); + out.0.swap(i, i - 1); + } + } + + /// The permutation the builders build for `stream`, computed outside + /// any measured region, to feed [`inverse_fill_only`]. + pub fn permutation_for(stream: &[u8; 512]) -> Line { + let mut l = Line([0u8; 64]); + fy_swaps_only(stream, &mut l); + l + } + + /// The reference builder's inverse fill alone: `inverse[perm[k]] = k`, + /// stores at secret offsets. + #[inline(never)] + pub fn inverse_fill_only(perm: &Line, out: &mut Line) { + for (index, val) in perm.0.iter().enumerate() { + out.0[*val as usize & 63] = index as u8; + } + } + + /// Control for [`inverse_fill_only`]: the same loads and stores, at the + /// public offset `index` (the loaded value is kept live, not used as an + /// address). + #[inline(never)] + pub fn inverse_fill_public(perm: &Line, out: &mut Line) { + for (index, val) in perm.0.iter().enumerate() { + core::hint::black_box(*val); + out.0[index] = index as u8; + } + } +} pub use crate::encrypt::OreEncrypt; use primitives::PrpError; use std::cmp::Ordering; diff --git a/packages/ore-rs/src/primitives/prp.rs b/packages/ore-rs/src/primitives/prp.rs index b203785a..bcfbe5b1 100644 --- a/packages/ore-rs/src/primitives/prp.rs +++ b/packages/ore-rs/src/primitives/prp.rs @@ -215,6 +215,25 @@ macro_rules! impl_lemire_fy_prp { Ok(perm) } + + /// [`Self::from_stream`] through a named builder whatever the + /// target: the reference builder or one of the portable + /// oblivious ones. For the `ct-bench` timing hooks only. + #[cfg(feature = "ct-bench")] + pub(crate) fn from_stream_with( + stream: &[u8], + build: fn(&[u8], &mut [u8; $domain], &mut [u8; $domain]), + ) -> PrpResult { + if stream.len() < ($domain - 1) * 8 { + return Err(PrpError); + } + let mut perm = Self { + permutation: [0u8; $domain], + inverse: [0u8; $domain], + }; + build(stream, &mut perm.permutation, &mut perm.inverse); + Ok(perm) + } } impl Prp for LemireFyPrp<$domain> { diff --git a/packages/ore-rs/src/primitives/prp/oblivious.rs b/packages/ore-rs/src/primitives/prp/oblivious.rs index 986974a2..b85735c9 100644 --- a/packages/ore-rs/src/primitives/prp/oblivious.rs +++ b/packages/ore-rs/src/primitives/prp/oblivious.rs @@ -16,8 +16,8 @@ //! - `swar` holds each table as eight `u64` words and does every //! secret-indexed read and write as a masked pass over the words, with //! exact SWAR byte-equality masks. Portable fallback. -//! - `scalar` (tests only) is the same construction a byte at a time with -//! `subtle_ng` choices. +//! - `scalar` (tests and benches only) is the same construction a byte at +//! a time with `subtle_ng` choices. //! - `neon` (aarch64) holds both tables in eight 16-byte registers for the //! whole build: `tbl` fetches `perm[j]` by a broadcast index, and //! compare-and-select against a constant index vector writes back. @@ -101,9 +101,10 @@ pub(crate) fn build(stream: &[u8], perm: &mut [u8; DOMAIN], inverse: &mut [u8; D /// The reference builder: textbook Fisher–Yates, swapping at the /// draw-derived index, then `inverse[perm[k]] = k`. Both use a secret value /// as an address, which is the timing channel the oblivious builders close, -/// so it is never compiled into the library: it exists only as the -/// specification the oblivious builders are tested against. -#[cfg(test)] +/// so it is never compiled into a shipped build: it exists only as the +/// specification the oblivious builders are tested against, and for the +/// `ct-bench` before/after timing hooks. +#[cfg(any(test, feature = "ct-bench"))] pub(crate) mod reference { use super::*; @@ -124,9 +125,10 @@ pub(crate) mod reference { /// Byte-at-a-time oblivious builder with `subtle_ng` choices: full-scan /// reads and masked full-table writes, the inverse maintained alongside. /// The most direct form of the construction, kept as a second reference -/// for the tests; `subtle_ng`'s per-choice optimisation barrier makes it -/// about 17x slower than [`swar`], which the dispatcher uses instead. -#[cfg(test)] +/// for the tests and the timing benches; `subtle_ng`'s per-choice +/// optimisation barrier makes it about 17x slower than [`swar`], which the +/// dispatcher uses instead. +#[cfg(any(test, feature = "ct-bench"))] pub(crate) mod scalar { use super::*; use subtle_ng::{ConditionallySelectable, ConstantTimeEq}; @@ -171,7 +173,7 @@ pub(crate) mod scalar { /// a public bound). The inverse is updated on the value side, as in the /// NEON builder. Pure integer arithmetic with no `subtle_ng` optimisation barrier /// per byte, so the compiler can keep the tables in registers. -#[cfg(any(not(target_arch = "aarch64"), test))] +#[cfg(any(not(target_arch = "aarch64"), test, feature = "ct-bench"))] pub(crate) mod swar { use super::*; From 89cc8ed3e988a9eb6c00d75b0ff5ff0151588dea Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 09:54:42 +1100 Subject: [PATCH 05/35] =?UTF-8?q?ci:=20verify=20workflow=20=E2=80=94=20Val?= =?UTF-8?q?grind=20taint,=20fuzz=20targets,=20Kani=20harnesses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs on every pull request regardless of base, like test.yml, so the stacked PRs get it. --- .github/workflows/verify.yml | 98 ++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 .github/workflows/verify.yml diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 00000000..8044100a --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,98 @@ +name: "Verify" +# Dynamic and formal evidence for the v2 ORE implementation, beyond the unit +# and property tests in `test.yml`: +# +# * ct-taint: the ctgrind-style Valgrind run (packages/ct-taint). Key and +# plaintext are marked undefined; every secret-dependent branch or +# secret-indexed access memcheck reports fails the job unless it is one of +# the documented exceptions in packages/ct-taint/valgrind.supp. +# * fuzz: the libFuzzer targets (packages/ore-rs/fuzz) for the ciphertext +# parsers and raw comparators, time-boxed per pull request. +# * kani: the bounded model-checking harnesses (`#[cfg(kani)]`) for the pure +# building blocks. +# +# Like test.yml, this runs on every pull request regardless of base branch, so +# the stacked PRs get it too. +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +defaults: + run: + shell: bash -l {0} + +jobs: + ct-taint: + runs-on: ubuntu-latest + name: "ðŸ”� Valgrind taint" + steps: + - uses: actions/checkout@v4 + + - name: setup rust + uses: dtolnay/rust-toolchain@stable + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + packages/ct-taint/target/ + key: ${{ runner.os }}-ct-taint-${{ hashFiles('packages/ct-taint/Cargo.lock', 'packages/ore-rs/src/**') }} + + # crabgrind binds the Valgrind client-request header at build time. + - name: install valgrind + run: sudo apt-get update -qq && sudo apt-get install -y -qq valgrind clang libclang-dev + + - name: taint run + run: scripts/ct-taint.sh + + fuzz: + runs-on: ubuntu-latest + name: "🧬 fuzz (${{ matrix.target }})" + strategy: + fail-fast: false + matrix: + target: [chained_parse, chained_compare, bit6_parse, bit8_parse] + steps: + - uses: actions/checkout@v4 + + - name: setup nightly (cargo-fuzz requires it) + uses: dtolnay/rust-toolchain@nightly + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + ~/.cargo/bin/cargo-fuzz + packages/ore-rs/fuzz/target/ + key: ${{ runner.os }}-fuzz-${{ hashFiles('packages/ore-rs/fuzz/Cargo.lock', 'packages/ore-rs/src/**') }} + + - name: install cargo-fuzz + run: command -v cargo-fuzz >/dev/null || cargo install cargo-fuzz --locked + + - name: fuzz + run: scripts/fuzz.sh 120 ${{ matrix.target }} + + - name: keep any crashing input + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fuzz-artifacts-${{ matrix.target }} + path: packages/ore-rs/fuzz/artifacts/ + if-no-files-found: ignore + + kani: + runs-on: ubuntu-latest + name: "✅ Kani" + steps: + - uses: actions/checkout@v4 + + - uses: model-checking/kani-github-action@v1.1 + with: + args: -p ore-rs From add3f0f07424a24efad4c51cb67ac2a64d9c63a0 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 12:23:27 +1100 Subject: [PATCH 06/35] test(ore-rs): kani harnesses for the pure building blocks Kani proof harnesses (cargo kani 0.67) for the unkeyed pieces, in from a full `cargo kani -p ore-rs` run (18/18 verified; the prp.rs harnesses re-timed one by one after the oblivious builder landed). scheme/width.rs - ct_select_byte_equals_index_up_to_256: ct_select_byte == block[idx] for every block of length 1..=256 and every idx < len. 519s - ct_select_byte_equals_index_right_blocks: same for the 8- and 32-byte right blocks, all contents and indices. 1s - ct_bit_equals_shift: ct_bit == (byte >> pos) & 1 for pos < 8, and 0 for pos >= 8; exhaustive. <1s primitives/prp.rs - oblivious_lookup_64_matches_index: Ok(table[i]) for i < 64, Err for i >= 64, all 64-entry tables. 2s - oblivious_lookup_256_matches_index: Ok(table[i]) for every u8 index, all 256-entry tables. 17s - oblivious_lookup_any_len_matches_index: same contract for every table length 0..=256. 439s - lemire_reduction_in_range: ((x * (i + 1)) >> 64) <= i for every u64 x and every i < 64. <1s (unbounded i did not finish in 9 minutes) - lemire_fy_reference_is_permutation_first_draws_symbolic: the reference (textbook Fisher-Yates) builder yields a permutation of 0..64 and its inverse when the first 6 draws are symbolic and the other 57 are a fixed stream. 200s. All 63 draws symbolic did not finish in 30 minutes; 8 and 12 symbolic draws did not finish in 20. - oblivious_swar_matches_reference_first_draws_symbolic: the SWAR oblivious builder's permutation and inverse equal the reference builder's, same stream shape. 872s. - lemire_fy_from_stream_rejects_short: Err for every stream shorter than 504 bytes. 335s scheme/decompose.rs - num_blocks_6bit_is_ceil_8n_over_6: least b with 6b >= 8n, every n with 8n representable. <1s - decompose_6bit_matches_bit_spec: inputs of 0..=16 bytes; every symbol < 64 and bit 5-t of block i is plaintext bit 6i+t (zero past the end). 5s - decompose_6bit_injective: inputs of 0..=16 bytes, any two lengths. 8s - decompose_6bit_preserves_order: equal-length inputs of 0..=16 bytes. 9s primitives/cmac.rs (full domains) - final_block_injective: equal blocks imply equal (branch, n, s, width). <1s - prefix_block_injective: equal blocks imply equal (pos, sym). <1s - prefix_and_final_blocks_disjoint: no prefix block equals a final block. <1s primitives/hash.rs - gf128_double_matches_reference: gf128_double_u128 and gf128_double equal a bytewise NIST SP 800-38B dbl for every 128-bit input. <1s Kani cannot model the NEON or SSSE3 intrinsics, so under cfg(kani) the oblivious builder dispatches to SWAR on every target; the equivalence harness is what makes that the builder proved. Cargo.toml declares cfg(kani) for the unexpected_cfgs lint, so stable builds stay warning-free. Cargo 1.78 ignores the check-cfg key with an "unused manifest key" note. --- packages/ore-rs/Cargo.toml | 4 + packages/ore-rs/src/primitives/cmac.rs | 43 ++++++ packages/ore-rs/src/primitives/hash.rs | 36 +++++ packages/ore-rs/src/primitives/prp.rs | 136 ++++++++++++++++++ .../ore-rs/src/primitives/prp/oblivious.rs | 21 +-- packages/ore-rs/src/scheme/decompose.rs | 100 +++++++++++++ packages/ore-rs/src/scheme/width.rs | 49 +++++++ 7 files changed, 380 insertions(+), 9 deletions(-) diff --git a/packages/ore-rs/Cargo.toml b/packages/ore-rs/Cargo.toml index 1a58f2ff..e28c8ac6 100644 --- a/packages/ore-rs/Cargo.toml +++ b/packages/ore-rs/Cargo.toml @@ -52,6 +52,10 @@ subtle-ng = "2.5.0" zeroize = { version = "1.5.7", features = ["zeroize_derive"] } thiserror = "1.0.38" +# `cfg(kani)` gates the Kani proof harnesses (compiled only by `cargo kani`). +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(kani)'] } + [[bench]] name = "oreaes128" harness = false diff --git a/packages/ore-rs/src/primitives/cmac.rs b/packages/ore-rs/src/primitives/cmac.rs index 35fec7a2..d4c89ceb 100644 --- a/packages/ore-rs/src/primitives/cmac.rs +++ b/packages/ore-rs/src/primitives/cmac.rs @@ -272,3 +272,46 @@ mod tests { } } } + +#[cfg(kani)] +mod kani_proofs { + use super::*; + + fn any_branch() -> Branch { + if kani::any() { + Branch::RoKey + } else { + Branch::PrpStream + } + } + + /// `final_block` is injective over its full domain: for all + /// `(branch, n, s, width)` pairs, equal blocks imply equal tuples. + #[kani::proof] + fn final_block_injective() { + let (b1, n1, s1, w1) = (any_branch(), kani::any(), kani::any(), kani::any()); + let (b2, n2, s2, w2) = (any_branch(), kani::any(), kani::any(), kani::any()); + if final_block(b1, n1, s1, w1) == final_block(b2, n2, s2, w2) { + assert!(b1 == b2 && n1 == n2 && s1 == s2 && w1 == w2); + } + } + + /// `prefix_block` is injective over its full domain: for all + /// `(pos, sym)` pairs, equal blocks imply equal pairs. + #[kani::proof] + fn prefix_block_injective() { + let (p1, y1): (u16, u8) = (kani::any(), kani::any()); + let (p2, y2): (u16, u8) = (kani::any(), kani::any()); + if prefix_block(p1, y1) == prefix_block(p2, y2) { + assert!(p1 == p2 && y1 == y2); + } + } + + /// No prefix block equals any final block, over both full domains. + #[kani::proof] + fn prefix_and_final_blocks_disjoint() { + let pb = prefix_block(kani::any(), kani::any()); + let fb = final_block(any_branch(), kani::any(), kani::any(), kani::any()); + assert!(pb != fb); + } +} diff --git a/packages/ore-rs/src/primitives/hash.rs b/packages/ore-rs/src/primitives/hash.rs index 173f64c7..898c6765 100644 --- a/packages/ore-rs/src/primitives/hash.rs +++ b/packages/ore-rs/src/primitives/hash.rs @@ -275,3 +275,39 @@ mod tests { assert_eq!(b, want); } } + +#[cfg(kani)] +mod kani_proofs { + use super::*; + + /// Reference GF(2^128) doubling on a big-endian byte string (NIST SP + /// 800-38B `dbl`): shift left one bit across bytes, then XOR `0x87` + /// into the last byte iff the dropped MSB was set. + fn dbl_reference(b: [u8; 16]) -> [u8; 16] { + let mut out = [0u8; 16]; + for k in 0..16 { + let next_msb = if k + 1 < 16 { b[k + 1] >> 7 } else { 0 }; + out[k] = (b[k] << 1) | next_msb; + } + if b[0] & 0x80 != 0 { + out[15] ^= 0x87; + } + out + } + + /// `gf128_double_u128` and the in-place `gf128_double` both equal the + /// bytewise reference `dbl` for every 128-bit input (full domain). + #[kani::proof] + #[kani::unwind(17)] + fn gf128_double_matches_reference() { + let b: [u8; 16] = kani::any(); + let expected = dbl_reference(b); + assert_eq!( + gf128_double_u128(u128::from_be_bytes(b)).to_be_bytes(), + expected + ); + let mut in_place = b; + gf128_double(&mut in_place); + assert_eq!(in_place, expected); + } +} diff --git a/packages/ore-rs/src/primitives/prp.rs b/packages/ore-rs/src/primitives/prp.rs index bcfbe5b1..24a96ed6 100644 --- a/packages/ore-rs/src/primitives/prp.rs +++ b/packages/ore-rs/src/primitives/prp.rs @@ -407,3 +407,139 @@ mod tests { } } } + +#[cfg(kani)] +mod kani_proofs { + use super::*; + + /// `oblivious_lookup` over a 64-entry table returns `Ok(table[i])` for + /// every `i < 64` and `Err` for every `i >= 64` (all tables, all `u8`). + #[kani::proof] + #[kani::unwind(65)] + fn oblivious_lookup_64_matches_index() { + let table: [u8; 64] = kani::any(); + let index: u8 = kani::any(); + let got = oblivious_lookup(&table, index); + if index < 64 { + assert!(matches!(got, Ok(v) if v == table[index as usize])); + } else { + assert!(got.is_err()); + } + } + + /// `oblivious_lookup` over a 256-entry table returns `Ok(table[i])` for + /// every `u8` index (all tables; no index is out of range). + #[kani::proof] + #[kani::unwind(257)] + fn oblivious_lookup_256_matches_index() { + let table: [u8; 256] = kani::any(); + let index: u8 = kani::any(); + assert!(matches!(oblivious_lookup(&table, index), Ok(v) if v == table[index as usize])); + } + + /// `oblivious_lookup` over a table of any length 0..=256 returns + /// `Ok(table[i])` when `i < len` and `Err` otherwise. + #[kani::proof] + #[kani::unwind(257)] + fn oblivious_lookup_any_len_matches_index() { + let buf: [u8; 256] = kani::any(); + let len: usize = kani::any(); + kani::assume(len <= 256); + let table = &buf[..len]; + let index: u8 = kani::any(); + let got = oblivious_lookup(table, index); + if usize::from(index) < len { + assert!(matches!(got, Ok(v) if v == table[index as usize])); + } else { + assert!(got.is_err()); + } + } + + /// Lemire reduction `((x * (i + 1)) >> 64)` is `<= i` for every `x: u64` + /// and every step index `i < 64` (the `LemireFyPrp<64>` domain), so the + /// Fisher–Yates swap index never exceeds the step index. + #[kani::proof] + fn lemire_reduction_in_range() { + let x: u64 = kani::any(); + let i: u64 = kani::any(); + kani::assume(i < 64); + let j = (x as u128 * (i as u128 + 1)) >> 64; + assert!(j <= i as u128); + } + + /// Asserts `perm` is a permutation of `0..64` with `inverse` its + /// two-sided inverse. Checking `perm[i] < 64` and `inverse[perm[i]] == i` + /// for every `i` suffices: it makes `perm` injective on a 64-element + /// domain into `0..64`, hence a bijection, and `inverse` then agrees with + /// `permâ�»Â¹` at every point of `0..64`. + fn assert_is_permutation(perm: &[u8; 64], inverse: &[u8; 64]) { + for (i, p) in perm.iter().enumerate() { + assert!(*p < 64); + assert_eq!(inverse[*p as usize] as usize, i); + } + } + + /// A fixed non-trivial keystream, used for the concrete draws of the + /// bounded harness below. + const FIXED_STREAM: [u8; 512] = { + let mut s = [0u8; 512]; + let mut n = 0; + while n < 512 { + s[n] = (n as u8).wrapping_mul(167).wrapping_add(13); + n += 1; + } + s + }; + + /// Number of leading symbolic draws in the bounded harness below. + const SYMBOLIC_DRAWS: usize = 6; + + /// The reference builder (textbook Fisher–Yates, which the oblivious + /// builders are proved equal to below) yields a permutation of `0..64` + /// and its inverse for every stream whose first `SYMBOLIC_DRAWS` draws + /// (driving steps 63 down to `64 - SYMBOLIC_DRAWS`) are arbitrary and + /// whose remaining draws come from `FIXED_STREAM`. Proved on the + /// reference rather than through `from_stream` (the SWAR builder under + /// Kani) because that takes under half the time; the harness below + /// carries the property over to the SWAR builder. + /// With all 63 draws symbolic CBMC does not finish in 30 minutes; 8 + /// symbolic draws does not finish in 20. + #[kani::proof] + #[kani::unwind(65)] + fn lemire_fy_reference_is_permutation_first_draws_symbolic() { + let mut stream = FIXED_STREAM; + let head: [u8; SYMBOLIC_DRAWS * 8] = kani::any(); + stream[..SYMBOLIC_DRAWS * 8].copy_from_slice(&head); + let (mut perm, mut inverse) = ([0u8; 64], [0u8; 64]); + oblivious::reference::build(&stream, &mut perm, &mut inverse); + assert_is_permutation(&perm, &inverse); + } + + /// The SWAR oblivious builder (what `from_stream` dispatches to under + /// Kani, and on targets with neither NEON nor SSSE3) builds exactly the + /// reference builder's `permutation` and `inverse` for every stream whose + /// first `SYMBOLIC_DRAWS` draws are arbitrary and whose remaining draws + /// come from `FIXED_STREAM`: same shape as the harness above. + #[kani::proof] + #[kani::unwind(65)] + fn oblivious_swar_matches_reference_first_draws_symbolic() { + let mut stream = FIXED_STREAM; + let head: [u8; SYMBOLIC_DRAWS * 8] = kani::any(); + stream[..SYMBOLIC_DRAWS * 8].copy_from_slice(&head); + let (mut perm, mut inverse) = ([0u8; 64], [0u8; 64]); + oblivious::swar::build(&stream, &mut perm, &mut inverse); + let (mut want_perm, mut want_inverse) = ([0u8; 64], [0u8; 64]); + oblivious::reference::build(&stream, &mut want_perm, &mut want_inverse); + assert!(perm == want_perm); + assert!(inverse == want_inverse); + } + + /// `from_stream` rejects every stream shorter than 63 * 8 bytes. + #[kani::proof] + fn lemire_fy_from_stream_rejects_short() { + let buf: [u8; 503] = [0u8; 503]; + let len: usize = kani::any(); + kani::assume(len < 63 * 8); + assert!(LemireFyPrp::<64>::from_stream(&buf[..len]).is_err()); + } +} diff --git a/packages/ore-rs/src/primitives/prp/oblivious.rs b/packages/ore-rs/src/primitives/prp/oblivious.rs index b85735c9..dad9a39e 100644 --- a/packages/ore-rs/src/primitives/prp/oblivious.rs +++ b/packages/ore-rs/src/primitives/prp/oblivious.rs @@ -79,15 +79,18 @@ fn lemire_draw(stream: &[u8], i: usize) -> u8 { pub(crate) fn build(stream: &[u8], perm: &mut [u8; DOMAIN], inverse: &mut [u8; DOMAIN]) { assert!(stream.len() >= STREAM_BYTES); - #[cfg(target_arch = "aarch64")] + // Kani cannot model the NEON and SSSE3 intrinsics, so under `cargo kani` + // every target dispatches to the SWAR builder, which the harnesses prove + // equal to the reference builder. + #[cfg(all(target_arch = "aarch64", not(kani)))] // SAFETY: NEON is baseline on aarch64; `stream` length asserted above. unsafe { neon::build(stream, perm, inverse) } - #[cfg(not(target_arch = "aarch64"))] + #[cfg(any(not(target_arch = "aarch64"), kani))] { - #[cfg(target_arch = "x86_64")] + #[cfg(all(target_arch = "x86_64", not(kani)))] if is_x86_feature_detected!("ssse3") { // SAFETY: SSSE3 presence just checked; `stream` length asserted. unsafe { ssse3::build(stream, perm, inverse) }; @@ -102,9 +105,9 @@ pub(crate) fn build(stream: &[u8], perm: &mut [u8; DOMAIN], inverse: &mut [u8; D /// draw-derived index, then `inverse[perm[k]] = k`. Both use a secret value /// as an address, which is the timing channel the oblivious builders close, /// so it is never compiled into a shipped build: it exists only as the -/// specification the oblivious builders are tested against, and for the -/// `ct-bench` before/after timing hooks. -#[cfg(any(test, feature = "ct-bench"))] +/// specification the oblivious builders are tested and proved (Kani) +/// against, and for the `ct-bench` before/after timing hooks. +#[cfg(any(test, kani, feature = "ct-bench"))] pub(crate) mod reference { use super::*; @@ -173,7 +176,7 @@ pub(crate) mod scalar { /// a public bound). The inverse is updated on the value side, as in the /// NEON builder. Pure integer arithmetic with no `subtle_ng` optimisation barrier /// per byte, so the compiler can keep the tables in registers. -#[cfg(any(not(target_arch = "aarch64"), test, feature = "ct-bench"))] +#[cfg(any(not(target_arch = "aarch64"), kani, test, feature = "ct-bench"))] pub(crate) mod swar { use super::*; @@ -254,7 +257,7 @@ pub(crate) mod swar { } } -#[cfg(target_arch = "aarch64")] +#[cfg(all(target_arch = "aarch64", not(kani)))] mod neon { use super::{lemire_draw, DOMAIN}; use core::arch::aarch64::*; @@ -351,7 +354,7 @@ mod neon { } } -#[cfg(target_arch = "x86_64")] +#[cfg(all(target_arch = "x86_64", not(kani)))] mod ssse3 { use super::{lemire_draw, DOMAIN}; use core::arch::x86_64::*; diff --git a/packages/ore-rs/src/scheme/decompose.rs b/packages/ore-rs/src/scheme/decompose.rs index f49b8af4..0b25715f 100644 --- a/packages/ore-rs/src/scheme/decompose.rs +++ b/packages/ore-rs/src/scheme/decompose.rs @@ -110,3 +110,103 @@ mod tests { assert_eq!(out, [0b111111, 0b000000, 0b111100]); } } + +#[cfg(kani)] +mod kani_proofs { + use super::*; + + /// Inputs up to this many bytes are covered by the bounded harnesses. + const MAX_LEN: usize = 16; + /// `num_blocks_6bit(MAX_LEN)`. + const MAX_BLOCKS: usize = 22; + + /// Bit `k` (MSB-first) of `bytes`, or 0 past the end. + fn input_bit(bytes: &[u8], k: usize) -> u8 { + if k / 8 < bytes.len() { + (bytes[k / 8] >> (7 - k % 8)) & 1 + } else { + 0 + } + } + + /// `num_blocks_6bit(n)` is the least `b` with `6b >= 8n`, for every `n` + /// with `8n` representable in `usize` (full domain of non-overflowing `n`). + #[kani::proof] + fn num_blocks_6bit_is_ceil_8n_over_6() { + let n: usize = kani::any(); + kani::assume(n <= usize::MAX / 8); + let b = num_blocks_6bit(n); + assert!(6 * (b as u128) >= 8 * (n as u128)); + if b > 0 { + assert!(6 * ((b - 1) as u128) < 8 * (n as u128)); + } + } + + /// For every input of length `0..=16`: every output symbol is `< 64`, and + /// bit `5 - t` of block `i` is plaintext bit `6i + t` (MSB-first, zero + /// past the end) — the exact packing spec, which implies round-trip. + #[kani::proof] + #[kani::unwind(23)] + fn decompose_6bit_matches_bit_spec() { + let buf: [u8; MAX_LEN] = kani::any(); + let len: usize = kani::any(); + kani::assume(len <= MAX_LEN); + let bytes = &buf[..len]; + let nb = num_blocks_6bit(len); + let mut out_buf = [0u8; MAX_BLOCKS]; + let out = &mut out_buf[..nb]; + decompose_6bit(bytes, out); + + let i: usize = kani::any(); + kani::assume(i < nb); + assert!(out[i] < 64); + let t: usize = kani::any(); + kani::assume(t < 6); + assert_eq!((out[i] >> (5 - t)) & 1, input_bit(bytes, 6 * i + t)); + } + + /// For any two inputs of lengths `0..=16`, equal decompositions (same + /// block count and same symbols) imply equal inputs: decomposition is + /// injective on that domain. + #[kani::proof] + #[kani::unwind(23)] + fn decompose_6bit_injective() { + let a_buf: [u8; MAX_LEN] = kani::any(); + let b_buf: [u8; MAX_LEN] = kani::any(); + let a_len: usize = kani::any(); + let b_len: usize = kani::any(); + kani::assume(a_len <= MAX_LEN && b_len <= MAX_LEN); + let a = &a_buf[..a_len]; + let b = &b_buf[..b_len]; + + let mut a_out = [0u8; MAX_BLOCKS]; + let mut b_out = [0u8; MAX_BLOCKS]; + let a_nb = num_blocks_6bit(a_len); + let b_nb = num_blocks_6bit(b_len); + decompose_6bit(a, &mut a_out[..a_nb]); + decompose_6bit(b, &mut b_out[..b_nb]); + + if a_out[..a_nb] == b_out[..b_nb] { + assert!(a == b); + } + } + + /// For any two inputs of the same length `0..=16`, lexicographic order + /// of the block values equals lexicographic order of the bytes. + #[kani::proof] + #[kani::unwind(23)] + fn decompose_6bit_preserves_order() { + let a_buf: [u8; MAX_LEN] = kani::any(); + let b_buf: [u8; MAX_LEN] = kani::any(); + let len: usize = kani::any(); + kani::assume(len <= MAX_LEN); + let a = &a_buf[..len]; + let b = &b_buf[..len]; + let nb = num_blocks_6bit(len); + let mut a_out = [0u8; MAX_BLOCKS]; + let mut b_out = [0u8; MAX_BLOCKS]; + decompose_6bit(a, &mut a_out[..nb]); + decompose_6bit(b, &mut b_out[..nb]); + assert!(a.cmp(b) == a_out[..nb].cmp(&b_out[..nb])); + } +} diff --git a/packages/ore-rs/src/scheme/width.rs b/packages/ore-rs/src/scheme/width.rs index 1dd841a0..ab8ed298 100644 --- a/packages/ore-rs/src/scheme/width.rs +++ b/packages/ore-rs/src/scheme/width.rs @@ -193,3 +193,52 @@ impl BlockWidth for Bit6 { type Prp = crate::primitives::prp::LemireFyPrp<64>; type RoKeyBuf = [AesBlock; 64]; } + +#[cfg(kani)] +mod kani_proofs { + use super::*; + + /// `ct_select_byte(block, idx) == block[idx]` for every block of length + /// 1..=256 (the stated domain) and every in-range `idx`. + #[kani::proof] + #[kani::unwind(257)] + fn ct_select_byte_equals_index_up_to_256() { + let buf: [u8; 256] = kani::any(); + let len: usize = kani::any(); + kani::assume(len >= 1 && len <= 256); + let idx: usize = kani::any(); + kani::assume(idx < len); + let block = &buf[..len]; + assert_eq!(ct_select_byte(block, idx), block[idx]); + } + + /// `ct_select_byte(block, idx) == block[idx]` for the two right-block + /// sizes the comparators use (8 and 32 bytes), all contents, all + /// in-range `idx`. + #[kani::proof] + #[kani::unwind(33)] + fn ct_select_byte_equals_index_right_blocks() { + let b8: [u8; 8] = kani::any(); + let i8: usize = kani::any(); + kani::assume(i8 < 8); + assert_eq!(ct_select_byte(&b8, i8), b8[i8]); + + let b32: [u8; 32] = kani::any(); + let i32: usize = kani::any(); + kani::assume(i32 < 32); + assert_eq!(ct_select_byte(&b32, i32), b32[i32]); + } + + /// `ct_bit(byte, pos) == (byte >> pos) & 1` for every byte and every + /// `pos` in `0..8`, and is 0 for every `pos >= 8` (exhaustive). + #[kani::proof] + fn ct_bit_equals_shift() { + let byte: u8 = kani::any(); + let pos: u8 = kani::any(); + if pos < 8 { + assert_eq!(ct_bit(byte, pos), (byte >> pos) & 1); + } else { + assert_eq!(ct_bit(byte, pos), 0); + } + } +} From f4d5a1c9813a53f0bf9ea3d6e20192e52c9c37de Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 12:25:15 +1100 Subject: [PATCH 07/35] test(ore-rs): run the two exhaustive-length Kani harnesses only with kani-full They prove ct_select_byte and oblivious_lookup for every table length up to 256 and take about 16 of the full run's 22 minutes; the lengths in use are proved by the neighbouring harnesses in seconds. CI runs without the feature. --- .github/workflows/verify.yml | 4 +++- packages/ore-rs/Cargo.toml | 4 ++++ packages/ore-rs/src/primitives/prp.rs | 3 +++ packages/ore-rs/src/scheme/width.rs | 3 +++ 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 8044100a..cf17ee62 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -9,7 +9,9 @@ name: "Verify" # * fuzz: the libFuzzer targets (packages/ore-rs/fuzz) for the ciphertext # parsers and raw comparators, time-boxed per pull request. # * kani: the bounded model-checking harnesses (`#[cfg(kani)]`) for the pure -# building blocks. +# building blocks. The two exhaustive-length harnesses behind the +# `kani-full` feature are not run here (about 16 minutes); run +# `cargo kani -p ore-rs --features kani-full` by hand for the full set. # # Like test.yml, this runs on every pull request regardless of base branch, so # the stacked PRs get it too. diff --git a/packages/ore-rs/Cargo.toml b/packages/ore-rs/Cargo.toml index e28c8ac6..eb58b3d1 100644 --- a/packages/ore-rs/Cargo.toml +++ b/packages/ore-rs/Cargo.toml @@ -36,6 +36,10 @@ decimal = ["dep:rust_decimal", "orderable-bytes/decimal"] # public API: a feature rather than a `cfg` so it never trips # `unexpected_cfgs`, and never enabled by a dependant. ct-bench = [] +# Adds the two exhaustive-length Kani harnesses (about 16 minutes of the +# full run). CI runs `cargo kani` without it; run +# `cargo kani -p ore-rs --features kani-full` for the complete set. +kani-full = [] [dependencies] aes = { version = "0.8.2", features = ["zeroize"] } diff --git a/packages/ore-rs/src/primitives/prp.rs b/packages/ore-rs/src/primitives/prp.rs index 24a96ed6..d1df2d13 100644 --- a/packages/ore-rs/src/primitives/prp.rs +++ b/packages/ore-rs/src/primitives/prp.rs @@ -439,6 +439,9 @@ mod kani_proofs { /// `oblivious_lookup` over a table of any length 0..=256 returns /// `Ok(table[i])` when `i < len` and `Err` otherwise. + // Every length 0..=256 takes about seven minutes; the two lengths in + // use are proved above, so this runs only with `kani-full`. + #[cfg(feature = "kani-full")] #[kani::proof] #[kani::unwind(257)] fn oblivious_lookup_any_len_matches_index() { diff --git a/packages/ore-rs/src/scheme/width.rs b/packages/ore-rs/src/scheme/width.rs index ab8ed298..0d6535ab 100644 --- a/packages/ore-rs/src/scheme/width.rs +++ b/packages/ore-rs/src/scheme/width.rs @@ -200,6 +200,9 @@ mod kani_proofs { /// `ct_select_byte(block, idx) == block[idx]` for every block of length /// 1..=256 (the stated domain) and every in-range `idx`. + // Every length 1..=256 takes about nine minutes; the shipped lengths are + // proved by the harness below, so this runs only with `kani-full`. + #[cfg(feature = "kani-full")] #[kani::proof] #[kani::unwind(257)] fn ct_select_byte_equals_index_up_to_256() { From a1f3a9151682b188db47ff2644c4297bf95c5412 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 12:45:18 +1100 Subject: [PATCH 08/35] test(ct-dudect): make the input pool size configurable CT_DUDECT_POOL=4 keeps every input in L1, which separates a cache-driven timing difference from an arithmetic one; the default 512 reflects inputs arriving from memory. --- packages/ct-dudect/src/main.rs | 55 +++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/packages/ct-dudect/src/main.rs b/packages/ct-dudect/src/main.rs index 63ed5ce3..9666b852 100644 --- a/packages/ct-dudect/src/main.rs +++ b/packages/ct-dudect/src/main.rs @@ -30,7 +30,14 @@ const K2: [u8; 16] = [ ]; /// Inputs per class, precomputed so the measured region is the call alone. -const POOL: usize = 512; +/// `CT_DUDECT_POOL` overrides it: a pool of a few entries keeps every input +/// in L1, which separates cache effects from arithmetic ones. +fn pool_size() -> usize { + std::env::var("CT_DUDECT_POOL") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(512) +} /// Measurements per invocation of a bench (dudect's `--continuous` loops). const SAMPLES: usize = 100_000; @@ -74,9 +81,9 @@ fn class(rng: &mut BenchRng) -> Class { /// 6-bit block differs (`Right`). fn bit6_compare_first_vs_last_block(runner: &mut CtRunner, rng: &mut BenchRng) { let ore = bit6(); - let mut left = Vec::with_capacity(POOL); - let mut right = Vec::with_capacity(POOL); - for _ in 0..POOL { + let mut left = Vec::with_capacity(pool_size()); + let mut right = Vec::with_capacity(pool_size()); + for _ in 0..pool_size() { let x: u64 = rng.random::(); // Flip the top bit: block 0 differs. let y0 = x ^ (1 << 63); @@ -98,7 +105,7 @@ fn bit6_compare_first_vs_last_block(runner: &mut CtRunner, rng: &mut BenchRng) { } else { &right }; - let (a, b) = &pool[rng.random_range(0..POOL)]; + let (a, b) = &pool[rng.random_range(0..pool_size())]; runner.run_one(c, || { black_box(OreAes128Bit6ChaCha20::compare_raw_slices( black_box(a), @@ -112,9 +119,9 @@ fn bit6_compare_first_vs_last_block(runner: &mut CtRunner, rng: &mut BenchRng) { /// (`Left`) versus only the last byte differs (`Right`). fn chained_compare_first_vs_last_byte(runner: &mut CtRunner, rng: &mut BenchRng) { let ore = chained(); - let mut left = Vec::with_capacity(POOL); - let mut right = Vec::with_capacity(POOL); - for _ in 0..POOL { + let mut left = Vec::with_capacity(pool_size()); + let mut right = Vec::with_capacity(pool_size()); + for _ in 0..pool_size() { let mut s = [0u8; 17]; for b in s.iter_mut() { *b = rng.random_range(b'a'..=b'z'); @@ -138,7 +145,7 @@ fn chained_compare_first_vs_last_byte(runner: &mut CtRunner, rng: &mut BenchRng) } else { &right }; - let (a, b) = &pool[rng.random_range(0..POOL)]; + let (a, b) = &pool[rng.random_range(0..pool_size())]; runner.run_one(c, || { black_box(OreAes128Bit6ChainedChaCha20::compare_raw_slices( black_box(a), @@ -153,8 +160,8 @@ fn chained_compare_first_vs_last_byte(runner: &mut CtRunner, rng: &mut BenchRng) /// measurements is the same whichever class comes next. fn bit6_encrypt_zero_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { let ore = bit6(); - let left = vec![0u64; POOL]; - let right: Vec = (0..POOL).map(|_| rng.random::()).collect(); + let left = vec![0u64; pool_size()]; + let right: Vec = (0..pool_size()).map(|_| rng.random::()).collect(); for _ in 0..SAMPLES { let c = class(rng); let pool = if matches!(c, Class::Left) { @@ -162,7 +169,7 @@ fn bit6_encrypt_zero_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { } else { &right }; - let x = pool[rng.random_range(0..POOL)]; + let x = pool[rng.random_range(0..pool_size())]; runner.run_one(c, || black_box(black_box(x).encrypt(&ore).unwrap())); } } @@ -173,8 +180,8 @@ fn bit6_encrypt_zero_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { /// zero class's other property, that it never looks like a pointer. fn bit6_encrypt_const_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { let ore = bit6(); - let left = vec![0x1234_5678_9abc_def0u64; POOL]; - let right: Vec = (0..POOL).map(|_| rng.random::()).collect(); + let left = vec![0x1234_5678_9abc_def0u64; pool_size()]; + let right: Vec = (0..pool_size()).map(|_| rng.random::()).collect(); for _ in 0..SAMPLES { let c = class(rng); let pool = if matches!(c, Class::Left) { @@ -182,7 +189,7 @@ fn bit6_encrypt_const_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { } else { &right }; - let x = pool[rng.random_range(0..POOL)]; + let x = pool[rng.random_range(0..pool_size())]; runner.run_one(c, || black_box(black_box(x).encrypt(&ore).unwrap())); } } @@ -192,8 +199,8 @@ fn bit6_encrypt_const_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { /// no random-oracle keys, hashing or right blocks. fn bit6_encrypt_left_const_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { let ore = bit6(); - let left = vec![0x1234_5678_9abc_def0u64; POOL]; - let right: Vec = (0..POOL).map(|_| rng.random::()).collect(); + let left = vec![0x1234_5678_9abc_def0u64; pool_size()]; + let right: Vec = (0..pool_size()).map(|_| rng.random::()).collect(); for _ in 0..SAMPLES { let c = class(rng); let pool = if matches!(c, Class::Left) { @@ -201,7 +208,7 @@ fn bit6_encrypt_left_const_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) } else { &right }; - let x = pool[rng.random_range(0..POOL)]; + let x = pool[rng.random_range(0..pool_size())]; runner.run_one(c, || black_box(black_box(x).encrypt_left(&ore).unwrap())); } } @@ -216,8 +223,8 @@ fn prp_build_const_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { for b in fixed.iter_mut() { *b = rng.random::(); } - let left = vec![fixed; POOL]; - let right: Vec<[u8; 512]> = (0..POOL) + let left = vec![fixed; pool_size()]; + let right: Vec<[u8; 512]> = (0..pool_size()) .map(|_| { let mut s = [0u8; 512]; for b in s.iter_mut() { @@ -233,7 +240,7 @@ fn prp_build_const_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { } else { &right }; - let s = &pool[rng.random_range(0..POOL)]; + let s = &pool[rng.random_range(0..pool_size())]; runner.run_one(c, || { black_box(ore_rs::ct_bench::lemire_fy_prp_from_stream(black_box(s))) }); @@ -276,8 +283,8 @@ fn bit6_encrypt_left_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRn /// lowercase (`Right`), both from precomputed pools. fn chained_encrypt_fixed_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { let ore = chained(); - let left: Vec = vec!["a".repeat(17); POOL]; - let right: Vec = (0..POOL) + let left: Vec = vec!["a".repeat(17); pool_size()]; + let right: Vec = (0..pool_size()) .map(|_| { (0..17) .map(|_| char::from(rng.random_range(b'a'..=b'z'))) @@ -291,7 +298,7 @@ fn chained_encrypt_fixed_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { } else { &right }; - let s = pool[rng.random_range(0..POOL)].as_str(); + let s = pool[rng.random_range(0..pool_size())].as_str(); runner.run_one(c, || black_box(ore.encrypt_str(black_box(s)).unwrap())); } } From eec298b203d86251c9fc61158423cb3fc48e2376 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 12:46:57 +1100 Subject: [PATCH 09/35] docs(review): dynamic and formal verification results for ORE v2 Records the Valgrind taint run and its documented exceptions, the fuzz results (including the legacy comparator panic they found), the 18 Kani harnesses, and the dudect timing measurements: the chained comparator's resolution step revealed the first differing block through an l-indexed load that missed cache (t = -248 over 154 M samples), and the PRP builder's time depended on the permutation it builds (small, but measurable in-process with no SMT), which the A4 one-cache-line argument does not cover. Both are fixed in the stack. For the builder, isolation benches put the whole signal in the Fisher-Yates swaps (|t| 18 to 50 against controls under 3; the inverse fill shows none), the oblivious builder removes it (fixed A versus fixed B from |t| 17-83 to under 2, mains power), and the taint suppression for the swap is gone. --- .../2026-10-09-ore-v2-dynamic-verification.md | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 docs/reviews/2026-10-09-ore-v2-dynamic-verification.md diff --git a/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md new file mode 100644 index 00000000..9f2b7226 --- /dev/null +++ b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md @@ -0,0 +1,264 @@ +# ORE v2 — dynamic and formal verification results + +**Date:** 2026-10-09 +**Scope:** the v2 schemes (`OreAes128Bit6`, `OreAes128Bit6Chained`), their +primitives, and the legacy `OreAes128` parsers and comparator. +**Machine for timing work:** Apple M4, macOS; Valgrind in an arm64 Linux +container (valgrind 3.19); rustc 1.94.1. + +This complements the static work (the crypto review brief A1–A4, the +constant-time analysis, the zeroize audits) with four kinds of evidence, +each reproducible from the repository: + +| what | where | gate | +|---|---|---| +| Valgrind taint (ctgrind technique) | `packages/ct-taint`, `scripts/ct-taint.sh` | CI (`verify.yml`), fails on any undocumented report | +| libFuzzer targets | `packages/ore-rs/fuzz`, `scripts/fuzz.sh` | CI, 120 s per target per PR | +| Kani proof harnesses | `#[cfg(kani)]` modules in `ore-rs` | CI (`cargo kani -p ore-rs`) | +| dudect timing tests | `packages/ct-dudect` | by hand; results below | + +## 1. Valgrind taint — secret-dependent branches and addresses + +The harness marks the key and the plaintext as undefined memory and runs each +v2 encryptor (Bit6 full and left-only, chained full and left-only). Memcheck +then reports every conditional jump and every memory address that depends on +them. All four modes are clean except for two report classes, each listed +with its reason in `packages/ct-taint/valgrind.supp`: + +1. **`oblivious_lookup`'s range check** (`index >= table.len()`) and its + callers' test of the returned `Result`. A comparison on the plaintext + symbol against a public length, whose direction is the same for every + in-domain symbol, so it carries no information; the static analysis had + not listed it. Removing it means an infallible `permute` over a typed + in-domain symbol, which is an API change and is not done here. +2. (Harness only, fixed.) `str::from_utf8`'s validation of the tainted + string; the harness now builds the `&str` unchecked. + +Nothing is reported from the 6-bit decomposition, the CMAC accumulator, the +σ-MMO hash, the SIMD indicator kernels, `ct_select_byte`, or AES. With the +suppressions removed every mode fails, so the gate is live. + +A third class, the Fisher–Yates swap at a keystream-derived index in +`LemireFyPrp::from_stream` (a pointer-sized use of an undefined value), was +suppressed when this run was first made; it was the secret-indexed write the +brief's A4 accepted under the one-cache-line argument. §4.2 is why it went: +the PRP builder is now oblivious, and its suppression has been removed. The +oblivious builders use the secret draws only as register operands (`tbl` / +`pshufb` indices, compare-and-select masks, SWAR arithmetic), which memcheck +propagates as undefined data without reporting, and touch memory only at +public offsets (the stream reads and the two final 64-byte stores), so the +run is expected to stay clean without it. That has not been run locally (no +Valgrind on macOS, no container here); the `verify.yml` ct-taint job, on +x86_64 Linux where the SSSE3 builder is dispatched, is the check. + +The comparators are not tainted: their inputs are ciphertexts, which are +public. Their timing question is §4's. + +## 2. Fuzzing — parsers and raw comparators on untrusted bytes + +Four targets, each asserting "never panics; an accepted ciphertext re-encodes +to the same bytes" (and for the comparators, "answers any pair"). First runs, +45 s each on the M4: + +| target | runs | result | +|---|---:|---| +| `chained_parse` (`VarCipherText::from_slice`) | 43.0 M | clean | +| `chained_compare` (`compare_raw_slices`, `compare_left_to_full`) | 37.6 M | clean | +| `bit6_parse` (`CipherText`/`Left::from_slice` at N = 11 and 6, `compare_raw_slices`) | 23.0 M | clean | +| `bit8_parse` (legacy, N = 8 and 4) | — | **panic on the first input** | + +The legacy `OreAes128::compare_raw_slices` inferred the block count from the +length without checking it: two empty slices underflowed the nonce +subtraction, and a partial block sliced past the end. Fixed (`None` for any +length that is not `n·(1 + 16 + 32) + 16`), with a regression test; 16.5 M +runs clean afterwards. This is a pre-existing bug in the frozen legacy +scheme, not a v2 regression, and it reaches any caller that compares stored +bytes without parsing them first. + +The corpora from these runs are committed as seeds. + +## 3. Kani — proofs for the pure building blocks + +Eighteen harnesses, all verified (full run 1 324 s before the oblivious PRP +builder; the two exhaustive-length harnesses, 16 minutes of that, run only +with the `kani-full` feature; the PRP harnesses were re-timed one at a time +after it, and the default set now takes about 25 minutes). Kani cannot execute AES symbolically, so nothing keyed +is in scope; these are the pieces where a wrong implementation would hide +behind matching known-answer vectors. + +| property | domain | time | +|---|---|---:| +| `ct_select_byte(block, i) == block[i]` | every length ≤ 256, every `i` (`kani-full`); the 8- and 32-byte right blocks | 519 s; 1 s | +| `ct_bit(byte, pos) == (byte >> pos) & 1`, and 0 for `pos ≥ 8` | exhaustive | < 1 s | +| `oblivious_lookup(table, i)` is `Ok(table[i])` for `i < len`, `Err` otherwise | every 64- and 256-entry table; every length ≤ 256 (`kani-full`) | 2 s; 17 s; 439 s | +| Lemire reduction `((x·(i+1)) >> 64) ≤ i` | every `u64` `x`, `i < 64` | < 1 s | +| the reference (textbook Fisher–Yates) builder yields a permutation of 0..64 with a correct inverse | first 6 of 63 draws symbolic, the rest fixed (all 63 symbolic did not finish in 30 min) | 200 s | +| the SWAR oblivious builder's `permutation` and `inverse` equal the reference builder's (under Kani `from_stream` dispatches to SWAR: Kani cannot model the NEON or SSSE3 intrinsics) | same stream shape | 872 s | +| `from_stream` rejects streams shorter than 504 bytes | every short length | 335 s (101 s before the oblivious builder) | +| `num_blocks_6bit(n)` is the least `b` with `6b ≥ 8n` | every `n ≤ usize::MAX / 8` | < 1 s | +| `decompose_6bit`: symbols < 64, bit `5−t` of block `i` is plaintext bit `6i+t`; injective; order-preserving for equal lengths | inputs ≤ 16 bytes | 5 s; 8 s; 9 s | +| `final_block` and `prefix_block` injective, and disjoint from each other | full domains | < 1 s each | +| `gf128_double` matches the byte-wise NIST SP 800-38B `dbl` | every 128-bit input | < 1 s | + +No harness found a bug. + +## 4. dudect — timing leakage + +Welch's t-test over two interleaved input classes; `|t| < 5` after millions +of samples means no distinguishable timing. `max tau` is the effect size in +units of the timing spread. Quiet machine, release build, hardware AES. + +### 4.1 Comparators: does the time reveal where the inputs first differ? + +Class `Left`: pairs differing in the first block. Class `Right`: pairs +sharing every block but the last. + +Before the fix described below: + +| bench | samples | t | tau | +|---|---:|---:|---:| +| `bit6_compare_first_vs_last_block` (u64, 11 blocks) | 151 M | +10.2 | +0.0008 | +| `chained_compare_first_vs_last_byte` (17 chars, 23 blocks) | 154 M | **−248** | **−0.020** | +| same, inputs held in L1 (`CT_DUDECT_POOL=4`) | 72 M | +25 | +0.003 | + +After it (same machine, same binary apart from the comparators): + +| bench | samples | t | tau | +|---|---:|---:|---:| +| `bit6_compare_first_vs_last_block` | 91 M | −7.1 | −0.0008 | +| `chained_compare_first_vs_last_byte` | 70 M | +12.5 | +0.0015 | + +**Finding.** The chained comparator's time depended on the position of the +first differing block. The prefix scan is constant-time, but the resolution +step after it loaded `f[l]`, `xt[l]` and the right block `right[l]` by the +secret-derived index `l`. The right block is the one region the scan never +touched, so whether its cache line was already present depended on `l`: with +inputs arriving from memory the effect was 2 % of the timing spread (t = −248 +over 154 M samples); with every input held in L1 it all but vanished +(tau 0.003). Bit6 was barely affected because its whole ciphertext spans five +cache lines, which the hardware prefetcher covers. + +What it leaked is `l`, the first differing block. In the Lewi–Wu model that +position is already revealed to whoever holds both ciphertexts (the left +tags are deterministic), so this did not widen the scheme's leakage +profile; it moved part of it from "the ciphertext holder" to "anyone who can +time the comparison", which the constant-time scan was written to prevent. + +**Fix (applied in the Bit6 and chained scheme commits).** The scan now +latches `xt[l]`, `f[l]` and `right[l]` as it runs, with +`width::ct_assign_bytes` under the choice "this is the first differing +block" (set for exactly one `n`), and the resolution step hashes and +bit-selects from the latched copies. Every load is now indexed by the public +loop counter. The added cost is one 8-byte right-block read and about 25 +masked byte copies per block, against a scan already comparing 17 bytes per +block; the chained benchmarks in `docs/benchmarks/` were not re-run, since +the comparator is not on the encrypt path they time. + +The chained effect size fell from 0.020 to 0.0015, below the L1-resident +control measured before the fix, so the cache component is gone. What +remains (t ≈ 12 at 70 M samples) is the same order as the Bit6 figure, which +the fix did not change (0.0008 before and after, sign flipped): a floor the +test resolves at these sample counts but whose source is not the +`l`-indexed loads. Both comparators keep the two branches that reveal only +the result the caller receives anyway: the early return on equality and the +final `test == 1`. + +### 4.2 Encryptors: does the time depend on the plaintext? + +As first measured, with the PRP builder that swapped at secret indices: + +| bench | classes | runs (100 k samples each) | t | +|---|---|---|---:| +| `bit6_encrypt_zero_vs_random` | 0 vs random u64 | 2 | +2.7, −3.0 | +| `bit6_encrypt_const_vs_random` | one random-looking constant vs random | 2 | +14.7, −7.0 | +| `bit6_encrypt_left_const_vs_random` | same, left-only | 2 | +5.6, +8.0 | +| `bit6_encrypt_left_fixed_a_vs_fixed_b` | two fixed values (fresh pair per run) | 2 | +21.6, +11.5 | +| `chained_encrypt_fixed_vs_random` | 17 × `a` vs random lowercase | 2 | −5.5, −5.3 | +| `prp_build_const_vs_random` | one fixed 512-byte stream vs random | 2 | −4.8, −7.8 | +| `prp_build_fixed_a_vs_fixed_b` | two fixed streams (fresh pair per run) | 2 | +65.5, −3.6 | +| `prp_build_fixed_a_vs_fixed_b`, continuous, pairs re-drawn | | 138 M | −16.4 (tau −0.0014) | + +**Finding (fixed).** The PRP builder's time depended on *which* permutation +it builds: for some pairs of draw streams the two were clearly +distinguishable (t = +65 at 100 k samples), for others not, and at population +level the effect was real but very small (tau −0.0014). The only +secret-dependent memory traffic in the builder was the Fisher–Yates swap at +the draw-derived index and the inverse-table fill, so this was the A4 access +pattern showing up as a within-process timing signature, on a core with no +SMT and no co-tenant. Setting the ARM DIT bit changed nothing, so it is not +the data-dependent prefetcher. The one-cache-line argument addresses +line-granularity cache leakage and says nothing about this. + +**Mechanism isolation.** Each half of the old builder, timed alone on two +fixed streams, next to a control doing the same work at public addresses +(the swap at `i, i−1` with the draw still computed; the fill at `k`). Apple +M4 on mains power, release build, three runs of 100 k samples, a fresh pair +per run: + +| bench | t, 3 runs | +|---|---| +| `iso_swap_secret` (swap loop, secret index) | −41.0, −17.6, −49.9 | +| `iso_swap_public` (control) | +1.1, −0.8, −1.5 | +| `iso_fill_secret` (inverse fill, secret offsets) | −0.8, −0.3, +0.5 | +| `iso_fill_public` (control) | +3.0, −0.7, −1.9 | + +The swap loop carries the whole signal; the fill carries none. Each swap +loads `perm[j]` and `perm[i]` just after the previous step stored to +`perm[i+1]` and `perm[j']`, so whether a load overlaps a store still in +flight depends on the draws, and with it whether the core forwards the +stored byte, waits, or speculates and replays. The fill only stores, to 64 +distinct offsets, with no load depending on those stores. Store-to-load +forwarding and memory disambiguation on byte-granular accesses at secret +offsets inside one L1-resident line is consistent with that; the +measurement does not prove it. + +**Fix.** The builder is now oblivious (`primitives/prp/oblivious.rs`): both +tables stay in registers, `perm[j]` is fetched by a table lookup on a +broadcast `j` and written back by compare-and-select, and the inverse is +kept by exchanging the values `i` and `j` in it at each step instead of a +fill. Same draws and swaps, byte-identical tables, no vector change. NEON on +aarch64, SSSE3 on x86_64 when present, SWAR elsewhere; on the M4 the NEON +builder is 20% faster than the one it replaced (199 ns against 248 ns). +Details and costs: `docs/benchmarks/2026-10-09-oblivious-prp-results.md`. + +**After**, same session and binary (`prp_build_reference_*` is the old +builder, kept as a test-only reference): + +| bench | before (old builder) | after | +|---|---|---| +| `prp_build_fixed_a_vs_fixed_b`, 5 runs | +57.7, +17.2, −26.7, +83.4, −38.8 | +1.9, −1.8, −1.1, +1.3, +1.5 | +| same, continuous 150 s, pairs re-drawn | +8.6 at 281 M (tau +0.0005) | −0.8 at 92 M (tau −0.0001) | +| `prp_build_swar_obl_fixed_a_vs_fixed_b`, 5 runs | — | −1.5, −0.8, −1.8, −1.7, +1.6 | +| `bit6_encrypt_left_fixed_a_vs_fixed_b`, 3 runs | +21.6, +11.5 (table above) | −2.4, −0.9, +1.2 | +| `chained_encrypt_fixed_vs_random`, 2 runs | −5.5, −5.3 (table above) | −1.8, +2.3 | +| `prp_build_const_vs_random`, continuous 150 s | — | +7.7 at 239 M (tau +0.0005) | + +The pooled continuous figure is weak evidence either way: each 100 k-sample +pair has its own sign, so pooling dilutes it (the old builder's +8.6 here, +against |t| up to 83 in a single pair). The per-pair runs are the test, and +there the dependence is gone. `prp_build_const_vs_random` keeps a small +residual (tau 0.0005); that test compares one repeated input against a pool +of 512 streams (256 KB, larger than L1), so its classes differ in where +their *input* comes from, and the fixed-A-versus-fixed-B test that removes +that difference shows nothing. The first pass of these measurements, in Low +Power Mode on battery, gave the same per-pair picture (its pooled old-builder +figure was larger, −45 at 135 M). + +Encryption time does not depend on the plaintext *value* in any way the test +could see once the inputs are symmetric (`zero_vs_random`: t ≈ 3); the +`const_vs_random` signals in the first table were the builder effect above, +seen through a fixed versus varying permutation sequence. + +## 5. What is not covered + +- The cryptographic argument (Lewi–Wu leakage, the BHKR bound for H, CMAC + branch separation) is reviewed by reading the brief, not by any tool here. +- Kani does not reach anything keyed; the end-to-end comparator and + encryptor correctness rest on the property tests and known-answer vectors. +- The taint run uses memcheck's definedness tracking, which does not follow + taint through a load at a tainted address (the loaded byte is "defined"). + Secret-indexed *reads* after the first one are therefore invisible to it; + that is exactly why the comparators are measured with dudect instead. +- dudect was run on one Apple M4. x86 (with and without SMT, where the + SSSE3 builder runs) and Graviton would be worth a run; the SSSE3 + builder's timing has not been measured on real x86 hardware. From 1b5f66857b86eff00a5d5b8a0815582a457f9cfa Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 19:31:28 +1100 Subject: [PATCH 10/35] bench(prp): criterion bench for the Bit6 PRP builder alone Times the reference builder (textbook Fisher-Yates, secret-indexed), the byte-at-a-time and SWAR oblivious builders, and the target's dispatched oblivious builder, each over a rotating pool of 256 random 512-byte streams. Needs the ct-bench feature, which exposes the builders. --- packages/ore-rs/Cargo.toml | 5 ++ packages/ore-rs/benches/prp_build.rs | 69 ++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 packages/ore-rs/benches/prp_build.rs diff --git a/packages/ore-rs/Cargo.toml b/packages/ore-rs/Cargo.toml index eb58b3d1..89c6acfd 100644 --- a/packages/ore-rs/Cargo.toml +++ b/packages/ore-rs/Cargo.toml @@ -82,5 +82,10 @@ required-features = ["chrono"] name = "chained" harness = false +[[bench]] +name = "prp_build" +harness = false +required-features = ["ct-bench"] + [[example]] name = "encrypt" diff --git a/packages/ore-rs/benches/prp_build.rs b/packages/ore-rs/benches/prp_build.rs new file mode 100644 index 00000000..db62cb85 --- /dev/null +++ b/packages/ore-rs/benches/prp_build.rs @@ -0,0 +1,69 @@ +//! The Bit6 PRP builder alone, per 512-byte draw stream: the reference +//! builder (textbook Fisher–Yates, secret-indexed; not used by any scheme), +//! the byte-at-a-time `subtle_ng` and SWAR oblivious builders, and the +//! target's dispatched oblivious builder (NEON on aarch64, SSSE3 or SWAR on +//! x86_64, SWAR elsewhere), which is what the schemes call. Each goes +//! through a `ct_bench` hook that also builds the struct, does one oblivious +//! `permute` and wipes the struct on drop, a fixed cost common to all. +//! +//! Each iteration takes the next of 256 precomputed random streams, so the +//! figure is an average over swap sequences rather than one sequence's +//! (the reference builder's time depends on the sequence). +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use ore_rs::ct_bench; + +const STREAMS: usize = 256; + +/// A `ct_bench` builder hook: one stream in, one permuted value out. +type Builder = fn(&[u8; 512]) -> u8; + +fn streams() -> Vec<[u8; 512]> { + let mut x = 0x9e37_79b9_7f4a_7c15u64; + (0..STREAMS) + .map(|_| { + let mut s = [0u8; 512]; + for b in s.iter_mut() { + // xorshift64: fixed, random-looking streams. + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + *b = (x >> 24) as u8; + } + s + }) + .collect() +} + +fn criterion_benchmark(c: &mut Criterion) { + let s = streams(); + let builders: [(&str, Builder); 4] = [ + ( + "prp-build-reference", + ct_bench::lemire_fy_prp_from_stream_reference, + ), + ( + "prp-build-scalar-oblivious", + ct_bench::lemire_fy_prp_from_stream_scalar_oblivious, + ), + ( + "prp-build-swar-oblivious", + ct_bench::lemire_fy_prp_from_stream_swar_oblivious, + ), + ( + "prp-build-dispatched-oblivious", + ct_bench::lemire_fy_prp_from_stream, + ), + ]; + for (name, build) in builders { + c.bench_function(name, |b| { + let mut n = 0usize; + b.iter(|| { + n = (n + 1) % STREAMS; + build(black_box(&s[n])) + }) + }); + } +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); From 7a4afd585abafce32e88a2f8f7342ffbcd98fd9f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 19:50:43 +1100 Subject: [PATCH 11/35] docs(bench): oblivious PRP builder results Mechanism isolation (the Fisher-Yates swaps carry the timing signal, the inverse fill does not), the NEON, SSSE3 and SWAR oblivious builders, their cost alone and end to end, and dudect before and after. Measured on an M4 on mains power with Low Power Mode off; a first pass in Low Power Mode on battery gave the same ratios. --- .../2026-10-09-oblivious-prp-results.md | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 docs/benchmarks/2026-10-09-oblivious-prp-results.md diff --git a/docs/benchmarks/2026-10-09-oblivious-prp-results.md b/docs/benchmarks/2026-10-09-oblivious-prp-results.md new file mode 100644 index 00000000..5b77ecfb --- /dev/null +++ b/docs/benchmarks/2026-10-09-oblivious-prp-results.md @@ -0,0 +1,228 @@ +# Oblivious PRP builder — NEON, SSSE3 and SWAR + +The dynamic verification (`docs/reviews/2026-10-09-ore-v2-dynamic-verification.md`, +§4.2) found that the Bit6 PRP builder's time depended on which permutation +it built. This note records which part of the builder carried that signal, +the oblivious builders that replaced it, and their cost and timing. + +All the oblivious builders produce the same `permutation` and `inverse` +tables as the old one for the same 512-byte stream, so every ciphertext is +unchanged. The old builder survives as the test-only *reference* builder. + +## Machine and method + +- Apple M4 (4 performance, 6 efficiency cores), macOS, on mains power with + Low Power Mode off; rustc 1.94.1, `--release`, hardware AES + (`--cfg aes_armv8`). Nothing else heavy ran during a measurement. +- A first pass was taken in Low Power Mode on battery (about half the + clock). Every figure here is from the rerun on mains power; the ratios + between builders and the dudect picture matched the first pass. +- Timings are criterion medians. The builder benchmark + (`benches/prp_build.rs`, `--features ct-bench`) cycles through 256 + precomputed random streams, because the reference builder's time depends + on the stream. Each call goes through a `ct_bench` hook that also builds + the struct, does one oblivious `permute` and wipes the struct on drop; + that fixed cost is in every builder figure. +- End-to-end figures come from `benches/bit6.rs` and `benches/chained.rs`, + all built with `--features ct-bench`, with the dispatcher pointed at each + builder in turn by a temporary, uncommitted edit to + `oblivious::build`. The NEON build was benchmarked twice, before and after + the others; the two runs agree within 3%, and the first is reported. +- dudect (`packages/ct-dudect`): "batch" is one run of 100,000 samples with a + freshly drawn pair of inputs; "continuous" runs for 150 s, redrawing the + pair every 100,000 samples and pooling the statistics. The figure is + dudect's `max t`. + +## 1. Which half of the old builder carries the signal + +The old builder does two things with secret addresses: the Fisher–Yates +swap at the draw-derived index `j`, and the `inverse[perm[k]] = k` fill. +Each half was timed alone on two fixed streams (fixed A against fixed B), +next to a control doing the same work at public addresses (the swap at +`i, i-1` with the draw still computed; the fill at `k`). + +| bench | batch t, 3 runs | +|---|---| +| `iso_swap_secret` (swap loop, secret index) | −41.0, −17.6, −49.9 | +| `iso_swap_public` (control) | +1.1, −0.8, −1.5 | +| `iso_fill_secret` (inverse fill, secret offsets) | −0.8, −0.3, +0.5 | +| `iso_fill_public` (control) | +3.0, −0.7, −1.9 | +| whole old builder, same session (`prp_build_reference_fixed_a_vs_fixed_b`, 5 runs) | +57.7, +17.2, −26.7, +83.4, −38.8 | + +**The swap loop carries the signal; the inverse fill carries none.** The +secret-index swap reaches |t| = 18 to 50 against at most 1.5 for its +control, and the fill sits with its control. + +Each swap loads `perm[j]` and `perm[i]` just after the previous step stored +to `perm[i+1]` and `perm[j']`. Whether a load overlaps a store still in +flight depends on the draws, and so does whether the core forwards the +stored byte, waits for it, or speculates past it and has to replay. The fill +only stores, to 64 distinct offsets, with no load depending on those stores, +so it has nothing to forward or mispredict. The measurement is consistent +with that mechanism (store-to-load forwarding and memory disambiguation on +byte-granular accesses at secret offsets within one L1-resident line); it +does not prove it. + +## 2. The builders + +All of them run the same 63 Lemire-reduced draws in the same order and apply +the same swaps. Code: `packages/ore-rs/src/primitives/prp/oblivious.rs`. + +- **NEON (aarch64, dispatched there).** Both tables live in eight 16-byte + vector registers for the whole build. `perm[j]` is fetched with a `tbl` + table lookup on a broadcast `j`; the swapped entries are written back with + compare-against-a-constant-index-vector and bitwise select. The only memory + traffic is the stream reads (at public offsets) and the two final 64-byte + stores. NEON is part of the aarch64 baseline, so there is no runtime check. +- **SSSE3 (x86_64, runtime detected).** The same shape with `pshufb` on each + 16-byte chunk, the right chunk picked by a mask. +- **SWAR (every other target, and under Kani).** Each table is eight `u64` + words. Byte equality against a broadcast value is exact zero-byte + detection, and every secret-indexed read or write is a masked pass over + the words. +- **Byte-at-a-time with `subtle_ng` (tests and benches only).** The most + literal form: a full-scan select for `perm[j]`, masked passes over all 64 + bytes for both writes and both inverse updates. +- **Reference (tests, Kani and benches only).** The old textbook builder, + which the others are pinned to. + +Two things make the vector and SWAR forms cheaper than the literal one, and +neither depends on a secret: + +1. **The inverse is updated on the value side.** Swapping positions `i` and + `j` changes the inverse by exchanging the values `i` and `j` wherever they + appear in it. That needs neither swapped entry, so it runs alongside the + permutation rather than after it, and there is no separate fill. +2. **Only the part of the table at or below `i` is searched.** Step `i` is + public and `j <= i`, so the builders search and rewrite only the 16-byte + chunks (or 8-byte words) covering `0..=i`. In the NEON form the fetch + becomes a 1-, 2-, 3- or 4-register `tbl`. + +In the first pass, a NEON version with full 64-byte passes and the inverse +updated through the swapped values was about 13% slower than the shipped one; +one applying the swap as a lane permutation (four 4-register `tbl`s per step) +was slower again, as was one computing the inverse after the loop. + +`from_stream` calls the dispatched builder, so Bit6 (through `Prp::new`) and +the chained scheme (through its `PRP_STREAM` branch) both use it with no +change at the call sites. + +### Byte-identical tables + +- Property tests (quickcheck, random streams) assert that the dispatched, + SWAR and `subtle_ng` builders produce exactly the reference builder's + `permutation` and `inverse`; edge streams (all `0x00`, all `0xff` and + others) are tested as well. +- Kani proves the SWAR builder equal to the reference for every stream whose + first 6 draws are symbolic (the rest fixed), and the reference a + permutation with a correct inverse over the same streams. +- The Bit6 (`compat_w6_vectors`) and chained (`compat_chained_vectors`) + known-answer vectors pass unchanged, which shows identical ciphertexts end + to end. +- `cargo test -p ore-rs --all-features` passes on aarch64, and under + Rosetta 2 for `--target x86_64-apple-darwin`, where SSSE3 is reported and + the dispatched test therefore exercises the SSSE3 builder. + +## 3. Cost + +### The builder alone (per 512-byte stream) + +| builder | median | vs reference | +|---|---:|---:| +| reference (old, secret-indexed) | 248 ns | — | +| **NEON (dispatched on aarch64)** | **199 ns** | **−20%** | +| SWAR (portable fallback) | 710 ns | +186% | +| byte-at-a-time, `subtle_ng` | 12.0 µs | 48× | + +Both the reference and the new builders wipe their 8-byte copy of each draw +with `zeroize`; in the first pass that wipe was about half the reference +builder's time and about a tenth of the NEON builder's. So against the +reference *without* the wipe the NEON builder would be slower; as shipped, +with the wipe in both, it is 20% faster. + +The `subtle_ng` form is slow because every `Choice` passes through a volatile +read, about 16,000 of them per build. It is kept only as a second reference +in the tests. + +### End to end + +| benchmark | reference | NEON | SWAR | +|---|---:|---:|---:| +| bit6-encrypt-u64 | 8.59 µs | **7.89 µs** (−8%) | 13.55 µs (+58%) | +| bit6-encrypt-left-u64 | 6.33 µs | **5.77 µs** (−9%) | 11.32 µs (+79%) | +| bit6-encrypt-u32 | 4.75 µs | **4.44 µs** (−7%) | 7.67 µs (+61%) | +| bit6-compare-u64 | 151 ns | 149 ns | — | +| chained-encrypt-str-5 | 4.67 µs | **4.37 µs** (−7%) | 7.86 µs (+68%) | +| chained-encrypt-str-17 | 15.20 µs | **13.70 µs** (−10%) | 25.18 µs (+66%) | +| chained-encrypt-str-43 | 36.88 µs | **34.12 µs** (−7%) | 64.47 µs (+75%) | +| chained-encrypt-left-str-17 | 11.29 µs | **10.21 µs** (−10%) | 22.05 µs (+95%) | +| chained-compare-str-17 | 318 ns | 316 ns | — | + +For Bit6 u64 the saving is the builder saving times the block count +(11 × ~50 ns ≈ 0.55 µs predicted, 0.70 µs measured). Left-only encryption +gains or loses most because the builder is a larger share of it. +Comparison is untouched (it builds no PRP). + +## 4. Timing after + +Same session, one binary; the reference builder's benches give the before. + +| bench | before: reference builder | after: NEON (dispatched) | +|---|---|---| +| `prp_build_*_fixed_a_vs_fixed_b`, batch, 5 runs | +57.7, +17.2, −26.7, +83.4, −38.8 | +1.9, −1.8, −1.1, +1.3, +1.5 | +| same, continuous 150 s | +8.6 at 281 M (tau +0.0005) | **−0.8 at 92 M (tau −0.0001)** | +| `bit6_encrypt_left_fixed_a_vs_fixed_b`, batch | +21.6, +11.5 (verification doc) | −2.4, −0.9, +1.2 | +| `chained_encrypt_fixed_vs_random`, batch | −5.5, −5.3 (verification doc) | −1.8, +2.3 | +| `prp_build_const_vs_random`, continuous 150 s | (batch: −4.8, −7.8, verification doc) | +7.7 at 239 M (tau +0.0005) | + +The portable builders, from the same binary: + +| builder | `fixed_a_vs_fixed_b` batch | +|---|---| +| SWAR, 5 runs | −1.5, −0.8, −1.8, −1.7, +1.6 | +| `subtle_ng`, 3 runs | −2.1, +2.0, +2.4 | + +**The dependence on which permutation is built is gone.** Fixed A against +fixed B, which reached |t| = 83 for the reference builder in a single pair, +stays under 2 for the NEON builder in every run and pools to −0.8 over +92 M samples. The end-to-end left-only encryption test, which showed +21.6 +and +11.5 before, shows nothing. + +The pooled continuous figure is weak evidence for the reference builder +(+8.6): each pair has its own sign, so pooling over re-drawn pairs dilutes +it. In the Low Power Mode pass the same test gave −45 at 135 M. The batch +runs are the stronger evidence in both directions. + +`prp_build_const_vs_random` still shows a small difference (t = +7.7, +tau 0.0005). That test compares one input repeated against inputs drawn from +a pool of 512 streams (256 KB, larger than L1), so the two classes differ in +where their *input* comes from; in the Low Power Mode pass, shrinking the +pool to 4 entries (every input in L1) cut it by more than half. The fixed-A-versus-B +test is designed to remove that difference, and there it is absent. The NEON +builder has no secret address for it to come from; the residual is not +explained further here. + +## Not measured + +- **x86_64 timing.** The SSSE3 builder compiles, passes clippy (stable and + 1.78) and passes the tests under Rosetta 2, which translates it to Arm + code; its speed and its timing behaviour on a real x86 core (with and + without SMT) are unknown. +- **Graviton or other Arm cores.** +- **The Valgrind taint run** without the swap suppression: no Valgrind on + macOS, so it is left to the `verify.yml` job. +- **AVX2.** An AVX2 form (two 32-byte registers per table) is a natural next + step for x86_64 and was not tried. + +## Outcome + +The NEON builder is the default on aarch64: it removes the measured +dependence on the permutation and is faster than the builder it replaced, by +20% for the builder and 7 to 10% end to end. On x86_64 the SSSE3 builder, +present on every x86_64 CPU of the last fifteen years, has the same +register-resident shape; benchmark it and rerun the dudect tests on real x86 +hardware, with and without SMT, before relying on either its speed or its +timing. The SWAR builder is the portable fallback for every other target: it +is oblivious and showed no timing dependence, at about 2.9 times the builder +cost and 58 to 95% end to end. The `subtle_ng` byte-at-a-time form ships +nowhere. From 784bdee7e50d250f42c0f2114b7fc87ddfe4d75b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 21:19:56 +1100 Subject: [PATCH 12/35] harden(compare): latch first differing block in legacy and Bit6 typed comparators; accept zero-block legacy input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy compare_raw_slices and the typed Ord impls for OreAes128 and Bit6 still loaded xt[l], f[l] and right[l] after the scan, indexed by the first differing block l. The right blocks are the one region the scan never touches, so their cache state reveals l to a timing observer — the channel measured for chained (t = -248) and already fixed in the Bit6 raw and chained comparators. All three now latch those values during the scan with ct_assign_bytes. Wire format unchanged. The legacy length guard also refused a nonce-only input, which is the valid ciphertext of a zero-byte plaintext and parses with CipherText::<_, 0>::from_slice. The raw comparator now returns Equal for it, matching the typed one; a regression test pins the agreement. Addresses PR #100 review findings 1 and 2. --- .../2026-10-09-ore-v2-dynamic-verification.md | 13 ++- packages/ore-rs/src/scheme/bit2.rs | 86 +++++++++++++------ .../ore-rs/src/scheme/bit2/block_types.rs | 6 ++ packages/ore-rs/src/scheme/bit2_w6.rs | 23 +++-- .../ore-rs/src/scheme/bit2_w6/block_types.rs | 7 ++ 5 files changed, 99 insertions(+), 36 deletions(-) diff --git a/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md index 9f2b7226..8b6a2232 100644 --- a/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md +++ b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md @@ -71,7 +71,9 @@ The legacy `OreAes128::compare_raw_slices` inferred the block count from the length without checking it: two empty slices underflowed the nonce subtraction, and a partial block sliced past the end. Fixed (`None` for any length that is not `n·(1 + 16 + 32) + 16`), with a regression test; 16.5 M -runs clean afterwards. This is a pre-existing bug in the frozen legacy +runs clean afterwards. `n = 0` is included: a nonce-only input is the valid +ciphertext of a zero-byte plaintext, which the parser also accepts, and both +comparators return `Equal` on it. This is a pre-existing bug in the frozen legacy scheme, not a v2 regression, and it reaches any caller that compares stored bytes without parsing them first. @@ -144,7 +146,9 @@ tags are deterministic), so this did not widen the scheme's leakage profile; it moved part of it from "the ciphertext holder" to "anyone who can time the comparison", which the constant-time scan was written to prevent. -**Fix (applied in the Bit6 and chained scheme commits).** The scan now +**Fix (applied to the Bit6 raw and chained comparators in their scheme +commits; in this PR to the Bit6 typed `Ord` and to both legacy `OreAes128` +comparators, raw and typed).** The scan now latches `xt[l]`, `f[l]` and `right[l]` as it runs, with `width::ct_assign_bytes` under the choice "this is the first differing block" (set for exactly one `n`), and the resolution step hashes and @@ -152,7 +156,10 @@ bit-selects from the latched copies. Every load is now indexed by the public loop counter. The added cost is one 8-byte right-block read and about 25 masked byte copies per block, against a scan already comparing 17 bytes per block; the chained benchmarks in `docs/benchmarks/` were not re-run, since -the comparator is not on the encrypt path they time. +the comparator is not on the encrypt path they time. The legacy comparator +had the same post-scan `l`-indexed loads and takes the same fix, which +leaves its frozen wire format unchanged; it has no dudect bench, so the +legacy fix rests on the same reasoning, not a measurement. The chained effect size fell from 0.020 to 0.0015, below the L1-resident control measured before the fix, so the cache component is gone. What diff --git a/packages/ore-rs/src/scheme/bit2.rs b/packages/ore-rs/src/scheme/bit2.rs index a1117b0d..1080f687 100644 --- a/packages/ore-rs/src/scheme/bit2.rs +++ b/packages/ore-rs/src/scheme/bit2.rs @@ -9,7 +9,7 @@ use crate::{ primitives::{ hash::Aes128Z2Hash, prf::Aes128Prf, AesBlock, Hash, HashKey, Prf, Prp, NONCE_SIZE, }, - scheme::width::{AesBlockBuf, Bit8, BlockWidth, RightBitVec}, + scheme::width::{ct_assign_bytes, AesBlockBuf, Bit8, BlockWidth, RightBitVec}, OreCipher, OreError, PlainText, }; @@ -236,45 +236,58 @@ impl OreCipher for OreAes128 { // The legacy wire has no header: the block count is implied by the // length, `n·(1 + left + right) + nonce`. Anything that does not fit - // that shape exactly (too short for a nonce, a partial block, or no - // blocks at all) is not a ciphertext of this scheme, and the slicing - // below would panic on it. + // that shape exactly (too short for a nonce, or a partial block) is + // not a ciphertext of this scheme, and the slicing below would panic + // on it. A nonce-only input is the valid `N = 0` ciphertext, which + // `CipherText::<_, 0>::from_slice` also accepts; it compares Equal. let body = a.len().checked_sub(NONCE_SIZE)?; let per_block = left_size + right_size + 1; - if body == 0 || body % per_block != 0 { + if body % per_block != 0 { return None; } let num_blocks = body / per_block; let mut is_equal = Choice::from(1); - let mut l: u64 = 0; // Unequal block - - // Slices for the PRF ("f") blocks + // What the resolution step needs from the first differing block `l`: + // `a`'s permuted symbol and PRF tag, and `b`'s right bitvector. All + // three are latched *inside* the scan, under the choice "this is the + // first difference", so no load after the loop is indexed by `l`. + // The right blocks are the one region the scan would otherwise never + // touch, so a direct `right[l]` afterwards hits or misses the cache + // according to `l`; measured as a timing signal, see `docs/reviews/`. + // The wire format is unchanged. + let mut sel_xt = 0u8; + let mut sel_f = [0u8; LeftBlock16::BLOCK_SIZE]; + let mut sel_right = [0u8; RightBlock32::BLOCK_SIZE]; + + // Slices for the PRF ("f") blocks and the right half. let a_f = &a[num_blocks..]; let b_f = &b[num_blocks..]; + let b_right = &b[num_blocks * (left_size + 1)..]; + let b_right_blocks = &b_right[NONCE_SIZE..]; for n in 0..num_blocks { let prp_eq: Choice = !a[n].ct_eq(&b[n]); let left_block_comparison: Choice = !left_block(a_f, n).ct_eq(left_block(b_f, n)); let condition: Choice = prp_eq | left_block_comparison; + // Set for exactly one `n`: the first differing block. + let first = is_equal & condition; - l.conditional_assign(&(n as u64), is_equal & condition); - is_equal.conditional_assign(&Choice::from(0), is_equal & condition); + sel_xt.conditional_assign(&a[n], first); + ct_assign_bytes(&mut sel_f, left_block(a_f, n), first); + ct_assign_bytes(&mut sel_right, right_block(b_right_blocks, n), first); + is_equal.conditional_assign(&Choice::from(0), first); } - let l: usize = l as usize; - if bool::from(is_equal) { return Some(Ordering::Equal); } - let b_right = &b[num_blocks * (left_size + 1)..]; let hash_key = HashKey::from_slice(&b_right[0..NONCE_SIZE]); let hash: Aes128Z2Hash = Hash::new(hash_key); - let h = hash.hash(left_block(a_f, l)); + let h = hash.hash(&sel_f); - let target_block = right_block(&b_right[NONCE_SIZE..], l); - let test = get_bit(target_block, a[l] as usize) ^ h; + let test = get_bit(&sel_right, sel_xt as usize) ^ h; if test == 1 { return Some(Ordering::Greater); @@ -316,26 +329,33 @@ impl PartialEq for CipherText { impl Ord for CipherText { fn cmp(&self, b: &Self) -> Ordering { let mut is_equal = Choice::from(1); - let mut l: u64 = 0; // Unequal block + // Latch the first differing block during the scan, as in + // `compare_raw_slices`: no load after the loop is indexed by its + // position. + let mut sel_xt = 0u8; + let mut sel_f = LeftBlock16::default(); + let mut sel_right = RightBlock32::default(); for n in 0..N { let condition: Choice = !(self.left.xt[n].ct_eq(&b.left.xt[n])) | !(self.left.f[n].ct_eq(&b.left.f[n])); + // Set for exactly one `n`: the first differing block. + let first = is_equal & condition; - l.conditional_assign(&(n as u64), is_equal & condition); - is_equal.conditional_assign(&Choice::from(0), is_equal & condition); + sel_xt.conditional_assign(&self.left.xt[n], first); + ct_assign_bytes(&mut sel_f, &self.left.f[n], first); + ct_assign_bytes(sel_right.bytes_mut(), b.right.data[n].bytes(), first); + is_equal.conditional_assign(&Choice::from(0), first); } - let l: usize = l as usize; - if bool::from(is_equal) { return Ordering::Equal; } let hash: Aes128Z2Hash = Hash::new(AesBlock::from_slice(&b.right.nonce)); - let h = hash.hash(&self.left.f[l]); + let h = hash.hash(&sel_f); - let test = b.right.data[l].get_bit(self.left.xt[l] as usize) ^ h; + let test = sel_right.get_bit(sel_xt as usize) ^ h; if test == 1 { return Ordering::Greater; } @@ -387,8 +407,6 @@ mod tests { assert!(Ore::compare_raw_slices(&good, &good).is_some()); assert_eq!(Ore::compare_raw_slices(&[], &[]), None); - let nonce_only = vec![0u8; NONCE_SIZE]; - assert_eq!(Ore::compare_raw_slices(&nonce_only, &nonce_only), None); let short = vec![0u8; NONCE_SIZE - 1]; assert_eq!(Ore::compare_raw_slices(&short, &short), None); let partial = &good[..good.len() - 1]; @@ -398,6 +416,24 @@ mod tests { assert_eq!(Ore::compare_raw_slices(&longer, &longer), None); } + /// A zero-block plaintext encrypts to a nonce-only ciphertext. The raw + /// comparator must agree with the parsed one on it, not refuse it. + #[test] + fn compare_raw_slices_accepts_zero_blocks() { + let ore = init_ore(); + let a = [0u8; 0].encrypt(&ore).unwrap(); + let b = [0u8; 0].encrypt(&ore).unwrap(); + let (a_bytes, b_bytes) = (a.to_bytes(), b.to_bytes()); + assert_eq!(a_bytes.len(), NONCE_SIZE); + + let parsed = CipherText::::from_slice(&a_bytes).unwrap(); + assert_eq!(parsed.cmp(&b), Ordering::Equal); + assert_eq!( + Ore::compare_raw_slices(&a_bytes, &b_bytes), + Some(Ordering::Equal) + ); + } + quickcheck! { fn compare_u64(x: u64, y: u64) -> bool { let ore = init_ore(); diff --git a/packages/ore-rs/src/scheme/bit2/block_types.rs b/packages/ore-rs/src/scheme/bit2/block_types.rs index 6012f452..4de40211 100644 --- a/packages/ore-rs/src/scheme/bit2/block_types.rs +++ b/packages/ore-rs/src/scheme/bit2/block_types.rs @@ -31,6 +31,12 @@ impl RightBlock32 { /// The raw bitvector bytes, LSB-first within each byte — the bit order /// used by [`Self::set_bit`] / [`Self::get_bit`]. #[inline] + pub(crate) fn bytes(&self) -> &[u8] { + &self.data + } + + /// Mutable form of [`Self::bytes`]. + #[inline] pub(crate) fn bytes_mut(&mut self) -> &mut [u8] { &mut self.data } diff --git a/packages/ore-rs/src/scheme/bit2_w6.rs b/packages/ore-rs/src/scheme/bit2_w6.rs index 80b8207d..f7226e49 100644 --- a/packages/ore-rs/src/scheme/bit2_w6.rs +++ b/packages/ore-rs/src/scheme/bit2_w6.rs @@ -30,7 +30,7 @@ use crate::{ primitives::{ hash::FixedPiZ2Hash, prf::Aes128Prf, stack, AesBlock, Hash, HashKey, Prf, Prp, NONCE_SIZE, }, - scheme::width::{ct_assign_bytes, AesBlockBuf, Bit6, BlockWidth}, + scheme::width::{ct_assign_bytes, AesBlockBuf, Bit6, BlockWidth, RightBitVec}, OreCipher, OreError, PlainText, }; @@ -384,26 +384,33 @@ impl PartialEq for CipherText { impl Ord for CipherText { fn cmp(&self, b: &Self) -> Ordering { let mut is_equal = Choice::from(1); - let mut l: u64 = 0; // Unequal block + // Latch the first differing block during the scan, as in + // `compare_raw_slices`: no load after the loop is indexed by its + // position. + let mut sel_xt = 0u8; + let mut sel_f = LeftBlock16::default(); + let mut sel_right = RightBlock8::default(); for n in 0..N { let condition: Choice = !(self.left.xt[n].ct_eq(&b.left.xt[n])) | !(self.left.f[n].ct_eq(&b.left.f[n])); + // Set for exactly one `n`: the first differing block. + let first = is_equal & condition; - l.conditional_assign(&(n as u64), is_equal & condition); - is_equal.conditional_assign(&Choice::from(0), is_equal & condition); + sel_xt.conditional_assign(&self.left.xt[n], first); + ct_assign_bytes(&mut sel_f, &self.left.f[n], first); + ct_assign_bytes(sel_right.as_mut_bytes(), b.right.data[n].bytes(), first); + is_equal.conditional_assign(&Choice::from(0), first); } - let l: usize = l as usize; - if bool::from(is_equal) { return Ordering::Equal; } let hash: Z2Hash = Hash::new(HashKey::from_slice(&b.right.nonce)); - let h = hash.hash(&self.left.f[l]); + let h = hash.hash(&sel_f); - let test = b.right.data[l].get_bit(self.left.xt[l] as usize) ^ h; + let test = sel_right.get_bit(sel_xt as usize) ^ h; if test == 1 { return Ordering::Greater; } diff --git a/packages/ore-rs/src/scheme/bit2_w6/block_types.rs b/packages/ore-rs/src/scheme/bit2_w6/block_types.rs index 97ced6cf..7ae81dbb 100644 --- a/packages/ore-rs/src/scheme/bit2_w6/block_types.rs +++ b/packages/ore-rs/src/scheme/bit2_w6/block_types.rs @@ -23,6 +23,13 @@ impl RightBlock8 { let byte = crate::scheme::width::ct_select_byte(&self.data, bit / 8); crate::scheme::width::ct_bit(byte, (bit % 8) as u8) } + + /// The raw bitvector bytes, LSB-first within each byte — the bit order + /// used by [`Self::get_bit`]. + #[inline] + pub(crate) fn bytes(&self) -> &[u8] { + &self.data + } } impl RightBitVec for RightBlock8 { From 71a1a19d22daa99ddbc9d4d058716db2e118aabc Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 21:28:09 +1100 Subject: [PATCH 13/35] harden(compare): zeroize the latched first-difference copies Each comparator latched xt, f and the right block of the first differing block into plain stack arrays that were never cleared. Pull the latch and the resolution step into width::FirstDiff, which derives ZeroizeOnDrop, so the copies are wiped on every return path including the early Equal return. All five latch sites use it: legacy and Bit6 compare_raw_slices and typed Ord, and chained compare_views (shared by compare_raw_slices and compare_left_to_full). The now-unused free get_bit helpers in bit2 and bit2_w6 are removed; FirstDiff::resolve keeps the domain debug_assert. --- .../2026-10-09-ore-v2-dynamic-verification.md | 4 +- packages/ore-rs/src/scheme/bit2.rs | 48 +++++++----------- packages/ore-rs/src/scheme/bit2_w6.rs | 47 +++++++----------- packages/ore-rs/src/scheme/chained.rs | 15 ++---- packages/ore-rs/src/scheme/width.rs | 49 ++++++++++++++++++- 5 files changed, 89 insertions(+), 74 deletions(-) diff --git a/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md index 8b6a2232..98e269d7 100644 --- a/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md +++ b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md @@ -153,7 +153,9 @@ latches `xt[l]`, `f[l]` and `right[l]` as it runs, with `width::ct_assign_bytes` under the choice "this is the first differing block" (set for exactly one `n`), and the resolution step hashes and bit-selects from the latched copies. Every load is now indexed by the public -loop counter. The added cost is one 8-byte right-block read and about 25 +loop counter. Every comparator (legacy and Bit6, raw and typed, and +chained) shares the latch as `width::FirstDiff`, which zeroizes the copies +on drop. The added cost is one 8-byte right-block read and about 25 masked byte copies per block, against a scan already comparing 17 bytes per block; the chained benchmarks in `docs/benchmarks/` were not re-run, since the comparator is not on the encrypt path they time. The legacy comparator diff --git a/packages/ore-rs/src/scheme/bit2.rs b/packages/ore-rs/src/scheme/bit2.rs index 1080f687..b3730564 100644 --- a/packages/ore-rs/src/scheme/bit2.rs +++ b/packages/ore-rs/src/scheme/bit2.rs @@ -9,7 +9,7 @@ use crate::{ primitives::{ hash::Aes128Z2Hash, prf::Aes128Prf, AesBlock, Hash, HashKey, Prf, Prp, NONCE_SIZE, }, - scheme::width::{ct_assign_bytes, AesBlockBuf, Bit8, BlockWidth, RightBitVec}, + scheme::width::{AesBlockBuf, Bit8, BlockWidth, FirstDiff, RightBitVec}, OreCipher, OreError, PlainText, }; @@ -256,9 +256,7 @@ impl OreCipher for OreAes128 { // touch, so a direct `right[l]` afterwards hits or misses the cache // according to `l`; measured as a timing signal, see `docs/reviews/`. // The wire format is unchanged. - let mut sel_xt = 0u8; - let mut sel_f = [0u8; LeftBlock16::BLOCK_SIZE]; - let mut sel_right = [0u8; RightBlock32::BLOCK_SIZE]; + let mut diff = FirstDiff::<{ RightBlock32::BLOCK_SIZE }>::new(); // Slices for the PRF ("f") blocks and the right half. let a_f = &a[num_blocks..]; @@ -273,9 +271,12 @@ impl OreCipher for OreAes128 { // Set for exactly one `n`: the first differing block. let first = is_equal & condition; - sel_xt.conditional_assign(&a[n], first); - ct_assign_bytes(&mut sel_f, left_block(a_f, n), first); - ct_assign_bytes(&mut sel_right, right_block(b_right_blocks, n), first); + diff.latch( + a[n], + left_block(a_f, n), + right_block(b_right_blocks, n), + first, + ); is_equal.conditional_assign(&Choice::from(0), first); } @@ -285,11 +286,8 @@ impl OreCipher for OreAes128 { let hash_key = HashKey::from_slice(&b_right[0..NONCE_SIZE]); let hash: Aes128Z2Hash = Hash::new(hash_key); - let h = hash.hash(&sel_f); - let test = get_bit(&sel_right, sel_xt as usize) ^ h; - - if test == 1 { + if diff.resolve(&hash) == 1 { return Some(Ordering::Greater); } @@ -310,16 +308,6 @@ fn right_block(input: &[u8], n: usize) -> &[u8] { &input[f_pos..(f_pos + RightBlock32::BLOCK_SIZE)] } -#[inline] -fn get_bit(block: &[u8], bit: usize) -> u8 { - debug_assert!(block.len() == RightBlock32::BLOCK_SIZE); - debug_assert!(bit < 256); - // `bit` is the secret permuted symbol; read the byte obliviously so the - // access address does not depend on it. See `width::ct_select_byte`. - let byte = crate::scheme::width::ct_select_byte(block, bit / 8); - crate::scheme::width::ct_bit(byte, (bit % 8) as u8) -} - impl PartialEq for CipherText { fn eq(&self, b: &Self) -> bool { matches!(self.cmp(b), Ordering::Equal) @@ -332,9 +320,7 @@ impl Ord for CipherText { // Latch the first differing block during the scan, as in // `compare_raw_slices`: no load after the loop is indexed by its // position. - let mut sel_xt = 0u8; - let mut sel_f = LeftBlock16::default(); - let mut sel_right = RightBlock32::default(); + let mut diff = FirstDiff::<{ RightBlock32::BLOCK_SIZE }>::new(); for n in 0..N { let condition: Choice = @@ -342,9 +328,12 @@ impl Ord for CipherText { // Set for exactly one `n`: the first differing block. let first = is_equal & condition; - sel_xt.conditional_assign(&self.left.xt[n], first); - ct_assign_bytes(&mut sel_f, &self.left.f[n], first); - ct_assign_bytes(sel_right.bytes_mut(), b.right.data[n].bytes(), first); + diff.latch( + self.left.xt[n], + &self.left.f[n], + b.right.data[n].bytes(), + first, + ); is_equal.conditional_assign(&Choice::from(0), first); } @@ -353,10 +342,7 @@ impl Ord for CipherText { } let hash: Aes128Z2Hash = Hash::new(AesBlock::from_slice(&b.right.nonce)); - let h = hash.hash(&sel_f); - - let test = sel_right.get_bit(sel_xt as usize) ^ h; - if test == 1 { + if diff.resolve(&hash) == 1 { return Ordering::Greater; } diff --git a/packages/ore-rs/src/scheme/bit2_w6.rs b/packages/ore-rs/src/scheme/bit2_w6.rs index f7226e49..e837daa5 100644 --- a/packages/ore-rs/src/scheme/bit2_w6.rs +++ b/packages/ore-rs/src/scheme/bit2_w6.rs @@ -30,7 +30,7 @@ use crate::{ primitives::{ hash::FixedPiZ2Hash, prf::Aes128Prf, stack, AesBlock, Hash, HashKey, Prf, Prp, NONCE_SIZE, }, - scheme::width::{ct_assign_bytes, AesBlockBuf, Bit6, BlockWidth, RightBitVec}, + scheme::width::{AesBlockBuf, Bit6, BlockWidth, FirstDiff}, OreCipher, OreError, PlainText, }; @@ -314,9 +314,7 @@ impl OreCipher for OreAes128Bit6 { // The right blocks are the one region the scan would otherwise never // touch, so a direct `right[l]` afterwards hits or misses the cache // according to `l`; measured as a timing signal, see `docs/reviews/`. - let mut sel_xt = 0u8; - let mut sel_f = [0u8; LeftBlock16::BLOCK_SIZE]; - let mut sel_right = [0u8; RightBlock8::BLOCK_SIZE]; + let mut diff = FirstDiff::<{ RightBlock8::BLOCK_SIZE }>::new(); // Slices for the PRF ("f") blocks and the right half. let a_f = &a[num_blocks..]; @@ -331,9 +329,12 @@ impl OreCipher for OreAes128Bit6 { // Set for exactly one `n`: the first differing block. let first = is_equal & condition; - sel_xt.conditional_assign(&a[n], first); - ct_assign_bytes(&mut sel_f, left_block(a_f, n), first); - ct_assign_bytes(&mut sel_right, right_block(b_right_blocks, n), first); + diff.latch( + a[n], + left_block(a_f, n), + right_block(b_right_blocks, n), + first, + ); is_equal.conditional_assign(&Choice::from(0), first); } @@ -342,10 +343,8 @@ impl OreCipher for OreAes128Bit6 { } let hash: Z2Hash = Hash::new(HashKey::from_slice(&b_right[0..NONCE_SIZE])); - let h = hash.hash(&sel_f); - let test = get_bit(&sel_right, sel_xt as usize) ^ h; - if test == 1 { + if diff.resolve(&hash) == 1 { return Some(Ordering::Greater); } @@ -365,16 +364,6 @@ fn right_block(input: &[u8], n: usize) -> &[u8] { &input[f_pos..(f_pos + RightBlock8::BLOCK_SIZE)] } -#[inline] -fn get_bit(block: &[u8], bit: usize) -> u8 { - debug_assert!(block.len() == RightBlock8::BLOCK_SIZE); - debug_assert!(bit < 64); - // `bit` is the secret permuted symbol; read the byte obliviously so the - // access address does not depend on it. See `width::ct_select_byte`. - let byte = crate::scheme::width::ct_select_byte(block, bit / 8); - crate::scheme::width::ct_bit(byte, (bit % 8) as u8) -} - impl PartialEq for CipherText { fn eq(&self, b: &Self) -> bool { matches!(self.cmp(b), Ordering::Equal) @@ -387,9 +376,7 @@ impl Ord for CipherText { // Latch the first differing block during the scan, as in // `compare_raw_slices`: no load after the loop is indexed by its // position. - let mut sel_xt = 0u8; - let mut sel_f = LeftBlock16::default(); - let mut sel_right = RightBlock8::default(); + let mut diff = FirstDiff::<{ RightBlock8::BLOCK_SIZE }>::new(); for n in 0..N { let condition: Choice = @@ -397,9 +384,12 @@ impl Ord for CipherText { // Set for exactly one `n`: the first differing block. let first = is_equal & condition; - sel_xt.conditional_assign(&self.left.xt[n], first); - ct_assign_bytes(&mut sel_f, &self.left.f[n], first); - ct_assign_bytes(sel_right.as_mut_bytes(), b.right.data[n].bytes(), first); + diff.latch( + self.left.xt[n], + &self.left.f[n], + b.right.data[n].bytes(), + first, + ); is_equal.conditional_assign(&Choice::from(0), first); } @@ -408,10 +398,7 @@ impl Ord for CipherText { } let hash: Z2Hash = Hash::new(HashKey::from_slice(&b.right.nonce)); - let h = hash.hash(&sel_f); - - let test = sel_right.get_bit(sel_xt as usize) ^ h; - if test == 1 { + if diff.resolve(&hash) == 1 { return Ordering::Greater; } diff --git a/packages/ore-rs/src/scheme/chained.rs b/packages/ore-rs/src/scheme/chained.rs index 786809be..4f392d93 100644 --- a/packages/ore-rs/src/scheme/chained.rs +++ b/packages/ore-rs/src/scheme/chained.rs @@ -52,7 +52,7 @@ use crate::primitives::hash::FixedPiZ2Hash; use crate::primitives::prp::LemireFyPrp; use crate::primitives::{stack, AesBlock, Hash, HashKey, Prp}; use crate::scheme::decompose::{decompose_6bit, num_blocks_6bit}; -use crate::scheme::width::{ct_assign_bytes, ct_bit, ct_select_byte}; +use crate::scheme::width::FirstDiff; use crate::OreError; const VERSION: u8 = 0x02; @@ -423,16 +423,12 @@ impl OreAes128Bit6Chained { fn compare_views(a: &[u8], ca: usize, b: &[u8], cb: usize) -> Ordering { let min_count = ca.min(cb); let mut is_equal = Choice::from(1u8); - let mut sel_xt = 0u8; - let mut sel_f = [0u8; F_LEN]; - let mut sel_right = [0u8; RIGHT_LEN]; + let mut diff = FirstDiff::::new(); for n in 0..min_count { let differs = !xt_at(a, n).ct_eq(&xt_at(b, n)) | !f_at(a, ca, n).ct_eq(f_at(b, cb, n)); // Set for exactly one `n`: the first differing block. let first = is_equal & differs; - sel_xt.conditional_assign(&xt_at(a, n), first); - ct_assign_bytes(&mut sel_f, f_at(a, ca, n), first); - ct_assign_bytes(&mut sel_right, right_at(b, cb, n), first); + diff.latch(xt_at(a, n), f_at(a, ca, n), right_at(b, cb, n), first); is_equal.conditional_assign(&Choice::from(0u8), first); } @@ -442,10 +438,7 @@ impl OreAes128Bit6Chained { } let hasher = FixedPiZ2Hash::new(HashKey::from_slice(nonce_at(b, cb))); - let h = hasher.hash(&sel_f); - let byte = ct_select_byte(&sel_right, (sel_xt / 8) as usize); - let test = ct_bit(byte, sel_xt % 8) ^ h; - if test == 1 { + if diff.resolve(&hasher) == 1 { Ordering::Greater } else { Ordering::Less diff --git a/packages/ore-rs/src/scheme/width.rs b/packages/ore-rs/src/scheme/width.rs index 0d6535ab..693705f5 100644 --- a/packages/ore-rs/src/scheme/width.rs +++ b/packages/ore-rs/src/scheme/width.rs @@ -11,9 +11,10 @@ //! as associated types instead. use crate::ciphertext::CipherTextBlock; -use crate::primitives::{AesBlock, Prp}; +use crate::primitives::{AesBlock, Hash, Prp}; use crate::scheme::bit2::block_types::RightBlock32; use crate::scheme::bit2_w6::block_types::RightBlock8; +use zeroize::{Zeroize, ZeroizeOnDrop}; mod sealed { pub trait Sealed {} @@ -129,6 +130,52 @@ pub(crate) fn ct_bit(byte: u8, pos: u8) -> u8 { out } +/// The first differing block of a comparator's prefix scan: `a`'s permuted +/// symbol `xt` and PRF tag `f`, and `b`'s `R`-byte right bitvector. +/// +/// The scan calls [`Self::latch`] for every block, under the choice "this is +/// the first difference", so nothing after the scan is loaded at an address +/// derived from that position (see [`ct_assign_bytes`]). The copies are +/// zeroized on drop, on every return path, so they do not outlive the +/// comparison on the stack. +#[derive(Zeroize, ZeroizeOnDrop)] +pub(crate) struct FirstDiff { + xt: u8, + f: [u8; 16], + right: [u8; R], +} + +impl FirstDiff { + pub(crate) fn new() -> Self { + Self { + xt: 0, + f: [0; 16], + right: [0; R], + } + } + + /// Copy one block's values in when `first` is set; every byte of the + /// latch and the inputs is touched either way. + #[inline] + pub(crate) fn latch(&mut self, xt: u8, f: &[u8], right: &[u8], first: subtle_ng::Choice) { + use subtle_ng::ConditionallySelectable; + self.xt.conditional_assign(&xt, first); + ct_assign_bytes(&mut self.f, f, first); + ct_assign_bytes(&mut self.right, right, first); + } + + /// The resolution step: bit `xt` of the right bitvector XOR the random + /// oracle on `f`. `1` means `a > b`. The bit is read with + /// [`ct_select_byte`] and [`ct_bit`], so its address does not depend on + /// `xt` either. + #[inline] + pub(crate) fn resolve(&self, hasher: &H) -> u8 { + debug_assert!(usize::from(self.xt) < R * 8); + let byte = ct_select_byte(&self.right, usize::from(self.xt / 8)); + ct_bit(byte, self.xt % 8) ^ hasher.hash(&self.f) + } +} + /// Per-block bitvector operations on a Right ciphertext block, one bit per /// value in the block domain. pub trait RightBitVec { From 9af04d3bc3bb332bfdc16dbea6ceb174abd78866 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 21:50:11 +1100 Subject: [PATCH 14/35] harden(prp): typed in-domain Symbol; infallible permute; drop taint suppressions oblivious_lookup range-checked the secret plaintext symbol before its constant-time scan, and every encryptor tested the Result with `?`. Memcheck reported both as secret-dependent branches, accepted in valgrind.supp through a glob over every `?` on a Result, which would also have hidden any future secret-dependent error path (PR #100 review findings 3 and 15). Remove the check at its root. Symbol is a byte in the D-element domain by type; Prp::permute, invert and indicator_mask_xor take one, so they are infallible and the lookup has no range branch. Blocks6 holds 6-bit symbols: Blocks6::decompose masks while packing (no branch on the plaintext) and feeds the OreEncrypt and string paths; Blocks6::check validates symbols a caller passes to Bit6 OreCipher::encrypt* or chained encrypt_var / encrypt_left_var, once at entry, and still returns OreError::PrpError. That replaces the debug_assert those entry points had. Ciphertexts are unchanged (wire vectors pass). valgrind.supp now has no entries. The taint gate passes all four modes with it on arm64 (NEON builder) and emulated amd64 (SSSE3 builder); the previous commit with the same empty file fails all four, every report being the range check. Kani: the lookup harnesses now prove table[i] for every symbol, a new harness covers Symbol::<64>::from_low_bits, and the kani-full any-length lookup harness is dropped since out-of-range lookups are unrepresentable. --- packages/ct-taint/valgrind.supp | 25 ++---- packages/ore-rs/Cargo.toml | 4 +- packages/ore-rs/src/lib.rs | 10 +-- packages/ore-rs/src/primitives.rs | 53 ++++++++++- packages/ore-rs/src/primitives/prp.rs | 114 ++++++++++++------------ packages/ore-rs/src/scheme/bit2.rs | 16 ++-- packages/ore-rs/src/scheme/bit2_w6.rs | 106 +++++++++++++--------- packages/ore-rs/src/scheme/chained.rs | 96 ++++++++++++-------- packages/ore-rs/src/scheme/decompose.rs | 78 +++++++++++++++- packages/ore-rs/src/scheme/width.rs | 8 +- 10 files changed, 331 insertions(+), 179 deletions(-) diff --git a/packages/ct-taint/valgrind.supp b/packages/ct-taint/valgrind.supp index 2c4039f3..8be00cdc 100644 --- a/packages/ct-taint/valgrind.supp +++ b/packages/ct-taint/valgrind.supp @@ -2,21 +2,10 @@ # secret-dependent access or branch that the design documents and accepts; # scripts/ct-taint.sh fails on anything not listed here. Add an entry only # with a reason. Frame names are demangled Rust symbols, matched as globs. - -# `oblivious_lookup` rejects an out-of-range index before the constant-time -# scan, and its callers test the `Result`. The comparison is against the -# public table length, and every symbol the encryptors produce is in range, -# so the branch direction is the same for every input and carries no -# information; it is still a comparison on the plaintext symbol, which is -# why memcheck reports it. Removing it means an infallible `permute` over a -# typed in-domain symbol. -{ - oblivious-lookup-range-check - Memcheck:Cond - fun:*oblivious_lookup* -} -{ - oblivious-lookup-range-check-result - Memcheck:Cond - fun:*branch u8 { LemireFyPrp::<64>::from_stream(stream) .expect("512 bytes is a full stream") - .permute(0) - .expect("0 is in the domain") + .permute(Symbol::from_low_bits(0)) + .get() } fn from_stream_with(stream: &[u8; 512], build: fn(&[u8], &mut [u8; 64], &mut [u8; 64])) -> u8 { LemireFyPrp::<64>::from_stream_with(stream, build) .expect("512 bytes is a full stream") - .permute(0) - .expect("0 is in the domain") + .permute(Symbol::from_low_bits(0)) + .get() } /// As [`lemire_fy_prp_from_stream`], through the reference builder: diff --git a/packages/ore-rs/src/primitives.rs b/packages/ore-rs/src/primitives.rs index 0928ae66..b45ffeb7 100644 --- a/packages/ore-rs/src/primitives.rs +++ b/packages/ore-rs/src/primitives.rs @@ -32,14 +32,59 @@ pub trait Hash { pub struct PrpError; pub type PrpResult = Result; -pub trait Prp: Sized { +/// A value of a `D`-element PRP domain: a byte known to be `< D`, for a +/// power-of-two `D ≤ 256`. +/// +/// The type carries the domain check, so [`Prp::permute`] and +/// [`Prp::invert`] are infallible and the oblivious table lookup behind them +/// has no range branch on the (secret) symbol. A symbol is made by +/// [`Self::from_low_bits`], which masks and never branches, or (for the +/// 256-element domain, where every byte is a symbol) by `From`. Callers +/// that take symbols from outside validate them first; see +/// `scheme::decompose::Blocks6`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Symbol(u8); + +impl Symbol { + const DOMAIN_OK: () = assert!( + D.is_power_of_two() && D <= 256, + "Symbol: the domain must be a power of two no larger than 256" + ); + + /// The low `log2(D)` bits of `b`: `b mod D`, by masking. Branch-free, and + /// the identity on any `b < D`. + #[inline] + #[allow(clippy::let_unit_value)] + pub fn from_low_bits(b: u8) -> Self { + let () = Self::DOMAIN_OK; + // `D - 1` is at most 255, so the mask fits a byte. + Self(b & (D - 1) as u8) + } + + #[inline] + pub fn get(self) -> u8 { + self.0 + } +} + +/// Every byte is a symbol of the 256-element domain. +impl From for Symbol<256> { + #[inline] + fn from(b: u8) -> Self { + Self(b) + } +} + +/// A pseudo-random permutation over the domain whose values are `S` (a +/// [`Symbol`]). Only key setup can fail; a symbol is in the domain by type. +pub trait Prp: Sized { fn new(key: &[u8]) -> PrpResult; - fn permute(&self, data: T) -> PrpResult; + fn permute(&self, data: S) -> S; /// Inverse of [`Self::permute`]. The encrypt path uses the bulk /// [`Self::indicator_mask_xor`] instead; this remains the per-value /// reference (used by the mask equivalence tests). #[allow(dead_code)] - fn invert(&self, data: T) -> PrpResult; + fn invert(&self, data: S) -> S; /// XOR the indicator mask for `data` into `out`: bit `j` of the mask is /// `1` iff `invert(j) > data`. Bit order matches @@ -49,5 +94,5 @@ pub trait Prp: Sized { /// This is the bulk form of the per-`j` `invert`-and-compare loop the /// right-ciphertext encoder needs; implementations walk their inverse /// table linearly instead of doing `DOMAIN` indexed lookups. - fn indicator_mask_xor(&self, data: T, out: &mut [u8]); + fn indicator_mask_xor(&self, data: S, out: &mut [u8]); } diff --git a/packages/ore-rs/src/primitives/prp.rs b/packages/ore-rs/src/primitives/prp.rs index d1df2d13..5eae50e1 100644 --- a/packages/ore-rs/src/primitives/prp.rs +++ b/packages/ore-rs/src/primitives/prp.rs @@ -1,7 +1,7 @@ pub(crate) mod oblivious; pub mod prng; use crate::primitives::prp::prng::Aes128Prng; -use crate::primitives::{AesBlock, Prp, PrpError, PrpResult}; +use crate::primitives::{AesBlock, Prp, PrpError, PrpResult, Symbol}; use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit}; use aes::Aes128Enc; use zeroize::{Zeroize, ZeroizeOnDrop}; @@ -12,16 +12,14 @@ use zeroize::{Zeroize, ZeroizeOnDrop}; /// table only defends cache-line granularity, and sub-line timing would /// otherwise reveal which part of the table was read. /// -/// Out-of-range `index` is an error, decided by a comparison against the -/// public table length. +/// `index` is in range by type, so there is no range check to branch on. +/// The tables are permutations of `0..D`, so every entry is itself a symbol; +/// `from_low_bits` is the identity on it. #[inline] -fn oblivious_lookup(table: &[u8], index: u8) -> PrpResult { - if usize::from(index) >= table.len() { - return Err(PrpError); - } - Ok(crate::scheme::width::ct_select_byte( +fn oblivious_lookup(table: &[u8; D], index: Symbol) -> Symbol { + Symbol::from_low_bits(crate::scheme::width::ct_select_byte( table, - usize::from(index), + usize::from(index.get()), )) } @@ -49,7 +47,7 @@ impl ZeroizeOnDrop for KnuthShufflePRP {} /// schemes only. macro_rules! impl_knuth_shuffle_prp { ($domain:literal, $gt_mask:path) => { - impl Prp for KnuthShufflePRP { + impl Prp> for KnuthShufflePRP { /* * Initialize a ($domain element) PRP using a KnuthShuffle * seeded from a 16-byte key @@ -90,26 +88,26 @@ macro_rules! impl_knuth_shuffle_prp { * * Forward permutations are only used once in the ORE scheme so this is OK */ - fn permute(&self, input: u8) -> PrpResult { + fn permute(&self, input: Symbol<$domain>) -> Symbol<$domain> { oblivious_lookup(&self.inverse, input) } /* * Performs the inverse permutation in constant time. */ - fn invert(&self, input: u8) -> PrpResult { + fn invert(&self, input: Symbol<$domain>) -> Symbol<$domain> { // Forward and inverse permutations are reversed for historical reasons oblivious_lookup(&self.permutation, input) } - fn indicator_mask_xor(&self, data: u8, out: &mut [u8]) { + fn indicator_mask_xor(&self, data: Symbol<$domain>, out: &mut [u8]) { debug_assert_eq!(out.len() * 8, $domain); // `invert(j)` is `self.permutation[j]` (see `invert` // above), so the mask is one pass over the table: a // bytewise `> data` compare packed to bits — vectorised // where the target supports it. - $gt_mask(&self.permutation, data, out); + $gt_mask(&self.permutation, data.get(), out); } } }; @@ -236,7 +234,7 @@ macro_rules! impl_lemire_fy_prp { } } - impl Prp for LemireFyPrp<$domain> { + impl Prp> for LemireFyPrp<$domain> { fn new(key: &[u8]) -> PrpResult { if key.len() < 16 { return Err(PrpError); @@ -270,17 +268,17 @@ macro_rules! impl_lemire_fy_prp { Ok(perm) } - fn permute(&self, input: u8) -> PrpResult { + fn permute(&self, input: Symbol<$domain>) -> Symbol<$domain> { oblivious_lookup(&self.inverse, input) } - fn invert(&self, input: u8) -> PrpResult { + fn invert(&self, input: Symbol<$domain>) -> Symbol<$domain> { oblivious_lookup(&self.permutation, input) } - fn indicator_mask_xor(&self, data: u8, out: &mut [u8]) { + fn indicator_mask_xor(&self, data: Symbol<$domain>, out: &mut [u8]) { debug_assert_eq!(out.len() * 8, $domain); - $gt_mask(&self.permutation, data, out); + $gt_mask(&self.permutation, data.get(), out); } } }; @@ -309,11 +307,11 @@ mod tests { let prp: KnuthShufflePRP = Prp::new(&key[0..16]).unwrap(); let mut mask = [0u8; 32]; - prp.indicator_mask_xor(x, &mut mask); + prp.indicator_mask_xor(Symbol::from(x), &mut mask); let mut reference = [0u8; 32]; for j in 0..=255u8 { - let indicator = u8::from(prp.invert(j).unwrap() > x); + let indicator = u8::from(prp.invert(Symbol::from(j)).get() > x); reference[(j / 8) as usize] |= indicator << (j % 8); } @@ -325,10 +323,11 @@ mod tests { fn test_invert() -> Result<(), PrpError> { let prp = init_prp()?; - for i in 0..=255 { + for i in 0..=255u8 { + let i = Symbol::from(i); assert_eq!( i, - prp.invert(prp.permute(i)?)?, + prp.invert(prp.permute(i)), "permutation round-trip failed" ); } @@ -340,6 +339,11 @@ mod tests { // LemireFyPrp (Bit6 PRP) // ----------------------------------------------------------------- + fn sym64(v: u8) -> Symbol<64> { + assert!(v < 64, "test symbol in 0..64"); + Symbol::from_low_bits(v) + } + fn init_fy(seed_byte: u8) -> LemireFyPrp<64> { Prp::new(&[seed_byte; 16]).unwrap() } @@ -351,7 +355,7 @@ mod tests { // Every value 0..64 appears exactly once in `permutation`. let mut seen = [false; 64]; for v in 0..64u8 { - let mapped = prp.invert(v).unwrap(); + let mapped = prp.invert(sym64(v)).get(); assert!(mapped < 64); assert!( !seen[mapped as usize], @@ -362,8 +366,9 @@ mod tests { } // Forward/inverse round-trip both directions. for v in 0..64u8 { - assert_eq!(v, prp.invert(prp.permute(v).unwrap()).unwrap()); - assert_eq!(v, prp.permute(prp.invert(v).unwrap()).unwrap()); + let v = sym64(v); + assert_eq!(v, prp.invert(prp.permute(v))); + assert_eq!(v, prp.permute(prp.invert(v))); } } } @@ -373,16 +378,16 @@ mod tests { let a = init_fy(7); let b = init_fy(7); for v in 0..64u8 { - assert_eq!(a.permute(v).unwrap(), b.permute(v).unwrap()); + assert_eq!(a.permute(sym64(v)), b.permute(sym64(v))); } // A different seed gives a different permutation (overwhelmingly). let c = init_fy(8); - assert!((0..64u8).any(|v| a.permute(v).unwrap() != c.permute(v).unwrap())); + assert!((0..64u8).any(|v| a.permute(sym64(v)) != c.permute(sym64(v)))); } #[test] fn fy_rejects_short_key() { - assert!( as Prp>::new(&[0u8; 8]).is_err()); + assert!( as Prp>>::new(&[0u8; 8]).is_err()); } quickcheck! { @@ -393,13 +398,14 @@ mod tests { return quickcheck::TestResult::discard(); } let prp: LemireFyPrp<64> = Prp::new(&key[0..16]).unwrap(); + let x = Symbol::<64>::from_low_bits(x); let mut mask = [0u8; 8]; prp.indicator_mask_xor(x, &mut mask); let mut reference = [0u8; 8]; for j in 0..64u8 { - let indicator = u8::from(prp.invert(j).unwrap() > x); + let indicator = u8::from(prp.invert(sym64(j)).get() > x.get()); reference[(j / 8) as usize] |= indicator << (j % 8); } @@ -412,49 +418,39 @@ mod tests { mod kani_proofs { use super::*; - /// `oblivious_lookup` over a 64-entry table returns `Ok(table[i])` for - /// every `i < 64` and `Err` for every `i >= 64` (all tables, all `u8`). + /// `oblivious_lookup` over a 64-entry table returns `table[i]` for every + /// symbol `i` (all tables whose entries are in the domain, as a + /// permutation's are). #[kani::proof] #[kani::unwind(65)] fn oblivious_lookup_64_matches_index() { let table: [u8; 64] = kani::any(); - let index: u8 = kani::any(); + kani::assume(table.iter().all(|&v| v < 64)); + let index = Symbol::<64>::from_low_bits(kani::any()); let got = oblivious_lookup(&table, index); - if index < 64 { - assert!(matches!(got, Ok(v) if v == table[index as usize])); - } else { - assert!(got.is_err()); - } + assert_eq!(got.get(), table[usize::from(index.get())]); } - /// `oblivious_lookup` over a 256-entry table returns `Ok(table[i])` for - /// every `u8` index (all tables; no index is out of range). + /// `oblivious_lookup` over a 256-entry table returns `table[i]` for every + /// symbol `i` (all tables). #[kani::proof] #[kani::unwind(257)] fn oblivious_lookup_256_matches_index() { let table: [u8; 256] = kani::any(); - let index: u8 = kani::any(); - assert!(matches!(oblivious_lookup(&table, index), Ok(v) if v == table[index as usize])); + let index = Symbol::<256>::from(kani::any::()); + let got = oblivious_lookup(&table, index); + assert_eq!(got.get(), table[usize::from(index.get())]); } - /// `oblivious_lookup` over a table of any length 0..=256 returns - /// `Ok(table[i])` when `i < len` and `Err` otherwise. - // Every length 0..=256 takes about seven minutes; the two lengths in - // use are proved above, so this runs only with `kani-full`. - #[cfg(feature = "kani-full")] + /// `Symbol::<64>::from_low_bits` always yields a value in `0..64`, and + /// is the identity on `0..64` (all `u8`). #[kani::proof] - #[kani::unwind(257)] - fn oblivious_lookup_any_len_matches_index() { - let buf: [u8; 256] = kani::any(); - let len: usize = kani::any(); - kani::assume(len <= 256); - let table = &buf[..len]; - let index: u8 = kani::any(); - let got = oblivious_lookup(table, index); - if usize::from(index) < len { - assert!(matches!(got, Ok(v) if v == table[index as usize])); - } else { - assert!(got.is_err()); + fn symbol_64_from_low_bits_in_domain() { + let b: u8 = kani::any(); + let s = Symbol::<64>::from_low_bits(b); + assert!(s.get() < 64); + if b < 64 { + assert_eq!(s.get(), b); } } diff --git a/packages/ore-rs/src/scheme/bit2.rs b/packages/ore-rs/src/scheme/bit2.rs index b3730564..42e0abd4 100644 --- a/packages/ore-rs/src/scheme/bit2.rs +++ b/packages/ore-rs/src/scheme/bit2.rs @@ -7,7 +7,7 @@ use crate::{ ciphertext::*, primitives::{ - hash::Aes128Z2Hash, prf::Aes128Prf, AesBlock, Hash, HashKey, Prf, Prp, NONCE_SIZE, + hash::Aes128Z2Hash, prf::Aes128Prf, AesBlock, Hash, HashKey, Prf, Prp, Symbol, NONCE_SIZE, }, scheme::width::{AesBlockBuf, Bit8, BlockWidth, FirstDiff, RightBitVec}, OreCipher, OreError, PlainText, @@ -97,7 +97,7 @@ fn derive_prp_seeds(prf2: &Aes128Prf, x: &PlainText) -> SeedB pub(crate) fn encode_right_block( block: &mut W::RightBlock, prp: &W::Prp, - x: u8, + x: W::Symbol, hasher: &H, ro_blocks: &mut [AesBlock], ) { @@ -121,7 +121,7 @@ impl OreAes128 { ) -> Result<(), OreError> { for n in 0..N { let prp: ::Prp = Prp::new(&seeds.0[n])?; - left.xt[n] = prp.permute(x[n])?; + left.xt[n] = prp.permute(Symbol::from(x[n])).get(); left.f[n][0..n].clone_from_slice(&x[0..n]); left.f[n][n] = left.xt[n]; @@ -192,7 +192,7 @@ impl OreCipher for OreAes128 { for n in 0..N { let prp: ::Prp = Prp::new(&seeds.0[n])?; - left.xt[n] = prp.permute(x[n])?; + left.xt[n] = prp.permute(Symbol::from(x[n])).get(); left.f[n][0..n].clone_from_slice(&x[0..n]); left.f[n][n] = left.xt[n]; @@ -212,7 +212,13 @@ impl OreCipher for OreAes128 { work.copy_from(&template); self.prf1.encrypt_all(work.as_mut_slice()); - encode_right_block::(&mut right.data[n], &prp, x[n], &hasher, &mut work); + encode_right_block::( + &mut right.data[n], + &prp, + Symbol::from(x[n]), + &hasher, + &mut work, + ); } self.prf1.encrypt_all(&mut left.f); diff --git a/packages/ore-rs/src/scheme/bit2_w6.rs b/packages/ore-rs/src/scheme/bit2_w6.rs index e837daa5..868e56e9 100644 --- a/packages/ore-rs/src/scheme/bit2_w6.rs +++ b/packages/ore-rs/src/scheme/bit2_w6.rs @@ -30,6 +30,7 @@ use crate::{ primitives::{ hash::FixedPiZ2Hash, prf::Aes128Prf, stack, AesBlock, Hash, HashKey, Prf, Prp, NONCE_SIZE, }, + scheme::decompose::Blocks6, scheme::width::{AesBlockBuf, Bit6, BlockWidth, FirstDiff}, OreCipher, OreError, PlainText, }; @@ -141,29 +142,25 @@ impl OreAes128Bit6 { self.prf2.encrypt_all(&mut seeds); SeedBuf(seeds) } -} -/// The encrypt paths without the stack scrub. Every caller runs them inside -/// [`stack::scrubbed`]: building a block's PRP leaves its seed and tables in -/// dead stack (see `primitives::stack`). -impl OreAes128Bit6 { - pub(crate) fn encrypt_left_unscrubbed( + /// [`OreCipher::encrypt_left`] over blocks already known to be in the + /// 6-bit domain, without the stack scrub. Every caller runs it inside + /// [`stack::scrubbed`]: building a block's PRP leaves its seed and tables + /// in dead stack (see `primitives::stack`). + pub(crate) fn encrypt_left_blocks( &self, - x: &PlainText, + x: &Blocks6>, ) -> EncryptLeftResult { assert!(N <= MAX_BLOCKS); - debug_assert!(x - .iter() - .all(|&b| (b as usize) < ::DOMAIN)); let mut output = Left::::init(); - let seeds = self.derive_prp_seeds(x); + let seeds = self.derive_prp_seeds(x.inner()); for n in 0..N { let prp: ::Prp = Prp::new(&seeds.0[n])?; - output.xt[n] = prp.permute(x[n])?; + output.xt[n] = prp.permute(x.symbol(n)).get(); - output.f[n][0..n].clone_from_slice(&x[0..n]); + output.f[n][0..n].clone_from_slice(&x.as_bytes()[0..n]); output.f[n][n] = output.xt[n]; output.f[n][N] = n as u8; output.f[n][15] = N as u8; @@ -173,21 +170,20 @@ impl OreAes128Bit6 { Ok(output) } - pub(crate) fn encrypt_unscrubbed( + /// [`OreCipher::encrypt`] over blocks already known to be in the 6-bit + /// domain, without the stack scrub; see [`Self::encrypt_left_blocks`]. + pub(crate) fn encrypt_blocks( &self, - x: &PlainText, + x: &Blocks6>, ) -> EncryptResult { assert!(N <= MAX_BLOCKS); - debug_assert!(x - .iter() - .all(|&b| (b as usize) < ::DOMAIN)); let mut left = Left::::init(); let mut right = Right::::init(); self.rng.borrow_mut().try_fill(&mut right.nonce)?; - let seeds = self.derive_prp_seeds(x); + let seeds = self.derive_prp_seeds(x.inner()); let hasher: Z2Hash = Hash::new(HashKey::from_slice(&right.nonce)); // RO key template, maintained incrementally (see the bit2 sibling). @@ -201,16 +197,16 @@ impl OreAes128Bit6 { for n in 0..N { let prp: ::Prp = Prp::new(&seeds.0[n])?; - left.xt[n] = prp.permute(x[n])?; + left.xt[n] = prp.permute(x.symbol(n)).get(); - left.f[n][0..n].clone_from_slice(&x[0..n]); + left.f[n][0..n].clone_from_slice(&x.as_bytes()[0..n]); left.f[n][n] = left.xt[n]; left.f[n][N] = n as u8; left.f[n][15] = N as u8; if n > 0 { for (j, entry) in template.iter_mut().enumerate() { - entry[n - 1] = x[n - 1]; + entry[n - 1] = x.as_bytes()[n - 1]; entry[n] = j as u8; entry[N] = n as u8; } @@ -222,7 +218,7 @@ impl OreAes128Bit6 { crate::scheme::bit2::encode_right_block::( &mut right.data[n], &prp, - x[n], + x.symbol(n), &hasher, &mut work, ); @@ -261,16 +257,18 @@ impl OreCipher for OreAes128Bit6 { }) } - /// Encrypt `x`, whose entries are **6-bit block values** (`< 64`, - /// produced by [`crate::scheme::decompose`]); `N` is the block count + /// Encrypt `x`, whose entries are **6-bit block values** (`< 64`, as + /// [`crate::scheme::decompose`] produces); `N` is the block count /// (≤ [`MAX_BLOCKS`]). The [`crate::OreEncrypt`] impls handle the - /// byte→block decomposition for primitive types. + /// byte→block decomposition for primitive types and skip the check + /// below. A value `>= 64` returns [`OreError::PrpError`]. fn encrypt_left(&self, x: &PlainText) -> EncryptLeftResult { - stack::scrubbed(|| self.encrypt_left_unscrubbed(x)) + stack::scrubbed(|| self.encrypt_left_blocks(&Blocks6::check(*x)?)) } + /// As [`Self::encrypt_left`], for the full ciphertext. fn encrypt(&self, x: &PlainText) -> EncryptResult { - stack::scrubbed(|| self.encrypt_unscrubbed(x)) + stack::scrubbed(|| self.encrypt_blocks(&Blocks6::check(*x)?)) } fn compare_raw_slices(a: &[u8], b: &[u8]) -> Option { @@ -421,7 +419,7 @@ impl Eq for CipherText {} mod encrypt_impls { use super::{OreAes128Bit6, MAX_BLOCKS}; use crate::primitives::stack; - use crate::scheme::decompose::{decompose_6bit, num_blocks_6bit}; + use crate::scheme::decompose::{num_blocks_6bit, Blocks6}; use crate::{CipherText, Left, OreEncrypt, OreError}; use orderable_bytes::{FixedOrderableBytes, OrderableBytes}; use rand::{Rng, SeedableRng}; @@ -447,18 +445,16 @@ mod encrypt_impls { // frame, so they go inside the scrub too. stack::scrubbed(|| { let bytes = self.to_orderable_bytes(); - let mut blocks = [0u8; $blocks_const]; - decompose_6bit(&bytes, &mut blocks); - cipher.encrypt_left_unscrubbed(&blocks) + let blocks = Blocks6::decompose(&bytes, [0u8; $blocks_const]); + cipher.encrypt_left_blocks(&blocks) }) } fn encrypt(&self, cipher: &OreAes128Bit6) -> Result { stack::scrubbed(|| { let bytes = self.to_orderable_bytes(); - let mut blocks = [0u8; $blocks_const]; - decompose_6bit(&bytes, &mut blocks); - cipher.encrypt_unscrubbed(&blocks) + let blocks = Blocks6::decompose(&bytes, [0u8; $blocks_const]); + cipher.encrypt_blocks(&blocks) }) } } @@ -506,6 +502,29 @@ mod tests { /// input `xt[0] ‖ 0×10 ‖ 0@11 ‖ 0×3 ‖ 11@15`; legacy block 11 of /// `(xt[0], 0×10, c, 0×3)` has input `xt[0] ‖ 0×10 ‖ xt'[11] ‖ 0×3 ‖ /// 11@15`, which matches whenever `c` permutes to 0. Search every `c`. + /// Blocks passed straight to `OreCipher` are checked: a value outside + /// the 6-bit domain is refused, and an in-domain array encrypts exactly + /// as the decomposing `OreEncrypt` path does. + #[test] + fn raw_blocks_are_checked() { + let ore = init_ore(); + let mut blocks = [0u8; 11]; + blocks[3] = 64; + assert!(matches!( + OreCipher::encrypt(&ore, &blocks), + Err(OreError::PrpError(_)) + )); + assert!(matches!( + OreCipher::encrypt_left(&ore, &blocks), + Err(OreError::PrpError(_)) + )); + + let x = 0x0123_4567_89ab_cdefu64; + let decomposed = crate::scheme::decompose::Blocks6::decompose(&x.to_be_bytes(), [0u8; 11]); + let raw = OreCipher::encrypt_left(&ore, decomposed.inner()).unwrap(); + assert_eq!(raw.to_bytes(), x.encrypt_left(&ore).unwrap().to_bytes()); + } + #[test] fn legacy_ciphertexts_never_publish_bit6_tags() { use crate::scheme::bit2::OreAes128ChaCha20; @@ -717,8 +736,9 @@ mod tests { for seed in seeds.0.iter() { let prp: ::Prp = Prp::new(seed).unwrap(); needles.push(seed.to_vec()); - needles.push((0..64).map(|i| prp.permute(i).unwrap()).collect()); - needles.push((0..64).map(|i| prp.invert(i).unwrap()).collect()); + let symbols = (0..64).map(crate::primitives::Symbol::<64>::from_low_bits); + needles.push(symbols.clone().map(|i| prp.permute(i).get()).collect()); + needles.push(symbols.map(|i| prp.invert(i).get()).collect()); } needles } @@ -728,7 +748,7 @@ mod tests { let k1 = [0x3cu8; 16]; let k2 = [0xc3u8; 16]; let x = 0x0123_4567_89ab_cdefu64; - let needles = block_secrets(&Ore::init(&k1, &k2).unwrap(), &u64_blocks(x)); + let needles = block_secrets(&Ore::init(&k1, &k2).unwrap(), u64_blocks(x).inner()); // Positive control and depth: without the scrub the secrets are // there, and the scrub must reach deeper than the encryption did. @@ -755,10 +775,8 @@ mod tests { } } - fn u64_blocks(x: u64) -> [u8; 11] { - let mut blocks = [0u8; 11]; - crate::scheme::decompose::decompose_6bit(&x.to_be_bytes(), &mut blocks); - blocks + fn u64_blocks(x: u64) -> Blocks6<[u8; 11]> { + Blocks6::decompose(&x.to_be_bytes(), [0u8; 11]) } #[test] @@ -768,7 +786,7 @@ mod tests { core::hint::black_box(x.encrypt(ore).unwrap()); }, |ore, x| { - core::hint::black_box(ore.encrypt_unscrubbed(&u64_blocks(x)).unwrap()); + core::hint::black_box(ore.encrypt_blocks(&u64_blocks(x)).unwrap()); }, ); } @@ -780,7 +798,7 @@ mod tests { core::hint::black_box(x.encrypt_left(ore).unwrap()); }, |ore, x| { - core::hint::black_box(ore.encrypt_left_unscrubbed(&u64_blocks(x)).unwrap()); + core::hint::black_box(ore.encrypt_left_blocks(&u64_blocks(x)).unwrap()); }, ); } diff --git a/packages/ore-rs/src/scheme/chained.rs b/packages/ore-rs/src/scheme/chained.rs index 4f392d93..c448f566 100644 --- a/packages/ore-rs/src/scheme/chained.rs +++ b/packages/ore-rs/src/scheme/chained.rs @@ -51,7 +51,7 @@ use crate::primitives::cmac::{final_block, prefix_block, Branch, CmacAccumulator use crate::primitives::hash::FixedPiZ2Hash; use crate::primitives::prp::LemireFyPrp; use crate::primitives::{stack, AesBlock, Hash, HashKey, Prp}; -use crate::scheme::decompose::{decompose_6bit, num_blocks_6bit}; +use crate::scheme::decompose::{num_blocks_6bit, Blocks6}; use crate::scheme::width::FirstDiff; use crate::OreError; @@ -234,23 +234,19 @@ impl OreAes128Bit6Chained { Ok(prp) } - /// Full Left+Right ciphertext for `x` (one 6-bit symbol per element). + /// Full Left+Right ciphertext for `x` (one 6-bit symbol per element). A + /// value `>= 64` returns [`OreError::PrpError`]. pub fn encrypt_var(&self, x: &[u8]) -> Result { - stack::scrubbed(|| self.encrypt_var_unscrubbed(x)) + stack::scrubbed(|| self.encrypt_blocks(&Blocks6::check(x)?)) } - /// Left-only ciphertext (smaller; for query plaintexts). - pub fn encrypt_left_var(&self, x: &[u8]) -> Result { - stack::scrubbed(|| self.encrypt_left_var_unscrubbed(x)) - } - - /// [`Self::encrypt_var`] without the stack scrub. Every caller runs it - /// inside [`stack::scrubbed`]: building a block's PRP and running the + /// [`Self::encrypt_var`] over blocks already known to be in the 6-bit + /// domain, without the stack scrub. Every caller runs it inside + /// [`stack::scrubbed`]: building a block's PRP and running the /// accumulator leave their secrets in dead stack (see /// `primitives::stack`). - fn encrypt_var_unscrubbed(&self, x: &[u8]) -> Result { + fn encrypt_blocks>(&self, x: &Blocks6) -> Result { let count = x.len(); - debug_assert!(x.iter().all(|&b| (b as usize) < DOMAIN)); if count > u16::MAX as usize { return Err(OreError::TooManyBlocks); } @@ -269,10 +265,11 @@ impl OreAes128Bit6Chained { // loop rather than once per block. let mut ro = [AesBlock::default(); DOMAIN]; - for (n, &sym) in x.iter().enumerate() { + for n in 0..count { + let sym = x.symbol(n); let n16 = n as u16; let prp = self.prp_at(&acc, n16)?; - let permuted = prp.permute(sym)?; + let permuted = prp.permute(sym).get(); xt.push(permuted); // ro_key for every domain value; f[n] = ro(n, xt[n]). @@ -300,7 +297,7 @@ impl OreAes128Bit6Chained { prp.indicator_mask_xor(sym, &mut rb); blocks.push(rb); - acc.absorb(&prefix_block(n16, sym)); + acc.absorb(&prefix_block(n16, sym.get())); } // `ro` held key-derived RO_KEY tags (then H outputs) for the last block. @@ -314,11 +311,16 @@ impl OreAes128Bit6Chained { }) } - /// [`Self::encrypt_left_var`] without the stack scrub; see - /// [`Self::encrypt_var_unscrubbed`]. - fn encrypt_left_var_unscrubbed(&self, x: &[u8]) -> Result { + /// Left-only ciphertext (smaller; for query plaintexts). A value `>= 64` + /// returns [`OreError::PrpError`]. + pub fn encrypt_left_var(&self, x: &[u8]) -> Result { + stack::scrubbed(|| self.encrypt_left_blocks(&Blocks6::check(x)?)) + } + + /// [`Self::encrypt_left_var`] over blocks already known to be in the + /// 6-bit domain, without the stack scrub; see [`Self::encrypt_blocks`]. + fn encrypt_left_blocks>(&self, x: &Blocks6) -> Result { let count = x.len(); - debug_assert!(x.iter().all(|&b| (b as usize) < DOMAIN)); if count > u16::MAX as usize { return Err(OreError::TooManyBlocks); } @@ -327,10 +329,11 @@ impl OreAes128Bit6Chained { let mut xt = Vec::with_capacity(count); let mut f = Vec::with_capacity(count); - for (n, &sym) in x.iter().enumerate() { + for n in 0..count { + let sym = x.symbol(n); let n16 = n as u16; let prp = self.prp_at(&acc, n16)?; - let permuted = prp.permute(sym)?; + let permuted = prp.permute(sym).get(); xt.push(permuted); f.push(acc.finalize(&final_block( Branch::RoKey, @@ -338,7 +341,7 @@ impl OreAes128Bit6Chained { permuted as u16, WIDTH_BIT6, ))); - acc.absorb(&prefix_block(n16, sym)); + acc.absorb(&prefix_block(n16, sym.get())); } Ok(VarLeft { xt, f }) } @@ -350,14 +353,14 @@ impl OreAes128Bit6Chained { /// as the order and common-prefix length that comparisons reveal; see /// the module docs. pub fn encrypt_str(&self, s: &str) -> Result { - stack::scrubbed(|| self.encrypt_var_unscrubbed(&str_to_blocks(s)?)) + stack::scrubbed(|| self.encrypt_blocks(&str_to_blocks(s)?)) } /// Left-only string ciphertext. Strings longer than [`MAX_STR_BYTES`] /// return [`OreError::TooManyBlocks`]. Like [`Self::encrypt_str`], it /// reveals the string's exact length. pub fn encrypt_left_str(&self, s: &str) -> Result { - stack::scrubbed(|| self.encrypt_left_var_unscrubbed(&str_to_blocks(s)?)) + stack::scrubbed(|| self.encrypt_left_blocks(&str_to_blocks(s)?)) } /// Compare two serialised full ciphertexts (lexicographic; shorter prefix @@ -470,17 +473,15 @@ const _: () = assert!(num_blocks_6bit(MAX_STR_BYTES + 1) > u16::MAX as usize); /// Decompose `s` into 6-bit blocks, refusing a string too long for the wire /// format *before* allocating or decomposing it. The decomposition is the /// whole plaintext (it inverts), so the buffer is wiped when dropped. -fn str_to_blocks(s: &str) -> Result>, OreError> { +fn str_to_blocks(s: &str) -> Result>>, OreError> { let bytes = s.as_bytes(); if bytes.len() > MAX_STR_BYTES { return Err(OreError::TooManyBlocks); } - let nb = num_blocks_6bit(bytes.len()); - let mut blocks = Zeroizing::new(vec![0u8; nb]); - if nb > 0 { - decompose_6bit(bytes, &mut blocks); - } - Ok(blocks) + Ok(Blocks6::decompose( + bytes, + Zeroizing::new(vec![0u8; num_blocks_6bit(bytes.len())]), + )) } #[cfg(test)] @@ -491,6 +492,28 @@ mod tests { OreAes128Bit6Chained::init(&[0x11; 16]).unwrap() } + /// Symbols passed straight to `encrypt_var` / `encrypt_left_var` are + /// checked: a value outside the 6-bit domain is refused, and in-domain + /// symbols encrypt exactly as the string path's decomposition does. + #[test] + fn raw_symbols_are_checked() { + let c = ore(); + assert!(matches!( + c.encrypt_var(&[1, 64, 2]), + Err(OreError::PrpError(_)) + )); + assert!(matches!( + c.encrypt_left_var(&[1, 64, 2]), + Err(OreError::PrpError(_)) + )); + + let blocks = str_to_blocks("alice").unwrap(); + assert_eq!( + c.encrypt_left_var(blocks.as_bytes()).unwrap().to_bytes(), + c.encrypt_left_str("alice").unwrap().to_bytes() + ); + } + #[test] fn strings_over_the_wire_limit_are_rejected() { let c = ore(); @@ -745,10 +768,11 @@ mod tests { let blocks = str_to_blocks(s).unwrap(); let mut acc = CmacAccumulator::new(&ore.k_acc); let mut needles = Vec::new(); - for (n, &sym) in blocks.iter().enumerate() { + for (n, &sym) in blocks.as_bytes().iter().enumerate() { let prp = ore.prp_at(&acc, n as u16).unwrap(); - needles.push((0..64).map(|i| prp.permute(i).unwrap()).collect()); - needles.push((0..64).map(|i| prp.invert(i).unwrap()).collect()); + let symbols = (0..64).map(crate::primitives::Symbol::<64>::from_low_bits); + needles.push(symbols.clone().map(|i| prp.permute(i).get()).collect()); + needles.push(symbols.map(|i| prp.invert(i).get()).collect()); acc.absorb(&prefix_block(n as u16, sym)); } needles @@ -792,7 +816,7 @@ mod tests { }, |ore, s| { let blocks = str_to_blocks(s).unwrap(); - core::hint::black_box(ore.encrypt_var_unscrubbed(&blocks).unwrap()); + core::hint::black_box(ore.encrypt_blocks(&blocks).unwrap()); }, ); } @@ -805,7 +829,7 @@ mod tests { }, |ore, s| { let blocks = str_to_blocks(s).unwrap(); - core::hint::black_box(ore.encrypt_left_var_unscrubbed(&blocks).unwrap()); + core::hint::black_box(ore.encrypt_left_blocks(&blocks).unwrap()); }, ); } diff --git a/packages/ore-rs/src/scheme/decompose.rs b/packages/ore-rs/src/scheme/decompose.rs index 0b25715f..6af50bfa 100644 --- a/packages/ore-rs/src/scheme/decompose.rs +++ b/packages/ore-rs/src/scheme/decompose.rs @@ -14,15 +14,14 @@ //! The 6-bit packing ships ahead of the Bit6 scheme (v2 plan, PR 5) because //! it is pure bit logic that can be pinned by property tests now. -// The 6-bit functions are consumed by the Bit6 scheme (v2 plan, PR 5); they -// land early so the packing is pinned by tests independent of that scheme. -#[allow(dead_code)] +use crate::primitives::{PrpError, Symbol}; +use crate::OreError; + /// Number of 6-bit blocks needed for `n` plaintext bytes. pub(crate) const fn num_blocks_6bit(n: usize) -> usize { (8 * n).div_ceil(6) } -#[allow(dead_code)] /// Decompose `bytes` MSB-first into 6-bit block values, writing them to /// `out`. `out.len()` must be exactly `num_blocks_6bit(bytes.len())`. /// @@ -47,6 +46,66 @@ pub(crate) fn decompose_6bit(bytes: &[u8], out: &mut [u8]) { } } +/// 6-bit block values, each `< 64` by construction, in any byte storage +/// (`[u8; N]` for Bit6, `Vec` for the chained scheme). +/// +/// There are two ways to make one. [`Self::decompose`] masks every value to +/// six bits as it packs, so it never branches on the plaintext; the +/// `OreEncrypt` and string paths use it. [`Self::check`] validates blocks a +/// caller passes in directly and branches once on the result. The encryptors +/// take a `Blocks6`, so the PRP gets an in-domain [`Symbol`] without a +/// per-block range check on the secret. +pub(crate) struct Blocks6(B); + +impl> Blocks6 { + /// Accept `blocks` if every value is `< 64`, else + /// [`OreError::PrpError`] (the error an out-of-domain symbol has always + /// produced). The values are folded together branch-free and the one + /// branch is on the folded result, so for well-formed input the branch + /// always goes the same way. + pub(crate) fn check(blocks: B) -> Result { + let high = blocks.as_ref().iter().fold(0u8, |acc, &b| acc | (b >> 6)); + if high != 0 { + return Err(OreError::PrpError(PrpError)); + } + Ok(Self(blocks)) + } + + /// The block values as bytes. + #[inline] + pub(crate) fn as_bytes(&self) -> &[u8] { + self.0.as_ref() + } + + /// Block `i` as a PRP symbol. `from_low_bits` is the identity here (the + /// value is `< 64`), and does not branch. + #[inline] + pub(crate) fn symbol(&self, i: usize) -> Symbol<64> { + Symbol::from_low_bits(self.0.as_ref()[i]) + } + + /// The number of blocks. + #[inline] + pub(crate) fn len(&self) -> usize { + self.0.as_ref().len() + } + + /// The underlying storage. + #[inline] + pub(crate) fn inner(&self) -> &B { + &self.0 + } +} + +impl + AsMut<[u8]>> Blocks6 { + /// Decompose `bytes` into `out` (see [`decompose_6bit`]). + /// `out.as_ref().len()` must be `num_blocks_6bit(bytes.len())`. + pub(crate) fn decompose(bytes: &[u8], mut out: B) -> Self { + decompose_6bit(bytes, out.as_mut()); + Self(out) + } +} + #[cfg(test)] mod tests { use super::*; @@ -102,6 +161,17 @@ mod tests { assert_eq!(num_blocks_6bit(0), 0); } + #[test] + fn blocks6_check_accepts_exactly_the_domain() { + assert!(Blocks6::check([0u8, 63, 17]).is_ok()); + assert!(matches!( + Blocks6::check([0u8, 64, 17]), + Err(OreError::PrpError(_)) + )); + assert!(Blocks6::check([255u8]).is_err()); + assert!(Blocks6::check(Vec::::new()).is_ok()); + } + #[test] fn known_packing() { // Bit stream 11111100 00001111 -> 111111 | 000000 | 1111 + 2 pad bits diff --git a/packages/ore-rs/src/scheme/width.rs b/packages/ore-rs/src/scheme/width.rs index 693705f5..3cff3cbf 100644 --- a/packages/ore-rs/src/scheme/width.rs +++ b/packages/ore-rs/src/scheme/width.rs @@ -11,7 +11,7 @@ //! as associated types instead. use crate::ciphertext::CipherTextBlock; -use crate::primitives::{AesBlock, Hash, Prp}; +use crate::primitives::{AesBlock, Hash, Prp, Symbol}; use crate::scheme::bit2::block_types::RightBlock32; use crate::scheme::bit2_w6::block_types::RightBlock8; use zeroize::{Zeroize, ZeroizeOnDrop}; @@ -207,8 +207,10 @@ pub trait BlockWidth: sealed::Sealed + 'static { const DOMAIN: usize = 1 << Self::BITS; /// Right-ciphertext block: a `DOMAIN`-bit masked truth-table row. type RightBlock: CipherTextBlock + RightBitVec; + /// A block value: a [`Symbol`] of the `DOMAIN`-element domain. + type Symbol: Copy; /// PRP over the block domain. - type Prp: Prp; + type Prp: Prp; /// Buffer holding `DOMAIN` random-oracle keys. type RoKeyBuf: AesBlockBuf; } @@ -221,6 +223,7 @@ pub struct Bit8; impl BlockWidth for Bit8 { const BITS: usize = 8; type RightBlock = RightBlock32; + type Symbol = Symbol<256>; type Prp = crate::primitives::prp::KnuthShufflePRP; type RoKeyBuf = [AesBlock; 256]; } @@ -238,6 +241,7 @@ impl BlockWidth for Bit6 { // Bit6's wire format is not frozen, so it adopts the constant-time, // ~9×-faster PRP construction. See `LemireFyPrp`. type Prp = crate::primitives::prp::LemireFyPrp<64>; + type Symbol = Symbol<64>; type RoKeyBuf = [AesBlock; 64]; } From 68ac5badbaeb0b39524f59b4ba3c0c5e400d06e8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 21:50:12 +1100 Subject: [PATCH 15/35] ci(verify): run the taint gate on x86_64 and aarch64 oblivious::build dispatches to SSSE3 on x86_64 and NEON on aarch64, so the ct-taint job on ubuntu-latest alone never checked the builder that ships on Arm (PR #100 review finding 7). Run it as a matrix over ubuntu-latest and ubuntu-24.04-arm, with a per-arch cache key. Also fix the kani-full comment: one exhaustive-length harness remains. --- .github/workflows/verify.yml | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index cf17ee62..7726f1e7 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -9,8 +9,8 @@ name: "Verify" # * fuzz: the libFuzzer targets (packages/ore-rs/fuzz) for the ciphertext # parsers and raw comparators, time-boxed per pull request. # * kani: the bounded model-checking harnesses (`#[cfg(kani)]`) for the pure -# building blocks. The two exhaustive-length harnesses behind the -# `kani-full` feature are not run here (about 16 minutes); run +# building blocks. The exhaustive-length harness behind the `kani-full` +# feature is not run here (about 9 minutes); run # `cargo kani -p ore-rs --features kani-full` by hand for the full set. # # Like test.yml, this runs on every pull request regardless of base branch, so @@ -28,8 +28,19 @@ defaults: jobs: ct-taint: - runs-on: ubuntu-latest - name: "ðŸ”� Valgrind taint" + # Both legs matter: `oblivious::build` dispatches to the SSSE3 builder on + # x86_64 and the NEON builder on aarch64, so each target's shipping + # builder is only taint-checked on its own runner. + runs-on: ${{ matrix.os }} + name: "ðŸ”� Valgrind taint (${{ matrix.arch }})" + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + arch: x86_64 + - os: ubuntu-24.04-arm + arch: aarch64 steps: - uses: actions/checkout@v4 @@ -43,7 +54,7 @@ jobs: ~/.cargo/registry/cache/ ~/.cargo/git/db/ packages/ct-taint/target/ - key: ${{ runner.os }}-ct-taint-${{ hashFiles('packages/ct-taint/Cargo.lock', 'packages/ore-rs/src/**') }} + key: ${{ runner.os }}-${{ runner.arch }}-ct-taint-${{ hashFiles('packages/ct-taint/Cargo.lock', 'packages/ore-rs/src/**') }} # crabgrind binds the Valgrind client-request header at build time. - name: install valgrind From c906e15687883ef1be5fea22389cb509b43b5b43 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 21:50:12 +1100 Subject: [PATCH 16/35] fix(ct-dudect): apply DIT in every bench, portably; correct the dudect and taint docs CT_DUDECT_DIT=1 only took effect through the Bit6 and chained cipher constructors, so the nine prp_build_* and iso_* benches never ran with DIT (PR #100 review finding 5). Every bench now calls data_independent_timing() first, which checks FEAT_DIT, sets the bit, and reads it back. The write uses the generic system register encoding s3_3_c4_c2_5: `msr DIT, #1` needs the ARMv8.4 `dit` target feature and failed to assemble on aarch64 Linux (finding 6); it now builds and runs there. Docs: - The prp_build_const_vs_random residual was put down to one repeated input against a 256 KB pool, but the Left pool is 512 copies indexed at random like the Right pool, so the classes differ only in content (finding 4). Marked as unexplained; a DIT run on an M3+ core, where DIT disables the data memory-dependent prefetcher, is the next step. - "Setting DIT changed nothing" did not cover the builder benches. - Taint section: suppressions removed, runs on both shipping builders, negative control; Kani section updated for the Symbol harnesses. --- .../2026-10-09-oblivious-prp-results.md | 24 +++--- .../2026-10-09-ore-v2-dynamic-verification.md | 76 +++++++++++++------ packages/ct-dudect/src/main.rs | 46 +++++++++-- 3 files changed, 107 insertions(+), 39 deletions(-) diff --git a/docs/benchmarks/2026-10-09-oblivious-prp-results.md b/docs/benchmarks/2026-10-09-oblivious-prp-results.md index 5b77ecfb..2a2a5e86 100644 --- a/docs/benchmarks/2026-10-09-oblivious-prp-results.md +++ b/docs/benchmarks/2026-10-09-oblivious-prp-results.md @@ -194,13 +194,18 @@ it. In the Low Power Mode pass the same test gave −45 at 135 M. The batch runs are the stronger evidence in both directions. `prp_build_const_vs_random` still shows a small difference (t = +7.7, -tau 0.0005). That test compares one input repeated against inputs drawn from -a pool of 512 streams (256 KB, larger than L1), so the two classes differ in -where their *input* comes from; in the Low Power Mode pass, shrinking the -pool to 4 entries (every input in L1) cut it by more than half. The fixed-A-versus-B -test is designed to remove that difference, and there it is absent. The NEON -builder has no secret address for it to come from; the residual is not -explained further here. +tau 0.0005), and it is not explained. An earlier version of this paragraph +said the classes differ in where their input comes from, one repeated input +against a 256 KB pool. They do not: the `Left` pool is 512 copies of the +fixed stream, indexed at random like the `Right` pool of 512 random streams, +so the classes have the same footprint and access pattern and differ only +in content. (Shrinking the pool to 4, which in the Low Power Mode pass cut +the residual by more than half, shrinks both classes.) The NEON builder has +no secret address or branch for it to come from, so the candidate is +content-dependent microarchitecture such as the data memory-dependent +prefetcher. DIT disables that on M3 and later, but the harness did not apply +DIT to the builder benches until the verification review; a continuous run +with `CT_DUDECT_DIT=1` on the M4 is the next measurement. ## Not measured @@ -209,8 +214,9 @@ explained further here. code; its speed and its timing behaviour on a real x86 core (with and without SMT) are unknown. - **Graviton or other Arm cores.** -- **The Valgrind taint run** without the swap suppression: no Valgrind on - macOS, so it is left to the `verify.yml` job. +- **The Valgrind taint run** without the swap suppression was not run in + this pass. It has since been run, clean, for both the NEON and the SSSE3 + builders; see the verification doc, §1. - **AVX2.** An AVX2 form (two 32-byte registers per table) is a natural next step for x86_64 and was not tried. diff --git a/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md index 98e269d7..73ffdd8b 100644 --- a/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md +++ b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md @@ -22,21 +22,29 @@ each reproducible from the repository: The harness marks the key and the plaintext as undefined memory and runs each v2 encryptor (Bit6 full and left-only, chained full and left-only). Memcheck then reports every conditional jump and every memory address that depends on -them. All four modes are clean except for two report classes, each listed -with its reason in `packages/ct-taint/valgrind.supp`: +them. All four modes are clean, and `packages/ct-taint/valgrind.supp` is +empty. Two report classes came up along the way: 1. **`oblivious_lookup`'s range check** (`index >= table.len()`) and its callers' test of the returned `Result`. A comparison on the plaintext symbol against a public length, whose direction is the same for every in-domain symbol, so it carries no information; the static analysis had - not listed it. Removing it means an infallible `permute` over a typed - in-domain symbol, which is an API change and is not done here. + not listed it. It was suppressed in the first runs, which needed a glob + over every `?` on a `Result`. It is now removed at the + root: `permute` and `invert` take a `Symbol`, a byte that is in the + domain by type, so they are infallible and the lookup has no range + branch. The encryptors take their symbols from the 6-bit decomposition, + which masks rather than checks. Callers that pass raw symbols + (`OreCipher::encrypt` for Bit6, chained `encrypt_var`) are checked once at + entry by `Blocks6::check`, still returning `OreError::PrpError`; no + harness mode takes that path. 2. (Harness only, fixed.) `str::from_utf8`'s validation of the tainted string; the harness now builds the `&str` unchecked. Nothing is reported from the 6-bit decomposition, the CMAC accumulator, the -σ-MMO hash, the SIMD indicator kernels, `ct_select_byte`, or AES. With the -suppressions removed every mode fails, so the gate is live. +σ-MMO hash, the SIMD indicator kernels, `ct_select_byte`, or AES. The gate +is live: the commit before the `Symbol` change, run with the same empty +suppression file, fails on exactly the range check. A third class, the Fisher–Yates swap at a keystream-derived index in `LemireFyPrp::from_stream` (a pointer-sized use of an undefined value), was @@ -46,10 +54,13 @@ the PRP builder is now oblivious, and its suppression has been removed. The oblivious builders use the secret draws only as register operands (`tbl` / `pshufb` indices, compare-and-select masks, SWAR arithmetic), which memcheck propagates as undefined data without reporting, and touch memory only at -public offsets (the stream reads and the two final 64-byte stores), so the -run is expected to stay clean without it. That has not been run locally (no -Valgrind on macOS, no container here); the `verify.yml` ct-taint job, on -x86_64 Linux where the SSSE3 builder is dispatched, is the check. +public offsets (the stream reads and the two final 64-byte stores). The run +is clean without it on both shipping builders: NEON in an arm64 container, +and SSSE3 in an amd64 container under emulation (the emulated CPU reports +SSSE3, so the dispatcher picks it). The left-only ciphertexts from the two +runs are byte-identical, as they must be. The `verify.yml` ct-taint job now +runs on native x86_64 and aarch64 runners, so each builder is checked on its +own target. The comparators are not tainted: their inputs are ciphertexts, which are public. Their timing question is §4's. @@ -82,9 +93,12 @@ The corpora from these runs are committed as seeds. ## 3. Kani — proofs for the pure building blocks Eighteen harnesses, all verified (full run 1 324 s before the oblivious PRP -builder; the two exhaustive-length harnesses, 16 minutes of that, run only -with the `kani-full` feature; the PRP harnesses were re-timed one at a time -after it, and the default set now takes about 25 minutes). Kani cannot execute AES symbolically, so nothing keyed +builder; the exhaustive-length `ct_select_byte` harness, about 9 minutes of +that, runs only with the `kani-full` feature; the PRP harnesses were +re-timed one at a time after it, and the default set now takes about 25 +minutes). With `Symbol`, the out-of-range `oblivious_lookup` cases no longer +exist to prove, so the any-length lookup harness was dropped and a +`Symbol` harness added. Kani cannot execute AES symbolically, so nothing keyed is in scope; these are the pieces where a wrong implementation would hide behind matching known-answer vectors. @@ -92,7 +106,8 @@ behind matching known-answer vectors. |---|---|---:| | `ct_select_byte(block, i) == block[i]` | every length ≤ 256, every `i` (`kani-full`); the 8- and 32-byte right blocks | 519 s; 1 s | | `ct_bit(byte, pos) == (byte >> pos) & 1`, and 0 for `pos ≥ 8` | exhaustive | < 1 s | -| `oblivious_lookup(table, i)` is `Ok(table[i])` for `i < len`, `Err` otherwise | every 64- and 256-entry table; every length ≤ 256 (`kani-full`) | 2 s; 17 s; 439 s | +| `oblivious_lookup(table, i) == table[i]` for every symbol `i` | every 64-entry table of in-domain values; every 256-entry table | 10 s; 29 s (M1 Max) | +| `Symbol::<64>::from_low_bits(b)` is `< 64`, and is `b` for `b < 64` | every `u8` | < 1 s | | Lemire reduction `((x·(i+1)) >> 64) ≤ i` | every `u64` `x`, `i < 64` | < 1 s | | the reference (textbook Fisher–Yates) builder yields a permutation of 0..64 with a correct inverse | first 6 of 63 draws symbolic, the rest fixed (all 63 symbolic did not finish in 30 min) | 200 s | | the SWAR oblivious builder's `permutation` and `inverse` equal the reference builder's (under Kani `from_stream` dispatches to SWAR: Kani cannot model the NEON or SSSE3 intrinsics) | same stream shape | 872 s | @@ -194,8 +209,12 @@ level the effect was real but very small (tau −0.0014). The only secret-dependent memory traffic in the builder was the Fisher–Yates swap at the draw-derived index and the inverse-table fill, so this was the A4 access pattern showing up as a within-process timing signature, on a core with no -SMT and no co-tenant. Setting the ARM DIT bit changed nothing, so it is not -the data-dependent prefetcher. The one-cache-line argument addresses +SMT and no co-tenant. Setting the ARM DIT bit was taken to rule out the +data memory-dependent prefetcher, but the harness only applied DIT through +the cipher constructors, so the `prp_build_*` and `iso_*` benches ran +without it whatever the environment said; for the builder-only measurements +the prefetcher was not ruled out. The harness now applies DIT in every bench +and fails if the bit does not stick. The one-cache-line argument addresses line-granularity cache leakage and says nothing about this. **Mechanism isolation.** Each half of the old builder, timed alone on two @@ -245,11 +264,20 @@ builder, kept as a test-only reference): The pooled continuous figure is weak evidence either way: each 100 k-sample pair has its own sign, so pooling dilutes it (the old builder's +8.6 here, against |t| up to 83 in a single pair). The per-pair runs are the test, and -there the dependence is gone. `prp_build_const_vs_random` keeps a small -residual (tau 0.0005); that test compares one repeated input against a pool -of 512 streams (256 KB, larger than L1), so its classes differ in where -their *input* comes from, and the fixed-A-versus-fixed-B test that removes -that difference shows nothing. The first pass of these measurements, in Low +there the dependence is gone. + +`prp_build_const_vs_random` keeps a small residual (tau 0.0005), and it is +not explained. An earlier version of this paragraph put it down to the +classes' footprints, one repeated input against a 256 KB pool. That is +wrong: the `Left` pool is 512 copies of the fixed stream, indexed at random +exactly as the `Right` pool of 512 random streams is, so the two classes +have the same footprint and access pattern and differ only in content. The +NEON builder has no secret-dependent branch or address (§1), so the +candidates are content-dependent microarchitecture, the data +memory-dependent prefetcher first. DIT disables that on M3 and later, and +this bench did not get DIT before (above), so a continuous run with +`CT_DUDECT_DIT=1` on the M4 is the next measurement. Fixed A against fixed +B, where both classes repeat one input, shows nothing. The first pass of these measurements, in Low Power Mode on battery, gave the same per-pair picture (its pooled old-builder figure was larger, −45 at 135 M). @@ -270,4 +298,8 @@ seen through a fixed versus varying permutation sequence. that is exactly why the comparators are measured with dudect instead. - dudect was run on one Apple M4. x86 (with and without SMT, where the SSSE3 builder runs) and Graviton would be worth a run; the SSSE3 - builder's timing has not been measured on real x86 hardware. + builder's timing has not been measured on real x86 hardware. The harness + now builds on aarch64 Linux (the DIT write uses the generic system + register encoding), so Graviton needs no changes. +- The `prp_build_const_vs_random` residual on the NEON builder (§4.2) is + open. diff --git a/packages/ct-dudect/src/main.rs b/packages/ct-dudect/src/main.rs index 9666b852..e4b91370 100644 --- a/packages/ct-dudect/src/main.rs +++ b/packages/ct-dudect/src/main.rs @@ -42,20 +42,41 @@ fn pool_size() -> usize { const SAMPLES: usize = 100_000; /// With `CT_DUDECT_DIT=1`, set the ARMv8.4 DIT (data-independent timing) -/// bit for this thread before measuring. On Apple Silicon this also disables -/// the data memory-dependent prefetcher, whose behaviour depends on whether -/// loaded *values* look like pointers; a difference that disappears under -/// DIT is that microarchitectural effect, not a data-dependent code path. +/// bit for this thread before measuring, and fail if it does not stick. On +/// Apple M3 and later this also disables the data memory-dependent +/// prefetcher, whose behaviour depends on whether loaded *values* look like +/// pointers; a difference that disappears under DIT is that +/// microarchitectural effect, not a data-dependent code path. (On M1 and M2 +/// DIT does not affect the prefetcher.) +/// +/// Every bench calls this first, so the setting covers all of them. fn data_independent_timing() { if std::env::var_os("CT_DUDECT_DIT").is_none() { return; } #[cfg(target_arch = "aarch64")] - // SAFETY: `msr DIT, #1` only sets the per-thread timing control bit; it - // touches no memory and has no other architectural effect. - unsafe { - std::arch::asm!("msr DIT, #1", options(nomem, nostack)); + { + assert!( + std::arch::is_aarch64_feature_detected!("dit"), + "CT_DUDECT_DIT is set but this CPU does not implement FEAT_DIT" + ); + // PSTATE.DIT is bit 24 of system register S3_3_C4_C2_5. The generic + // encoding assembles without the ARMv8.4 `dit` target feature, which + // aarch64 Linux targets do not enable (only Apple's baseline does). + const DIT: u64 = 1 << 24; + let mut v: u64; + // SAFETY: reads and sets the per-thread timing control bit only; it + // touches no memory and has no other architectural effect. FEAT_DIT + // is checked above, so the register exists. + unsafe { + std::arch::asm!("mrs {v}, s3_3_c4_c2_5", v = out(reg) v, options(nomem, nostack)); + std::arch::asm!("msr s3_3_c4_c2_5, {v}", v = in(reg) v | DIT, options(nomem, nostack)); + std::arch::asm!("mrs {v}, s3_3_c4_c2_5", v = out(reg) v, options(nomem, nostack)); + } + assert!(v & DIT != 0, "CT_DUDECT_DIT: the DIT bit did not stick"); } + #[cfg(not(target_arch = "aarch64"))] + panic!("CT_DUDECT_DIT is set, but DIT is an aarch64 feature"); } fn bit6() -> OreAes128Bit6ChaCha20 { @@ -219,6 +240,7 @@ fn bit6_encrypt_left_const_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) /// decides every Fisher–Yates swap, so this isolates the builder from /// everything else in the encryptor. fn prp_build_const_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { + data_independent_timing(); let mut fixed = [0u8; 512]; for b in fixed.iter_mut() { *b = rng.random::(); @@ -252,6 +274,7 @@ fn prp_build_const_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { /// same for each; a difference here means the time depends on *which* /// permutation is built, not merely on whether the input repeats. fn prp_build_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) { + data_independent_timing(); let mut a = [0u8; 512]; let mut b = [0u8; 512]; for x in a.iter_mut().chain(b.iter_mut()) { @@ -316,6 +339,7 @@ fn stream_pair(rng: &mut BenchRng) -> ([u8; 512], [u8; 512]) { /// Mechanism isolation, swap half: the Fisher–Yates swap loop alone, at /// the secret draw-derived index, on two fixed streams. fn iso_swap_secret_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) { + data_independent_timing(); let (a, b) = stream_pair(rng); for _ in 0..SAMPLES { let c = class(rng); @@ -330,6 +354,7 @@ fn iso_swap_secret_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) /// Control for the swap half: the same draws and swaps at a public index. fn iso_swap_public_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) { + data_independent_timing(); let (a, b) = stream_pair(rng); for _ in 0..SAMPLES { let c = class(rng); @@ -345,6 +370,7 @@ fn iso_swap_public_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) /// Mechanism isolation, inverse half: the inverse fill alone, from the two /// permutations two fixed streams build (computed outside the measurement). fn iso_fill_secret_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) { + data_independent_timing(); let (a, b) = stream_pair(rng); let (pa, pb) = (ct_bench::permutation_for(&a), ct_bench::permutation_for(&b)); for _ in 0..SAMPLES { @@ -360,6 +386,7 @@ fn iso_fill_secret_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) /// Control for the inverse half: the same loads and stores at public offsets. fn iso_fill_public_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) { + data_independent_timing(); let (a, b) = stream_pair(rng); let (pa, pb) = (ct_bench::permutation_for(&a), ct_bench::permutation_for(&b)); for _ in 0..SAMPLES { @@ -376,6 +403,7 @@ fn iso_fill_public_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) /// `prp_build_fixed_a_vs_fixed_b` through the reference builder (textbook /// Fisher–Yates, secret-indexed), for before/after comparison in one binary. fn prp_build_reference_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) { + data_independent_timing(); let (a, b) = stream_pair(rng); for _ in 0..SAMPLES { let c = class(rng); @@ -389,6 +417,7 @@ fn prp_build_reference_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut Bench /// `prp_build_fixed_a_vs_fixed_b` through the portable scalar oblivious /// builder. fn prp_build_scalar_obl_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) { + data_independent_timing(); let (a, b) = stream_pair(rng); for _ in 0..SAMPLES { let c = class(rng); @@ -404,6 +433,7 @@ fn prp_build_scalar_obl_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut Benc /// `prp_build_fixed_a_vs_fixed_b` through the portable SWAR oblivious /// builder (the fallback on targets with neither NEON nor SSSE3). fn prp_build_swar_obl_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) { + data_independent_timing(); let (a, b) = stream_pair(rng); for _ in 0..SAMPLES { let c = class(rng); From 9c848ef7a445fef16e10b9e22d8d113783fc32af Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 21:58:36 +1100 Subject: [PATCH 17/35] refactor(prp): from_stream delegates to one from_stream_with from_stream and the ct-bench-only from_stream_with duplicated the length check and table construction, so a change to one (say, wiping on error) would not reach the other and the timing hooks would time a different function from the one the schemes call (PR #100 review finding 14). from_stream_with is now always compiled and is the only implementation; from_stream calls it with oblivious::build. The builder is a generic FnOnce parameter rather than a function pointer, so the scheme path is monomorphised and the builder still inlines. --- packages/ore-rs/src/primitives/prp.rs | 30 ++++++++++----------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/packages/ore-rs/src/primitives/prp.rs b/packages/ore-rs/src/primitives/prp.rs index 5eae50e1..8762da11 100644 --- a/packages/ore-rs/src/primitives/prp.rs +++ b/packages/ore-rs/src/primitives/prp.rs @@ -195,33 +195,25 @@ macro_rules! impl_lemire_fy_prp { /// tables come from the oblivious builder ([`oblivious::build`]), /// so no address depends on the stream. pub(crate) fn from_stream(stream: &[u8]) -> PrpResult { - if stream.len() < ($domain - 1) * 8 { - return Err(PrpError); - } - // Fisher–Yates with Lemire-reduced wide draws: draw `d` // (8 bytes) drives step `$domain - 1 - d`. Fixed trip count, // branch-free index reduction, and no secret-dependent // address: the oblivious builder keeps both tables in // registers (or masked words) and maintains the inverse // alongside the swaps. It wipes its copies of each draw. - let mut perm = Self { - permutation: [0u8; $domain], - inverse: [0u8; $domain], - }; - oblivious::build(stream, &mut perm.permutation, &mut perm.inverse); - - Ok(perm) + Self::from_stream_with(stream, oblivious::build) } - /// [`Self::from_stream`] through a named builder whatever the - /// target: the reference builder or one of the portable - /// oblivious ones. For the `ct-bench` timing hooks only. - #[cfg(feature = "ct-bench")] - pub(crate) fn from_stream_with( - stream: &[u8], - build: fn(&[u8], &mut [u8; $domain], &mut [u8; $domain]), - ) -> PrpResult { + /// [`Self::from_stream`] through a given builder. The schemes + /// only ever use [`oblivious::build`]; the `ct-bench` timing + /// hooks pass the reference builder or a portable oblivious one, + /// so they time this same function. `build` is a generic + /// parameter, not a function pointer, so the scheme path is + /// monomorphised and the builder still inlines. + pub(crate) fn from_stream_with(stream: &[u8], build: F) -> PrpResult + where + F: FnOnce(&[u8], &mut [u8; $domain], &mut [u8; $domain]), + { if stream.len() < ($domain - 1) * 8 { return Err(PrpError); } From b47add1071266eaf1e322da1974952a37594a314 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 21:59:04 +1100 Subject: [PATCH 18/35] refactor(ct-bench): reuse lemire_draw and the reference builder in the isolation hooks ct_bench::draw re-implemented oblivious::lemire_draw, and permutation_for re-ran a copy of the reference builder's swap loop, so a change to the draw offset or reduction would leave the isolation benches timing a different permutation from the one the builders produce (PR #100 review finding 13). draw now calls lemire_draw (made pub(crate)), and permutation_for calls oblivious::reference::build. fy_swaps_only keeps its own loop, since it exists to time the swaps without the fill; a new test pins it and inverse_fill_only to the reference builder's tables. lemire_draw wipes its copies of the draw, which the old inline draw did not, so the iso_swap_* benches now include that work, as the reference builder always has. Their recorded figures predate this. --- packages/ore-rs/src/lib.rs | 45 +++++++++++++++---- .../ore-rs/src/primitives/prp/oblivious.rs | 2 +- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/packages/ore-rs/src/lib.rs b/packages/ore-rs/src/lib.rs index 2092a891..c5dfdd1d 100644 --- a/packages/ore-rs/src/lib.rs +++ b/packages/ore-rs/src/lib.rs @@ -199,17 +199,17 @@ pub mod ct_bench { #[repr(C, align(64))] pub struct Line(pub [u8; 64]); - /// Lemire draw for step `i`, reduced to `0..=i`, as the builders do. + /// Lemire draw for step `i`, reduced to `0..=i`: the builders' own + /// [`oblivious::lemire_draw`], so the benches cannot drift from it. #[inline(always)] fn draw(stream: &[u8; 512], i: usize) -> usize { - let d = 63 - i; - let mut b = [0u8; 8]; - b.copy_from_slice(&stream[d * 8..d * 8 + 8]); - ((u64::from_le_bytes(b) as u128 * (i as u128 + 1)) >> 64) as usize + usize::from(oblivious::lemire_draw(stream, i)) } /// The reference builder's Fisher–Yates swap loop alone (no inverse - /// fill): swaps at the secret draw-derived index. + /// fill): swaps at the secret draw-derived index. The loop is + /// [`oblivious::reference::build`]'s, restated so it can be timed + /// without the fill; a test pins it to that builder's permutation. #[inline(never)] pub fn fy_swaps_only(stream: &[u8; 512], out: &mut Line) { for (k, p) in out.0.iter_mut().enumerate() { @@ -236,10 +236,12 @@ pub mod ct_bench { } /// The permutation the builders build for `stream`, computed outside - /// any measured region, to feed [`inverse_fill_only`]. + /// any measured region by the reference builder itself, to feed + /// [`inverse_fill_only`]. pub fn permutation_for(stream: &[u8; 512]) -> Line { let mut l = Line([0u8; 64]); - fy_swaps_only(stream, &mut l); + let mut inverse = [0u8; 64]; + oblivious::reference::build(stream, &mut l.0, &mut inverse); l } @@ -262,6 +264,33 @@ pub mod ct_bench { out.0[index] = index as u8; } } + + #[cfg(test)] + mod tests { + use super::*; + + /// The isolated swap loop and inverse fill build exactly what the + /// reference builder builds, so the isolation benches time the real + /// construction. + #[test] + fn isolation_halves_match_the_reference_builder() { + for seed in 0u8..16 { + let stream: [u8; 512] = + core::array::from_fn(|k| (k as u8).wrapping_mul(167) ^ seed.wrapping_mul(29)); + let mut want_perm = [0u8; 64]; + let mut want_inverse = [0u8; 64]; + oblivious::reference::build(&stream, &mut want_perm, &mut want_inverse); + + let mut swaps = Line([0u8; 64]); + fy_swaps_only(&stream, &mut swaps); + assert_eq!(swaps.0, want_perm); + + let mut inverse = Line([0u8; 64]); + inverse_fill_only(&permutation_for(&stream), &mut inverse); + assert_eq!(inverse.0, want_inverse); + } + } + } } pub use crate::encrypt::OreEncrypt; use primitives::PrpError; diff --git a/packages/ore-rs/src/primitives/prp/oblivious.rs b/packages/ore-rs/src/primitives/prp/oblivious.rs index dad9a39e..e1126def 100644 --- a/packages/ore-rs/src/primitives/prp/oblivious.rs +++ b/packages/ore-rs/src/primitives/prp/oblivious.rs @@ -59,7 +59,7 @@ pub(crate) const STREAM_BYTES: usize = (DOMAIN - 1) * 8; /// `63 - i`, as a little-endian u64, mapped to `0..=i` by multiply-high. /// Identical to the indexed builder's reduction. #[inline(always)] -fn lemire_draw(stream: &[u8], i: usize) -> u8 { +pub(crate) fn lemire_draw(stream: &[u8], i: usize) -> u8 { let d = DOMAIN - 1 - i; let mut draw = [0u8; 8]; draw.copy_from_slice(&stream[d * 8..d * 8 + 8]); From 451dbec90af20994ba84d80c969c68c490a76687 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 22:03:09 +1100 Subject: [PATCH 19/35] test(decompose): prove order preservation across different lengths decompose_6bit_preserves_order only covered equal-length inputs, but the chained scheme compares strings of different lengths: the first differing block decides, else the shorter sorts first. That is lexicographic order over the block arrays, and it holds only if the zero padding of a shorter input's last block never exceeds the longer input's real bits (PR #100 review finding 9). The Kani harness now takes two independent lengths 0..=16 (strictly stronger than before; 31 s on an M1 Max). Two quickcheck properties cover unbounded lengths: arbitrary pairs, and b = a ++ tail, the case padding could break, which random pairs almost never hit. Mutation check: padding with ones instead of zeros fails both the Kani harness and the prefix property; the arbitrary-pair property alone does not catch it, which is why the prefix one is there. --- packages/ore-rs/src/scheme/decompose.rs | 50 ++++++++++++++++++++----- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/packages/ore-rs/src/scheme/decompose.rs b/packages/ore-rs/src/scheme/decompose.rs index 6af50bfa..fe7a8703 100644 --- a/packages/ore-rs/src/scheme/decompose.rs +++ b/packages/ore-rs/src/scheme/decompose.rs @@ -124,6 +124,29 @@ mod tests { xb.cmp(&yb) == xq.cmp(&yq) } + /// Order is preserved across different lengths too, with a proper + /// prefix sorting first: the chained scheme's string order. + fn order_preserved_any_lengths(a: Vec, b: Vec) -> bool { + let mut aq = vec![0u8; num_blocks_6bit(a.len())]; + let mut bq = vec![0u8; num_blocks_6bit(b.len())]; + decompose_6bit(&a, &mut aq); + decompose_6bit(&b, &mut bq); + a.cmp(&b) == aq.cmp(&bq) + } + + /// The case padding could break: `b` extends `a`, so the two share + /// `a`'s bits and `a`'s zero-padded last block meets `b`'s real + /// bits. Random pairs almost never share a prefix, hence this case + /// on its own. + fn order_preserved_proper_prefix(a: Vec, tail: Vec) -> bool { + let b: Vec = a.iter().chain(&tail).copied().collect(); + let mut aq = vec![0u8; num_blocks_6bit(a.len())]; + let mut bq = vec![0u8; num_blocks_6bit(b.len())]; + decompose_6bit(&a, &mut aq); + decompose_6bit(&b, &mut bq); + a.cmp(&b) == aq.cmp(&bq) + } + /// Every block value is in the 6-bit domain. fn blocks_in_domain(bytes: Vec) -> bool { let mut out = vec![0u8; num_blocks_6bit(bytes.len())]; @@ -261,22 +284,29 @@ mod kani_proofs { } } - /// For any two inputs of the same length `0..=16`, lexicographic order - /// of the block values equals lexicographic order of the bytes. + /// For any two inputs of lengths `0..=16`, equal or not, lexicographic + /// order of the block values (a proper prefix sorting first) equals + /// lexicographic order of the bytes. This is the order the chained + /// comparator computes for strings of different lengths: the first + /// differing block decides, else the shorter sorts first. The zero + /// padding of a shorter input's last block is what could break it, and + /// does not: it is never greater than the longer input's real bits. #[kani::proof] #[kani::unwind(23)] fn decompose_6bit_preserves_order() { let a_buf: [u8; MAX_LEN] = kani::any(); let b_buf: [u8; MAX_LEN] = kani::any(); - let len: usize = kani::any(); - kani::assume(len <= MAX_LEN); - let a = &a_buf[..len]; - let b = &b_buf[..len]; - let nb = num_blocks_6bit(len); + let a_len: usize = kani::any(); + let b_len: usize = kani::any(); + kani::assume(a_len <= MAX_LEN && b_len <= MAX_LEN); + let a = &a_buf[..a_len]; + let b = &b_buf[..b_len]; + let a_nb = num_blocks_6bit(a_len); + let b_nb = num_blocks_6bit(b_len); let mut a_out = [0u8; MAX_BLOCKS]; let mut b_out = [0u8; MAX_BLOCKS]; - decompose_6bit(a, &mut a_out[..nb]); - decompose_6bit(b, &mut b_out[..nb]); - assert!(a.cmp(b) == a_out[..nb].cmp(&b_out[..nb])); + decompose_6bit(a, &mut a_out[..a_nb]); + decompose_6bit(b, &mut b_out[..b_nb]); + assert!(a.cmp(b) == a_out[..a_nb].cmp(&b_out[..b_nb])); } } From eda3f20a8f37bbc6f501640af464ab6da9e54a66 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 22:04:08 +1100 Subject: [PATCH 20/35] test(fuzz): round-trip Right::from_slice for Bit6 and legacy No target parsed untrusted bytes as a right-only ciphertext, although Right::::from_slice reads stored bytes for both fixed-width schemes (PR #100 review finding 10). bit6_parse and bit8_parse now parse every input as a right half too, at the same widths, and require an accepted one to re-encode to itself. The finding also named VarLeft::from_slice, which does not exist: the chained left-only query is consumed as raw bytes by compare_left_to_full, which chained_compare already fuzzes. The doc says so. 20 s per target in an arm64 container: all four clean, 5.7 M to 13.1 M runs. --- .../2026-10-09-ore-v2-dynamic-verification.md | 6 ++++++ packages/ore-rs/fuzz/fuzz_targets/bit6_parse.rs | 16 ++++++++++++---- packages/ore-rs/fuzz/fuzz_targets/bit8_parse.rs | 12 ++++++++++-- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md index 73ffdd8b..4d1b923e 100644 --- a/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md +++ b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md @@ -90,6 +90,12 @@ bytes without parsing them first. The corpora from these runs are committed as seeds. +`bit6_parse` and `bit8_parse` now also parse every input as a right-only +ciphertext (`Right::from_slice`), which no target covered before; 20 s per +target in an arm64 container, all four clean (5.7 M to 13.1 M runs). The +chained left-only query has no parser: `compare_left_to_full` reads it as +raw bytes, and `chained_compare` fuzzes that. + ## 3. Kani — proofs for the pure building blocks Eighteen harnesses, all verified (full run 1 324 s before the oblivious PRP diff --git a/packages/ore-rs/fuzz/fuzz_targets/bit6_parse.rs b/packages/ore-rs/fuzz/fuzz_targets/bit6_parse.rs index f483d45f..1ee67b0b 100644 --- a/packages/ore-rs/fuzz/fuzz_targets/bit6_parse.rs +++ b/packages/ore-rs/fuzz/fuzz_targets/bit6_parse.rs @@ -2,12 +2,13 @@ use libfuzzer_sys::fuzz_target; use ore_rs::scheme::bit2_w6::OreAes128Bit6ChaCha20; -use ore_rs::{CipherText, Left, OreCipher, OreOutput}; +use ore_rs::{CipherText, Left, OreCipher, OreOutput, Right}; // The fixed-width v2 scheme at its two shipped widths (11 blocks for 64-bit -// values, 6 for 32-bit). Parsing untrusted bytes must never panic, an -// accepted ciphertext must re-encode to itself, and the raw comparator must -// answer arbitrary pairs without panicking. +// values, 6 for 32-bit). Parsing untrusted bytes as a full, left-only or +// right-only ciphertext must never panic, anything accepted must re-encode +// to itself, and the raw comparator must answer arbitrary pairs without +// panicking. fn check(data: &[u8]) { if let Ok(ct) = CipherText::::from_slice(data) { assert_eq!( @@ -23,6 +24,13 @@ fn check(data: &[u8]) { "accepted left ciphertext did not round-trip" ); } + if let Ok(right) = Right::::from_slice(data) { + assert_eq!( + right.to_bytes(), + data, + "accepted right ciphertext did not round-trip" + ); + } } fuzz_target!(|pair: (&[u8], &[u8])| { diff --git a/packages/ore-rs/fuzz/fuzz_targets/bit8_parse.rs b/packages/ore-rs/fuzz/fuzz_targets/bit8_parse.rs index 00c7d9c6..e2f10095 100644 --- a/packages/ore-rs/fuzz/fuzz_targets/bit8_parse.rs +++ b/packages/ore-rs/fuzz/fuzz_targets/bit8_parse.rs @@ -2,10 +2,11 @@ use libfuzzer_sys::fuzz_target; use ore_rs::scheme::bit2::OreAes128ChaCha20; -use ore_rs::{CipherText, Left, OreCipher, OreOutput}; +use ore_rs::{CipherText, Left, OreCipher, OreOutput, Right}; // The legacy 8-bit-block scheme, whose wire format is frozen and whose rows -// already sit in customer databases. Same contract as the v2 targets: +// already sit in customer databases. Same contract as the v2 targets, for +// the full, left-only and right-only parsers: // never panic on untrusted bytes, accepted bytes round-trip, the raw // comparator answers any pair. fn check(data: &[u8]) { @@ -23,6 +24,13 @@ fn check(data: &[u8]) { "accepted left ciphertext did not round-trip" ); } + if let Ok(right) = Right::::from_slice(data) { + assert_eq!( + right.to_bytes(), + data, + "accepted right ciphertext did not round-trip" + ); + } } fuzz_target!(|pair: (&[u8], &[u8])| { From 4b11458ca8a05c61a87e9c5944c1757ad7ef6cf0 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 22:04:09 +1100 Subject: [PATCH 21/35] fix(fuzz.sh): build only the targets being run; run every target even after a crash `cargo fuzz build` with no target builds all of them, so each of the four CI matrix jobs compiled every target; and with `set -e` the first crashing target ended a local all-targets run (PR #100 review finding 12). Each target is now built and run on its own (`cargo fuzz build ` passes `--bin `), failures are collected, and the script exits 1 at the end naming them. The target list comes from `cargo fuzz list`, so a new target needs no edit here, and a failing listing stops the script rather than yielding an empty run. Portable to macOS's bash 3.2. Checked: all targets clean in a Linux container; a run where every build fails reports all four and exits 1. --- scripts/fuzz.sh | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/scripts/fuzz.sh b/scripts/fuzz.sh index 2ce2dba1..7a0f74a6 100755 --- a/scripts/fuzz.sh +++ b/scripts/fuzz.sh @@ -9,6 +9,11 @@ # The targets assert that the ciphertext parsers and raw comparators never # panic on arbitrary bytes and that accepted bytes re-encode to themselves. # A crash leaves its input under packages/ore-rs/fuzz/artifacts//. +# +# Each target is built and run on its own, so a single-target run (one CI +# matrix job) compiles only that target, and a crash in one target does not +# stop the others: every target runs, and the script fails at the end if +# any of them did. set -euo pipefail seconds="${1:-60}" @@ -17,11 +22,30 @@ cd "$(dirname "$0")/../packages/ore-rs/fuzz" if [ $# -ge 2 ]; then targets=("$2") else - targets=(chained_parse chained_compare bit6_parse bit8_parse) + # Every target cargo-fuzz knows, so a new target needs no edit here. A + # plain assignment, so `set -e` stops the script if the listing fails. + list="$(cargo +nightly fuzz list)" + targets=() + while IFS= read -r t; do + [ -n "$t" ] && targets+=("$t") + done <<<"$list" +fi +if [ ${#targets[@]} -eq 0 ]; then + echo "no fuzz targets found" >&2 + exit 1 fi -cargo +nightly fuzz build +failed=() for target in "${targets[@]}"; do echo "== $target (${seconds}s)" - cargo +nightly fuzz run "$target" -- -max_total_time="$seconds" + if ! cargo +nightly fuzz build "$target" || + ! cargo +nightly fuzz run "$target" -- -max_total_time="$seconds"; then + echo " FAIL: $target" + failed+=("$target") + fi done + +if [ ${#failed[@]} -gt 0 ]; then + echo "failed: ${failed[*]}" + exit 1 +fi From ffb574bef07788b19d686e5562282253bdc1f674 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 22:18:37 +1100 Subject: [PATCH 22/35] test(prp): prove lemire_draw itself; test every swap of the shipped builders on their targets The Lemire Kani harness proved an inline restatement of the formula, so a wrong draw offset or byte order in oblivious::lemire_draw would pass it; and the NEON and SSSE3 builders, which ship, are never model-checked because Kani cannot model their intrinsics and dispatches to SWAR (PR #100 review finding 8). - lemire_draw_in_range calls oblivious::lemire_draw on a fully symbolic 504-byte stream for every step 1..64 and checks it returns (x * (i + 1)) >> 64 <= i of the right little-endian draw. 113 s on an M1 Max. Mutation check: reading the draw big-endian fails it. - builders_match_reference_for_every_swap forces every (i, j) with j <= i (2 079 draws, each inside an otherwise varied full build) through the dispatched, SWAR and reference builders and requires equal tables. This is the strongest evidence the intrinsic builders can get. - A new verify.yml job runs the PRP tests on ubuntu-24.04-arm, where the dispatcher picks NEON; test.yml runs them on x86_64 only (SSSE3). --- .github/workflows/verify.yml | 17 +++++++++++ .../2026-10-09-ore-v2-dynamic-verification.md | 11 ++++++- packages/ore-rs/src/primitives/prp.rs | 29 +++++++++++++------ .../ore-rs/src/primitives/prp/oblivious.rs | 29 +++++++++++++++++++ 4 files changed, 76 insertions(+), 10 deletions(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 7726f1e7..4a1b4ce9 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -100,6 +100,23 @@ jobs: path: packages/ore-rs/fuzz/artifacts/ if-no-files-found: ignore + # The PRP builder that ships on Arm is NEON, which Kani cannot model, so + # its evidence is the equivalence tests against the reference builder + # (every step with every swap index, plus random and edge streams). test.yml + # runs on x86_64 only, where those tests exercise SSSE3; this runs them + # where the dispatcher picks NEON. + prp-builders-aarch64: + runs-on: ubuntu-24.04-arm + name: "🧮 PRP builders (aarch64)" + steps: + - uses: actions/checkout@v4 + + - name: setup rust + uses: dtolnay/rust-toolchain@stable + + - name: builder equivalence tests + run: cargo test -p ore-rs --lib primitives::prp + kani: runs-on: ubuntu-latest name: "✅ Kani" diff --git a/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md index 4d1b923e..b4aad995 100644 --- a/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md +++ b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md @@ -114,7 +114,7 @@ behind matching known-answer vectors. | `ct_bit(byte, pos) == (byte >> pos) & 1`, and 0 for `pos ≥ 8` | exhaustive | < 1 s | | `oblivious_lookup(table, i) == table[i]` for every symbol `i` | every 64-entry table of in-domain values; every 256-entry table | 10 s; 29 s (M1 Max) | | `Symbol::<64>::from_low_bits(b)` is `< 64`, and is `b` for `b < 64` | every `u8` | < 1 s | -| Lemire reduction `((x·(i+1)) >> 64) ≤ i` | every `u64` `x`, `i < 64` | < 1 s | +| `oblivious::lemire_draw` itself reads step `i`'s draw from bytes `(63−i)·8 ..+ 8` (little-endian) and returns `(x·(i+1)) >> 64 ≤ i` | every 504-byte stream, every step `1..64` | 113 s (M1 Max) | | the reference (textbook Fisher–Yates) builder yields a permutation of 0..64 with a correct inverse | first 6 of 63 draws symbolic, the rest fixed (all 63 symbolic did not finish in 30 min) | 200 s | | the SWAR oblivious builder's `permutation` and `inverse` equal the reference builder's (under Kani `from_stream` dispatches to SWAR: Kani cannot model the NEON or SSSE3 intrinsics) | same stream shape | 872 s | | `from_stream` rejects streams shorter than 504 bytes | every short length | 335 s (101 s before the oblivious builder) | @@ -125,6 +125,15 @@ behind matching known-answer vectors. No harness found a bug. +The NEON and SSSE3 builders, the ones that ship, are outside Kani: it cannot +model their intrinsics, so under `cargo kani` the dispatcher picks SWAR, +which is what the equivalence harness proves. Their evidence is tests against +the reference builder: random and edge streams, and every step `i` with every +swap index `j ≤ i` (2 079 forced draws, each inside a full build). Those run +on x86_64 in `test.yml` (SSSE3) and on an aarch64 runner in `verify.yml` +(NEON). Until the `lemire_draw` harness above, the draw was proved only as a +restated formula. + ## 4. dudect — timing leakage Welch's t-test over two interleaved input classes; `|t| < 5` after millions diff --git a/packages/ore-rs/src/primitives/prp.rs b/packages/ore-rs/src/primitives/prp.rs index 8762da11..5bc9bed8 100644 --- a/packages/ore-rs/src/primitives/prp.rs +++ b/packages/ore-rs/src/primitives/prp.rs @@ -446,16 +446,27 @@ mod kani_proofs { } } - /// Lemire reduction `((x * (i + 1)) >> 64)` is `<= i` for every `x: u64` - /// and every step index `i < 64` (the `LemireFyPrp<64>` domain), so the - /// Fisher–Yates swap index never exceeds the step index. + /// `oblivious::lemire_draw`, the draw every builder uses, reads step + /// `i`'s draw from bytes `(63 - i) * 8 ..+ 8` as a little-endian `u64` + /// and reduces it to `0..=i` by multiply-high: for every stream and every + /// step `1..64` it is `(x * (i + 1)) >> 64` of that draw, and so never + /// exceeds `i`. Proved on the function itself, not a restatement of its + /// formula. The steps are a loop rather than a symbolic `i`, so every + /// slice offset is concrete once unwound. #[kani::proof] - fn lemire_reduction_in_range() { - let x: u64 = kani::any(); - let i: u64 = kani::any(); - kani::assume(i < 64); - let j = (x as u128 * (i as u128 + 1)) >> 64; - assert!(j <= i as u128); + #[kani::unwind(64)] + fn lemire_draw_in_range() { + let stream: [u8; 63 * 8] = kani::any(); + for i in 1..64 { + let d = 63 - i; + let mut draw = [0u8; 8]; + draw.copy_from_slice(&stream[d * 8..d * 8 + 8]); + let x = u64::from_le_bytes(draw); + + let j = oblivious::lemire_draw(&stream, i); + assert_eq!(u128::from(j), (u128::from(x) * (i as u128 + 1)) >> 64); + assert!(usize::from(j) <= i); + } } /// Asserts `perm` is a permutation of `0..64` with `inverse` its diff --git a/packages/ore-rs/src/primitives/prp/oblivious.rs b/packages/ore-rs/src/primitives/prp/oblivious.rs index e1126def..fdfc744f 100644 --- a/packages/ore-rs/src/primitives/prp/oblivious.rs +++ b/packages/ore-rs/src/primitives/prp/oblivious.rs @@ -506,6 +506,35 @@ mod tests { } } + /// The smallest draw that `lemire_draw` reduces to `j` at step `i`: + /// `ceil(j * 2^64 / (i + 1))`. + fn draw_for(i: usize, j: usize) -> u64 { + let n = i as u128 + 1; + (((j as u128) << 64).div_ceil(n)) as u64 + } + + /// Every step `i` with every swap index `j` in `0..=i`, through the + /// dispatched builder (NEON on aarch64, SSSE3 or SWAR on x86_64) and the + /// portable ones. Kani cannot model the NEON and SSSE3 intrinsics, so + /// this is the coverage those builders get: each of the 2 079 `(i, j)` + /// swaps is exercised at least once, inside a full build whose other + /// draws vary, and the tables must equal the reference builder's. + #[test] + fn builders_match_reference_for_every_swap() { + for i in 1..DOMAIN { + for j in 0..=i { + let mut s = stream_from(&[i as u8, j as u8]); + let d = DOMAIN - 1 - i; + s[d * 8..d * 8 + 8].copy_from_slice(&draw_for(i, j).to_le_bytes()); + assert_eq!(usize::from(lemire_draw(&s, i)), j, "draw_for({i}, {j})"); + + let want = run(reference::build, &s); + assert_eq!(run(build, &s), want, "dispatched, i {i} j {j}"); + assert_eq!(run(swar::build, &s), want, "swar, i {i} j {j}"); + } + } + } + /// The reference tables are a permutation of `0..64` and its inverse, /// so matching them means every builder builds one. #[test] From 3c5f285d48a28565d3a56d806f78ba3b2daedcb7 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 22:18:52 +1100 Subject: [PATCH 23/35] docs(review): Kani row for cross-length order preservation Follows c5a436e (PR #100 review finding 9): the decompose_6bit order harness now covers any two lengths up to 16 bytes. --- docs/reviews/2026-10-09-ore-v2-dynamic-verification.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md index b4aad995..ac51f42d 100644 --- a/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md +++ b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md @@ -119,7 +119,7 @@ behind matching known-answer vectors. | the SWAR oblivious builder's `permutation` and `inverse` equal the reference builder's (under Kani `from_stream` dispatches to SWAR: Kani cannot model the NEON or SSSE3 intrinsics) | same stream shape | 872 s | | `from_stream` rejects streams shorter than 504 bytes | every short length | 335 s (101 s before the oblivious builder) | | `num_blocks_6bit(n)` is the least `b` with `6b ≥ 8n` | every `n ≤ usize::MAX / 8` | < 1 s | -| `decompose_6bit`: symbols < 64, bit `5−t` of block `i` is plaintext bit `6i+t`; injective; order-preserving for equal lengths | inputs ≤ 16 bytes | 5 s; 8 s; 9 s | +| `decompose_6bit`: symbols < 64, bit `5−t` of block `i` is plaintext bit `6i+t`; injective; order-preserving for any two lengths (a proper prefix sorts first, the chained scheme's string order) | inputs ≤ 16 bytes | 5 s; 8 s; 31 s (M1 Max; equal lengths only, 9 s, before) | | `final_block` and `prefix_block` injective, and disjoint from each other | full domains | < 1 s each | | `gf128_double` matches the byte-wise NIST SP 800-38B `dbl` | every 128-bit input | < 1 s | From 25f327a1fa087ec9a6a7539a4de896e7acd5fe3c Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 22:38:09 +1100 Subject: [PATCH 24/35] ci(kani): make the default harness set fit CI; time-limit the job The Kani job ran with no timeout and has failed on five of the last six Verify runs: the SWAR-builder equivalence harness (872 s on an M4) is cancelled 26 to 32 minutes into its run on ubuntu-latest every time, and the one success took 57 minutes. The 335 s from_stream length harness (552 s in CI) proved 504 concrete cases (PR #100 review finding 11). - The SWAR equivalence harness joins the exhaustive ct_select_byte harness behind kani-full, run by hand. Every CI run still tests SWAR against the reference on every (step, swap index) pair. - The from_stream length check is an exhaustive unit test instead. - timeout-minutes: 30 on the job. The default set is now 15 harnesses: 8 m 50 s on an M1 Max, all verified, so about 15 minutes in CI. --- .github/workflows/verify.yml | 9 ++++-- .../2026-10-09-ore-v2-dynamic-verification.md | 22 ++++++++------ packages/ore-rs/Cargo.toml | 5 ++-- packages/ore-rs/src/primitives/prp.rs | 30 +++++++++++++------ 4 files changed, 44 insertions(+), 22 deletions(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 4a1b4ce9..c5bfef47 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -9,8 +9,10 @@ name: "Verify" # * fuzz: the libFuzzer targets (packages/ore-rs/fuzz) for the ciphertext # parsers and raw comparators, time-boxed per pull request. # * kani: the bounded model-checking harnesses (`#[cfg(kani)]`) for the pure -# building blocks. The exhaustive-length harness behind the `kani-full` -# feature is not run here (about 9 minutes); run +# building blocks. The two heaviest, behind the `kani-full` feature, are +# not run here: the exhaustive-length `ct_select_byte` harness (about 9 +# minutes) and the SWAR-builder equivalence harness (about 15 minutes on +# an M4, and cancelled on GitHub's runners); run # `cargo kani -p ore-rs --features kani-full` by hand for the full set. # # Like test.yml, this runs on every pull request regardless of base branch, so @@ -120,6 +122,9 @@ jobs: kani: runs-on: ubuntu-latest name: "✅ Kani" + # A hung or runaway CBMC run should fail the job rather than hold a + # runner for GitHub's six-hour default. + timeout-minutes: 30 steps: - uses: actions/checkout@v4 diff --git a/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md index ac51f42d..2b49e234 100644 --- a/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md +++ b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md @@ -98,13 +98,18 @@ raw bytes, and `chained_compare` fuzzes that. ## 3. Kani — proofs for the pure building blocks -Eighteen harnesses, all verified (full run 1 324 s before the oblivious PRP -builder; the exhaustive-length `ct_select_byte` harness, about 9 minutes of -that, runs only with the `kani-full` feature; the PRP harnesses were -re-timed one at a time after it, and the default set now takes about 25 -minutes). With `Symbol`, the out-of-range `oblivious_lookup` cases no longer -exist to prove, so the any-length lookup harness was dropped and a -`Symbol` harness added. Kani cannot execute AES symbolically, so nothing keyed +Seventeen harnesses, all verified. Fifteen run in CI; the two heaviest run +only with the `kani-full` feature, by hand: the exhaustive-length +`ct_select_byte` harness (about 9 minutes) and the SWAR-builder equivalence +harness (872 s on the M4). The latter is out of CI because it never fit: +on GitHub's `ubuntu-latest` runners it was cancelled 26 to 32 minutes in on +five of six runs, and the sixth took 57 minutes. The default set takes +8 m 50 s on an M1 Max (about 15 minutes expected in CI, which runs about +1.7 times slower); the job has a 30-minute timeout. With `Symbol`, the +out-of-range `oblivious_lookup` cases no longer exist to prove, so the +any-length lookup harness was dropped and a `Symbol` harness added; the +`from_stream` length check, a 335 s harness (552 s in CI) for 504 cases, is +now an exhaustive unit test. Kani cannot execute AES symbolically, so nothing keyed is in scope; these are the pieces where a wrong implementation would hide behind matching known-answer vectors. @@ -116,8 +121,7 @@ behind matching known-answer vectors. | `Symbol::<64>::from_low_bits(b)` is `< 64`, and is `b` for `b < 64` | every `u8` | < 1 s | | `oblivious::lemire_draw` itself reads step `i`'s draw from bytes `(63−i)·8 ..+ 8` (little-endian) and returns `(x·(i+1)) >> 64 ≤ i` | every 504-byte stream, every step `1..64` | 113 s (M1 Max) | | the reference (textbook Fisher–Yates) builder yields a permutation of 0..64 with a correct inverse | first 6 of 63 draws symbolic, the rest fixed (all 63 symbolic did not finish in 30 min) | 200 s | -| the SWAR oblivious builder's `permutation` and `inverse` equal the reference builder's (under Kani `from_stream` dispatches to SWAR: Kani cannot model the NEON or SSSE3 intrinsics) | same stream shape | 872 s | -| `from_stream` rejects streams shorter than 504 bytes | every short length | 335 s (101 s before the oblivious builder) | +| the SWAR oblivious builder's `permutation` and `inverse` equal the reference builder's (under Kani `from_stream` dispatches to SWAR: Kani cannot model the NEON or SSSE3 intrinsics) | same stream shape (`kani-full`) | 872 s | | `num_blocks_6bit(n)` is the least `b` with `6b ≥ 8n` | every `n ≤ usize::MAX / 8` | < 1 s | | `decompose_6bit`: symbols < 64, bit `5−t` of block `i` is plaintext bit `6i+t`; injective; order-preserving for any two lengths (a proper prefix sorts first, the chained scheme's string order) | inputs ≤ 16 bytes | 5 s; 8 s; 31 s (M1 Max; equal lengths only, 9 s, before) | | `final_block` and `prefix_block` injective, and disjoint from each other | full domains | < 1 s each | diff --git a/packages/ore-rs/Cargo.toml b/packages/ore-rs/Cargo.toml index dffa0c53..501b6aed 100644 --- a/packages/ore-rs/Cargo.toml +++ b/packages/ore-rs/Cargo.toml @@ -36,8 +36,9 @@ decimal = ["dep:rust_decimal", "orderable-bytes/decimal"] # public API: a feature rather than a `cfg` so it never trips # `unexpected_cfgs`, and never enabled by a dependant. ct-bench = [] -# Adds the exhaustive-length Kani harness (about 9 minutes of the full -# run). CI runs `cargo kani` without it; run +# Adds the two heaviest Kani harnesses: exhaustive-length `ct_select_byte` +# (about 9 minutes) and SWAR-builder equivalence (about 15 minutes on an M4, +# too heavy for GitHub's runners). CI runs `cargo kani` without it; run # `cargo kani -p ore-rs --features kani-full` for the complete set. kani-full = [] diff --git a/packages/ore-rs/src/primitives/prp.rs b/packages/ore-rs/src/primitives/prp.rs index 5bc9bed8..ed269d6e 100644 --- a/packages/ore-rs/src/primitives/prp.rs +++ b/packages/ore-rs/src/primitives/prp.rs @@ -377,6 +377,22 @@ mod tests { assert!((0..64u8).any(|v| a.permute(sym64(v)) != c.permute(sym64(v)))); } + /// `from_stream` rejects every stream shorter than 63 * 8 bytes and + /// accepts the first full length. Exhaustive over the 504 short lengths; + /// this was a Kani harness, which took 335 s to prove the same thing. + #[test] + fn fy_from_stream_rejects_every_short_stream() { + let buf = [0u8; 63 * 8]; + for len in 0..buf.len() { + assert!( + LemireFyPrp::<64>::from_stream(&buf[..len]).is_err(), + "len {}", + len + ); + } + assert!(LemireFyPrp::<64>::from_stream(&buf).is_ok()); + } + #[test] fn fy_rejects_short_key() { assert!( as Prp>>::new(&[0u8; 8]).is_err()); @@ -522,6 +538,11 @@ mod kani_proofs { /// reference builder's `permutation` and `inverse` for every stream whose /// first `SYMBOLIC_DRAWS` draws are arbitrary and whose remaining draws /// come from `FIXED_STREAM`: same shape as the harness above. + // 872 s on an M4, and on GitHub's ubuntu-latest runners it has been + // cancelled 26 to 32 minutes in on every run but one, so it runs only + // with `kani-full`. Every CI run still tests SWAR against the reference + // on every swap (`oblivious::tests`). + #[cfg(feature = "kani-full")] #[kani::proof] #[kani::unwind(65)] fn oblivious_swar_matches_reference_first_draws_symbolic() { @@ -535,13 +556,4 @@ mod kani_proofs { assert!(perm == want_perm); assert!(inverse == want_inverse); } - - /// `from_stream` rejects every stream shorter than 63 * 8 bytes. - #[kani::proof] - fn lemire_fy_from_stream_rejects_short() { - let buf: [u8; 503] = [0u8; 503]; - let len: usize = kani::any(); - kani::assume(len < 63 * 8); - assert!(LemireFyPrp::<64>::from_stream(&buf[..len]).is_err()); - } } From 86fcf473271694c34b06d65643242c85c7c37a46 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 10 Oct 2026 10:46:23 +1100 Subject: [PATCH 25/35] docs(dudect): DIT does not remove the prp_build_const_vs_random residual Four alternating 150 s continuous runs on the M4 at daf0344: DIT off +20.4/+19.6 (tau 0.0013/0.0012), DIT on +21.6/+21.8 (tau 0.0016/0.0016). The data memory-dependent prefetcher does not explain it; it stays open. The fixed A vs fixed B control stays under 2.5 both ways. The original commit (6760707), rerun in the same session, gave +31.4, so the larger figures are the session's, not the code's. --- .../2026-10-09-oblivious-prp-results.md | 19 +++++++- .../2026-10-09-ore-v2-dynamic-verification.md | 45 ++++++++++++++++--- 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/docs/benchmarks/2026-10-09-oblivious-prp-results.md b/docs/benchmarks/2026-10-09-oblivious-prp-results.md index 2a2a5e86..90828e74 100644 --- a/docs/benchmarks/2026-10-09-oblivious-prp-results.md +++ b/docs/benchmarks/2026-10-09-oblivious-prp-results.md @@ -204,8 +204,23 @@ the residual by more than half, shrinks both classes.) The NEON builder has no secret address or branch for it to come from, so the candidate is content-dependent microarchitecture such as the data memory-dependent prefetcher. DIT disables that on M3 and later, but the harness did not apply -DIT to the builder benches until the verification review; a continuous run -with `CT_DUDECT_DIT=1` on the M4 is the next measurement. +DIT to the builder benches until the verification review. It has now been +tested, at `daf0344`: continuous, 150 s per run, DIT off and on alternately, +on the same M4 (macOS 26.5.2, mains power, Low Power Mode off). + +| run, in order | DIT | n | max t | max tau | +|---|---|---|---|---| +| off-1 | off | 240 M | +20.4 | +0.0013 | +| on-1 | on | 189 M | +21.6 | +0.0016 | +| off-2 | off | 251 M | +19.6 | +0.0012 | +| on-2 | on | 191 M | +21.8 | +0.0016 | + +DIT does not remove or reduce it, so on four runs it is not the prefetcher, +and it stays unexplained. The figures are larger than the +7.7 above, but +the commit that measured +7.7 (`6760707`), rerun in the same session, gave ++31.4 at 215 M, so the change is in the session, not the code. Fixed A +against fixed B stays under 2.5 both ways (five batch runs each). Details: +verification doc, §4.2. ## Not measured diff --git a/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md index 2b49e234..79f6b937 100644 --- a/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md +++ b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md @@ -285,18 +285,46 @@ pair has its own sign, so pooling dilutes it (the old builder's +8.6 here, against |t| up to 83 in a single pair). The per-pair runs are the test, and there the dependence is gone. -`prp_build_const_vs_random` keeps a small residual (tau 0.0005), and it is -not explained. An earlier version of this paragraph put it down to the +`prp_build_const_vs_random` keeps a small residual (tau 0.0005 in the table +above, 0.0012–0.0016 in the DIT runs below), and it is not explained. An earlier version of this paragraph put it down to the classes' footprints, one repeated input against a 256 KB pool. That is wrong: the `Left` pool is 512 copies of the fixed stream, indexed at random exactly as the `Right` pool of 512 random streams is, so the two classes have the same footprint and access pattern and differ only in content. The NEON builder has no secret-dependent branch or address (§1), so the candidates are content-dependent microarchitecture, the data -memory-dependent prefetcher first. DIT disables that on M3 and later, and -this bench did not get DIT before (above), so a continuous run with -`CT_DUDECT_DIT=1` on the M4 is the next measurement. Fixed A against fixed -B, where both classes repeat one input, shows nothing. The first pass of these measurements, in Low +memory-dependent prefetcher first. + +**DIT test: not the prefetcher.** DIT disables the prefetcher on M3 and +later, and this bench did not get DIT before (above), so it was run +continuously at `daf0344`, 150 s per run, DIT off and on alternately. Apple +M4 (`FEAT_DIT` = 1), macOS 26.5.2 (25F84), mains power with the battery +charged, Low Power Mode off; one other Claude Code session was open but +idle. With `CT_DUDECT_DIT=1` the harness reads the bit back and panics if it +did not stick; both DIT runs completed. + +| run, in order | DIT | n | max t | max tau | +|---|---|---|---|---| +| off-1 | off | 240 M | +20.4 | +0.0013 | +| on-1 | on | 189 M | +21.6 | +0.0016 | +| off-2 | off | 251 M | +19.6 | +0.0012 | +| on-2 | on | 191 M | +21.8 | +0.0016 | + +The residual is there both ways and DIT does not reduce it; tau is a +little higher with DIT on, which also runs about 20% slower (fewer samples +in the same 150 s). So on this evidence the data memory-dependent +prefetcher does not explain it. Four runs are evidence, not proof, and the +residual stays open. tau holds steady through each run from 50 M samples +on (0.0013–0.0014 off, 0.0015–0.0017 on), so t grows with √n: a constant +offset, not drift. The signal is larger than the first measurement (+7.7 at +239 M, tau 0.0005), but the builder and harness at the commit of that +measurement (`6760707`), run in the same session with DIT off, gave +31.4 +at 215 M (tau 0.0021). The larger figures therefore come from this +session's conditions, not from the code changes since `6760707`. + +Fixed A against fixed B, where both classes repeat one input, shows nothing +either way: five batch runs each, DIT off −0.9, +2.3, −2.0, +1.5, +2.2, DIT +on −2.4, −1.6, −1.7, +0.4, +1.6. The first pass of these measurements, in Low Power Mode on battery, gave the same per-pair picture (its pooled old-builder figure was larger, −45 at 135 M). @@ -321,4 +349,7 @@ seen through a fixed versus varying permutation sequence. now builds on aarch64 Linux (the DIT write uses the generic system register encoding), so Graviton needs no changes. - The `prp_build_const_vs_random` residual on the NEON builder (§4.2) is - open. + open. On the M4 it is the same with DIT on and off (t ≈ +20, tau + 0.0012–0.0016, four 150 s runs), so on that evidence it is not the data + memory-dependent prefetcher. A run on another core would show whether it + is specific to Apple's. From d7749295ebaa7fcbf32df0da34ded377d9276a2b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 10 Oct 2026 12:06:27 +1100 Subject: [PATCH 26/35] fix(ct-dudect): remove the layout and timer artifacts from the PRP builder benches prp_build_const_vs_random's residual (+7.7 to +31 on the M4, +55 on an M1 Max, unchanged by DIT) came from the bench, not the builder: - Placement. Each class read from its own heap allocation. In one shared pool, moving the fixed stream from the even to the odd slots flips the sign (-12.6 to +23.5). - Reporting. dudect's max t is the largest |t| over 101 tests, 100 of them cropped at runtime percentiles. A one-build sample (about 200 ns) takes only a handful of values on Apple's 24 MHz timer, the percentiles sit on those values, and where the classes differ in spread the cropped tests report a large t with no mean difference: +27.7 reported against +1.10 uncropped. prp_build_const_vs_random now draws both classes from one pool with the slots assigned at random each batch, and times 16 builds per sample; prp_build_fixed_a_vs_fixed_b also times 16. On the M1 Max the corrected bench gives uncropped t = -0.04 over 7.3 M samples per class (dudect -1.8). The old bench stays as prp_build_const_vs_random_separate_pools, with prp_build_const_vs_random_interleaved_even/_odd as layout controls. scripts/dudect-uncropped.sh runs a bench continuously, streams every sample through a FIFO into per-class histograms, and prints the uncropped Welch t, means, spreads and tick-bin shares next to dudect's figure. dudect-bencher writes every sample with class 0, so the classes are recovered from row order. --- packages/ct-dudect/src/main.rs | 135 ++++++++++++++++++++++++++++----- scripts/dudect-uncropped.sh | 68 +++++++++++++++++ 2 files changed, 184 insertions(+), 19 deletions(-) create mode 100755 scripts/dudect-uncropped.sh diff --git a/packages/ct-dudect/src/main.rs b/packages/ct-dudect/src/main.rs index e4b91370..48034ba8 100644 --- a/packages/ct-dudect/src/main.rs +++ b/packages/ct-dudect/src/main.rs @@ -234,27 +234,81 @@ fn bit6_encrypt_left_const_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) } } +/// Builds timed per sample by `prp_build_const_vs_random` and +/// `prp_build_fixed_a_vs_fixed_b`. One build takes about 200 ns, five ticks +/// of Apple's 24 MHz counter, so a one-build sample takes only a handful of +/// values and a class difference is a sliver of samples crossing a tick +/// boundary, which dudect's percentile cropping (cut exactly at those tick +/// values) exaggerates. Sixteen builds per sample span about eighty ticks. +const BUILDS_PER_SAMPLE: usize = 16; + +/// A fresh random 512-byte draw stream. +fn random_stream(rng: &mut BenchRng) -> [u8; 512] { + let mut s = [0u8; 512]; + for b in s.iter_mut() { + *b = rng.random::(); + } + s +} + /// The PRP builder alone (`LemireFyPrp::from_stream`, the oblivious builder /// the schemes use, through the `ct-bench` hook): one fixed 512-byte draw /// stream (`Left`) versus fresh random streams (`Right`). The draw stream /// decides every Fisher–Yates swap, so this isolates the builder from /// everything else in the encryptor. +/// +/// Both classes share one pool of `2 * pool` streams, and a random half of +/// its slots, redrawn every batch, holds the fixed stream, so the classes +/// differ in content only. With a separate pool per class they also differ +/// in where their inputs sit, and that alone moved t from -13 to +55 with +/// the layout (`prp_build_const_vs_random_separate_pools` and the +/// interleaved controls below). Each sample times `BUILDS_PER_SAMPLE` +/// builds over streams from the class's slots. fn prp_build_const_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { data_independent_timing(); - let mut fixed = [0u8; 512]; - for b in fixed.iter_mut() { - *b = rng.random::(); + let fixed = random_stream(rng); + let n = 2 * pool_size(); + // Fisher–Yates over slot indices: the first half are `Left`. + let mut order: Vec = (0..n).collect(); + for i in (1..n).rev() { + order.swap(i, rng.random_range(0..=i)); } - let left = vec![fixed; pool_size()]; - let right: Vec<[u8; 512]> = (0..pool_size()) - .map(|_| { - let mut s = [0u8; 512]; - for b in s.iter_mut() { - *b = rng.random::(); + let (left_idx, right_idx) = order.split_at(pool_size()); + let mut pool = vec![[0u8; 512]; n]; + for &k in left_idx { + pool[k] = fixed; + } + for &k in right_idx { + pool[k] = random_stream(rng); + } + for _ in 0..SAMPLES { + let c = class(rng); + let idx = if matches!(c, Class::Left) { + left_idx + } else { + right_idx + }; + let picks: [&[u8; 512]; BUILDS_PER_SAMPLE] = + std::array::from_fn(|_| &pool[idx[rng.random_range(0..pool_size())]]); + runner.run_one(c, || { + let mut acc = 0u8; + for s in picks { + acc ^= ore_rs::ct_bench::lemire_fy_prp_from_stream(black_box(s)); } - s - }) - .collect(); + black_box(acc) + }); + } +} + +/// Artifact control: `prp_build_const_vs_random` as first written, one +/// build per sample and a separate pool per class (`Left`'s, 512 copies of +/// the fixed stream, allocated first). This is the bench behind the +7.7 to +/// +31 residuals; it is kept to show that they come from the harness. +fn prp_build_const_vs_random_separate_pools(runner: &mut CtRunner, rng: &mut BenchRng) { + data_independent_timing(); + let fixed = random_stream(rng); + let left = vec![fixed; pool_size()]; + let right: Vec<[u8; 512]> = (0..pool_size()).map(|_| random_stream(rng)).collect(); for _ in 0..SAMPLES { let c = class(rng); let pool = if matches!(c, Class::Left) { @@ -269,22 +323,62 @@ fn prp_build_const_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { } } +/// Artifact control: one build per sample, both classes in one pool, +/// interleaved, `Left` at the slots of parity `left_parity`. Placement is +/// systematic but differs only by a 512-byte offset; the result's sign +/// follows the parity. +fn interleaved(runner: &mut CtRunner, rng: &mut BenchRng, left_parity: usize) { + data_independent_timing(); + let fixed = random_stream(rng); + let mut pool = vec![[0u8; 512]; 2 * pool_size()]; + for (k, s) in pool.iter_mut().enumerate() { + *s = if k % 2 == left_parity { + fixed + } else { + random_stream(rng) + }; + } + for _ in 0..SAMPLES { + let c = class(rng); + let parity = if matches!(c, Class::Left) { + left_parity + } else { + 1 - left_parity + }; + let s = &pool[2 * rng.random_range(0..pool_size()) + parity]; + runner.run_one(c, || { + black_box(ore_rs::ct_bench::lemire_fy_prp_from_stream(black_box(s))) + }); + } +} + +/// [`interleaved`] with the fixed stream at even slots. +fn prp_build_const_vs_random_interleaved_even(runner: &mut CtRunner, rng: &mut BenchRng) { + interleaved(runner, rng, 0); +} + +/// [`interleaved`] with the fixed stream at odd slots. +fn prp_build_const_vs_random_interleaved_odd(runner: &mut CtRunner, rng: &mut BenchRng) { + interleaved(runner, rng, 1); +} + /// The PRP builder on two *different fixed* streams, `A` (`Left`) and `B` /// (`Right`). Both classes repeat their input, so predictor training is the /// same for each; a difference here means the time depends on *which* -/// permutation is built, not merely on whether the input repeats. +/// permutation is built, not merely on whether the input repeats. Each +/// sample times `BUILDS_PER_SAMPLE` builds. fn prp_build_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) { data_independent_timing(); - let mut a = [0u8; 512]; - let mut b = [0u8; 512]; - for x in a.iter_mut().chain(b.iter_mut()) { - *x = rng.random::(); - } + let (a, b) = (random_stream(rng), random_stream(rng)); for _ in 0..SAMPLES { let c = class(rng); let s = if matches!(c, Class::Left) { &a } else { &b }; runner.run_one(c, || { - black_box(ore_rs::ct_bench::lemire_fy_prp_from_stream(black_box(s))) + let mut acc = 0u8; + for _ in 0..BUILDS_PER_SAMPLE { + acc ^= ore_rs::ct_bench::lemire_fy_prp_from_stream(black_box(s)); + } + black_box(acc) }); } } @@ -454,6 +548,9 @@ ctbench_main!( bit6_encrypt_left_const_vs_random, bit6_encrypt_left_fixed_a_vs_fixed_b, prp_build_const_vs_random, + prp_build_const_vs_random_separate_pools, + prp_build_const_vs_random_interleaved_even, + prp_build_const_vs_random_interleaved_odd, prp_build_fixed_a_vs_fixed_b, chained_encrypt_fixed_vs_random, iso_swap_secret_fixed_a_vs_fixed_b, diff --git a/scripts/dudect-uncropped.sh b/scripts/dudect-uncropped.sh new file mode 100755 index 00000000..3aaea9de --- /dev/null +++ b/scripts/dudect-uncropped.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Run one ct-dudect bench continuously and report the *uncropped* Welch t +# next to dudect's own figure. +# +# scripts/dudect-uncropped.sh prp_build_const_vs_random # 150 s +# CT_DUDECT_DIT=1 scripts/dudect-uncropped.sh 300 +# +# dudect reports the largest |t| over 101 tests, 100 of which first drop +# every sample above a percentile of the runtime distribution. When the two +# classes differ in spread, dropping the slow tail removes more of one class +# than the other, and the cropped tests can report a large t with no +# difference in mean. On Apple Silicon the timer is a 24 MHz counter (41.67 +# ns ticks), so the percentiles also fall exactly on tick values. This +# script records every sample (streamed through a FIFO into per-class +# histograms, so a long run stays small) and prints the plain Welch t, the +# per-class means and spreads, and the share of samples in each tick bin. +# +# Needs python3. dudect-bencher writes every sample with class 0; the classes +# are recovered from the row order (it writes Left, Right pairs). +set -euo pipefail + +bench="${1:?usage: dudect-uncropped.sh [seconds]}" +seconds="${2:-150}" +cd "$(dirname "$0")/../packages/ct-dudect" + +cargo +stable build --release --locked --quiet +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +mkfifo "$tmp/samples" + +awk -F, 'NR > 1 && NF == 3 && $3 ~ /^[0-9]+$/ { + k++; h[(k % 2 == 1 ? "L" : "R") " " $3]++ + } + END { for (x in h) print x, h[x] }' "$tmp/samples" >"$tmp/hist" & +timeout "$seconds" ./target/release/ct-dudect --continuous "$bench" \ + --out "$tmp/samples" >"$tmp/dudect.log" 2>&1 || true +wait + +echo "dudect: $(grep 'max t' "$tmp/dudect.log" | tail -1 | sed -E 's/.*: (n ==.*)/\1/')" +python3 - "$tmp/hist" <<'EOF' +import math, sys +from collections import defaultdict + +h = {"L": defaultdict(int), "R": defaultdict(int)} +for line in open(sys.argv[1]): + c, v, k = line.split() + h[c][float(v)] += int(k) + +def moments(d): + n = sum(d.values()) + m = sum(v * c for v, c in d.items()) / n + var = sum(c * (v - m) ** 2 for v, c in d.items()) / (n - 1) + return n, m, var + +(nl, ml, vl), (nr, mr, vr) = moments(h["L"]), moments(h["R"]) +t = (ml - mr) / math.sqrt(vl / nl + vr / nr) +print(f"uncropped: n = {nl / 1e6:.1f} M + {nr / 1e6:.1f} M, " + f"t = {t:+.2f}, tau = {t / math.sqrt(nl + nr):+.5f}") +print(f"mean Left {ml:.2f}, Right {mr:.2f} (diff {ml - mr:+.3f}); " + f"sd Left {math.sqrt(vl):.1f}, Right {math.sqrt(vr):.1f}") +print("bin Left share Right share z") +top = sorted(set(h["L"]) | set(h["R"]), key=lambda v: -(h["L"][v] + h["R"][v]))[:8] +for b in sorted(top): + pl, pr = h["L"][b] / nl, h["R"][b] / nr + p = (h["L"][b] + h["R"][b]) / (nl + nr) + se = math.sqrt(p * (1 - p) * (1 / nl + 1 / nr)) or 1.0 + print(f"{b:7.0f} {pl:10.5f} {pr:11.5f} {(pl - pr) / se:+6.1f}") +EOF From 3edeb5f43d3922e3d7f6dbdae69e091c95376f3c Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 10 Oct 2026 12:06:28 +1100 Subject: [PATCH 27/35] docs(dudect): the const-vs-random residual was the harness Record the controls that traced prp_build_const_vs_random's residual to input placement and dudect's percentile cropping on a coarse timer, and the corrected bench's result (uncropped t = -0.04 on an M1 Max). The M4 rerun of the corrected bench is pending. Note that the encryptor const_vs_random benches share the old layout and have not been rerun. --- .../2026-10-09-oblivious-prp-results.md | 13 +++-- .../2026-10-09-ore-v2-dynamic-verification.md | 58 ++++++++++++++++--- 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/docs/benchmarks/2026-10-09-oblivious-prp-results.md b/docs/benchmarks/2026-10-09-oblivious-prp-results.md index 90828e74..b322ad72 100644 --- a/docs/benchmarks/2026-10-09-oblivious-prp-results.md +++ b/docs/benchmarks/2026-10-09-oblivious-prp-results.md @@ -193,8 +193,14 @@ The pooled continuous figure is weak evidence for the reference builder it. In the Low Power Mode pass the same test gave −45 at 135 M. The batch runs are the stronger evidence in both directions. -`prp_build_const_vs_random` still shows a small difference (t = +7.7, -tau 0.0005), and it is not explained. An earlier version of this paragraph +`prp_build_const_vs_random` showed a small difference (t = +7.7, +tau 0.0005). It came from the bench, not the builder: the classes' inputs +sat in separate heap allocations, and dudect's percentile cropping, on a +24 MHz timer that gives a one-build sample only a handful of values, +exaggerates the difference that makes. With both classes in one pool, +slots assigned at random, and 16 builds per sample, it is gone (uncropped +t = −0.04, M1 Max); details in the verification doc, §4.2. The rest of +this paragraph is the record of how that was found. An earlier version of this paragraph said the classes differ in where their input comes from, one repeated input against a 256 KB pool. They do not: the `Left` pool is 512 copies of the fixed stream, indexed at random like the `Right` pool of 512 random streams, @@ -215,8 +221,7 @@ on the same M4 (macOS 26.5.2, mains power, Low Power Mode off). | off-2 | off | 251 M | +19.6 | +0.0012 | | on-2 | on | 191 M | +21.8 | +0.0016 | -DIT does not remove or reduce it, so on four runs it is not the prefetcher, -and it stays unexplained. The figures are larger than the +7.7 above, but +DIT does not remove or reduce it, so on four runs it is not the prefetcher. The figures are larger than the +7.7 above, but the commit that measured +7.7 (`6760707`), rerun in the same session, gave +31.4 at 215 M, so the change is in the session, not the code. Fixed A against fixed B stays under 2.5 both ways (five batch runs each). Details: diff --git a/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md index 79f6b937..09ab81c3 100644 --- a/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md +++ b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md @@ -285,8 +285,10 @@ pair has its own sign, so pooling dilutes it (the old builder's +8.6 here, against |t| up to 83 in a single pair). The per-pair runs are the test, and there the dependence is gone. -`prp_build_const_vs_random` keeps a small residual (tau 0.0005 in the table -above, 0.0012–0.0016 in the DIT runs below), and it is not explained. An earlier version of this paragraph put it down to the +`prp_build_const_vs_random` kept a small residual (tau 0.0005 in the table +above, 0.0012–0.0016 in the DIT runs below). It turned out to come from the +bench, not the builder; the account below keeps the steps that got there. +An earlier version of this paragraph put it down to the classes' footprints, one repeated input against a 256 KB pool. That is wrong: the `Left` pool is 512 copies of the fixed stream, indexed at random exactly as the `Right` pool of 512 random streams is, so the two classes @@ -322,6 +324,48 @@ measurement (`6760707`), run in the same session with DIT off, gave +31.4 at 215 M (tau 0.0021). The larger figures therefore come from this session's conditions, not from the code changes since `6760707`. +**Cause: the harness.** Two things in the bench, not the builder, produce +the residual. Measured on an M1 Max, 150 s continuous runs, one build per +sample unless stated. (The M1 has a data memory-dependent prefetcher that +DIT cannot switch off; the controls below change only the bench's layout +and statistics, with the prefetcher the same throughout.) + +| variant | dudect max t | uncropped t | +|---|---:|---:| +| as written: a separate pool per class, `Left`'s allocated first | +54.7; +27.7 | +1.10 (14.7 M per class) | +| allocation order swapped | +40.1 | — | +| one pool, interleaved, fixed stream at even slots | −12.6 | — | +| same, fixed stream at odd slots | +23.5 | — | +| one pool, slots assigned to classes at random each batch | +18.4; +6.4 | +1.34 (15.0 M) | +| the same, 16 builds per sample (the bench as it now is) | −1.8 | **−0.04** (7.3 M samples, 117 M builds, per class) | +| `prp_build_fixed_a_vs_fixed_b`, 16 builds per sample | +2.7 | −0.12 (8.6 M samples) | + +1. *Placement.* The classes' inputs sat in two separate heap allocations, so + they differed in where they were as well as what they held. Moving the + fixed stream from the even to the odd slots of one shared pool flips the + sign (−12.6 to +23.5), and the size of the effect changes from session + to session with the heap layout, which is why the M4 sessions gave +7.7, + +20 and +31 for the same code. +2. *What dudect reports.* `max t` is the largest |t| over 101 tests, 100 of + which first drop every sample above a percentile of the runtime + distribution. Apple's timer is a 24 MHz counter, so a one-build sample + (about 200 ns) takes only a handful of values (208, 250, 291, 333 ns), + and the percentiles fall exactly on them. Where the classes differ in + spread (sd 333 against 501 ns as written), cropping removes more of one + class's tail than the other's, and the cropped tests report a large t + with almost no difference in mean: +27.7 reported, +1.10 uncropped. + +The bench now draws both classes from one pool with the slots assigned at +random each batch, and times 16 builds per sample (about 80 ticks). There +it shows nothing, cropped or uncropped. `scripts/dudect-uncropped.sh` +records every sample and prints the uncropped t beside dudect's figure; +the old bench is kept as `prp_build_const_vs_random_separate_pools`, and +the interleaved controls as `…_interleaved_even` and `…_odd`. The +encryptor `const_vs_random` benches in the first table use the same +separate-pool layout; a call there is microseconds, so the timer is not a +factor, but placement and cropping may be, and they have not been +rerun. + Fixed A against fixed B, where both classes repeat one input, shows nothing either way: five batch runs each, DIT off −0.9, +2.3, −2.0, +1.5, +2.2, DIT on −2.4, −1.6, −1.7, +0.4, +1.6. The first pass of these measurements, in Low @@ -348,8 +392,8 @@ seen through a fixed versus varying permutation sequence. builder's timing has not been measured on real x86 hardware. The harness now builds on aarch64 Linux (the DIT write uses the generic system register encoding), so Graviton needs no changes. -- The `prp_build_const_vs_random` residual on the NEON builder (§4.2) is - open. On the M4 it is the same with DIT on and off (t ≈ +20, tau - 0.0012–0.0016, four 150 s runs), so on that evidence it is not the data - memory-dependent prefetcher. A run on another core would show whether it - is specific to Apple's. +- The `prp_build_const_vs_random` residual (§4.2) came from the bench + (input placement and dudect's cropping on a coarse timer), not the + builder. The corrected bench is clean on the M1 Max; it has not yet been + rerun on the M4. The encryptor `const_vs_random` benches share the old + layout and have not been rerun. From ba9d03c33661a5d8f4dacb85152a86b31f22b69b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 10 Oct 2026 13:13:13 +1100 Subject: [PATCH 28/35] docs(dudect): the corrected const-vs-random bench is clean on the M4 Six 150 s runs of the corrected bench at b5c8233, DIT off and on: uncropped t +1.10, -0.61, -1.23, -0.27, +0.18, +0.47. The controls behave as on the M1 Max: separate pools +30.4 cropped / +0.52 uncropped; interleaved even/odd -47.3/+47.3 cropped, -2.43/+4.14 uncropped, with mirror-image bin shifts; fixed A vs fixed B +0.59. With DIT on only, three runs show a small repeatable tick-bin shift (|z| up to 6.4, mean effect under 1 ns) and cropped t of +4.4 to +9.3; recorded as unexplained. --- .../2026-10-09-oblivious-prp-results.md | 4 +- .../2026-10-09-ore-v2-dynamic-verification.md | 62 ++++++++++++++----- 2 files changed, 48 insertions(+), 18 deletions(-) diff --git a/docs/benchmarks/2026-10-09-oblivious-prp-results.md b/docs/benchmarks/2026-10-09-oblivious-prp-results.md index b322ad72..ffb2403b 100644 --- a/docs/benchmarks/2026-10-09-oblivious-prp-results.md +++ b/docs/benchmarks/2026-10-09-oblivious-prp-results.md @@ -199,7 +199,9 @@ sat in separate heap allocations, and dudect's percentile cropping, on a 24 MHz timer that gives a one-build sample only a handful of values, exaggerates the difference that makes. With both classes in one pool, slots assigned at random, and 16 builds per sample, it is gone (uncropped -t = −0.04, M1 Max); details in the verification doc, §4.2. The rest of +t = −0.04, M1 Max); details in the verification doc, §4.2. On the M4 it is +clean too: uncropped |t| ≤ 1.23 in six 150 s runs, DIT off and on (macOS +26.5.2, mains power, Low Power Mode off). The rest of this paragraph is the record of how that was found. An earlier version of this paragraph said the classes differ in where their input comes from, one repeated input against a 256 KB pool. They do not: the `Left` pool is 512 copies of the diff --git a/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md index 09ab81c3..ce954179 100644 --- a/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md +++ b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md @@ -325,20 +325,22 @@ at 215 M (tau 0.0021). The larger figures therefore come from this session's conditions, not from the code changes since `6760707`. **Cause: the harness.** Two things in the bench, not the builder, produce -the residual. Measured on an M1 Max, 150 s continuous runs, one build per -sample unless stated. (The M1 has a data memory-dependent prefetcher that -DIT cannot switch off; the controls below change only the bench's layout -and statistics, with the prefetcher the same throughout.) - -| variant | dudect max t | uncropped t | -|---|---:|---:| -| as written: a separate pool per class, `Left`'s allocated first | +54.7; +27.7 | +1.10 (14.7 M per class) | -| allocation order swapped | +40.1 | — | -| one pool, interleaved, fixed stream at even slots | −12.6 | — | -| same, fixed stream at odd slots | +23.5 | — | -| one pool, slots assigned to classes at random each batch | +18.4; +6.4 | +1.34 (15.0 M) | -| the same, 16 builds per sample (the bench as it now is) | −1.8 | **−0.04** (7.3 M samples, 117 M builds, per class) | -| `prp_build_fixed_a_vs_fixed_b`, 16 builds per sample | +2.7 | −0.12 (8.6 M samples) | +the residual. Measured first on an M1 Max, then on the M4 at `b5c8233` +(conditions below), 150 s continuous runs, one build per sample unless +stated, DIT off unless stated. (The M1 has a data memory-dependent +prefetcher that DIT cannot switch off; the controls below change only the +bench's layout and statistics, with the prefetcher the same throughout.) + +| variant | M1 Max: dudect max t | M1 Max: uncropped t | M4: dudect max t | M4: uncropped t | +|---|---:|---:|---:|---:| +| as written: a separate pool per class, `Left`'s allocated first | +54.7; +27.7 | +1.10 (14.7 M per class) | +30.4 | +0.52 (22.1 M) | +| allocation order swapped | +40.1 | — | — | — | +| one pool, interleaved, fixed stream at even slots | −12.6 | — | −47.3 | −2.43 (21.8 M) | +| same, fixed stream at odd slots | +23.5 | — | +47.3 | +4.14 (21.9 M) | +| one pool, slots assigned to classes at random each batch | +18.4; +6.4 | +1.34 (15.0 M) | — | — | +| the same, 16 builds per sample (the bench as it now is) | −1.8 | **−0.04** (7.3 M samples, 117 M builds, per class) | −2.3; −2.2; +3.7 | **+1.10; −1.23; +0.18** (9.9–10.3 M) | +| the same, DIT on | — | — | +4.4; +8.1; +9.3 | **−0.61; −0.27; +0.47** (9.4–9.5 M) | +| `prp_build_fixed_a_vs_fixed_b`, 16 builds per sample | +2.7 | −0.12 (8.6 M samples) | +1.9 | +0.59 (11.9 M) | 1. *Placement.* The classes' inputs sat in two separate heap allocations, so they differed in where they were as well as what they held. Moving the @@ -366,6 +368,30 @@ separate-pool layout; a call there is microseconds, so the timer is not a factor, but placement and cropping may be, and they have not been rerun. +**On the M4.** Apple M4, macOS 26.5.2 (25F84), mains power with the +battery charged, Low Power Mode off. Other Claude Code sessions were open, +and a Chrome renderer was at 180% CPU when the first DIT-off run started, so +that run was repeated; the table gives both. The corrected bench is clean +uncropped both ways: |t| ≤ 1.23 in six runs, DIT off and on alternately. +The controls behave as on the M1. Separate pools give +30.4 cropped against ++0.52 uncropped. With the fixed stream at even slots, then at odd, the +cropped figures are −47.3 and +47.3 and the uncropped −2.43 and +4.14, and +the tick-bin shares move in mirror image (|z| ≈ 30): whichever class sits +at the even slots has more samples at 166–167 ns and fewer at +208–209 ns. Placement is a real timing difference, and cropping inflates +it. Fixed A against fixed B shows nothing. + +One thing is left over, and only with DIT on. dudect's cropped figure is ++4.4, +8.1 and +9.3 in the three DIT-on runs, against −2.3, −2.2 and +3.7 +with DIT off. In all three DIT-on runs, `Left` (the fixed stream) has +slightly fewer samples than `Right` at 2875–2917 ns and slightly more at +3000 ns, a share difference of 0.0005–0.001 per bin with |z| up to 6.4. +The DIT-off runs show no such shift (|z| ≤ 3.5, with no consistent sign). +The shift moves the mean by under a nanosecond (Left − Right −0.31, −0.19, ++0.39 ns, against an sd of 800–1900 ns), below what the uncropped test can +resolve, so by the |t| < 5 rule nothing is detected. But it repeated three +times and only with DIT on, and its cause is not known. + Fixed A against fixed B, where both classes repeat one input, shows nothing either way: five batch runs each, DIT off −0.9, +2.3, −2.0, +1.5, +2.2, DIT on −2.4, −1.6, −1.7, +0.4, +1.6. The first pass of these measurements, in Low @@ -394,6 +420,8 @@ seen through a fixed versus varying permutation sequence. register encoding), so Graviton needs no changes. - The `prp_build_const_vs_random` residual (§4.2) came from the bench (input placement and dudect's cropping on a coarse timer), not the - builder. The corrected bench is clean on the M1 Max; it has not yet been - rerun on the M4. The encryptor `const_vs_random` benches share the old - layout and have not been rerun. + builder. The corrected bench is clean on the M1 Max and on the M4 + (uncropped |t| ≤ 1.23 in six runs, DIT off and on). With DIT on, the M4 + shows a small tick-bin shift that repeated in three runs, below what the + uncropped test resolves (§4.2); its cause is not known. The encryptor + `const_vs_random` benches share the old layout and have not been rerun. From 1e9100fe2f9129a714c556350f60ea68999fd0a8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 12 Oct 2026 09:32:18 +1100 Subject: [PATCH 29/35] fix(chained): refuse oversized raw symbols before scanning them encrypt_var and encrypt_left_var checked every symbol's domain before encrypt_blocks enforced the u16 block limit, so oversized input cost a scan of its whole length and could report PrpError instead of TooManyBlocks. Check the length first. --- packages/ore-rs/src/scheme/chained.rs | 31 +++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/ore-rs/src/scheme/chained.rs b/packages/ore-rs/src/scheme/chained.rs index c448f566..ec5278b4 100644 --- a/packages/ore-rs/src/scheme/chained.rs +++ b/packages/ore-rs/src/scheme/chained.rs @@ -237,7 +237,7 @@ impl OreAes128Bit6Chained { /// Full Left+Right ciphertext for `x` (one 6-bit symbol per element). A /// value `>= 64` returns [`OreError::PrpError`]. pub fn encrypt_var(&self, x: &[u8]) -> Result { - stack::scrubbed(|| self.encrypt_blocks(&Blocks6::check(x)?)) + stack::scrubbed(|| self.encrypt_blocks(&check_symbols(x)?)) } /// [`Self::encrypt_var`] over blocks already known to be in the 6-bit @@ -314,7 +314,7 @@ impl OreAes128Bit6Chained { /// Left-only ciphertext (smaller; for query plaintexts). A value `>= 64` /// returns [`OreError::PrpError`]. pub fn encrypt_left_var(&self, x: &[u8]) -> Result { - stack::scrubbed(|| self.encrypt_left_blocks(&Blocks6::check(x)?)) + stack::scrubbed(|| self.encrypt_left_blocks(&check_symbols(x)?)) } /// [`Self::encrypt_left_var`] over blocks already known to be in the @@ -470,6 +470,16 @@ pub const MAX_STR_BYTES: usize = u16::MAX as usize * 6 / 8; const _: () = assert!(num_blocks_6bit(MAX_STR_BYTES) <= u16::MAX as usize); const _: () = assert!(num_blocks_6bit(MAX_STR_BYTES + 1) > u16::MAX as usize); +/// Check raw symbols for [`OreAes128Bit6Chained::encrypt_var`] and +/// [`OreAes128Bit6Chained::encrypt_left_var`], refusing more blocks than the +/// wire format can number *before* scanning them. +fn check_symbols(x: &[u8]) -> Result, OreError> { + if x.len() > u16::MAX as usize { + return Err(OreError::TooManyBlocks); + } + Blocks6::check(x) +} + /// Decompose `s` into 6-bit blocks, refusing a string too long for the wire /// format *before* allocating or decomposing it. The decomposition is the /// whole plaintext (it inverts), so the buffer is wiped when dropped. @@ -528,6 +538,23 @@ mod tests { )); } + /// Raw symbols over the wire limit are refused on their length, before + /// any symbol is checked: out-of-domain symbols give `TooManyBlocks`, + /// not `PrpError`. + #[test] + fn raw_symbols_over_the_wire_limit_are_rejected() { + let c = ore(); + let too_many = vec![64u8; u16::MAX as usize + 1]; + assert!(matches!( + c.encrypt_var(&too_many), + Err(OreError::TooManyBlocks) + )); + assert!(matches!( + c.encrypt_left_var(&too_many), + Err(OreError::TooManyBlocks) + )); + } + #[test] fn comparators_reject_out_of_domain_symbols() { let c = ore(); From 24e55a51ccf24f5511c97bdcb13b40b027fb0fe1 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 12 Oct 2026 09:32:18 +1100 Subject: [PATCH 30/35] ci(test): run the ct_bench tests The ct_bench module and its isolation-equivalence test compile only with the ct-bench feature. The clippy step builds them but plain cargo test never ran them. --- .github/workflows/test.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 65d4617c..07ea1537 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -45,6 +45,11 @@ jobs: - name: test run: cargo test --all-features + # `ct_bench` and its tests compile only with this feature; the clippy + # step above builds them but never runs them. + - name: test (ct-bench) + run: cargo test -p ore-rs --features ct-bench --lib ct_bench + bench: runs-on: ubuntu-latest name: "â�± Benchmark" From 70039d35a2010d3c0b77ae57bdc9967d425b658f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 12 Oct 2026 11:24:55 +1100 Subject: [PATCH 31/35] ct-taint: refuse a vacuous pass; cover more types and the raw entry points The gate had no positive control. taint() discarded mark_memory's result, and valgrind.supp was empty, so a run in which nothing was tainted (a request that returned NoValgrind, crabgrind built without its valgrind feature, a key kept in a register past the mark) passed exactly like a clean one. scripts/ct-taint.sh now sets CT_TAINT_GATE, under which: - a taint request that fails is fatal (exit 3); - each mode reads its output's V bits before untainting it and fails if every byte is defined (exit 4), so every run shows that the taint reached the ciphertext; - a canary mode branches on a tainted byte and indexes a table with it, and the script requires memcheck to fail it. With crabgrind's requests compiled to no-ops, every mode now fails with exit 4 and the canary check fails. The marks also take a mutable pointer. Coverage: the modes ran Bit6 on one u64 and the chained scheme on one string. They now add Bit6 i64, f64 and u32 through OreEncrypt (each runs its own to_orderable_bytes transform on the secret; u32 has 6 blocks, not 11), and the raw-block entry points of both schemes. Those report the one branch in Blocks6::check, which is now the single named suppression, with its reason. Blocks6::check is #[inline(never)] so that the branch has the same frame on every target; x86_64 inlined it into a closure. The legacy encryptor is out of scope, and verify.yml and the verification doc now say so. Clean on arm64 (NEON) and amd64 (SSSE3, emulated), with the canary failing on both. --- .github/workflows/verify.yml | 10 +- .../2026-10-09-ore-v2-dynamic-verification.md | 45 +++- packages/ct-taint/src/main.rs | 211 +++++++++++++++--- packages/ct-taint/valgrind.supp | 26 ++- packages/ore-rs/src/scheme/decompose.rs | 5 + scripts/ct-taint.sh | 25 ++- 6 files changed, 277 insertions(+), 45 deletions(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index c5bfef47..ac1c55db 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -2,10 +2,14 @@ name: "Verify" # Dynamic and formal evidence for the v2 ORE implementation, beyond the unit # and property tests in `test.yml`: # -# * ct-taint: the ctgrind-style Valgrind run (packages/ct-taint). Key and -# plaintext are marked undefined; every secret-dependent branch or +# * ct-taint: the ctgrind-style Valgrind run (packages/ct-taint) over the v2 +# encryptors; the legacy OreAes128 encryptor is out of scope (its PRP +# swaps at secret indices by design, and its wire format is frozen). Key +# and plaintext are marked undefined; every secret-dependent branch or # secret-indexed access memcheck reports fails the job unless it is one of -# the documented exceptions in packages/ct-taint/valgrind.supp. +# the documented exceptions in packages/ct-taint/valgrind.supp. The run +# fails if the taint never reaches a mode's output, and a canary mode +# that branches on a tainted byte must fail. # * fuzz: the libFuzzer targets (packages/ore-rs/fuzz) for the ciphertext # parsers and raw comparators, time-boxed per pull request. # * kani: the bounded model-checking harnesses (`#[cfg(kani)]`) for the pure diff --git a/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md index ce954179..e98189e9 100644 --- a/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md +++ b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md @@ -20,10 +20,38 @@ each reproducible from the repository: ## 1. Valgrind taint — secret-dependent branches and addresses The harness marks the key and the plaintext as undefined memory and runs each -v2 encryptor (Bit6 full and left-only, chained full and left-only). Memcheck -then reports every conditional jump and every memory address that depends on -them. All four modes are clean, and `packages/ct-taint/valgrind.supp` is -empty. Two report classes came up along the way: +v2 encryptor. Memcheck then reports every conditional jump and every memory +address that depends on them. The modes cover: + +- Bit6 through `OreEncrypt`, full and left-only for `u64`, and full for `i64`, + `f64` and `u32`. Each type runs its own `to_orderable_bytes` transform on + the secret (the float's `-0.0` canonicalisation compiles to a select, not a + branch), and `u32` has a different block count (6, not 11). +- Bit6's raw-block entry points, `OreCipher::encrypt` and `encrypt_left`. +- The chained scheme, full and left-only, through `encrypt_str` and through + the raw-symbol `encrypt_var` and `encrypt_left_var`. + +The legacy `OreAes128` encryptor is out of scope. Its Knuth-shuffle PRP +rejection-samples and swaps at secret indices by design, and its wire format +is frozen. + +The typed modes are clean. The raw-block modes report one branch, in +`Blocks6::check`, which is the one entry in `packages/ct-taint/valgrind.supp`. +It ORs the symbols' high bits together and branches once on the result, so the +branch reveals only whether some symbol was out of the domain, and it goes the +same way for every well-formed input. + +**The gate cannot pass vacuously.** `scripts/ct-taint.sh` sets +`CT_TAINT_GATE`, under which every taint request must succeed (exit 3 +otherwise), and each mode checks, before it untaints its output, that some of +the output is still undefined (exit 4 otherwise). That proves on every run +that the taint flowed from the key and the plaintext into the ciphertext. A +`canary` mode branches on a tainted byte and indexes a table with it; the +script requires memcheck to fail it. With crabgrind built without its +`valgrind` feature, so that every request is a no-op, the gate fails each mode +with exit 4 and the canary check fails as well. + +Two report classes came up along the way: 1. **`oblivious_lookup`'s range check** (`index >= table.len()`) and its callers' test of the returned `Result`. A comparison on the plaintext @@ -36,15 +64,16 @@ empty. Two report classes came up along the way: branch. The encryptors take their symbols from the 6-bit decomposition, which masks rather than checks. Callers that pass raw symbols (`OreCipher::encrypt` for Bit6, chained `encrypt_var`) are checked once at - entry by `Blocks6::check`, still returning `OreError::PrpError`; no - harness mode takes that path. + entry by `Blocks6::check`, still returning `OreError::PrpError`; that is the + suppressed branch above. 2. (Harness only, fixed.) `str::from_utf8`'s validation of the tainted string; the harness now builds the `&str` unchecked. Nothing is reported from the 6-bit decomposition, the CMAC accumulator, the σ-MMO hash, the SIMD indicator kernels, `ct_select_byte`, or AES. The gate -is live: the commit before the `Symbol` change, run with the same empty -suppression file, fails on exactly the range check. +was first shown live by hand (the commit before the `Symbol` change fails on +exactly the range check); the canary and the output check above now show it +on every run. A third class, the Fisher–Yates swap at a keystream-derived index in `LemireFyPrp::from_stream` (a pointer-sized use of an undefined value), was diff --git a/packages/ct-taint/src/main.rs b/packages/ct-taint/src/main.rs index 1475cfdb..0dd3665a 100644 --- a/packages/ct-taint/src/main.rs +++ b/packages/ct-taint/src/main.rs @@ -14,29 +14,96 @@ //! question (dudect), not a taint one. //! //! Outside Valgrind the marks are no-ops and the modes just run. +//! +//! **Gate mode** (`CT_TAINT_GATE=1`, set by `scripts/ct-taint.sh`) refuses a +//! vacuous pass. Every taint request must succeed, and each mode checks, +//! before untainting, that its output is still partly undefined: the taint +//! reached the ciphertext, so a clean run means the path really is free of +//! secret-dependent branches and addresses. The `canary` mode branches on a +//! tainted byte and indexes memory with it, and the script requires memcheck +//! to fail it. +//! +//! Scope: the v2 encryptors only. The legacy `OreAes128` encryptor is not +//! run: its Knuth-shuffle PRP rejection-samples and swaps at secret indices +//! by design, and its wire format is frozen (v2 plan, Non-goals). use std::env; use std::process::exit; -use crabgrind::memcheck::{mark_memory, MemState}; +use crabgrind::memcheck::{mark_memory, vbits, MemState}; use hex_literal::hex; use ore_rs::scheme::bit2_w6::OreAes128Bit6ChaCha20; use ore_rs::scheme::chained::{OreAes128Bit6Chained, OreAes128Bit6ChainedChaCha20}; use ore_rs::{OreCipher, OreEncrypt, OreOutput}; +/// Whether this run is the gate (`CT_TAINT_GATE` set), which must run under +/// memcheck and must not pass vacuously. +fn gate() -> bool { + env::var_os("CT_TAINT_GATE").is_some() +} + /// Mark `bytes` undefined: from here on, anything computed from them is /// tracked as tainted by memcheck. fn taint(bytes: &mut [u8]) { - // `Err(NoValgrind)` means the binary is not running under Valgrind; the - // run is then a plain exercise of the path, which is still useful. - let _ = mark_memory(bytes.as_ptr() as *const _, bytes.len(), MemState::Undefined); + let marked = mark_memory( + bytes.as_mut_ptr() as *const _, + bytes.len(), + MemState::Undefined, + ); + // Outside Valgrind the request fails and the run is a plain exercise of + // the path. In the gate that would make every mode pass while checking + // nothing, so it is fatal there. + if gate() && marked.is_err() { + eprintln!("ct-taint: mark_memory failed; the gate must run under memcheck"); + exit(3); + } +} + +/// Fail the gate if every byte of `out` is defined. The ciphertext is +/// computed from the tainted key and plaintext, so some of it must be +/// undefined; if none is, the taint was lost (a request that did nothing, or +/// a key the optimiser kept in a register past the mark) and a clean run +/// proves nothing. +fn assert_tainted(out: &[u8]) { + if !gate() { + return; + } + // GET_VBITS wants 4-byte-aligned buffers whose length is a multiple of 4. + let words = out.len() / 4; + let mut src = vec![0u32; words]; + let mut bits = vec![0u32; words]; + // SAFETY: both buffers hold `words` u32s; the byte views cover them exactly. + let (src_bytes, bits_bytes) = unsafe { + ( + std::slice::from_raw_parts_mut(src.as_mut_ptr() as *mut u8, words * 4), + std::slice::from_raw_parts_mut(bits.as_mut_ptr() as *mut u8, words * 4), + ) + }; + // The copy carries the V bits with it. + src_bytes.copy_from_slice(&out[..words * 4]); + if let Err(e) = vbits(src_bytes.as_ptr() as *const _, bits_bytes) { + eprintln!("ct-taint: reading V bits failed ({e:?}); the gate must run under memcheck"); + exit(3); + } + if bits_bytes.iter().all(|&b| b == 0) { + eprintln!("ct-taint: the output is fully defined, so the taint never reached it"); + exit(4); + } + // Let the copy be dropped without memcheck tracking it as tainted. + untaint(src_bytes); } /// Mark `bytes` defined again, so that using the *output* of an operation -/// (printing it, hashing it) is not itself reported. +/// (printing it, hashing it) is not itself reported. Checks first, in the +/// gate, that the taint reached it. +fn untaint_output(bytes: &mut [u8]) { + assert_tainted(bytes); + untaint(bytes); +} + fn untaint(bytes: &mut [u8]) { let _ = mark_memory( - bytes.as_ptr() as *const _, + bytes.as_mut_ptr() as *const _, bytes.len(), MemState::DefinedIfAddressable, ); @@ -57,29 +124,76 @@ fn keys() -> ([u8; 16], [u8; 16]) { ) } -fn bit6_full() -> Vec { +/// `x` with its bytes marked undefined. +fn tainted(mut x: T) -> T { + // SAFETY: `T` is plain `Copy` data; this views its bytes to mark them. + let bytes = unsafe { + std::slice::from_raw_parts_mut(&mut x as *mut T as *mut u8, std::mem::size_of::()) + }; + taint(bytes); + x +} + +fn bit6() -> OreAes128Bit6ChaCha20 { let (mut k1, mut k2) = keys(); taint(&mut k1); taint(&mut k2); - let ore: OreAes128Bit6ChaCha20 = OreCipher::init(&k1, &k2).expect("init"); - let mut x = 0x1234_5678_9abc_def0u64.to_le_bytes(); - taint(&mut x); - let x = u64::from_le_bytes(x); - let mut out = x.encrypt(&ore).expect("encrypt").to_bytes(); - untaint(&mut out); + OreCipher::init(&k1, &k2).expect("init") +} + +/// Full Bit6 encryption of a tainted `x` through `OreEncrypt`, which runs the +/// type's own `to_orderable_bytes` transform on the secret. +fn bit6_typed>(x: T) -> Vec { + let ore = bit6(); + let mut out = tainted(x).encrypt(&ore).expect("encrypt").to_bytes(); + untaint_output(&mut out); out } +fn bit6_full() -> Vec { + bit6_typed(0x1234_5678_9abc_def0u64) +} + fn bit6_left() -> Vec { - let (mut k1, mut k2) = keys(); - taint(&mut k1); - taint(&mut k2); - let ore: OreAes128Bit6ChaCha20 = OreCipher::init(&k1, &k2).expect("init"); - let mut x = 0x1234_5678_9abc_def0u64.to_le_bytes(); - taint(&mut x); - let x = u64::from_le_bytes(x); + let ore = bit6(); + let x = tainted(0x1234_5678_9abc_def0u64); let mut out = x.encrypt_left(&ore).expect("encrypt_left").to_bytes(); - untaint(&mut out); + untaint_output(&mut out); + out +} + +fn bit6_i64() -> Vec { + bit6_typed(-0x1234_5678_9abc_def0i64) +} + +fn bit6_f64() -> Vec { + bit6_typed(-1234.5678f64) +} + +/// `u32` is 6 blocks, a different `N` from the 64-bit types' 11. +fn bit6_u32() -> Vec { + bit6_typed(0x89ab_cdefu32) +} + +/// The 11 six-bit blocks of a u64, as a caller of the raw-block entry points +/// would pass them. +const RAW_BLOCKS: [u8; 11] = [4, 35, 5, 22, 15, 9, 43, 60, 55, 59, 48]; + +fn bit6_raw() -> Vec { + let ore = bit6(); + let x = tainted(RAW_BLOCKS); + let mut out = OreCipher::encrypt(&ore, &x).expect("encrypt").to_bytes(); + untaint_output(&mut out); + out +} + +fn bit6_raw_left() -> Vec { + let ore = bit6(); + let x = tainted(RAW_BLOCKS); + let mut out = OreCipher::encrypt_left(&ore, &x) + .expect("encrypt_left") + .to_bytes(); + untaint_output(&mut out); out } @@ -93,27 +207,62 @@ fn tainted_str(buf: &mut [u8; 17]) -> &str { unsafe { std::str::from_utf8_unchecked(buf) } } -fn chained_full() -> Vec { +fn chained() -> OreAes128Bit6ChainedChaCha20 { let (mut k1, _) = keys(); taint(&mut k1); - let ore: OreAes128Bit6ChainedChaCha20 = OreAes128Bit6Chained::init(&k1).expect("init"); + OreAes128Bit6Chained::init(&k1).expect("init") +} + +fn chained_full() -> Vec { + let ore = chained(); let mut buf = [0u8; 17]; let s = tainted_str(&mut buf); let mut out = ore.encrypt_str(s).expect("encrypt_str").to_bytes(); - untaint(&mut out); + untaint_output(&mut out); out } fn chained_left() -> Vec { - let (mut k1, _) = keys(); - taint(&mut k1); - let ore: OreAes128Bit6ChainedChaCha20 = OreAes128Bit6Chained::init(&k1).expect("init"); + let ore = chained(); let mut buf = [0u8; 17]; let s = tainted_str(&mut buf); let mut out = ore .encrypt_left_str(s) .expect("encrypt_left_str") .to_bytes(); + untaint_output(&mut out); + out +} + +fn chained_raw() -> Vec { + let ore = chained(); + let x = tainted(RAW_BLOCKS); + let mut out = ore.encrypt_var(&x).expect("encrypt_var").to_bytes(); + untaint_output(&mut out); + out +} + +fn chained_raw_left() -> Vec { + let ore = chained(); + let x = tainted(RAW_BLOCKS); + let mut out = ore + .encrypt_left_var(&x) + .expect("encrypt_left_var") + .to_bytes(); + untaint_output(&mut out); + out +} + +/// Positive control: branches on a tainted byte and indexes memory with it. +/// Memcheck must report both, so `scripts/ct-taint.sh` requires this mode to +/// fail. If it passes, the gate cannot see a secret-dependent branch. +fn canary() -> Vec { + let secret = tainted(std::hint::black_box(0x5au8)); + let table = std::hint::black_box([0u8; 256]); + let mut out = vec![table[usize::from(secret)]]; + if secret & 1 == 0 { + out.push(std::hint::black_box(1)); + } untaint(&mut out); out } @@ -121,8 +270,16 @@ fn chained_left() -> Vec { const MODES: &[(&str, fn() -> Vec)] = &[ ("bit6-encrypt", bit6_full), ("bit6-encrypt-left", bit6_left), + ("bit6-encrypt-i64", bit6_i64), + ("bit6-encrypt-f64", bit6_f64), + ("bit6-encrypt-u32", bit6_u32), + ("bit6-encrypt-raw", bit6_raw), + ("bit6-encrypt-left-raw", bit6_raw_left), ("chained-encrypt", chained_full), ("chained-encrypt-left", chained_left), + ("chained-encrypt-raw", chained_raw), + ("chained-encrypt-left-raw", chained_raw_left), + ("canary", canary), ]; fn main() { diff --git a/packages/ct-taint/valgrind.supp b/packages/ct-taint/valgrind.supp index 8be00cdc..493378d1 100644 --- a/packages/ct-taint/valgrind.supp +++ b/packages/ct-taint/valgrind.supp @@ -3,9 +3,23 @@ # scripts/ct-taint.sh fails on anything not listed here. Add an entry only # with a reason. Frame names are demangled Rust symbols, matched as globs. # -# There are none. The PRP's range check on the plaintext symbol, the last -# accepted report, is gone: `permute` takes a typed in-domain `Symbol`, and -# the encryptors get their symbols from the 6-bit decomposition, which masks -# rather than checks. (`Blocks6::check`, the validation for callers that pass -# raw symbols to `OreCipher::encrypt` or `encrypt_var`, is not on any path -# the harness runs.) +# The typed entry points report nothing: `permute` takes an in-domain +# `Symbol`, and `OreEncrypt` and the string encryptors get their symbols from +# the 6-bit decomposition, which masks rather than checks. +# +# The one accepted report is `Blocks6::check`, on the raw-block entry points +# (`OreCipher::encrypt`/`encrypt_left` for Bit6, `encrypt_var`/ +# `encrypt_left_var` for the chained scheme). A caller of those passes symbols +# that may be out of the 6-bit domain, and the function must refuse them. It +# ORs every symbol's high bits together without branching and branches once +# on the result, so the branch reveals only whether some symbol was out of +# domain. For a well-formed input it always goes the same way. `check` is +# `#[inline(never)]`, so the branch is always in its own frame, whose name is +# `ore_rs::scheme::decompose::Blocks6::check` (or its v0-mangled form, which +# also contains `Blocks6` and `check`). + +{ + blocks6-check: one branch on whether any raw symbol is out of domain + Memcheck:Cond + fun:*Blocks6*check* +} diff --git a/packages/ore-rs/src/scheme/decompose.rs b/packages/ore-rs/src/scheme/decompose.rs index fe7a8703..c99896b5 100644 --- a/packages/ore-rs/src/scheme/decompose.rs +++ b/packages/ore-rs/src/scheme/decompose.rs @@ -63,6 +63,11 @@ impl> Blocks6 { /// produced). The values are folded together branch-free and the one /// branch is on the folded result, so for well-formed input the branch /// always goes the same way. + /// + /// Kept out of line so that this branch, the one the taint harness + /// accepts, has a frame of its own whatever the caller inlines + /// (`packages/ct-taint/valgrind.supp`). + #[inline(never)] pub(crate) fn check(blocks: B) -> Result { let high = blocks.as_ref().iter().fold(0u8, |acc, &b| acc | (b >> 6)); if high != 0 { diff --git a/scripts/ct-taint.sh b/scripts/ct-taint.sh index 14f14153..d2579527 100755 --- a/scripts/ct-taint.sh +++ b/scripts/ct-taint.sh @@ -12,14 +12,37 @@ # memcheck report is a secret-dependent branch ("Cond") or a secret-indexed # address ("Value*"). The reports that are accepted, and why, are the entries # of packages/ct-taint/valgrind.supp. +# +# The gate refuses to pass vacuously. CT_TAINT_GATE makes every mode fail if a +# taint request does nothing or if its output comes out fully defined (the +# taint never reached it), and the `canary` mode, which branches on a tainted +# byte, must fail. set -euo pipefail cd "$(dirname "$0")/../packages/ct-taint" cargo build --release --quiet bin="${CARGO_TARGET_DIR:-target}/release/ct-taint" -modes=(bit6-encrypt bit6-encrypt-left chained-encrypt chained-encrypt-left) +modes=( + bit6-encrypt bit6-encrypt-left bit6-encrypt-i64 bit6-encrypt-f64 bit6-encrypt-u32 + bit6-encrypt-raw bit6-encrypt-left-raw + chained-encrypt chained-encrypt-left chained-encrypt-raw chained-encrypt-left-raw +) status=0 +export CT_TAINT_GATE=1 + +if [ "${1:-}" != "--gen" ]; then + echo "== canary (must fail)" + canary=0 + valgrind -q --tool=memcheck --leak-check=no --error-exitcode=99 \ + --suppressions=valgrind.supp "$bin" canary >/dev/null 2>&1 || canary=$? + if [ "$canary" -eq 99 ]; then + echo " ok: memcheck reported the tainted branch" + else + echo " FAIL: canary exited $canary, not 99; the gate cannot see a tainted branch" + status=1 + fi +fi for mode in "${modes[@]}"; do echo "== $mode" From 9fc1a08e3e0700b14cd7b9332037952b12d31522 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 12 Oct 2026 11:25:02 +1100 Subject: [PATCH 32/35] ci(test): run the full test suite on aarch64 too test.yml ran only on x86_64, and the one aarch64 job in verify.yml ran only primitives::prp. So CI never ran hardware AES (--cfg aes_armv8), the NEON lsb_mask_256 and gt_mask_xor_256 kernels, or the compat vector tests on Arm. On x86_64, lsb_mask_dispatched_matches_scalar compares scalar with scalar. A change that flipped a hash bit in a NEON kernel would have left every job green while Arm hosts wrote right ciphertexts that do not compare with x86_64 ones. The test job now runs on ubuntu-latest and ubuntu-24.04-arm, with the cache keyed by architecture. verify.yml's prp-builders-aarch64 job is covered by it and removed. --- .github/workflows/test.yml | 17 ++++++++++++++--- .github/workflows/verify.yml | 17 ----------------- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 07ea1537..25dff0d2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,8 +15,19 @@ defaults: jobs: test: - runs-on: ubuntu-latest - name: "🧪 Test" + # Both architectures: aarch64 runs hardware AES (`--cfg aes_armv8`), the + # NEON kernels and the NEON PRP builder, which x86_64 never reaches, against + # the same pinned vectors. + runs-on: ${{ matrix.os }} + name: "🧪 Test (${{ matrix.arch }})" + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + arch: x86_64 + - os: ubuntu-24.04-arm + arch: aarch64 steps: - uses: actions/checkout@v4 @@ -29,7 +40,7 @@ jobs: ~/.cargo/registry/cache/ ~/.cargo/git/db/ target/ - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }} - name: setup rust 1.78 uses: dtolnay/rust-toolchain@1.78.0 diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index ac1c55db..d816e4ad 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -106,23 +106,6 @@ jobs: path: packages/ore-rs/fuzz/artifacts/ if-no-files-found: ignore - # The PRP builder that ships on Arm is NEON, which Kani cannot model, so - # its evidence is the equivalence tests against the reference builder - # (every step with every swap index, plus random and edge streams). test.yml - # runs on x86_64 only, where those tests exercise SSSE3; this runs them - # where the dispatcher picks NEON. - prp-builders-aarch64: - runs-on: ubuntu-24.04-arm - name: "🧮 PRP builders (aarch64)" - steps: - - uses: actions/checkout@v4 - - - name: setup rust - uses: dtolnay/rust-toolchain@stable - - - name: builder equivalence tests - run: cargo test -p ore-rs --lib primitives::prp - kani: runs-on: ubuntu-latest name: "✅ Kani" From 95697199e2af5d243c97762e15f6b3a96f6a264b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 12 Oct 2026 11:48:00 +1100 Subject: [PATCH 33/35] fuzz: check that the raw comparator and the typed Ord agree Each scheme has two comparators, compare_raw_slices on bytes and Ord on a parsed CipherText, and this stack rewrote both. The fuzz targets only checked that the raw one does not panic. If the two disagree, a database index and an application order the same rows differently. bit8_parse and bit6_parse now assert, whenever both inputs parse as full ciphertexts, that compare_raw_slices(a, b) == Some(a.cmp(&b)), at both of each scheme's widths. Random pairs almost never both parse, so each run also checks a against a copy of itself with b written over everything after the header, both ways round. Antisymmetry is not asserted: it holds only for genuine ciphertexts. Reversing the typed comparison in the assertion makes both targets fail within 60 seconds; the real targets ran clean for 13.8M (bit8) and 9.8M (bit6) iterations. --- .../ore-rs/fuzz/fuzz_targets/bit6_parse.rs | 36 ++++++++++++++++++- .../ore-rs/fuzz/fuzz_targets/bit8_parse.rs | 36 ++++++++++++++++++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/packages/ore-rs/fuzz/fuzz_targets/bit6_parse.rs b/packages/ore-rs/fuzz/fuzz_targets/bit6_parse.rs index 1ee67b0b..641c842c 100644 --- a/packages/ore-rs/fuzz/fuzz_targets/bit6_parse.rs +++ b/packages/ore-rs/fuzz/fuzz_targets/bit6_parse.rs @@ -8,7 +8,7 @@ use ore_rs::{CipherText, Left, OreCipher, OreOutput, Right}; // values, 6 for 32-bit). Parsing untrusted bytes as a full, left-only or // right-only ciphertext must never panic, anything accepted must re-encode // to itself, and the raw comparator must answer arbitrary pairs without -// panicking. +// panicking and agree with the typed `Ord` on pairs that parse. fn check(data: &[u8]) { if let Ok(ct) = CipherText::::from_slice(data) { assert_eq!( @@ -33,10 +33,44 @@ fn check(data: &[u8]) { } } +/// When both inputs parse as full ciphertexts, the raw comparator and the +/// typed `Ord` must give the same order: a database index (raw bytes) and an +/// application (parsed values) must not order the same rows differently. +/// Antisymmetry is not asserted; it holds only for genuine ciphertexts. +fn agree(a: &[u8], b: &[u8]) { + let (Ok(ct_a), Ok(ct_b)) = ( + CipherText::::from_slice(a), + CipherText::::from_slice(b), + ) else { + return; + }; + assert_eq!( + OreAes128Bit6ChaCha20::compare_raw_slices(a, b), + Some(ct_a.cmp(&ct_b)), + "raw and typed comparators disagree" + ); +} + +/// `a` with its first `header` bytes kept and `b` written over the rest. +/// Random pairs almost never both parse; this pair has `a`'s length and +/// header, so when `a` parses it usually does too. +fn overlay(a: &[u8], b: &[u8], header: usize) -> Vec { + let mut out = a.to_vec(); + for (o, &x) in out.iter_mut().skip(header).zip(b) { + *o = x; + } + out +} + fuzz_target!(|pair: (&[u8], &[u8])| { let (a, b) = pair; check::<11>(a); check::<6>(a); + let b2 = overlay(a, b, 4); + for (x, y) in [(a, b), (a, &b2[..]), (&b2[..], a)] { + agree::<11>(x, y); + agree::<6>(x, y); + } let _ = OreAes128Bit6ChaCha20::compare_raw_slices(a, b); let _ = OreAes128Bit6ChaCha20::compare_raw_slices(b, a); }); diff --git a/packages/ore-rs/fuzz/fuzz_targets/bit8_parse.rs b/packages/ore-rs/fuzz/fuzz_targets/bit8_parse.rs index e2f10095..d18601ff 100644 --- a/packages/ore-rs/fuzz/fuzz_targets/bit8_parse.rs +++ b/packages/ore-rs/fuzz/fuzz_targets/bit8_parse.rs @@ -8,7 +8,7 @@ use ore_rs::{CipherText, Left, OreCipher, OreOutput, Right}; // already sit in customer databases. Same contract as the v2 targets, for // the full, left-only and right-only parsers: // never panic on untrusted bytes, accepted bytes round-trip, the raw -// comparator answers any pair. +// comparator answers any pair and agrees with the typed `Ord`. fn check(data: &[u8]) { if let Ok(ct) = CipherText::::from_slice(data) { assert_eq!( @@ -33,10 +33,44 @@ fn check(data: &[u8]) { } } +/// When both inputs parse as full ciphertexts, the raw comparator and the +/// typed `Ord` must give the same order: a database index (raw bytes) and an +/// application (parsed values) must not order the same rows differently. +/// Antisymmetry is not asserted; it holds only for genuine ciphertexts. +fn agree(a: &[u8], b: &[u8]) { + let (Ok(ct_a), Ok(ct_b)) = ( + CipherText::::from_slice(a), + CipherText::::from_slice(b), + ) else { + return; + }; + assert_eq!( + OreAes128ChaCha20::compare_raw_slices(a, b), + Some(ct_a.cmp(&ct_b)), + "raw and typed comparators disagree" + ); +} + +/// `a` with its first `header` bytes kept and `b` written over the rest. +/// Random pairs almost never both parse; this pair has `a`'s length and +/// header, so when `a` parses it usually does too. +fn overlay(a: &[u8], b: &[u8], header: usize) -> Vec { + let mut out = a.to_vec(); + for (o, &x) in out.iter_mut().skip(header).zip(b) { + *o = x; + } + out +} + fuzz_target!(|pair: (&[u8], &[u8])| { let (a, b) = pair; check::<8>(a); check::<4>(a); + let b2 = overlay(a, b, 0); + for (x, y) in [(a, b), (a, &b2[..]), (&b2[..], a)] { + agree::<8>(x, y); + agree::<4>(x, y); + } let _ = OreAes128ChaCha20::compare_raw_slices(a, b); let _ = OreAes128ChaCha20::compare_raw_slices(b, a); }); From 09d50ad5c3139fff8cf0ca95f05bf82f5e50479d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 12 Oct 2026 12:03:38 +1100 Subject: [PATCH 34/35] chore: update the detached lockfiles to orderable-bytes 0.2 ct-taint and the fuzz crate keep their own lockfiles. Rebasing onto main moved the workspace's orderable-bytes from 0.1.1 to 0.2.0. --- packages/ct-taint/Cargo.lock | 4 ++-- packages/ore-rs/fuzz/Cargo.lock | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ct-taint/Cargo.lock b/packages/ct-taint/Cargo.lock index e0bcd30f..29677e4d 100644 --- a/packages/ct-taint/Cargo.lock +++ b/packages/ct-taint/Cargo.lock @@ -324,11 +324,11 @@ dependencies = [ [[package]] name = "orderable-bytes" -version = "0.1.1" +version = "0.2.0" [[package]] name = "ore-rs" -version = "0.8.3" +version = "0.8.4" dependencies = [ "aes", "block-modes", diff --git a/packages/ore-rs/fuzz/Cargo.lock b/packages/ore-rs/fuzz/Cargo.lock index 71157491..0ce1a0b1 100644 --- a/packages/ore-rs/fuzz/Cargo.lock +++ b/packages/ore-rs/fuzz/Cargo.lock @@ -238,11 +238,11 @@ dependencies = [ [[package]] name = "orderable-bytes" -version = "0.1.1" +version = "0.2.0" [[package]] name = "ore-rs" -version = "0.8.3" +version = "0.8.4" dependencies = [ "aes", "block-modes", From 2ca0ecc0e53f9180f8bfbafe594cf98bc9a9df10 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 12 Oct 2026 12:06:04 +1100 Subject: [PATCH 35/35] docs(dudect): name the measured commit by subject, not SHA The verification doc named the commit the M4 runs were made at by SHA, and every rebase of the stack rewrites it. Name it by subject instead. --- docs/reviews/2026-10-09-ore-v2-dynamic-verification.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md index e98189e9..4b77c600 100644 --- a/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md +++ b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md @@ -354,7 +354,7 @@ at 215 M (tau 0.0021). The larger figures therefore come from this session's conditions, not from the code changes since `6760707`. **Cause: the harness.** Two things in the bench, not the builder, produce -the residual. Measured first on an M1 Max, then on the M4 at `b5c8233` +the residual. Measured first on an M1 Max, then on the M4 at the commit "docs(dudect): the const-vs-random residual was the harness" (conditions below), 150 s continuous runs, one build per sample unless stated, DIT off unless stated. (The M1 has a data memory-dependent prefetcher that DIT cannot switch off; the controls below change only the