From 42129c1168ac91e77549b2db2b32bd92f8f761c1 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 13 Jun 2026 00:55:58 +1000 Subject: [PATCH 1/3] refactor: width abstraction, seed/tag separation, template RO keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the ORE v2 core abstractions (plan §1) with byte-identical output, verified by the PR 1 compatibility vectors: - BlockWidth trait (Bit8 today; Bit6 arrives with its scheme) carrying the domain-sized types stable Rust can't express const-generically. - 6-bit MSB-first block decomposition with order-preservation property tests, ahead of the scheme that consumes it. - PRP seeds (PRF2 outputs, key-equivalent material) now live in a dedicated zeroize-on-drop SeedBuf, never in the serialisable Left; tag inputs are built directly into Left.f. - RO keys maintained as an incrementally-updated template plus a work buffer for the in-place PRF/hash passes (3 byte-writes per entry per block instead of a full rebuild), both zeroized after use. - Indicator encoding extracted behind encode_right_block (naive form retained; bulk form lands in the next PR). Drops the now-unused lazy_static dependency. Part of the ORE v2 program (docs/plans/2026-06-12-ore-v2-architecture.md, PR 2). --- Cargo.lock | 7 - packages/ore-rs/Cargo.toml | 1 - packages/ore-rs/src/scheme.rs | 3 + packages/ore-rs/src/scheme/bit2.rs | 206 ++++++++++++++---------- packages/ore-rs/src/scheme/decompose.rs | 112 +++++++++++++ packages/ore-rs/src/scheme/width.rs | 96 +++++++++++ 6 files changed, 331 insertions(+), 94 deletions(-) create mode 100644 packages/ore-rs/src/scheme/decompose.rs create mode 100644 packages/ore-rs/src/scheme/width.rs diff --git a/Cargo.lock b/Cargo.lock index bf77b9dc..607b1d90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -342,12 +342,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "lazy_static" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" - [[package]] name = "libc" version = "0.2.155" @@ -472,7 +466,6 @@ dependencies = [ "criterion", "hex", "hex-literal", - "lazy_static", "num", "orderable-bytes", "quickcheck", diff --git a/packages/ore-rs/Cargo.toml b/packages/ore-rs/Cargo.toml index 4adfe178..d865b63b 100644 --- a/packages/ore-rs/Cargo.toml +++ b/packages/ore-rs/Cargo.toml @@ -45,7 +45,6 @@ num = "0.4.0" hex = "0.4.3" subtle-ng = "2.5.0" zeroize = { version = "1.5.7", features = ["zeroize_derive"] } -lazy_static = "1.4.0" thiserror = "1.0.38" [[bench]] diff --git a/packages/ore-rs/src/scheme.rs b/packages/ore-rs/src/scheme.rs index 1a83cb38..33198b49 100644 --- a/packages/ore-rs/src/scheme.rs +++ b/packages/ore-rs/src/scheme.rs @@ -6,3 +6,6 @@ /// 2-bit-indicator BlockORE with AES-128 PRF and Knuth-shuffle PRP. pub mod bit2; + +pub(crate) mod decompose; +pub(crate) mod width; diff --git a/packages/ore-rs/src/scheme/bit2.rs b/packages/ore-rs/src/scheme/bit2.rs index 96631016..36c8594a 100644 --- a/packages/ore-rs/src/scheme/bit2.rs +++ b/packages/ore-rs/src/scheme/bit2.rs @@ -7,20 +7,19 @@ use crate::{ ciphertext::*, primitives::{ - hash::Aes128Z2Hash, prf::Aes128Prf, prp::KnuthShufflePRP, AesBlock, Hash, HashKey, Prf, - Prp, NONCE_SIZE, + hash::Aes128Z2Hash, prf::Aes128Prf, AesBlock, Hash, HashKey, Prf, Prp, NONCE_SIZE, }, + scheme::width::{AesBlockBuf, Bit8, BlockWidth, RightBitVec}, OreCipher, OreError, PlainText, }; use aes::cipher::generic_array::GenericArray; -use lazy_static::lazy_static; use rand::{Rng, SeedableRng}; use rand_chacha::ChaCha20Rng; use std::cell::RefCell; use std::cmp::Ordering; use subtle_ng::{Choice, ConditionallySelectable, ConstantTimeEq}; -use zeroize::ZeroizeOnDrop; +use zeroize::{Zeroize, ZeroizeOnDrop}; /// Per-block ciphertext component types ([`LeftBlock16`], [`RightBlock32`]) /// used by this scheme. @@ -51,6 +50,79 @@ fn cmp(a: u8, b: u8) -> u8 { u8::from(a > b) } +/// Derive the per-block PRP seeds for `x` under `prf2`: seed `n` is +/// `PRF₂(x[0..n] ‖ 0…)`. The seeds are **key-equivalent material** — anyone +/// holding seed `n` can rebuild that block's permutation and invert `xt[n]` +/// — so they live in their own buffer, never in the (serialisable) `Left`, +/// and the caller must consume them via [`SeedBuf`]'s zeroize-on-drop. +struct SeedBuf([AesBlock; N]); + +impl Drop for SeedBuf { + fn drop(&mut self) { + for seed in self.0.iter_mut() { + seed.zeroize(); + } + } +} + +fn derive_prp_seeds(prf2: &Aes128Prf, x: &PlainText) -> SeedBuf { + let mut seeds = [AesBlock::default(); N]; + for (n, block) in seeds.iter_mut().enumerate() { + block[0..n].clone_from_slice(&x[0..n]); + // TODO (tracked in v2 plan, fixed for new schemes by the §5(b) + // accumulator): the block index is not bound here, so a run of + // identical prefix bytes yields identical seeds. + } + prf2.encrypt_all(&mut seeds); + SeedBuf(seeds) +} + +/// Build the right-ciphertext bitvector for one block: for every candidate +/// value `j`, bit `j` is `(π⁻¹(j) > x) ⊕ h[j]`. +/// +/// This is the naive per-bit form, retained in this refactor for risk +/// staging; the permutation-direct bulk form replaces it in the next PR +/// (v2 plan §2) without changing the output bytes. +fn encode_right_block( + block: &mut W::RightBlock, + prp: &W::Prp, + x: u8, + hashes: &[u8], +) -> Result<(), OreError> { + debug_assert_eq!(hashes.len(), W::DOMAIN); + for (j, h) in hashes.iter().enumerate() { + let jstar = prp.invert(j as u8)?; + let indicator = cmp(jstar, x); + block.set_bit(j, indicator ^ h); + } + Ok(()) +} + +impl OreAes128 { + /// Fill `left` with the PRF₁ tag inputs and permuted values for `x`, + /// using the given PRP seeds. After this returns, `left.f[n]` holds the + /// *unencrypted* tag input `(x[0..n] ‖ xt[n] ‖ n)`; the caller runs the + /// final PRF₁ pass once any other use of the inputs (the RO key + /// template) is done. + fn build_left( + &self, + left: &mut Left, + seeds: &SeedBuf, + x: &PlainText, + ) -> Result<(), OreError> { + for n in 0..N { + let prp: ::Prp = Prp::new(&seeds.0[n])?; + left.xt[n] = prp.permute(x[n])?; + + left.f[n][0..n].clone_from_slice(&x[0..n]); + left.f[n][n] = left.xt[n]; + // Include the block number in the value passed to the Random Oracle + left.f[n][N] = n as u8; + } + Ok(()) + } +} + impl OreCipher for OreAes128 { type LeftBlockType = LeftBlock16; type RightBlockType = RightBlock32; @@ -71,37 +143,8 @@ impl OreCipher for OreAes128 { fn encrypt_left(&self, x: &PlainText) -> EncryptLeftResult { let mut output = Left::::init(); - // Build the prefixes - // TODO: Don't modify struct values directly - use a function on a "Left" trait - output.f.iter_mut().enumerate().for_each(|(n, block)| { - block[0..n].clone_from_slice(&x[0..n]); - // TODO: Include the block number in the prefix to avoid repeating values for common - // blocks in a long prefix - // e.g. when plaintext is 4700 (2-bytes/blocks) - // xt = [17, 17, 17, 17, 17, 17, 223, 76] - }); - - self.prf2.encrypt_all(&mut output.f); - - for (n, xn) in x.iter().enumerate().take(N) { - // Set prefix and create PRP for the block - let prp: KnuthShufflePRP = Prp::new(&output.f[n])?; - - output.xt[n] = prp.permute(*xn)?; - } - - // Reset the f block - // We don't actually need to clear sensitive data here, we - // just need fast "zero set". Reassigning the value will drop the old one and allocate new - // data to the stack - output.f = [Default::default(); N]; - - for n in 0..N { - output.f[n][0..n].clone_from_slice(&x[0..n]); - output.f[n][n] = output.xt[n]; - // Include the block number in the value passed to the Random Oracle - output.f[n][N] = n as u8; - } + let seeds = derive_prp_seeds(&self.prf2, x); + self.build_left(&mut output, &seeds, x)?; self.prf1.encrypt_all(&mut output.f); Ok(output) @@ -114,74 +157,65 @@ impl OreCipher for OreAes128 { // Generate a 16-byte random nonce self.rng.borrow_mut().try_fill(&mut right.nonce)?; - // Build the prefixes - // TODO: Don't modify struct values directly - use a function on a "Left" - left.f.iter_mut().enumerate().for_each(|(n, block)| { - block[0..n].clone_from_slice(&x[0..n]); - }); - - self.prf2.encrypt_all(&mut left.f); - - // To make zeroizing / resetting the RO keys - // Since the AesBlock type is stack allocated this should get optimised to a single memcpy - lazy_static! { - static ref ZEROED_RO_KEYS: [AesBlock; 256] = [Default::default(); 256]; + let seeds = derive_prp_seeds(&self.prf2, x); + + /* TODO: This seems to work but it is technically using the nonce as the key + * (instead of using it as the plaintext). This appears to be how the original + * ORE implementation does it but it feels a bit wonky to me. + * Alternatives are catalogued in the v2 plan §6 and will be settled + * there for new schemes; this scheme keeps the construction for + * wire compatibility. + */ + let hasher: Aes128Z2Hash = Hash::new(AesBlock::from_slice(&right.nonce)); + + // RO key template: entry `j` of the template holds the *input* + // `(x[0..n] ‖ j ‖ n)` for the current block. Between blocks only + // three bytes per entry change (the prefix byte just fixed, the + // candidate byte position, and the block index), so the template is + // maintained incrementally; `work` receives a copy each block for + // the in-place PRF/hash passes. Template and work hold secret + // intermediate state and are zeroized at the end. + let mut template = ::RoKeyBuf::zeroed(); + for (j, entry) in template.iter_mut().enumerate() { + entry[0] = j as u8; } - - let mut ro_keys = *ZEROED_RO_KEYS; + let mut work = ::RoKeyBuf::zeroed(); for n in 0..N { - // Set prefix and create PRP for the block - let prp: KnuthShufflePRP = Prp::new(&left.f[n])?; - + let prp: ::Prp = Prp::new(&seeds.0[n])?; left.xt[n] = prp.permute(x[n])?; - // Reset the f block - left.f[n].default_in_place(); - left.f[n][0..n].clone_from_slice(&x[0..n]); left.f[n][n] = left.xt[n]; // Include the block number in the value passed to the Random Oracle left.f[n][N] = n as u8; - for (j, ro_key) in ro_keys.iter_mut().enumerate() { - /* - * The output of F in H(F(k1, y|i-1||j), r) - */ - ro_key[0..n].clone_from_slice(&x[0..n]); - ro_key[n] = j as u8; - ro_key[N] = n as u8; + if n > 0 { + // Extend the prefix over the previous candidate position and + // move the candidate byte and block index along. + for (j, entry) in template.iter_mut().enumerate() { + entry[n - 1] = x[n - 1]; + entry[n] = j as u8; + entry[N] = n as u8; + } } - self.prf1.encrypt_all(&mut ro_keys); - - /* TODO: This seems to work but it is technically using the nonce as the key - * (instead of using it as the plaintext). This appears to be how the original - * ORE implementation does it but it feels a bit wonky to me. Should check with David. - * It is useful though because the AES crate makes it easy to encrypt groups of 8 - * plaintexts under the same key. We really want the ability to encrypt the same - * plaintext (i.e. the nonce) under different keys but this may be an acceptable - * approximation. - * - * If not, we will probably need to implement our own parallel encrypt using intrisics - * like in the AES crate: https://github.com/RustCrypto/block-ciphers/blob/master/aes/src/ni/aes128.rs#L26 - */ - let hasher: Aes128Z2Hash = Hash::new(AesBlock::from_slice(&right.nonce)); - let hashes = hasher.hash_all(&mut ro_keys); - - // FIXME: force casting to u8 from usize could cause a panic - for (j, h) in hashes.iter().enumerate() { - let jstar = prp.invert(j as u8)?; - let indicator = cmp(jstar, x[n]); - right.data[n].set_bit(j, indicator ^ h); - } + work.copy_from(&template); + self.prf1.encrypt_all(work.as_mut_slice()); - // Zeroize / reset the RO keys before the next loop iteration - ro_keys.clone_from_slice(&*ZEROED_RO_KEYS); + let hashes = hasher.hash_all(work.as_mut_slice()); + encode_right_block::(&mut right.data[n], &prp, x[n], &hashes)?; } self.prf1.encrypt_all(&mut left.f); + for entry in template.iter_mut() { + entry.zeroize(); + } + for entry in work.as_mut_slice() { + entry.zeroize(); + } + Ok(CipherText { left, right }) } diff --git a/packages/ore-rs/src/scheme/decompose.rs b/packages/ore-rs/src/scheme/decompose.rs new file mode 100644 index 00000000..f49b8af4 --- /dev/null +++ b/packages/ore-rs/src/scheme/decompose.rs @@ -0,0 +1,112 @@ +//! Decomposition of canonical plaintext bytes into per-block values. +//! +//! Plaintext canonicalisation (making a value's natural order match +//! lexicographic byte order) lives in `orderable-bytes`. This module handles +//! the next step: splitting those bytes into block values `< DOMAIN` for a +//! given [`super::width::BlockWidth`]. +//! +//! - 8-bit: identity — one byte per block. +//! - 6-bit: MSB-first bit-packing — `N` bytes become `ceil(8N / 6)` blocks, +//! with the final block zero-padded in its low bits. MSB-first packing +//! preserves lexicographic order, and for fixed-length inputs the trailing +//! zero padding is order-neutral. +//! +//! 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)] +/// 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())`. +/// +/// Block `i` holds plaintext bits `[6i, 6i + 6)` (bit 0 = MSB of byte 0), +/// so lexicographic order over block values equals lexicographic order over +/// the input bytes. +pub(crate) fn decompose_6bit(bytes: &[u8], out: &mut [u8]) { + debug_assert_eq!(out.len(), num_blocks_6bit(bytes.len())); + + for (i, slot) in out.iter_mut().enumerate() { + let bit = 6 * i; + let byte = bit / 8; + let offset = bit % 8; + + // Take 6 bits starting at `offset` within `bytes[byte]`, spilling + // into the next byte (or zero padding past the end). + let hi = bytes[byte] as u16; + let lo = *bytes.get(byte + 1).unwrap_or(&0) as u16; + let window = (hi << 8) | lo; + + *slot = ((window >> (10 - offset)) & 0x3f) as u8; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + quickcheck! { + /// MSB-first 6-bit packing preserves lexicographic order for + /// equal-length inputs. + fn order_preserved_u64(x: u64, y: u64) -> bool { + let xb = x.to_be_bytes(); + let yb = y.to_be_bytes(); + let mut xq = [0u8; num_blocks_6bit(8)]; + let mut yq = [0u8; num_blocks_6bit(8)]; + decompose_6bit(&xb, &mut xq); + decompose_6bit(&yb, &mut yq); + + xb.cmp(&yb) == xq.cmp(&yq) + } + + /// 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())]; + decompose_6bit(&bytes, &mut out); + out.iter().all(|&b| b < 64) + } + + /// Decomposition is injective: it can be inverted by re-concatenating + /// the 6-bit values and truncating to the original bit length. + fn roundtrip(bytes: Vec) -> bool { + let mut out = vec![0u8; num_blocks_6bit(bytes.len())]; + decompose_6bit(&bytes, &mut out); + + let mut rebuilt = vec![0u8; bytes.len()]; + for (i, &v) in out.iter().enumerate() { + let bit = 6 * i; + let byte = bit / 8; + let offset = bit % 8; + let window = (v as u16) << (10 - offset); + rebuilt[byte] |= (window >> 8) as u8; + if byte + 1 < rebuilt.len() { + rebuilt[byte + 1] |= (window & 0xff) as u8; + } + } + rebuilt == bytes + } + } + + #[test] + fn block_counts() { + assert_eq!(num_blocks_6bit(4), 6); // u32: 32 bits -> 6 blocks + assert_eq!(num_blocks_6bit(8), 11); // u64: 64 bits -> 11 blocks + assert_eq!(num_blocks_6bit(14), 19); // Decimal + assert_eq!(num_blocks_6bit(16), 22); // u128 + assert_eq!(num_blocks_6bit(0), 0); + } + + #[test] + fn known_packing() { + // Bit stream 11111100 00001111 -> 111111 | 000000 | 1111 + 2 pad bits + let mut out = [0u8; 3]; + decompose_6bit(&[0b1111_1100, 0b0000_1111], &mut out); + assert_eq!(out, [0b111111, 0b000000, 0b111100]); + } +} diff --git a/packages/ore-rs/src/scheme/width.rs b/packages/ore-rs/src/scheme/width.rs new file mode 100644 index 00000000..40a9dc6f --- /dev/null +++ b/packages/ore-rs/src/scheme/width.rs @@ -0,0 +1,96 @@ +//! Block-width abstraction for BlockORE schemes. +//! +//! A [`BlockWidth`] fixes the number of plaintext bits consumed per ORE +//! block and everything that follows from it: the block domain size, the +//! right-ciphertext bitvector type, the PRP over the domain, and the +//! random-oracle key buffer. Widths are sealed: the comparator and wire +//! format are width-specific, so downstream crates must not add widths. +//! +//! Stable Rust cannot express `[u8; 1 << BITS]` for a generic width +//! (`generic_const_exprs`), so the width carries its domain-sized buffers +//! as associated types instead. + +use crate::ciphertext::CipherTextBlock; +use crate::primitives::{AesBlock, Prp}; +use crate::scheme::bit2::block_types::RightBlock32; + +mod sealed { + pub trait Sealed {} + impl Sealed for super::Bit8 {} + impl Sealed for [super::AesBlock; 256] {} +} + +/// A domain-sized buffer of AES blocks used for per-block random-oracle +/// keys. Implemented for `[AesBlock; DOMAIN]` arrays (which have no +/// `Default` for large N on stable Rust). +pub trait AesBlockBuf: sealed::Sealed { + /// A zeroed buffer. + fn zeroed() -> Self; + /// View as a mutable slice for batched PRF passes. (Concrete `[T; N]` + /// callers resolve to the inherent array method; this exists for + /// width-generic code, which arrives with the Bit6 scheme.) + #[allow(dead_code)] + fn as_mut_slice(&mut self) -> &mut [AesBlock]; + /// Overwrite `self` with `other` (a domain-sized memcpy). + fn copy_from(&mut self, other: &Self); +} + +impl AesBlockBuf for [AesBlock; 256] { + fn zeroed() -> Self { + [AesBlock::default(); 256] + } + fn as_mut_slice(&mut self) -> &mut [AesBlock] { + self + } + fn copy_from(&mut self, other: &Self) { + self.clone_from_slice(other); + } +} + +/// Per-block bitvector operations on a Right ciphertext block, one bit per +/// value in the block domain. +pub trait RightBitVec { + /// Set bit `bit` to `value` (`0` or `1`). + fn set_bit(&mut self, bit: usize, value: u8); + /// Read bit `bit`. (The width-generic comparator lands with the Bit6 + /// scheme; the legacy comparator calls the inherent method.) + #[allow(dead_code)] + fn get_bit(&self, bit: usize) -> u8; +} + +impl RightBitVec for RightBlock32 { + fn set_bit(&mut self, bit: usize, value: u8) { + RightBlock32::set_bit(self, bit, value) + } + fn get_bit(&self, bit: usize) -> u8 { + RightBlock32::get_bit(self, bit) + } +} + +/// The number of plaintext bits consumed per ORE block, and the types that +/// depend on it. See the module docs. +pub trait BlockWidth: sealed::Sealed + 'static { + /// Bits of plaintext per block. + const BITS: usize; + /// Block domain size: `1 << BITS`. Block values are `0..DOMAIN`. + const DOMAIN: usize; + /// Right-ciphertext block: a `DOMAIN`-bit masked truth-table row. + type RightBlock: CipherTextBlock + RightBitVec; + /// PRP over the block domain. + type Prp: Prp; + /// Buffer holding `DOMAIN` random-oracle keys. + type RoKeyBuf: AesBlockBuf; +} + +/// The 8-bit block width used by the legacy [`crate::scheme::bit2`] scheme: +/// one plaintext byte per block, domain 256. +#[derive(Debug)] +pub struct Bit8; + +impl BlockWidth for Bit8 { + const BITS: usize = 8; + const DOMAIN: usize = 256; + type RightBlock = RightBlock32; + type Prp = crate::primitives::prp::KnuthShufflePRP; + type RoKeyBuf = [AesBlock; 256]; +} From 94888d01d01566a7c6c6a0b776e8777ff83b204e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 8 Oct 2026 19:58:18 +1100 Subject: [PATCH 2/3] refactor(width): derive BlockWidth::DOMAIN from BITS BITS was declared but never read, which stable clippy reports as dead code (CI's 1.78 does not). DOMAIN is documented as 1 << BITS, but each impl spelled both out, so they could disagree. DOMAIN is now a default of 1 << Self::BITS and impls declare only BITS. --- packages/ore-rs/src/scheme/width.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ore-rs/src/scheme/width.rs b/packages/ore-rs/src/scheme/width.rs index 40a9dc6f..43557337 100644 --- a/packages/ore-rs/src/scheme/width.rs +++ b/packages/ore-rs/src/scheme/width.rs @@ -72,8 +72,9 @@ impl RightBitVec for RightBlock32 { pub trait BlockWidth: sealed::Sealed + 'static { /// Bits of plaintext per block. const BITS: usize; - /// Block domain size: `1 << BITS`. Block values are `0..DOMAIN`. - const DOMAIN: usize; + /// Block domain size. Block values are `0..DOMAIN`. Derived from + /// [`BITS`](Self::BITS) so the two cannot disagree. + const DOMAIN: usize = 1 << Self::BITS; /// Right-ciphertext block: a `DOMAIN`-bit masked truth-table row. type RightBlock: CipherTextBlock + RightBitVec; /// PRP over the block domain. @@ -89,7 +90,6 @@ pub struct Bit8; impl BlockWidth for Bit8 { const BITS: usize = 8; - const DOMAIN: usize = 256; type RightBlock = RightBlock32; type Prp = crate::primitives::prp::KnuthShufflePRP; type RoKeyBuf = [AesBlock; 256]; From ad9e19dd8d36e340fb28e3227a508d6a8a19a940 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 12 Oct 2026 10:48:32 +1100 Subject: [PATCH 3/3] docs(bit2): document the legacy zero-run seed leakage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TODO on derive_prp_seeds gave the wrong condition. Seeds for blocks n < m collide when x[n..m] is all zero, not over runs of identical bytes, and the leak shows in a single ciphertext: zero runs repeat π(0) in xt, and leading zero bytes give an integer's magnitude as the same value under every key. The wire format is frozen, so the code keeps it. Replace the TODO with the correct condition and a link to the PR whose schemes bind the block index, document the leak in the crate README and the v2 plan, and stop the README claiming that the block number avoids repeated prefixes (it is bound into the left tag only). --- docs/plans/2026-06-12-ore-v2-architecture.md | 31 ++++++++++++++++++++ packages/ore-rs/README.md | 11 ++++++- packages/ore-rs/src/scheme/bit2.rs | 11 +++++-- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/docs/plans/2026-06-12-ore-v2-architecture.md b/docs/plans/2026-06-12-ore-v2-architecture.md index aceb03c1..96439efd 100644 --- a/docs/plans/2026-06-12-ore-v2-architecture.md +++ b/docs/plans/2026-06-12-ore-v2-architecture.md @@ -18,6 +18,34 @@ - **No wire-format header.** `compare_raw_slices` infers the block count from the slice length; ciphertexts of different schemes/sizes are distinguishable only by length. +### Known leakage of the legacy scheme + +The 8-bit scheme derives the PRP seed for block `n` as `PRF₂(x[0..n] ‖ 0…)`. The input +binds neither `n` nor the block count, so the seeds for blocks `n < m` are equal when +`x[n..m]` is all zero. A run of equal non-zero bytes does not collide (`[5, 5, 5]` gives +three seeds). Blocks with equal seeds share a permutation, so every zero byte in a run +encrypts to the same `xt` value, `π(0)`. The pinned vectors (`tests/compat_vectors`) show +it: + +- `0u64` gives `xt = 90 90 90 90 90 90 90 90`; +- `1u64 << 32` gives `xt = 90 90 90 27 d5 d5 d5 d5`; +- `1.5f64` gives `xt = 92 52 6a 6a 6a 6a 6a 6a`. + +One ciphertext is enough (a Left alone shows it). It gives away: + +- the number of leading zero bytes of an integer, which is its order of magnitude: the + leading-zero seed is `PRF₂(0…)` in every ciphertext, so its `π(0)` is the same value + (`0x90` above) in every ciphertext under a key; +- runs of zero bytes elsewhere, such as the trailing zero mantissa bytes of a "round" + float; +- equal `xt` values across positions and across ciphertexts when their seeds match + (`xt[7]` of `1u64` and `xt[3]` of `1u64 << 32` are both `0x27`). + +Lewi-Wu ORE leaks only the order and the first differing block of a compared pair; this +is more. The wire format is frozen (see Non-goals), so the legacy scheme keeps it, and it +is documented in the crate README. New schemes must bind the block index and the block +count into the seed input. + This plan covers four goals, structured as a sequence of PRs: 1. **Efficient unary encoding** — replace the per-bit iterative right-block construction @@ -399,6 +427,9 @@ PR 2's trait change, which should be called out in the changelog). never reachable from `Left`/`Right` types (PR 6). - [ ] GF(2^128) doubling constant-time, if Candidate C is chosen (PR 6). - [ ] String leakage profile documented and acknowledged at product level (PR 6). +- [ ] Legacy zero-run seed leakage (Context, "Known leakage of the legacy scheme") + documented in the README; new schemes bind the block index and count into their + PRP seeds (PR 2, 5). ## Decisions taken (revisit if needed) diff --git a/packages/ore-rs/README.md b/packages/ore-rs/README.md index 121f107d..6db224cf 100644 --- a/packages/ore-rs/README.md +++ b/packages/ore-rs/README.md @@ -13,7 +13,7 @@ It makes the following improvements on the original scheme: * Exclusive use of AES as a Random Oracle * Pipeline optimisations, for higher throughput * Hardware AES acceleration on `x86_64` and ARM (via the [`aes`](https://crates.io/crates/aes) crate's runtime detection, on stable Rust) -* Inclusion of the block number in block prefixes, to avoid repeated prefixes +* Inclusion of the block number in the left-tag (PRF₁) input, to avoid repeated tags. The PRP seed input does not include it: see [Known leakage](#known-leakage-of-oreaes128) ## Usage Documentation @@ -62,6 +62,15 @@ Hardware AES is provided by the [`aes`](https://crates.io/crates/aes) crate, whi The underlying scheme (Lewi-Wu Block-ORE) has been well studied, but this implementation has not had a public third-party audit. Evaluate it against your own threat model before using it in production. +### Known leakage of `OreAes128` + +Beyond the order and the first differing block of a compared pair, an `OreAes128` ciphertext shows its runs of zero bytes. The PRP seed for block `n` is derived from the bytes before it, padded with zeros, and binds neither the block index nor the block count. Blocks inside a run of zero bytes therefore share a permutation and encrypt to the same value. One ciphertext (a Left alone is enough) gives away: + +* the number of leading zero bytes of an integer, which is its order of magnitude, as the same value in every ciphertext under a key; +* other runs of zero bytes, such as the trailing zero mantissa bytes of a round float. + +The wire format of `OreAes128` is frozen, so this cannot change for existing data. See "Known leakage of the legacy scheme" in [the v2 plan](https://github.com/cipherstash/ore.rs/blob/main/docs/plans/2026-06-12-ore-v2-architecture.md). + To report a security issue, see [SECURITY.md](https://github.com/cipherstash/ore.rs/blob/main/SECURITY.md) or email security@cipherstash.com. ## License diff --git a/packages/ore-rs/src/scheme/bit2.rs b/packages/ore-rs/src/scheme/bit2.rs index 36c8594a..92f9fb52 100644 --- a/packages/ore-rs/src/scheme/bit2.rs +++ b/packages/ore-rs/src/scheme/bit2.rs @@ -69,9 +69,14 @@ fn derive_prp_seeds(prf2: &Aes128Prf, x: &PlainText) -> SeedB let mut seeds = [AesBlock::default(); N]; for (n, block) in seeds.iter_mut().enumerate() { block[0..n].clone_from_slice(&x[0..n]); - // TODO (tracked in v2 plan, fixed for new schemes by the §5(b) - // accumulator): the block index is not bound here, so a run of - // identical prefix bytes yields identical seeds. + // Known leakage, kept because the wire format is frozen: the seed + // input binds neither the block index nor the block count, so the + // seeds for blocks n < m are equal when x[n..m] is all zero. Those + // blocks share one permutation, so a zero run repeats π(0) in `xt` + // and a single Left ciphertext shows it. See "Known leakage of the + // legacy scheme" in the v2 plan. New schemes bind the block index + // and count into the seed: + // https://github.com/cipherstash/ore.rs/pull/82 } prf2.encrypt_all(&mut seeds); SeedBuf(seeds)