From a9d24cbac63237bacef38d0bb510d116c3f0e648 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 13 Jun 2026 01:33:34 +1000 Subject: [PATCH 01/19] feat: 6-bit block width scheme (OreAes128Bit6) with v2 wire format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the (Bit6, packed prefix, fixed-N) scheme from the v2 plan: - scheme::bit2_w6::OreAes128Bit6{,ChaCha20}: 6-bit blocks (domain 64), 64 RO evaluations per block (vs 256), 8-byte right blocks (vs 32). u64 ciphertexts are 295 bytes (vs 408) and encrypt in ~11.5us on M1 Max (vs 25.1us for the optimized legacy scheme). - v2 wire header (version || scheme_id || block_count u16 BE) via a new OreCipher::WIRE_HEADER associated const, None for the legacy scheme (whose bytes are unchanged — vectors pass). One header per serialised artifact; parse and compare validate version, scheme and count, so cross-scheme and cross-shape comparisons fail loudly (tested). - Block count bound into byte 15 of all PRF inputs (left tags, RO keys, PRP seeds) for domain separation across plaintext shapes under shared keys; fixes the repeated-PRP-seed behaviour for this scheme. - KnuthShufflePRP generalised over domain via macro (256 wire-frozen, 64 new); NEON 64-lane indicator kernel added. - Z2 hash: fixed-pi MMO construction (plan section 6, option 3) — both this and the legacy construction are implemented; the scheme picks via a one-line type alias. PENDING CRYPTO REVIEW; wire format not frozen, no vectors pinned yet, doc comments warn against storing ciphertexts. - OreEncrypt impls for bool/u8..u64/i8..i64/char/f32/f64 with MSB-first 6-bit decomposition and compile-time block-count assertions. u128/ i128/Decimal exceed the 14-block packed-prefix cap and stay legacy- only until the chained prefix lands. BREAKING-ish: the previous blanket impls over T: OreCipher are now scheme-specific (coherence with per-scheme block counts); code generic over OreCipher that called .encrypt() must name a scheme. Part of the ORE v2 program (docs/plans/2026-06-12-ore-v2-architecture.md, PR 5). --- packages/ore-rs/Cargo.toml | 4 + packages/ore-rs/benches/bit6.rs | 42 ++ packages/ore-rs/src/chrono.rs | 22 +- packages/ore-rs/src/ciphertext.rs | 170 ++++- packages/ore-rs/src/decimal.rs | 12 +- packages/ore-rs/src/encrypt.rs | 29 +- packages/ore-rs/src/lib.rs | 5 + packages/ore-rs/src/primitives/hash.rs | 77 +++ packages/ore-rs/src/primitives/prp.rs | 145 +++-- packages/ore-rs/src/primitives/simd.rs | 79 ++- packages/ore-rs/src/scheme.rs | 5 + packages/ore-rs/src/scheme/bit2_w6.rs | 582 ++++++++++++++++++ .../ore-rs/src/scheme/bit2_w6/block_types.rs | 65 ++ packages/ore-rs/src/scheme/width.rs | 44 +- 14 files changed, 1137 insertions(+), 144 deletions(-) create mode 100644 packages/ore-rs/benches/bit6.rs create mode 100644 packages/ore-rs/src/scheme/bit2_w6.rs create mode 100644 packages/ore-rs/src/scheme/bit2_w6/block_types.rs diff --git a/packages/ore-rs/Cargo.toml b/packages/ore-rs/Cargo.toml index 34850c6..3ddf561 100644 --- a/packages/ore-rs/Cargo.toml +++ b/packages/ore-rs/Cargo.toml @@ -49,6 +49,10 @@ thiserror = "1.0.38" name = "oreaes128" harness = false +[[bench]] +name = "bit6" +harness = false + [[bench]] name = "decimal" harness = false diff --git a/packages/ore-rs/benches/bit6.rs b/packages/ore-rs/benches/bit6.rs new file mode 100644 index 0000000..1d0a8e7 --- /dev/null +++ b/packages/ore-rs/benches/bit6.rs @@ -0,0 +1,42 @@ +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use hex_literal::hex; +use ore_rs::{scheme::bit2_w6::OreAes128Bit6ChaCha20, OreCipher, OreEncrypt, OreOutput}; + +fn init_ore() -> OreAes128Bit6ChaCha20 { + let k1: [u8; 16] = hex!("00010203 04050607 08090a0b 0c0d0e0f"); + let k2: [u8; 16] = hex!("d0d1d2d3 d4d5d6d7 d8d9dadb dcdddedf"); + OreCipher::init(&k1, &k2).unwrap() +} + +fn criterion_benchmark(c: &mut Criterion) { + let ore = init_ore(); + + c.bench_function("bit6-encrypt-u64", |b| { + b.iter(|| black_box(25u64).encrypt(&ore).unwrap()) + }); + + c.bench_function("bit6-encrypt-left-u64", |b| { + b.iter(|| black_box(25u64).encrypt_left(&ore).unwrap()) + }); + + c.bench_function("bit6-encrypt-u32", |b| { + b.iter(|| black_box(25u32).encrypt(&ore).unwrap()) + }); + + let a = 25u64.encrypt(&ore).unwrap(); + let b_ct = 1025u64.encrypt(&ore).unwrap(); + c.bench_function("bit6-compare-u64", |bench| { + bench.iter(|| black_box(&a).partial_cmp(black_box(&b_ct))) + }); + + let a_bytes = a.to_bytes(); + let b_bytes = b_ct.to_bytes(); + c.bench_function("bit6-compare-u64-slice", |bench| { + bench.iter(|| { + OreAes128Bit6ChaCha20::compare_raw_slices(black_box(&a_bytes), black_box(&b_bytes)) + }) + }); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/packages/ore-rs/src/chrono.rs b/packages/ore-rs/src/chrono.rs index 06d770e..1994784 100644 --- a/packages/ore-rs/src/chrono.rs +++ b/packages/ore-rs/src/chrono.rs @@ -9,35 +9,37 @@ //! ordering properties. use crate::ciphertext::*; +use crate::scheme::bit2::OreAes128; use crate::{OreCipher, OreEncrypt, OreError}; use ::chrono::{DateTime, NaiveDate, Utc}; use orderable_bytes::ToOrderableBytes; +use rand::{Rng, SeedableRng}; const NAIVE_DATE_LEN: usize = ::ENCODED_LEN; const DATETIME_UTC_LEN: usize = as ToOrderableBytes>::ENCODED_LEN; -impl OreEncrypt for NaiveDate { - type LeftOutput = Left; - type FullOutput = CipherText; +impl OreEncrypt> for NaiveDate { + type LeftOutput = Left, NAIVE_DATE_LEN>; + type FullOutput = CipherText, NAIVE_DATE_LEN>; - fn encrypt_left(&self, cipher: &T) -> Result { + fn encrypt_left(&self, cipher: &OreAes128) -> Result { cipher.encrypt_left(&self.to_orderable_bytes()) } - fn encrypt(&self, cipher: &T) -> Result { + fn encrypt(&self, cipher: &OreAes128) -> Result { cipher.encrypt(&self.to_orderable_bytes()) } } -impl OreEncrypt for DateTime { - type LeftOutput = Left; - type FullOutput = CipherText; +impl OreEncrypt> for DateTime { + type LeftOutput = Left, DATETIME_UTC_LEN>; + type FullOutput = CipherText, DATETIME_UTC_LEN>; - fn encrypt_left(&self, cipher: &T) -> Result { + fn encrypt_left(&self, cipher: &OreAes128) -> Result { cipher.encrypt_left(&self.to_orderable_bytes()) } - fn encrypt(&self, cipher: &T) -> Result { + fn encrypt(&self, cipher: &OreAes128) -> Result { cipher.encrypt(&self.to_orderable_bytes()) } } diff --git a/packages/ore-rs/src/ciphertext.rs b/packages/ore-rs/src/ciphertext.rs index 8b34b63..ad3df3a 100644 --- a/packages/ore-rs/src/ciphertext.rs +++ b/packages/ore-rs/src/ciphertext.rs @@ -3,6 +3,70 @@ use thiserror::Error; use crate::primitives::NONCE_SIZE; pub use crate::OreCipher; +/// Wire-format header prepended to every serialised artifact (Left, Right +/// or combined ciphertext) of schemes introduced from ORE v2 onwards. +/// +/// Layout: `version ‖ scheme_id ‖ block_count (u16 BE)` — 4 bytes. The +/// legacy [`crate::scheme::bit2`] scheme predates headers and remains +/// headerless forever ([`OreCipher::WIRE_HEADER`] is `None` for it); +/// ciphertexts of headered schemes can never be confused with each other +/// (version + scheme id are validated on parse and compare), and each +/// *type* only ever parses its own format. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WireHeader { + /// Wire format version. `0x02` is the first headered format. + pub version: u8, + /// Scheme identifier (encodes block width, prefix mode and cipher + /// suite). See the scheme modules for assigned values. + pub scheme_id: u8, +} + +/// Serialised size of a [`WireHeader`] plus the block count it carries. +pub(crate) const WIRE_HEADER_LEN: usize = 4; + +impl WireHeader { + pub(crate) fn write(&self, num_blocks: usize, out: &mut Vec) { + debug_assert!(num_blocks <= u16::MAX as usize); + out.push(self.version); + out.push(self.scheme_id); + out.extend_from_slice(&(num_blocks as u16).to_be_bytes()); + } + + /// Validate `data`'s header against `self` and an expected block count, + /// returning the payload after the header. + pub(crate) fn strip<'a>( + &self, + num_blocks: usize, + data: &'a [u8], + ) -> Result<&'a [u8], ParseError> { + let (header, body) = parse_header(data)?; + if header != (self.version, self.scheme_id, num_blocks) { + return Err(ParseError); + } + Ok(body) + } +} + +/// `(version, scheme_id, block_count)` as parsed from a wire header. +pub(crate) type ParsedHeader = (u8, u8, usize); + +/// Split a headered slice into `((version, scheme_id, block_count), body)`. +pub(crate) fn parse_header(data: &[u8]) -> Result<(ParsedHeader, &[u8]), ParseError> { + if data.len() < WIRE_HEADER_LEN { + return Err(ParseError); + } + let count = u16::from_be_bytes([data[2], data[3]]) as usize; + Ok(((data[0], data[1], count), &data[WIRE_HEADER_LEN..])) +} + +fn header_len() -> usize { + if S::WIRE_HEADER.is_some() { + WIRE_HEADER_LEN + } else { + 0 + } +} + /// The trait of any encryption output (either Left, Right or combined). pub trait OreOutput: Sized { /// The size (in bytes) of this encrypted value @@ -72,7 +136,7 @@ pub trait CipherTextBlock: Default + Copy + std::fmt::Debug { } /// Error returned when a serialised ciphertext can't be parsed (wrong -/// length, malformed block, etc.). +/// length, malformed block, bad or mismatched wire header, etc.). #[derive(Debug, Error)] #[error("Unable to parse ORE Ciphertext")] pub struct ParseError; @@ -84,23 +148,23 @@ impl Left { f: [S::LeftBlockType::default(); N], } } -} -impl OreOutput for Left { - fn size() -> usize { + /// Serialised size of the headerless body. + pub(crate) fn body_size() -> usize { N * (S::LeftBlockType::BLOCK_SIZE + 1) } - fn to_bytes(&self) -> Vec { - let mut vec = Vec::with_capacity(N * S::LeftBlockType::BLOCK_SIZE); + fn write_body(&self, vec: &mut Vec) { + vec.extend_from_slice(&self.xt); self.f .iter() .for_each(|&block| vec.append(&mut block.to_bytes())); - - [self.xt.to_vec(), vec].concat() } - fn from_slice(data: &[u8]) -> Result { + fn from_body(data: &[u8]) -> Result { + if data.len() != Self::body_size() { + return Err(ParseError); + } let mut out = Self::init(); out.xt.copy_from_slice(&data[0..N]); for i in 0..N { @@ -114,6 +178,29 @@ impl OreOutput for Left { } } +impl OreOutput for Left { + fn size() -> usize { + header_len::() + Self::body_size() + } + + fn to_bytes(&self) -> Vec { + let mut vec = Vec::with_capacity(Self::size()); + if let Some(header) = S::WIRE_HEADER { + header.write(N, &mut vec); + } + self.write_body(&mut vec); + vec + } + + fn from_slice(data: &[u8]) -> Result { + let body = match S::WIRE_HEADER { + Some(header) => header.strip(N, data)?, + None => data, + }; + Self::from_body(body) + } +} + impl Right { pub(crate) fn init() -> Self { Self { @@ -121,23 +208,23 @@ impl Right { data: [Default::default(); N], } } -} -impl OreOutput for Right { - fn size() -> usize { + /// Serialised size of the headerless body. + pub(crate) fn body_size() -> usize { (N * S::RightBlockType::BLOCK_SIZE) + NONCE_SIZE } - fn to_bytes(&self) -> Vec { - let mut vec = Vec::with_capacity(N * S::RightBlockType::BLOCK_SIZE); + fn write_body(&self, vec: &mut Vec) { + vec.extend_from_slice(&self.nonce); self.data .iter() .for_each(|&block| vec.append(&mut block.to_bytes())); - - [self.nonce.to_vec(), vec].concat() } - fn from_slice(data: &[u8]) -> Result { + fn from_body(data: &[u8]) -> Result { + if data.len() != Self::body_size() { + return Err(ParseError); + } let mut out = Self::init(); out.nonce.copy_from_slice(&data[0..NONCE_SIZE]); for i in 0..N { @@ -150,24 +237,59 @@ impl OreOutput for Right { } } +impl OreOutput for Right { + fn size() -> usize { + header_len::() + Self::body_size() + } + + fn to_bytes(&self) -> Vec { + let mut vec = Vec::with_capacity(Self::size()); + if let Some(header) = S::WIRE_HEADER { + header.write(N, &mut vec); + } + self.write_body(&mut vec); + vec + } + + fn from_slice(data: &[u8]) -> Result { + let body = match S::WIRE_HEADER { + Some(header) => header.strip(N, data)?, + None => data, + }; + Self::from_body(body) + } +} + impl OreOutput for CipherText { fn size() -> usize { - Left::::size() + Right::::size() + header_len::() + Left::::body_size() + Right::::body_size() } - /// Serialize the ciphertext into a vector of bytes + /// Serialize the ciphertext into a vector of bytes. Headered schemes + /// emit exactly one header for the combined artifact (not one per + /// half). fn to_bytes(&self) -> Vec { - [self.left.to_bytes(), self.right.to_bytes()].concat() + let mut vec = Vec::with_capacity(Self::size()); + if let Some(header) = S::WIRE_HEADER { + header.write(N, &mut vec); + } + self.left.write_body(&mut vec); + self.right.write_body(&mut vec); + vec } /// Deserialize from a slice of bytes fn from_slice(data: &[u8]) -> Result { - if data.len() != (Left::::size() + Right::::size()) { + let body = match S::WIRE_HEADER { + Some(header) => header.strip(N, data)?, + None => data, + }; + if body.len() != Left::::body_size() + Right::::body_size() { return Err(ParseError); } - let (left, right) = data.split_at(Left::::size()); - let left = Left::::from_slice(left)?; - let right = Right::::from_slice(right)?; + let (left, right) = body.split_at(Left::::body_size()); + let left = Left::::from_body(left)?; + let right = Right::::from_body(right)?; Ok(Self { left, right }) } diff --git a/packages/ore-rs/src/decimal.rs b/packages/ore-rs/src/decimal.rs index 2253bde..c0d4c68 100644 --- a/packages/ore-rs/src/decimal.rs +++ b/packages/ore-rs/src/decimal.rs @@ -9,21 +9,23 @@ use crate::ciphertext::{CipherText, Left}; use crate::encrypt::OreEncrypt; +use crate::scheme::bit2::OreAes128; use crate::{OreCipher, OreError}; use orderable_bytes::ToOrderableBytes; +use rand::{Rng, SeedableRng}; use rust_decimal::Decimal; const ENCODED_LEN: usize = ::ENCODED_LEN; -impl OreEncrypt for Decimal { - type LeftOutput = Left; - type FullOutput = CipherText; +impl OreEncrypt> for Decimal { + type LeftOutput = Left, ENCODED_LEN>; + type FullOutput = CipherText, ENCODED_LEN>; - fn encrypt_left(&self, cipher: &T) -> Result { + fn encrypt_left(&self, cipher: &OreAes128) -> Result { cipher.encrypt_left(&self.to_orderable_bytes()) } - fn encrypt(&self, cipher: &T) -> Result { + fn encrypt(&self, cipher: &OreAes128) -> Result { cipher.encrypt(&self.to_orderable_bytes()) } } diff --git a/packages/ore-rs/src/encrypt.rs b/packages/ore-rs/src/encrypt.rs index ed37ddb..48b6601 100644 --- a/packages/ore-rs/src/encrypt.rs +++ b/packages/ore-rs/src/encrypt.rs @@ -1,7 +1,9 @@ use crate::ciphertext::*; +use crate::scheme::bit2::OreAes128; use crate::PlainText; use crate::{OreCipher, OreError}; use orderable_bytes::ToOrderableBytes; +use rand::{Rng, SeedableRng}; /// Type-directed entry point for encrypting plaintext values with a given /// [`OreCipher`]. @@ -49,17 +51,24 @@ const CHAR_LEN: usize = ::ENCODED_LEN; const F32_LEN: usize = ::ENCODED_LEN; const F64_LEN: usize = ::ENCODED_LEN; +// These impls are specific to the legacy byte-per-block scheme (one +// plaintext byte = one ORE block, so the orderable-bytes encoding feeds +// the cipher directly). The 6-bit scheme provides its own impls in +// `scheme::bit2_w6`, which interpose the byte->block decomposition. They +// were previously blanket impls over `T: OreCipher`; scheme-specific +// impls keep coherence simple now that block count != byte count for +// some schemes. macro_rules! impl_ore_encrypt_via_orderable_bytes { ($type:ty, $len_const:ident) => { - impl OreEncrypt for $type { - type LeftOutput = Left; - type FullOutput = CipherText; + impl OreEncrypt> for $type { + type LeftOutput = Left, $len_const>; + type FullOutput = CipherText, $len_const>; - fn encrypt_left(&self, cipher: &T) -> Result { + fn encrypt_left(&self, cipher: &OreAes128) -> Result { cipher.encrypt_left(&self.to_orderable_bytes()) } - fn encrypt(&self, cipher: &T) -> Result { + fn encrypt(&self, cipher: &OreAes128) -> Result { cipher.encrypt(&self.to_orderable_bytes()) } } @@ -81,15 +90,15 @@ impl_ore_encrypt_via_orderable_bytes!(char, CHAR_LEN); impl_ore_encrypt_via_orderable_bytes!(f32, F32_LEN); impl_ore_encrypt_via_orderable_bytes!(f64, F64_LEN); -impl OreEncrypt for PlainText { - type LeftOutput = Left; - type FullOutput = CipherText; +impl OreEncrypt> for PlainText { + type LeftOutput = Left, N>; + type FullOutput = CipherText, N>; - fn encrypt_left(&self, cipher: &T) -> Result { + fn encrypt_left(&self, cipher: &OreAes128) -> Result { cipher.encrypt_left(self) } - fn encrypt(&self, cipher: &T) -> Result { + fn encrypt(&self, cipher: &OreAes128) -> Result { cipher.encrypt(self) } } diff --git a/packages/ore-rs/src/lib.rs b/packages/ore-rs/src/lib.rs index 09e2af5..c7e1ca5 100644 --- a/packages/ore-rs/src/lib.rs +++ b/packages/ore-rs/src/lib.rs @@ -190,6 +190,11 @@ pub trait OreCipher: Sized { /// truth-table row). type RightBlockType: CipherTextBlock; + /// Wire-format header for this scheme's serialised artifacts, or + /// `None` for the legacy headerless format. Schemes introduced from + /// ORE v2 onwards must set this; see [`WireHeader`]. + const WIRE_HEADER: Option = None; + /// Initialise the cipher from two 16-byte keys: `k1` for the /// per-block-tag PRF and `k2` for the per-block PRP seed PRF. fn init(k1: &[u8; 16], k2: &[u8; 16]) -> Result; diff --git a/packages/ore-rs/src/primitives/hash.rs b/packages/ore-rs/src/primitives/hash.rs index 9321e1f..e233bfc 100644 --- a/packages/ore-rs/src/primitives/hash.rs +++ b/packages/ore-rs/src/primitives/hash.rs @@ -47,6 +47,83 @@ impl Hash for Aes128Z2Hash { } } +/// Z2 hash instantiated as `LSB(π(x ⊕ r) ⊕ x)` with `π` a *fixed public* +/// AES-128 permutation and `r` the per-ciphertext nonce — the +/// fixed-key-AES MMO construction proposed in the v2 plan §6 (option 3), +/// analysed in the random-permutation model (cf. BHKR13 / GKWY20). +/// +/// The `Hash::new` "key" parameter carries the **nonce** (same calling +/// convention as [`Aes128Z2Hash`], which uses the nonce as an AES key); +/// the AES key here is the public constant [`PI_KEY`] and is expanded +/// once per process. +/// +/// **Status: pending crypto review** (v2 plan §6) — used only by post-v2 +/// schemes whose wire format is not yet frozen. +pub struct FixedPiZ2Hash { + nonce: AesBlock, +} + +/// The public, fixed AES key for `π`. Nothing-up-my-sleeve: the ASCII +/// bytes of `"ORE-rs.v2.H-pi.1"`. This key is deliberately *not* secret — +/// the construction's security rests on AES being a good public random +/// permutation, not on key secrecy (the comparator must be able to +/// evaluate H with no key material). +pub const PI_KEY: [u8; 16] = *b"ORE-rs.v2.H-pi.1"; + +fn pi() -> &'static Aes128 { + use std::sync::OnceLock; + static PI: OnceLock = OnceLock::new(); + PI.get_or_init(|| Aes128::new(GenericArray::from_slice(&PI_KEY))) +} + +impl Hash for FixedPiZ2Hash { + fn new(nonce: &HashKey) -> Self { + Self { nonce: *nonce } + } + + fn hash(&self, data: &[u8]) -> u8 { + assert_eq!(data.len(), 16); + let x_lsb = data[0] & 1u8; + let mut block = [0u8; 16]; + for (slot, (&x, &r)) in block.iter_mut().zip(data.iter().zip(self.nonce.iter())) { + *slot = x ^ r; + } + let block = GenericArray::from_mut_slice(&mut block); + pi().encrypt_block(block); + (block[0] & 1u8) ^ x_lsb + } + + fn hash_all_into(&self, data: &mut [AesBlock], out: &mut [u8]) { + debug_assert_eq!(out.len() * 8, data.len()); + + // Feedforward: capture the x LSBs before overwriting, then + // out = lsb(x) ^ lsb(π(x ⊕ r)). + if data.len() == 256 { + crate::primitives::simd::lsb_mask_256(data, out); + } else { + crate::primitives::simd::scalar::lsb_mask(data, out); + } + + for block in data.iter_mut() { + for (slot, &r) in block.iter_mut().zip(self.nonce.iter()) { + *slot ^= r; + } + } + pi().encrypt_blocks(data); + + let mut pi_mask = [0u8; 32]; + let pi_mask = &mut pi_mask[..out.len()]; + if data.len() == 256 { + crate::primitives::simd::lsb_mask_256(data, pi_mask); + } else { + crate::primitives::simd::scalar::lsb_mask(data, pi_mask); + } + for (slot, &m) in out.iter_mut().zip(pi_mask.iter()) { + *slot ^= m; + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/ore-rs/src/primitives/prp.rs b/packages/ore-rs/src/primitives/prp.rs index 829d68d..2ab7e5e 100644 --- a/packages/ore-rs/src/primitives/prp.rs +++ b/packages/ore-rs/src/primitives/prp.rs @@ -19,78 +19,93 @@ impl Drop for KnuthShufflePRP { // Impl the ZeroizeOnDrop marker trait since we're zeroizing above impl ZeroizeOnDrop for KnuthShufflePRP {} -impl Prp for KnuthShufflePRP { - /* - * Initialize an 8-bit (256 element) PRP using a KnuthShuffle - * and a 64-bit random seed - */ - fn new(key: &[u8]) -> PrpResult { - let mut rng = Aes128Prng::init(key); // TODO: Use Result type here, too - - let mut perm = Self { - permutation: [0u8; 256], - inverse: [0u8; 256], - }; - - // Initialize values - for i in 0..=255 { - perm.permutation[i] = i as u8; - } - - // Iterations stop at i = 1: the i = 0 step always degenerates to - // `swap(0, 0)` after drawing rejection-sampled bytes until one is - // zero (expected 256 draws — a full PRNG regeneration), and the RNG - // is dropped right after this loop, so skipping it consumes no - // observable state and yields a byte-identical permutation. - (1..=255usize).rev().for_each(|i| { - let j = rng.gen_range(i as u8); - perm.permutation.swap(i, j as usize); - }); - - for (index, val) in perm.permutation.iter().enumerate() { - perm.inverse[*val as usize] = index as u8; - } +/// Implements `Prp` for a Knuth-shuffle PRP over a `$domain`-element +/// space (`$domain ≤ 256`), with `$gt_mask` as the bulk indicator kernel. +/// The shuffle algorithm and PRNG byte consumption are identical across +/// domains; only the iteration bounds change. For the 256 domain this is +/// wire-frozen (the legacy bit2 scheme); other domains are used by new +/// schemes only. +macro_rules! impl_knuth_shuffle_prp { + ($domain:literal, $gt_mask:path) => { + impl Prp for KnuthShufflePRP { + /* + * Initialize a ($domain element) PRP using a KnuthShuffle + * seeded from a 16-byte key + */ + fn new(key: &[u8]) -> PrpResult { + let mut rng = Aes128Prng::init(key); // TODO: Use Result type here, too + + let mut perm = Self { + permutation: [0u8; $domain], + inverse: [0u8; $domain], + }; + + // Initialize values + for i in 0..$domain { + perm.permutation[i] = i as u8; + } + + // Iterations stop at i = 1: the i = 0 step always + // degenerates to `swap(0, 0)` after drawing + // rejection-sampled bytes until one is zero (expected 256 + // draws — a full PRNG regeneration), and the RNG is dropped + // right after this loop, so skipping it consumes no + // observable state and yields a byte-identical permutation. + (1..$domain).rev().for_each(|i| { + let j = rng.gen_range(i as u8); + perm.permutation.swap(i, j as usize); + }); + + for (index, val) in perm.permutation.iter().enumerate() { + perm.inverse[*val as usize] = index as u8; + } + + Ok(perm) + } - Ok(perm) - } + /* + * Permutes a number under the Pseudo-Random Permutation in constant time. + * + * Forward permutations are only used once in the ORE scheme so this is OK + */ + fn permute(&self, input: u8) -> PrpResult { + let index = usize::from(input); + + match self.inverse.get(index) { + Some(i) => Ok(*i), + None => Err(PrpError), + } + } - /* - * Permutes a number under the Pseudo-Random Permutation in constant time. - * - * Forward permutations are only used once in the ORE scheme so this is OK - */ - fn permute(&self, input: u8) -> PrpResult { - let index = usize::from(input); - - match self.inverse.get(index) { - Some(i) => Ok(*i), - None => Err(PrpError), - } - } + /* + * Performs the inverse permutation in constant time. + */ + fn invert(&self, input: u8) -> PrpResult { + let index = usize::from(input); + + // Forward an inverse permutations are reversed for historical reasons + match self.permutation.get(index) { + Some(i) => Ok(*i), + None => Err(PrpError), + } + } - /* - * Performs the inverse permutation in constant time. - */ - fn invert(&self, input: u8) -> PrpResult { - let index = usize::from(input); + fn indicator_mask_xor(&self, data: u8, out: &mut [u8]) { + debug_assert_eq!(out.len() * 8, $domain); - // Forward an inverse permutations are reversed for historical reasons - match self.permutation.get(index) { - Some(i) => Ok(*i), - None => Err(PrpError), + // `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); + } } - } - - fn indicator_mask_xor(&self, data: u8, out: &mut [u8]) { - debug_assert_eq!(out.len() * 8, 256); - - // `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. - crate::primitives::simd::gt_mask_xor_256(&self.permutation, data, out); - } + }; } +impl_knuth_shuffle_prp!(256, crate::primitives::simd::gt_mask_xor_256); +impl_knuth_shuffle_prp!(64, crate::primitives::simd::gt_mask_xor_64); + #[cfg(test)] mod tests { use super::*; diff --git a/packages/ore-rs/src/primitives/simd.rs b/packages/ore-rs/src/primitives/simd.rs index 4aad834..5b15168 100644 --- a/packages/ore-rs/src/primitives/simd.rs +++ b/packages/ore-rs/src/primitives/simd.rs @@ -42,6 +42,7 @@ pub(crate) fn gt_mask_xor_256(table: &[u8; 256], x: u8, out: &mut [u8]) { // SAFETY: NEON is baseline on aarch64; `out` length asserted above. unsafe { neon::gt_mask_xor_256(table, x, out); + return; } #[cfg(target_arch = "x86_64")] @@ -53,10 +54,26 @@ pub(crate) fn gt_mask_xor_256(table: &[u8; 256], x: u8, out: &mut [u8]) { } } - // Scalar fallback; excluded on aarch64 (the NEON path above always handles - // it) so the dead tail needs no blanket `#[allow(unreachable_code)]`. - #[cfg(not(target_arch = "aarch64"))] - scalar::gt_mask_xor_256(table, x, out); + #[allow(unreachable_code)] + scalar::gt_mask_xor(table, x, out); +} + +/// 64-lane variant of [`gt_mask_xor_256`] for the Bit6 block domain. +/// AVX2 gains little at this size (two 32-lane compares vs the scalar +/// loop's ~9 ns); only NEON is dispatched. +#[inline] +pub(crate) fn gt_mask_xor_64(table: &[u8; 64], x: u8, out: &mut [u8]) { + debug_assert_eq!(out.len(), 8); + + #[cfg(target_arch = "aarch64")] + // SAFETY: NEON is baseline on aarch64; `out` length asserted above. + unsafe { + neon::gt_mask_xor_64(table, x, out); + return; + } + + #[allow(unreachable_code)] + scalar::gt_mask_xor(table, x, out); } /// Pack the LSB of byte 0 of each of 256 AES blocks into `out`: @@ -64,34 +81,26 @@ pub(crate) fn gt_mask_xor_256(table: &[u8; 256], x: u8, out: &mut [u8]) { /// (callers pass `out` slots they own; bits are assigned, not accumulated). #[inline] pub(crate) fn lsb_mask_256(blocks: &[AesBlock], out: &mut [u8]) { - // Real asserts (not debug_assert): the NEON path gathers `blocks` via raw - // pointers assuming exactly 256 blocks, so a shorter slice would read out - // of bounds (UB) in a release build without this check. - assert_eq!(blocks.len(), 256); - assert_eq!(out.len(), 32); + debug_assert_eq!(blocks.len(), 256); + debug_assert_eq!(out.len(), 32); #[cfg(target_arch = "aarch64")] // SAFETY: NEON is baseline on aarch64; lengths asserted above. unsafe { neon::lsb_mask_256(blocks, out); + return; } - // Scalar fallback; excluded on aarch64 (the NEON path above always handles - // it) so the dead tail needs no blanket `#[allow(unreachable_code)]`. - #[cfg(not(target_arch = "aarch64"))] + #[allow(unreachable_code)] scalar::lsb_mask(blocks, out); } pub(crate) mod scalar { use super::AesBlock; - // Used by the non-aarch64 `gt_mask_xor_256` dispatcher and as the test - // oracle; on aarch64 the lib always reaches NEON, so outside `cfg(test)` - // this fn is unreferenced there. Compiling it exactly where it's used keeps - // it from being dead code without an `allow`. (`lsb_mask` below stays - // always compiled — `hash.rs` calls it directly for non-256 inputs.) - #[cfg(any(not(target_arch = "aarch64"), test))] - pub(crate) fn gt_mask_xor_256(table: &[u8; 256], x: u8, out: &mut [u8]) { + /// Length-generic scalar indicator pack: `table.len()` must be a + /// multiple of 8 and equal to `out.len() * 8`. + pub(crate) fn gt_mask_xor(table: &[u8], x: u8, out: &mut [u8]) { for (slot, chunk) in out.iter_mut().zip(table.chunks_exact(8)) { let mut byte = 0u8; for (bit, &p) in chunk.iter().enumerate() { @@ -142,6 +151,17 @@ mod neon { } } + #[target_feature(enable = "neon")] + pub(super) unsafe fn gt_mask_xor_64(table: &[u8; 64], x: u8, out: &mut [u8]) { + let xv = vdupq_n_u8(x); + for i in 0..4 { + let t = vld1q_u8(table.as_ptr().add(i * 16)); + let (lo, hi) = pack_addv(vcgtq_u8(t, xv)); + out[2 * i] ^= lo; + out[2 * i + 1] ^= hi; + } + } + /// `IDX[g]` places the 4 block-start bytes of 64-byte group `g` /// (offsets 0, 16, 32, 48) at lanes 4g..4g+3; all other lanes are 0xFF /// (out-of-range, yielding 0 in `tbl`). @@ -230,7 +250,26 @@ mod tests { let mut b = a; gt_mask_xor_256(&table, x, &mut a); - scalar::gt_mask_xor_256(&table, x, &mut b); + scalar::gt_mask_xor(&table, x, &mut b); + assert_eq!(a, b); + } + } + + #[test] + fn gt_mask_64_dispatched_matches_scalar() { + let mut rng = ChaCha20Rng::seed_from_u64(13); + for _ in 0..5_000 { + let mut table = [0u8; 64]; + rng.fill(&mut table[..]); + // Block values and table entries are < 64 in real use, but the + // kernels must agree on all byte values. + let x: u8 = rng.gen(); + let mut a = [0u8; 8]; + rng.fill(&mut a[..]); + let mut b = a; + + gt_mask_xor_64(&table, x, &mut a); + scalar::gt_mask_xor(&table, x, &mut b); assert_eq!(a, b); } } diff --git a/packages/ore-rs/src/scheme.rs b/packages/ore-rs/src/scheme.rs index 33198b4..25df7c0 100644 --- a/packages/ore-rs/src/scheme.rs +++ b/packages/ore-rs/src/scheme.rs @@ -7,5 +7,10 @@ /// 2-bit-indicator BlockORE with AES-128 PRF and Knuth-shuffle PRP. pub mod bit2; +/// 6-bit-block variant of [`bit2`]: 4x less AES work per block and 4x +/// smaller right blocks. Wire format v2 (headered). Not yet frozen — +/// pending crypto review of the Z2 hash (v2 plan, section 6). +pub mod bit2_w6; + pub(crate) mod decompose; pub(crate) mod width; diff --git a/packages/ore-rs/src/scheme/bit2_w6.rs b/packages/ore-rs/src/scheme/bit2_w6.rs new file mode 100644 index 0000000..9b1d831 --- /dev/null +++ b/packages/ore-rs/src/scheme/bit2_w6.rs @@ -0,0 +1,582 @@ +//! BlockORE with **6-bit input blocks** (domain 64): the `(Bit6, packed +//! prefix, fixed-N)` scheme from the ORE v2 plan. +//! +//! Plaintext bytes are decomposed MSB-first into 6-bit block values (see +//! [`crate::scheme::decompose`]); `N` in the types below counts *blocks*, +//! not bytes (a `u64` is 11 blocks). Compared to the legacy 8-bit scheme, +//! each block costs 64 random-oracle evaluations instead of 256 and stores +//! an 8-byte right block instead of 32 — a `u64` ciphertext is 295 bytes +//! (vs 408) including the 4-byte v2 wire header. +//! +//! The packed prefix caps `N` at 14 (`prefix ≤ 13 ‖ value ‖ index` plus +//! the block count in byte 15 must fit one AES block), which covers all +//! primitives up to 64 bits. `u128`/`i128`/`Decimal` stay on the legacy +//! scheme until the chained-prefix construction lands (plan §5). +//! +//! **Status: wire format NOT yet frozen.** The Z2 hash is the fixed-π MMO +//! construction proposed in plan §6 (option 3), pending crypto review; +//! flipping [`Z2Hash`] re-keys the right ciphertexts without any other +//! code change. Do not store ciphertexts produced by this scheme until +//! the review lands and vectors are pinned. + +use crate::{ + ciphertext::*, + primitives::{ + hash::FixedPiZ2Hash, prf::Aes128Prf, AesBlock, Hash, HashKey, Prf, Prp, NONCE_SIZE, + }, + scheme::width::{AesBlockBuf, Bit6, BlockWidth, RightBitVec}, + OreCipher, OreError, PlainText, +}; + +use aes::cipher::generic_array::GenericArray; +use rand::{Rng, SeedableRng}; +use rand_chacha::ChaCha20Rng; +use std::cell::RefCell; +use std::cmp::Ordering; +use subtle_ng::{Choice, ConditionallySelectable, ConstantTimeEq}; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +/// Per-block ciphertext component types for this scheme. +pub mod block_types; +pub use self::block_types::*; + +/// The Z2 random-oracle instantiation for this scheme (plan §6, option 3 — +/// pending review; option 2 fallback is `Aes128Z2Hash` with an extra +/// feedforward, and the legacy construction is `Aes128Z2Hash`). +type Z2Hash = FixedPiZ2Hash; + +/// Maximum number of blocks: the packed prefix (`prefix ‖ value ‖ index`) +/// plus the block count at byte 15 must fit one 16-byte AES input. +pub const MAX_BLOCKS: usize = 14; + +/// AES-128 BlockORE cipher over 6-bit blocks, generic over the RNG used +/// for per-encryption nonces. Keys are zeroised on drop. +#[derive(Debug, ZeroizeOnDrop)] +pub struct OreAes128Bit6 { + prf1: Aes128Prf, + prf2: Aes128Prf, + #[zeroize(skip)] + rng: RefCell, +} + +/// Convenience alias for [`OreAes128Bit6`] backed by `ChaCha20Rng`. +pub type OreAes128Bit6ChaCha20 = OreAes128Bit6; + +type EncryptLeftResult = Result, N>, OreError>; +type EncryptResult = Result, N>, OreError>; + +/// v2 wire header for this scheme: version 2, scheme id 0x02 +/// (= AES-128 suite, 6-bit blocks, packed prefix). +const WIRE: WireHeader = WireHeader { + version: 0x02, + scheme_id: 0x02, +}; + +/// PRP seeds are key-equivalent material; see the bit2 sibling for the +/// full rationale. Zeroized on drop. +struct SeedBuf([AesBlock; N]); + +impl Drop for SeedBuf { + fn drop(&mut self) { + for seed in self.0.iter_mut() { + seed.zeroize(); + } + } +} + +impl OreAes128Bit6 { + /// Per-block PRP seeds: `PRF₂(x[0..n] ‖ 0… ‖ N)`. Unlike the legacy + /// scheme, the block count is bound into byte 15 (domain separation + /// across plaintext shapes under shared keys, plan §4) — though the + /// per-block index is still absent by construction (prefix-equal + /// plaintexts must share seeds only up to the first differing block; + /// binding the *count* keeps cross-type prefixes apart). + fn derive_prp_seeds(&self, 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]); + block[15] = N as u8; + } + self.prf2.encrypt_all(&mut seeds); + SeedBuf(seeds) + } +} + +fn encode_right_block( + block: &mut ::RightBlock, + prp: &::Prp, + x: u8, + hasher: &Z2Hash, + ro_blocks: &mut [AesBlock], +) { + debug_assert_eq!(ro_blocks.len(), ::DOMAIN); + let out = block.as_mut_bytes(); + hasher.hash_all_into(ro_blocks, out); + prp.indicator_mask_xor(x, out); +} + +impl OreCipher for OreAes128Bit6 { + type LeftBlockType = LeftBlock16; + type RightBlockType = RightBlock8; + + const WIRE_HEADER: Option = Some(WIRE); + + fn init(k1: &[u8; 16], k2: &[u8; 16]) -> Result { + let rng: R = SeedableRng::from_entropy(); + + Ok(OreAes128Bit6 { + prf1: Prf::new(GenericArray::from_slice(k1)), + prf2: Prf::new(GenericArray::from_slice(k2)), + rng: RefCell::new(rng), + }) + } + + /// Encrypt `x`, whose entries are **6-bit block values** (`< 64`, + /// produced by [`crate::scheme::decompose`]); `N` is the block count + /// (≤ [`MAX_BLOCKS`]). The [`crate::OreEncrypt`] impls handle the + /// byte→block decomposition for primitive types. + fn encrypt_left(&self, x: &PlainText) -> 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); + + for n in 0..N { + let prp: ::Prp = Prp::new(&seeds.0[n])?; + output.xt[n] = prp.permute(x[n])?; + + output.f[n][0..n].clone_from_slice(&x[0..n]); + output.f[n][n] = output.xt[n]; + output.f[n][N] = n as u8; + output.f[n][15] = N as u8; + } + self.prf1.encrypt_all(&mut output.f); + + Ok(output) + } + + fn encrypt(&self, x: &PlainText) -> 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 hasher: Z2Hash = Hash::new(HashKey::from_slice(&right.nonce)); + + // RO key template, maintained incrementally (see the bit2 sibling). + // Entry j for block n is (x[0..n] ‖ j ‖ 0… ‖ n@N ‖ N@15). + let mut template = ::RoKeyBuf::zeroed(); + for (j, entry) in template.iter_mut().enumerate() { + entry[0] = j as u8; + entry[15] = N as u8; + } + let mut work = ::RoKeyBuf::zeroed(); + + 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]; + 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] = j as u8; + entry[N] = n as u8; + } + } + + 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); + } + + 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 }) + } + + fn compare_raw_slices(a: &[u8], b: &[u8]) -> Option { + if a.len() != b.len() { + return None; + } + let (header_a, a) = parse_header(a).ok()?; + let (header_b, b) = parse_header(b).ok()?; + if header_a != header_b || (header_a.0, header_a.1) != (WIRE.version, WIRE.scheme_id) { + return None; + } + let num_blocks = header_a.2; + if num_blocks > MAX_BLOCKS { + return None; + } + + let left_size = Self::LeftBlockType::BLOCK_SIZE; + let right_size = Self::RightBlockType::BLOCK_SIZE; + if a.len() != num_blocks * (left_size + 1 + right_size) + NONCE_SIZE { + return None; + } + + let mut is_equal = Choice::from(1); + let mut l: u64 = 0; // Unequal block + + // Slices for the PRF ("f") blocks + let a_f = &a[num_blocks..]; + let b_f = &b[num_blocks..]; + + 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; + + l.conditional_assign(&(n as u64), is_equal & condition); + is_equal.conditional_assign(&Choice::from(0), is_equal & condition); + } + + 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: Z2Hash = Hash::new(HashKey::from_slice(&b_right[0..NONCE_SIZE])); + let h = hash.hash(left_block(a_f, l)); + + let target_block = right_block(&b_right[NONCE_SIZE..], l); + let test = get_bit(target_block, a[l] as usize) ^ h; + + if test == 1 { + return Some(Ordering::Greater); + } + + Some(Ordering::Less) + } +} + +#[inline] +fn left_block(input: &[u8], n: usize) -> &[u8] { + let f_pos = n * LeftBlock16::BLOCK_SIZE; + &input[f_pos..(f_pos + LeftBlock16::BLOCK_SIZE)] +} + +#[inline] +fn right_block(input: &[u8], n: usize) -> &[u8] { + let f_pos = n * RightBlock8::BLOCK_SIZE; + &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); + let byte_index = bit / 8; + let position = bit % 8; + let v = 1 << position; + + (block[byte_index] & v) >> position +} + +impl PartialEq for CipherText { + fn eq(&self, b: &Self) -> bool { + matches!(self.cmp(b), Ordering::Equal) + } +} + +impl Ord for CipherText { + fn cmp(&self, b: &Self) -> Ordering { + let mut is_equal = Choice::from(1); + let mut l: u64 = 0; // Unequal block + + 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])); + + l.conditional_assign(&(n as u64), is_equal & condition); + is_equal.conditional_assign(&Choice::from(0), is_equal & condition); + } + + 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 test = b.right.data[l].get_bit(self.left.xt[l] as usize) ^ h; + if test == 1 { + return Ordering::Greater; + } + + Ordering::Less + } +} + +impl PartialOrd for CipherText { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Eq for CipherText {} + +// --------------------------------------------------------------------------- +// OreEncrypt impls: byte → 6-bit-block decomposition per primitive type. +// --------------------------------------------------------------------------- + +mod encrypt_impls { + use super::{OreAes128Bit6, MAX_BLOCKS}; + use crate::scheme::decompose::{decompose_6bit, num_blocks_6bit}; + use crate::{CipherText, Left, OreCipher, OreEncrypt, OreError}; + use orderable_bytes::ToOrderableBytes; + use rand::{Rng, SeedableRng}; + + macro_rules! impl_ore_encrypt_bit6 { + ($type:ty, $blocks_const:ident) => { + /// Block count for this type at 6-bit width. + const $blocks_const: usize = num_blocks_6bit(<$type as ToOrderableBytes>::ENCODED_LEN); + // The packed prefix caps the block count; types beyond it + // (u128, i128, Decimal) must not get these impls. + const _: () = assert!($blocks_const <= MAX_BLOCKS); + + impl OreEncrypt> for $type { + type LeftOutput = Left, $blocks_const>; + type FullOutput = CipherText, $blocks_const>; + + fn encrypt_left( + &self, + cipher: &OreAes128Bit6, + ) -> Result { + let bytes = self.to_orderable_bytes(); + let mut blocks = [0u8; $blocks_const]; + decompose_6bit(&bytes, &mut blocks); + cipher.encrypt_left(&blocks) + } + + fn encrypt(&self, cipher: &OreAes128Bit6) -> Result { + let bytes = self.to_orderable_bytes(); + let mut blocks = [0u8; $blocks_const]; + decompose_6bit(&bytes, &mut blocks); + cipher.encrypt(&blocks) + } + } + }; + } + + impl_ore_encrypt_bit6!(bool, BOOL_BLOCKS); + impl_ore_encrypt_bit6!(u8, U8_BLOCKS); + impl_ore_encrypt_bit6!(i8, I8_BLOCKS); + impl_ore_encrypt_bit6!(u16, U16_BLOCKS); + impl_ore_encrypt_bit6!(i16, I16_BLOCKS); + impl_ore_encrypt_bit6!(u32, U32_BLOCKS); + impl_ore_encrypt_bit6!(i32, I32_BLOCKS); + impl_ore_encrypt_bit6!(u64, U64_BLOCKS); + impl_ore_encrypt_bit6!(i64, I64_BLOCKS); + impl_ore_encrypt_bit6!(char, CHAR_BLOCKS); + impl_ore_encrypt_bit6!(f32, F32_BLOCKS); + impl_ore_encrypt_bit6!(f64, F64_BLOCKS); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::encrypt::OreEncrypt; + use crate::OreOutput; + use quickcheck::TestResult; + + type Ore = OreAes128Bit6ChaCha20; + + fn init_ore() -> Ore { + let mut k1: [u8; 16] = Default::default(); + let mut k2: [u8; 16] = Default::default(); + + let mut rng = ChaCha20Rng::from_entropy(); + + rng.fill(&mut k1); + rng.fill(&mut k2); + + OreCipher::init(&k1, &k2).unwrap() + } + + quickcheck! { + fn compare_u64(x: u64, y: u64) -> bool { + let ore = init_ore(); + let a = x.encrypt(&ore).unwrap(); + let b = y.encrypt(&ore).unwrap(); + + match x.cmp(&y) { + Ordering::Greater => a > b, + Ordering::Less => a < b, + Ordering::Equal => a == b + } + } + + fn compare_u64_raw_slices(x: u64, y: u64) -> bool { + let ore = init_ore(); + let a = x.encrypt(&ore).unwrap().to_bytes(); + let b = y.encrypt(&ore).unwrap().to_bytes(); + + match Ore::compare_raw_slices(&a, &b) { + Some(Ordering::Greater) => x > y, + Some(Ordering::Less) => x < y, + Some(Ordering::Equal) => x == y, + None => false + } + } + + fn equality_u64(x: u64) -> bool { + let ore = init_ore(); + let a = x.encrypt(&ore).unwrap(); + let b = x.encrypt(&ore).unwrap(); + + a == b + } + + fn compare_u32(x: u32, y: u32) -> bool { + let ore = init_ore(); + let a = x.encrypt(&ore).unwrap(); + let b = y.encrypt(&ore).unwrap(); + + match x.cmp(&y) { + Ordering::Greater => a > b, + Ordering::Less => a < b, + Ordering::Equal => a == b + } + } + + fn compare_f64(x: f64, y: f64) -> TestResult { + if x.is_nan() || x.is_infinite() || y.is_nan() || y.is_infinite() { + return TestResult::discard(); + } + + let ore = init_ore(); + let a = x.encrypt(&ore).unwrap(); + let b = y.encrypt(&ore).unwrap(); + + match x.partial_cmp(&y) { + Some(Ordering::Greater) => TestResult::from_bool(a > b), + Some(Ordering::Less) => TestResult::from_bool(a < b), + Some(Ordering::Equal) => TestResult::from_bool(a == b), + None => TestResult::failed() + } + } + + fn serialize_roundtrip_u64(x: u64) -> bool { + let ore = init_ore(); + let a = x.encrypt(&ore).unwrap(); + let bytes = a.to_bytes(); + let b = CipherText::::from_slice(&bytes).unwrap(); + a == b + } + } + + #[test] + fn ciphertext_sizes() { + // u64: 11 blocks. header(4) + xt(11) + f(11*16) + nonce(16) + right(11*8) + assert_eq!(CipherText::::size(), 4 + 11 + 176 + 16 + 88); + let ore = init_ore(); + let ct = 456u64.encrypt(&ore).unwrap(); + assert_eq!(ct.to_bytes().len(), 295); + } + + #[test] + fn header_emitted_and_validated() { + let ore = init_ore(); + let mut bytes = 456u64.encrypt(&ore).unwrap().to_bytes(); + assert_eq!(&bytes[0..4], &[0x02, 0x02, 0x00, 11]); + + // Corrupt each header field; parsing must fail. + for i in 0..4 { + let mut bad = bytes.clone(); + bad[i] ^= 0xff; + assert!(CipherText::::from_slice(&bad).is_err()); + } + + // Truncation fails. + bytes.pop(); + assert!(CipherText::::from_slice(&bytes).is_err()); + } + + #[test] + fn cross_scheme_comparison_rejected() { + use crate::scheme::bit2::OreAes128ChaCha20; + + let k1 = [1u8; 16]; + let k2 = [2u8; 16]; + let legacy: OreAes128ChaCha20 = OreCipher::init(&k1, &k2).unwrap(); + let bit6: Ore = OreCipher::init(&k1, &k2).unwrap(); + + let a = 456u64.encrypt(&legacy).unwrap().to_bytes(); + let b = 456u64.encrypt(&bit6).unwrap().to_bytes(); + + assert_eq!(Ore::compare_raw_slices(&a, &b), None); + assert_eq!(Ore::compare_raw_slices(&b, &a), None); + // The legacy comparator infers block count from length; a Bit6 + // u64 ciphertext (295 bytes) never matches a legacy length for + // equal-length inputs, and unequal lengths return None up front. + assert_eq!(OreAes128ChaCha20::compare_raw_slices(&a, &b), None); + } + + #[test] + fn cross_block_count_comparison_rejected() { + let ore = init_ore(); + let a = 456u64.encrypt(&ore).unwrap().to_bytes(); + let b = 456u32.encrypt(&ore).unwrap().to_bytes(); + assert_eq!(Ore::compare_raw_slices(&a, &b), None); + } + + #[test] + fn smallest_to_largest() { + let ore = init_ore(); + let a = 0u64.encrypt(&ore).unwrap(); + let b = u64::MAX.encrypt(&ore).unwrap(); + + assert!(a < b); + } + + #[test] + fn comparisons_in_last_block() { + let ore = init_ore(); + let a = 10u64.encrypt(&ore).unwrap(); + let b = 73u64.encrypt(&ore).unwrap(); + + assert!(a < b); + assert!(b > a); + } + + #[test] + fn different_keys_not_equal() { + let k1 = [1u8; 16]; + let k2 = [2u8; 16]; + let k3 = [3u8; 16]; + + let ore1: Ore = OreCipher::init(&k1, &k2).unwrap(); + let ore2: Ore = OreCipher::init(&k3, &k2).unwrap(); + + let a = 1000u32.encrypt(&ore1).unwrap().to_bytes(); + let b = 1000u32.encrypt(&ore2).unwrap().to_bytes(); + + assert_ne!(Some(Ordering::Equal), Ore::compare_raw_slices(&a, &b)); + } +} diff --git a/packages/ore-rs/src/scheme/bit2_w6/block_types.rs b/packages/ore-rs/src/scheme/bit2_w6/block_types.rs new file mode 100644 index 0000000..403a4d7 --- /dev/null +++ b/packages/ore-rs/src/scheme/bit2_w6/block_types.rs @@ -0,0 +1,65 @@ +use zeroize::Zeroize; + +use crate::ciphertext::{CipherTextBlock, ParseError}; +use crate::scheme::width::RightBitVec; + +pub use crate::scheme::bit2::block_types::LeftBlock16; + +/// Per-block component of the Right half of a `bit2_w6` ciphertext: an +/// 8-byte (64-bit) bitvector encoding one masked truth-table row, one bit +/// per value in the 6-bit block domain. +#[derive(Debug, Copy, Clone, Default)] +pub struct RightBlock8 { + data: [u8; 8], +} + +impl RightBlock8 { + /// Read bit `bit` (in `0..64`); returns `0` or `1`. + #[inline] + pub fn get_bit(&self, bit: usize) -> u8 { + debug_assert!(bit < 64); + let byte_index = bit / 8; + let position = bit % 8; + let v = 1 << position; + + (self.data[byte_index] & v) >> position + } +} + +impl RightBitVec for RightBlock8 { + fn set_bit(&mut self, bit: usize, value: u8) { + debug_assert!(bit < 64); + let byte_index = bit / 8; + let mask = bit % 8; + self.data[byte_index] |= value << mask; + } + fn get_bit(&self, bit: usize) -> u8 { + RightBlock8::get_bit(self, bit) + } + fn as_mut_bytes(&mut self) -> &mut [u8] { + &mut self.data + } +} + +impl CipherTextBlock for RightBlock8 { + const BLOCK_SIZE: usize = 8; + + fn to_bytes(self) -> Vec { + self.data.to_vec() + } + + fn from_bytes(data: &[u8]) -> Result { + if data.len() != Self::BLOCK_SIZE { + Err(ParseError) + } else { + let mut arr = [0; 8]; + arr.clone_from_slice(data); + + Ok(Self { data: arr }) + } + } + + fn default_in_place(&mut self) { + self.data.zeroize() + } +} diff --git a/packages/ore-rs/src/scheme/width.rs b/packages/ore-rs/src/scheme/width.rs index e07417b..7399d21 100644 --- a/packages/ore-rs/src/scheme/width.rs +++ b/packages/ore-rs/src/scheme/width.rs @@ -13,11 +13,14 @@ use crate::ciphertext::CipherTextBlock; use crate::primitives::{AesBlock, Prp}; use crate::scheme::bit2::block_types::RightBlock32; +use crate::scheme::bit2_w6::block_types::RightBlock8; mod sealed { pub trait Sealed {} impl Sealed for super::Bit8 {} + impl Sealed for super::Bit6 {} impl Sealed for [super::AesBlock; 256] {} + impl Sealed for [super::AesBlock; 64] {} } /// A domain-sized buffer of AES blocks used for per-block random-oracle @@ -35,18 +38,25 @@ pub trait AesBlockBuf: sealed::Sealed { 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); - } +macro_rules! impl_aes_block_buf { + ($n:literal) => { + impl AesBlockBuf for [AesBlock; $n] { + fn zeroed() -> Self { + [AesBlock::default(); $n] + } + fn as_mut_slice(&mut self) -> &mut [AesBlock] { + self + } + fn copy_from(&mut self, other: &Self) { + self.clone_from_slice(other); + } + } + }; } +impl_aes_block_buf!(256); +impl_aes_block_buf!(64); + /// Per-block bitvector operations on a Right ciphertext block, one bit per /// value in the block domain. pub trait RightBitVec { @@ -102,3 +112,17 @@ impl BlockWidth for Bit8 { type Prp = crate::primitives::prp::KnuthShufflePRP; type RoKeyBuf = [AesBlock; 256]; } + +/// The 6-bit block width used by [`crate::scheme::bit2_w6`]: six plaintext +/// bits per block, domain 64. Right blocks are 8 bytes (vs 32) and each +/// block costs 64 RO evaluations (vs 256). +#[derive(Debug)] +pub struct Bit6; + +impl BlockWidth for Bit6 { + const BITS: usize = 6; + const DOMAIN: usize = 64; + type RightBlock = RightBlock8; + type Prp = crate::primitives::prp::KnuthShufflePRP; + type RoKeyBuf = [AesBlock; 64]; +} From c44106ac40a676e41d45380ebaea769f9f2fdede Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 13 Jun 2026 11:40:35 +1000 Subject: [PATCH 02/19] =?UTF-8?q?docs:=20PRP=20spike=20results=20=E2=80=94?= =?UTF-8?q?=20fixed-draw=20Fisher-Yates=20selected,=20swap-or-not=20reject?= =?UTF-8?q?ed=20at=20q=3DN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-06-12-ore-v2-architecture.md | 32 +++++++++++++++++--- 1 file changed, 28 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 3dbb859..b88f811 100644 --- a/docs/plans/2026-06-12-ore-v2-architecture.md +++ b/docs/plans/2026-06-12-ore-v2-architecture.md @@ -436,10 +436,34 @@ PR 2's trait change, which should be called out in the changelog). ## Open questions -1. **Cheaper PRP for new schemes:** for Bit6's 64-element domain, a small-domain - constant-time PRP (e.g. swap-or-not or a sorting network) could beat the Knuth - shuffle and be SIMD-friendly. New schemes have no compatibility constraint — worth a - spike during PR 5, not a blocker. +1. **Cheaper PRP for new schemes — RESOLVED by spike (2026-06-13, M1 Max, hw AES; + code at `/tmp/ore-prp-spike`, full analysis in its RESULTS.md):** + - **Winner: fixed-draw Fisher–Yates with pre-scheduled stream derivation** — + 63 Lemire-reduced 64-bit draws (fixed 32 AES-CTR blocks, zero rejection + sampling, branch-free), with the stream produced under an already-scheduled + cipher instead of keying AES per block. **153 ns per block** (construction + + permute + indicator mask) vs ~1.36 µs for a port of today's Knuth-64. + Projected Bit6 u64 encrypt: **≈3.3 µs** (vs 11.5 µs in PR 5). Security story: + exact statistical distance ≤ 2⁻⁵⁵ from a uniformly random permutation — the + object Lewi-Wu already models — so it adds a pure statistical term, no new + assumption. One review item: secret-indexed swaps, defended by the + one-cache-line argument (64-byte `#[repr(align(64))]` table). + - **Swap-or-not is REJECTED for this setting**, not on speed but on proof: the + right ciphertext exposes a block PRP's full codebook across encryptions + sharing a prefix, so the honest query budget is q = N, where the HMR bound + `8N^{3/2}/(r+4)` is vacuous for any practical round count at N = 64. The + full-security fix (Morris–Rogaway 2014 sometimes-recurse) introduces + key-dependent recursion depth (a timing channel) and plain r=64 was 3× + slower than the winner anyway. Kept in the spike as the strictly + constant-time fallback if review rejects the cache-line argument. + - **Status-quo deficiency found:** the current rejection-sampled PRNG's draw + count is seed-dependent, and seeds derive from the plaintext prefix — an + encrypt-side plaintext-dependent timing channel in the legacy scheme (wire- + frozen, so document rather than fix there; the new PRP eliminates it). + - The pre-scheduled stream shape saves ~140–165 ns/block for *every* variant + and composes with §5(b): the CMAC accumulator can emit the fixed-count PRP + stream as one more branch family. Adopt in a PR 5 follow-up (Bit6 wire is + not frozen) pending the same crypto review as §6. 2. **`u16` vs `u8` block count in the v2 header:** u16 chosen for strings; confirm no need for >65 535 blocks (≈48 KiB plaintext at Bit6). 3. **Should Bit6 become the default scheme** recommended in the README once shipped, with From a8ecf8a4407b23822211d96e7d5fbd65990ce9a5 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 13 Jun 2026 13:13:27 +1000 Subject: [PATCH 03/19] perf+sec: Bit6 PRP via fixed-draw Fisher-Yates (closes timing channel) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces Bit6's rejection-sampled Knuth shuffle with LemireFyPrp: a Fisher-Yates shuffle driven by a fixed count of wide (64-bit) draws, each reduced to range by Lemire multiply-high ((x * range) >> 64). Why it matters beyond speed: the Knuth shuffle's rejection sampling does a seed-dependent number of draws, and the seed is PRF(plaintext prefix), so PRP construction time was weakly plaintext-dependent — a timing side channel. Fixed-count draws make construction time seed-independent and branch-free, closing it. Uniformity is provable: each Lemire reduction is within range/2^64 of uniform, so the permutation is within < 2^-55 of a uniformly random permutation (the object Lewi-Wu models) — a pure statistical term, no new assumption. Scope: - Bit6 only. Bit8 (legacy) stays on the Knuth shuffle: its byte-exact output is wire-frozen, so the timing channel there is documented in the plan rather than fixed (a fix would change ciphertext bytes). - Seed-keyed shape (i): drops into the existing Prp::new(seed) with no architectural change. Bit6 u64 encrypt 11.5us -> 8.6us. - The remaining win to ~3.3us (pre-scheduled stream, shape ii — no per-block AES key schedule) is deferred to PR 6, where the CMAC accumulator can emit the PRP keystream as a branch family under the same crypto review. Documented in the plan + bench doc. Tests: permutation validity + bidirectional round-trip over 32 seeds, determinism, short-key rejection, and indicator-mask-vs-reference quickcheck (guards the gt_mask_xor_64 kernel). Plan open-question 1 and bench results updated. Part of the ORE v2 program (docs/plans/2026-06-12-ore-v2-architecture.md, PR 5). --- .../benchmarks/2026-06-13-bit6-prp-results.md | 51 +++++ docs/plans/2026-06-12-ore-v2-architecture.md | 17 +- packages/ore-rs/src/primitives/prp.rs | 201 +++++++++++++++++- packages/ore-rs/src/scheme/width.rs | 5 +- 4 files changed, 269 insertions(+), 5 deletions(-) create mode 100644 docs/benchmarks/2026-06-13-bit6-prp-results.md diff --git a/docs/benchmarks/2026-06-13-bit6-prp-results.md b/docs/benchmarks/2026-06-13-bit6-prp-results.md new file mode 100644 index 0000000..ee0c789 --- /dev/null +++ b/docs/benchmarks/2026-06-13-bit6-prp-results.md @@ -0,0 +1,51 @@ +# Benchmark results — Bit6 PRP swap to fixed-draw Fisher–Yates + +Apple M1 Max, rustc 1.87.0, hardware AES (`--cfg aes_armv8`), criterion +medians. Change: Bit6's per-block PRP moved from the rejection-sampled +Knuth shuffle to `LemireFyPrp` (fixed-count wide draws + Lemire reduction), +**seed-keyed shape (i)** — the drop-in replacement that fits the existing +`Prp::new(seed)` signature. + +| Bit6 benchmark | before (Knuth) | after (Lemire FY, shape i) | +|---|---:|---:| +| encrypt-u64 (11 blocks) | 11.5 µs | **8.6 µs** | +| encrypt-left-u64 | ~8.9 µs | **5.7 µs** | +| encrypt-u32 (6 blocks) | ~7.8 µs | **4.8 µs** | +| compare-u64 | 182 ns | 183 ns (unchanged) | + +## Why not the spike's 3.3 µs yet + +The spike's headline (153 ns/block, ≈3.3 µs encrypt) is the **pre-scheduled +stream, shape (ii)** — the PRP keystream produced under an already-scheduled +cipher, eliminating the AES key schedule per block (~172 ns × 11 ≈ 1.9 µs). +Shape (i), shipped here, still keys a fresh AES from each block's seed, so it +pays those 11 schedules — hence ~8.6 µs, not 3.3 µs. + +Shape (i) is the right increment for this PR: it fits the `Prp` trait with no +architectural change, and its security story is "identical key-usage +structure to the old PRP, with rejection sampling replaced by fixed-count +Lemire draws." Shape (ii) requires deriving the PRP stream under k2 / as a +PRF branch family, which is exactly the structure the §5(b) CMAC accumulator +introduces — so it lands with PR 6, under the same crypto review, and takes +Bit6 u64 encrypt from 8.6 µs to a projected ~3.3 µs. + +## What this PR's change actually buys now + +1. **Closes the timing channel.** Draw count is fixed and seed-independent; + no rejection loop. Encryption time no longer varies with the (plaintext- + derived) PRP seed. This is the security-relevant part and it ships now. +2. **Removes modulo/​rejection bias** in favour of a provable ≤ 2⁻⁵⁵ + statistical distance from uniform — a clean statistical term in the + Lewi-Wu argument. +3. **25% faster encrypt** even in shape (i), before the larger shape-(ii) + win. + +## End-to-end (u64 encrypt, for context) + +| Build | encrypt-u64 | +|---|---:| +| pre-v2 default (Bit8, software AES) | 381 µs | +| Bit8, hardware AES + bulk encoding (#80) | 25.1 µs | +| Bit6, Knuth PRP (#82 initial) | 11.5 µs | +| **Bit6, Lemire FY shape (i) (this change)** | **8.6 µs** | +| Bit6, Lemire FY shape (ii) (projected, PR 6) | ~3.3 µs | diff --git a/docs/plans/2026-06-12-ore-v2-architecture.md b/docs/plans/2026-06-12-ore-v2-architecture.md index b88f811..ddc5221 100644 --- a/docs/plans/2026-06-12-ore-v2-architecture.md +++ b/docs/plans/2026-06-12-ore-v2-architecture.md @@ -462,8 +462,21 @@ PR 2's trait change, which should be called out in the changelog). frozen, so document rather than fix there; the new PRP eliminates it). - The pre-scheduled stream shape saves ~140–165 ns/block for *every* variant and composes with §5(b): the CMAC accumulator can emit the fixed-count PRP - stream as one more branch family. Adopt in a PR 5 follow-up (Bit6 wire is - not frozen) pending the same crypto review as §6. + stream as one more branch family. + - **SHIPPED in PR 5 (`LemireFyPrp<64>`, Bit6 only — Bit8 stays wire-frozen on + the Knuth shuffle):** the **seed-keyed shape (i)** is in, because it is a + drop-in for the existing `Prp::new(seed)` signature and its security story is + "identical key-usage structure, rejection sampling → fixed-count Lemire + draws." This already closes the timing channel, removes the bias, and takes + Bit6 u64 encrypt **11.5 µs → 8.6 µs** (benchmarks: + `docs/benchmarks/2026-06-13-bit6-prp-results.md`). + - **DEFERRED to PR 6:** the **pre-scheduled shape (ii)** — deriving the PRP + keystream under an already-scheduled cipher (no per-block AES key schedule; + the ~1.9 µs that separates 8.6 µs from the projected ≈3.3 µs). It needs the + PRP stream to come from a PRF/branch family rather than a fresh per-seed key + schedule, which is exactly what the §5(b) CMAC accumulator provides — so it + lands there, under the same crypto review, rather than as a bespoke + key-reuse pattern bolted onto PR 5. 2. **`u16` vs `u8` block count in the v2 header:** u16 chosen for strings; confirm no need for >65 535 blocks (≈48 KiB plaintext at Bit6). 3. **Should Bit6 become the default scheme** recommended in the README once shipped, with diff --git a/packages/ore-rs/src/primitives/prp.rs b/packages/ore-rs/src/primitives/prp.rs index 2ab7e5e..81b6e15 100644 --- a/packages/ore-rs/src/primitives/prp.rs +++ b/packages/ore-rs/src/primitives/prp.rs @@ -1,6 +1,8 @@ pub mod prng; use crate::primitives::prp::prng::Aes128Prng; -use crate::primitives::{Prp, PrpError, PrpResult}; +use crate::primitives::{AesBlock, Prp, PrpError, PrpResult}; +use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit}; +use aes::Aes128; use zeroize::{Zeroize, ZeroizeOnDrop}; #[derive(Zeroize)] @@ -103,8 +105,132 @@ macro_rules! impl_knuth_shuffle_prp { }; } +// The legacy bit2 scheme (256) is wire-frozen on the Knuth shuffle. New +// schemes use [`LemireFyPrp`] below; no other domain is instantiated here. impl_knuth_shuffle_prp!(256, crate::primitives::simd::gt_mask_xor_256); -impl_knuth_shuffle_prp!(64, crate::primitives::simd::gt_mask_xor_64); + +/// PRP over a small domain via a Fisher–Yates shuffle driven by a fixed +/// number of **wide draws** — full 64-bit values reduced to range by +/// Lemire's multiply-high (`(x * range) >> 64`) — with the randomness +/// produced by AES-CTR under the 16-byte seed. Same field/method +/// semantics as [`KnuthShufflePRP`] (`permute` ↦ `inverse`, `invert` and +/// the indicator mask ↦ `permutation`). +/// +/// Contrast with [`KnuthShufflePRP`], which draws single bytes and uses +/// **rejection sampling** to avoid modulo bias: there the number of draws +/// (and PRNG buffer regenerations, and branches taken) depends on the +/// seed, and the seed is `PRF(plaintext prefix)`, so PRP construction time +/// is weakly plaintext-dependent — a timing side-channel. This construction +/// has a seed-independent, branch-free draw count, closing that channel, +/// and is ~9× faster (no rejection loop, no `%`, just multiply-high). +/// +/// Uniformity: each Lemire reduction to range `m` deviates from uniform by +/// at most `m / 2^64`; over the `N-1` draws the output permutation is within +/// statistical distance `< 2^-55` (for `N = 64`) of a uniformly random +/// permutation — exactly the object Lewi-Wu's analysis assumes. That is a +/// pure statistical term on top of the scheme's existing PRF advantage: no +/// new assumption, no new idealised model. +/// +/// New (non-wire-frozen) schemes only. +#[derive(Zeroize)] +pub struct LemireFyPrp { + permutation: [u8; N], + inverse: [u8; N], +} + +impl Drop for LemireFyPrp { + fn drop(&mut self) { + self.zeroize(); + } +} + +impl ZeroizeOnDrop for LemireFyPrp {} + +/// Implements `Prp` for [`LemireFyPrp`] over `$domain` elements, drawing +/// `$domain - 1` wide values from `$stream_blocks` AES-CTR blocks +/// (`$stream_blocks == ((($domain - 1) * 8) + 15) / 16`). `$gt_mask` is the +/// bulk indicator kernel for the domain. +macro_rules! impl_lemire_fy_prp { + ($domain:literal, $stream_blocks:literal, $gt_mask:path) => { + impl Prp for LemireFyPrp<$domain> { + fn new(key: &[u8]) -> PrpResult { + if key.len() < 16 { + return Err(PrpError); + } + + // Fixed-count AES-CTR keystream from the seed: counter in + // the first 4 bytes (big-endian), matching the existing + // PRNG's counter convention. + let cipher = Aes128::new(GenericArray::from_slice(&key[0..16])); + let mut blocks = [AesBlock::default(); $stream_blocks]; + for (i, b) in blocks.iter_mut().enumerate() { + b[0..4].copy_from_slice(&(i as u32).to_be_bytes()); + } + cipher.encrypt_blocks(&mut blocks); + + let mut stream = [0u8; $stream_blocks * 16]; + for (i, b) in blocks.iter().enumerate() { + stream[i * 16..(i + 1) * 16].copy_from_slice(b); + } + + let mut perm = Self { + permutation: [0u8; $domain], + inverse: [0u8; $domain], + }; + for (i, p) in perm.permutation.iter_mut().enumerate() { + *p = i as u8; + } + + // Fisher–Yates with Lemire-reduced wide draws: draw `d` + // (8 bytes) drives step `i`. Fixed trip count, branch-free + // index reduction (the swap address is secret — defended by + // the single-cache-line argument; the table is `$domain` + // bytes). + for i in (1..$domain).rev() { + let d = $domain - 1 - 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 = ((x as u128 * (i as u128 + 1)) >> 64) as usize; + perm.permutation.swap(i, j); + } + + for (index, val) in perm.permutation.iter().enumerate() { + perm.inverse[*val as usize] = index as u8; + } + + // The keystream determined the permutation — wipe it. + stream.zeroize(); + for b in blocks.iter_mut() { + b.as_mut_slice().zeroize(); + } + + Ok(perm) + } + + fn permute(&self, input: u8) -> PrpResult { + match self.inverse.get(usize::from(input)) { + Some(i) => Ok(*i), + None => Err(PrpError), + } + } + + fn invert(&self, input: u8) -> PrpResult { + match self.permutation.get(usize::from(input)) { + Some(i) => Ok(*i), + None => Err(PrpError), + } + } + + fn indicator_mask_xor(&self, data: u8, out: &mut [u8]) { + debug_assert_eq!(out.len() * 8, $domain); + $gt_mask(&self.permutation, data, out); + } + } + }; +} + +impl_lemire_fy_prp!(64, 32, crate::primitives::simd::gt_mask_xor_64); #[cfg(test)] mod tests { @@ -153,4 +279,75 @@ mod tests { Ok(()) } + + // ----------------------------------------------------------------- + // LemireFyPrp (Bit6 PRP) + // ----------------------------------------------------------------- + + fn init_fy(seed_byte: u8) -> LemireFyPrp<64> { + Prp::new(&[seed_byte; 16]).unwrap() + } + + #[test] + fn fy_is_a_permutation_and_round_trips() { + for seed in 0u8..32 { + let prp = init_fy(seed); + // 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(); + assert!(mapped < 64); + assert!( + !seen[mapped as usize], + "value {} repeated (seed {})", + mapped, seed + ); + seen[mapped as usize] = true; + } + // 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()); + } + } + } + + #[test] + fn fy_is_deterministic() { + 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()); + } + // 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())); + } + + #[test] + fn fy_rejects_short_key() { + assert!( as Prp>::new(&[0u8; 8]).is_err()); + } + + quickcheck! { + /// The bulk indicator mask must agree with the per-bit reference for + /// the Bit6 PRP too (guards the gt_mask_xor_64 kernel path). + fn fy_indicator_mask_matches_reference(key: Vec, x: u8) -> quickcheck::TestResult { + if key.len() < 16 { + return quickcheck::TestResult::discard(); + } + let prp: LemireFyPrp<64> = Prp::new(&key[0..16]).unwrap(); + + 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); + reference[(j / 8) as usize] |= indicator << (j % 8); + } + + quickcheck::TestResult::from_bool(mask == reference) + } + } } diff --git a/packages/ore-rs/src/scheme/width.rs b/packages/ore-rs/src/scheme/width.rs index 7399d21..754a262 100644 --- a/packages/ore-rs/src/scheme/width.rs +++ b/packages/ore-rs/src/scheme/width.rs @@ -123,6 +123,9 @@ impl BlockWidth for Bit6 { const BITS: usize = 6; const DOMAIN: usize = 64; type RightBlock = RightBlock8; - type Prp = crate::primitives::prp::KnuthShufflePRP; + // Fixed-draw Fisher–Yates, not the rejection-sampled Knuth shuffle: + // 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 RoKeyBuf = [AesBlock; 64]; } From 51fe0d49e2b8fbd3c294e208c1b921933326448c Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 13 Jun 2026 13:18:22 +1000 Subject: [PATCH 04/19] docs: research-blog draft on the PRP timing channel + wide-draw fix Draft analysis for the CipherStash research blog covering the rejection-sampling timing channel in PRP generation, the wide-draw/Lemire fix and its security framing (2^-55 statistical distance vs Lewi-Wu's random-permutation model), the swap-or-not rejection on proof grounds, and the hardware-AES build-flag finding. Cross-references the vitaminc issue. --- ...2026-06-13-the-shuffle-that-leaked-time.md | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 docs/blog/2026-06-13-the-shuffle-that-leaked-time.md diff --git a/docs/blog/2026-06-13-the-shuffle-that-leaked-time.md b/docs/blog/2026-06-13-the-shuffle-that-leaked-time.md new file mode 100644 index 0000000..c8680ba --- /dev/null +++ b/docs/blog/2026-06-13-the-shuffle-that-leaked-time.md @@ -0,0 +1,205 @@ +# The shuffle that leaked time: making order-revealing encryption constant-time *and* faster + +*Draft for the CipherStash research blog — 2026-06-13* + +We've been rewriting `ore-rs`, the order-revealing encryption (ORE) library at the +heart of CipherStash's searchable encryption. The goal was speed. Along the way we +found two things worth writing down: a build-configuration trap that was making our +cryptography run **60× slower** than it should on a whole class of hardware, and a +small **timing side-channel hiding inside a random shuffle** — one that turns out to +be a general-purpose footgun we then found in a second codebase of our own. + +The fix for the second one is a nice example of a recurring theme in applied +cryptography: the "obviously correct" textbook approach (rejection sampling for +unbiased random numbers) was both slower *and* less safe than a slightly cleverer +one, and the cleverer one has a cleaner security proof. Let's walk through it. + +## Background: what the shuffle is doing in ORE + +ORE lets you encrypt numbers so that ciphertexts can be *compared* — you can ask "is +A < B?" on encrypted data — without being able to decrypt them. We use the Lewi–Wu +construction, which breaks a plaintext into small blocks and, for each block, +applies a **pseudo-random permutation (PRP)**: a keyed bijection that shuffles the +block's value space (e.g. all 64 values of a 6-bit block) into a secret order. + +The PRP is built with a **Fisher–Yates shuffle** — the standard in-place shuffle +(what Knuth popularized as Algorithm P). To shuffle `n` items, you walk from the top +down and, at each position `i`, pick a uniformly random index `j` in `0..=i` and swap. +The shuffle is seeded from a key derived (via a PRF) from the plaintext prefix, so +that two values sharing a prefix get the same permutation for the block where they +first differ — that's what makes the comparison work. + +So the whole thing hinges on drawing **a uniform random integer in `0..=i`**. And +that is where the trouble was. + +## The trap: uniform integers in an awkward range + +Your randomness source gives you uniform *bits* — say, a uniform byte in `0..=255`. +But `i+1` is almost never a power of two, so there's no clean way to fold 256 equally +likely bytes into, say, 3 equally likely outcomes. 256 isn't divisible by 3. + +If you just take `byte % 3`, you get **modulo bias**: the values 0..255 don't split +evenly, so some results are slightly more likely than others. For a permutation, that +bias is a real defect — it makes some permutations more probable than others, which is +exactly what a PRP must not do. + +The textbook cure is **rejection sampling**: figure out the largest in-range value you +can accept without bias, and if your draw lands above it, throw it away and draw again. +That's what our code did, and what virtually every "draw a bounded random integer" +helper does. It's unbiased and correct. + +It also has a problem. + +## The problem: rejection sampling leaks time + +Rejection sampling draws a **variable** number of random values. Most of the time you +accept on the first try; sometimes you reject and loop. How many times you loop +depends on the random bytes you happen to draw. + +In our setting those bytes come from a seed, and the seed is a deterministic function +of the **plaintext**. So the number of loop iterations — and therefore the time the +shuffle takes — depends, weakly, on the secret being encrypted. That is a textbook +**timing side-channel**: secret-dependent control flow. + +How exploitable is it, really? Honestly: not very. The variation is tens of +nanoseconds, buried in noise, smeared across many blocks, and an attacker would need an +enormous number of timed samples of chosen or known plaintexts to extract anything. We +are not aware of a practical attack. + +But "we couldn't find an attack" is not the bar we hold cryptographic code to. +Constant-time execution — runtime independent of secret data — is a *discipline*, not a +cost-benefit calculation, precisely because timing channels have a long history of +going from "theoretically interesting" to "practically devastating" when someone finds +the right amplification. A shuffle whose duration depends on the plaintext violates the +discipline. So we fixed it — and the fix happened to make it dramatically faster too. + +## The fix: wide draws and Lemire's multiply-high + +Two ideas, both about *how you draw the random index*, neither touching the shuffle +itself. + +**Idea 1 — draw wide.** Instead of a single byte, draw a full 64-bit value. The +irreducible bias from squeezing a power-of-two range into `n` buckets is always "at +most one bucket is one element bigger than the others." What matters is that ±1 +*relative to the bucket size*: + +- A byte (256 values) split into 63 buckets: 256 = 4·63 + 4, so 4 buckets are + over-represented. Bias ≈ 4/256 ≈ **1.5%**. Significant. +- A 64-bit value split into 63 buckets: still ~63 buckets over-represented, but out of + 2⁶⁴. Bias ≈ 63/2⁶⁴ ≈ **2⁻⁵⁸**. A rounding error. + +The wide draw doesn't remove the bias — it makes it so small that you no longer need +rejection sampling to hide it. That's the unlock: drop the retry loop, and the draw +count becomes **fixed**. No secret-dependent branches, no timing channel. + +**Idea 2 — Lemire's multiply-high.** That still leaves *how* to map a 64-bit value `x` +into `0..=i` without a division (modulo is not only biased but also a variable-latency +instruction on many CPUs). Daniel Lemire's trick: treat `x` as a fraction of the way +through the 64-bit range and scale it into the target range with a single widening +multiply and a shift: + +```rust +// uniform-ish index in [0, range), no division, no rejection +let j = ((x as u128 * range as u128) >> 64) as u64; +``` + +This is `floor(range · x / 2⁶⁴)` — it drops `x` into one of `range` near-equal buckets +using a multiply and a shift, both constant-latency. No loop, no `%`, no branch. + +Put together: a fixed sequence of 63 wide draws, each reduced with a multiply-high, +replaces a variable-length loop of byte draws with rejection. The shuffle is identical; +only the randomness plumbing changed. + +## The security argument: trading "exact" for "constant-time + provable" + +Here's the part a cryptographer cares about. Rejection sampling gives you an *exactly* +uniform permutation. Our replacement gives you one that is uniform up to a **statistical +distance of about 2⁻⁵⁵** from perfectly uniform (we computed the exact bound by summing +the per-draw deviations; the power-of-two ranges contribute exactly zero). + +Is giving up "exact" a loss? No — and this is the elegant part. The Lewi–Wu security +analysis already models each block's permutation as a **uniformly random permutation** +(realized via a PRF). Our construction instantiates exactly that object, up to a +2⁻⁵⁵ statistical term. So the change adds a single, tiny number to the existing +security bound — **no new assumption, no new idealized model, no round-count to argue +about.** A reviewer consumes it as one inequality. + +We were tempted by a fancier option, and rejecting it is instructive. + +## The road not taken: swap-or-not + +For small domains there's a beautiful constant-time PRP called **swap-or-not** +(Hoang–Morris–Rogaway). It's branch-free by construction and vectorizes wonderfully — +on paper it's the "right" cryptographic object for an enciphering problem like ours. We +prototyped it at several round counts. + +We rejected it, and not on speed (though our fixed-draw shuffle was actually faster). +We rejected it on the *proof*. The swap-or-not security bound is excellent when the +adversary can query only a small fraction of the domain. But in ORE, the right-hand +ciphertext of a block effectively exposes the **entire codebook** of that block's +permutation: a single ciphertext reveals an ordered constraint for every value, and +across encryptions sharing a prefix an adversary can reconstruct the whole table. So +the honest query budget is "the adversary sees all N points" — and at full codebook the +swap-or-not bound becomes *vacuous* for any practical number of rounds on a 64-element +domain. The known fix (the "sometimes-recurse" shuffle) reintroduces data-dependent +control flow — a timing channel — which defeats the entire reason we liked it. + +So: a more sophisticated primitive, slower in practice, that we'd have had to ship on a +*heuristic* security argument. Versus a humble shuffle with fixed-count wide draws, +faster, with a one-line proof that plugs into the model we already use. The humble +option wins. (We kept the swap-or-not prototype around as a strictly-constant-time +fallback in case review ever rejects the cache-line argument for the shuffle's one +remaining secret-indexed memory access — but that's a story for another post.) + +## The other finding: your hardware AES might be asleep + +While benchmarking, we noticed our AES was running suspiciously slowly on Apple Silicon +and other ARM64 hardware. The cause: the Rust `aes` crate (v0.8) does **not** +automatically use the ARMv8 cryptography extensions. You have to opt in with a build +flag (`RUSTFLAGS="--cfg aes_armv8"`). Without it, you silently get a software AES +implementation that is roughly **60× slower per block** — and nothing warns you. + +For a u64 encryption in `ore-rs`, just setting that flag took us from 381 µs to 39 µs — +a 9.7× speedup before we changed a single line of algorithm. If you ship cryptography +that runs on ARM servers (gravitons, Apple CI, etc.), check whether your AES is +actually hardware-accelerated. It's an easy and enormous win, and an easy and enormous +thing to miss. + +## Results + +Combining the hardware-AES fix, an allocation-free bulk encoding rewrite, SIMD +kernels, the new 6-bit block scheme, and the constant-time PRP, a `u64` encryption went +from **381 µs to 8.6 µs** on an M1 Max, with a further drop to a projected ~3.3 µs once +the PRP keystream is derived without a per-block key schedule (a change that rides along +with other work in flight). The PRP construction alone went from ~1.36 µs to 153 ns — +about **9× faster** — while *removing* a timing channel rather than adding risk. + +## Responsible disclosure, internally + +The rejection-sampling-as-timing-channel pattern is general. Once we'd named it, we went +looking — and found the same shape in a second CipherStash library, `vitaminc`, in its +bounded-random helper (which feeds both a permutation key generator and a password +generator). Same root cause; same fix. While there we also found that the helper's +power-of-two code path silently broke its own "inclusive range" contract — biasing the +permutation — and that the inclusive bound could drive a reachable out-of-bounds panic +in password generation. All three are resolved by the same move: a single fixed-count, +half-open, wide-draw helper. We've filed an issue and are fixing it. + +## Takeaways + +1. **Rejection sampling is a timing channel** whenever the rejection count depends on + secret-derived randomness. If you draw bounded random integers anywhere near secret + data, prefer a fixed-count method. +2. **Wide draws + Lemire multiply-high** give you fixed-count, branch-free, division-free + bounded integers, biased only to ~2⁻⁵⁵ — negligible, and a clean statistical term in + a security proof rather than a heuristic. +3. **"Exact uniform via rejection" is not automatically the safer choice.** For + secret-dependent draws, "uniform-to-2⁻⁵⁵ in constant time" beats "exactly uniform in + variable time." +4. **The most sophisticated primitive is not always the right one.** Swap-or-not is + lovely; its proof doesn't fit our threat model, so the humble shuffle wins. +5. **Check that your AES is actually hardware AES.** A build flag can be worth 60×. + +*The constant-time PRP and the wide-draw helper are in the `ore-rs` v2 work; the +`vitaminc` fix is tracked in its issue tracker. The cryptographic constructions +described here are under internal review before release.* From cad2ad24f6e8c8b4f59d434fa6fc4ed69894b138 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 13 Jun 2026 14:33:16 +1000 Subject: [PATCH 05/19] docs: rewrite blog draft to CipherStash writing guidelines Restructured to the CipherStash standard (cipherstash-js-suite/prompts/ _shared/writing-guidelines.md): definition -> why it matters -> how it works -> example -> related, with you/we voice, Note/Tip/Warning callouts, a meta description, title options, and a short 'Why this matters for CipherStash' framing. Genre-adapted for a crypto-internals post (Rust snippets rather than the TS default; no sales CTAs). --- ...2026-06-13-the-shuffle-that-leaked-time.md | 366 +++++++++--------- 1 file changed, 174 insertions(+), 192 deletions(-) diff --git a/docs/blog/2026-06-13-the-shuffle-that-leaked-time.md b/docs/blog/2026-06-13-the-shuffle-that-leaked-time.md index c8680ba..0c991ea 100644 --- a/docs/blog/2026-06-13-the-shuffle-that-leaked-time.md +++ b/docs/blog/2026-06-13-the-shuffle-that-leaked-time.md @@ -1,205 +1,187 @@ -# The shuffle that leaked time: making order-revealing encryption constant-time *and* faster - -*Draft for the CipherStash research blog — 2026-06-13* - -We've been rewriting `ore-rs`, the order-revealing encryption (ORE) library at the -heart of CipherStash's searchable encryption. The goal was speed. Along the way we -found two things worth writing down: a build-configuration trap that was making our -cryptography run **60× slower** than it should on a whole class of hardware, and a -small **timing side-channel hiding inside a random shuffle** — one that turns out to -be a general-purpose footgun we then found in a second codebase of our own. - -The fix for the second one is a nice example of a recurring theme in applied -cryptography: the "obviously correct" textbook approach (rejection sampling for -unbiased random numbers) was both slower *and* less safe than a slightly cleverer -one, and the cleverer one has a cleaner security proof. Let's walk through it. - -## Background: what the shuffle is doing in ORE - -ORE lets you encrypt numbers so that ciphertexts can be *compared* — you can ask "is -A < B?" on encrypted data — without being able to decrypt them. We use the Lewi–Wu -construction, which breaks a plaintext into small blocks and, for each block, -applies a **pseudo-random permutation (PRP)**: a keyed bijection that shuffles the -block's value space (e.g. all 64 values of a 6-bit block) into a secret order. - -The PRP is built with a **Fisher–Yates shuffle** — the standard in-place shuffle -(what Knuth popularized as Algorithm P). To shuffle `n` items, you walk from the top -down and, at each position `i`, pick a uniformly random index `j` in `0..=i` and swap. -The shuffle is seeded from a key derived (via a PRF) from the plaintext prefix, so -that two values sharing a prefix get the same permutation for the block where they -first differ — that's what makes the comparison work. - -So the whole thing hinges on drawing **a uniform random integer in `0..=i`**. And -that is where the trouble was. - -## The trap: uniform integers in an awkward range - -Your randomness source gives you uniform *bits* — say, a uniform byte in `0..=255`. -But `i+1` is almost never a power of two, so there's no clean way to fold 256 equally -likely bytes into, say, 3 equally likely outcomes. 256 isn't divisible by 3. - -If you just take `byte % 3`, you get **modulo bias**: the values 0..255 don't split -evenly, so some results are slightly more likely than others. For a permutation, that -bias is a real defect — it makes some permutations more probable than others, which is -exactly what a PRP must not do. - -The textbook cure is **rejection sampling**: figure out the largest in-range value you -can accept without bias, and if your draw lands above it, throw it away and draw again. -That's what our code did, and what virtually every "draw a bounded random integer" -helper does. It's unbiased and correct. - -It also has a problem. - -## The problem: rejection sampling leaks time - -Rejection sampling draws a **variable** number of random values. Most of the time you -accept on the first try; sometimes you reject and loop. How many times you loop -depends on the random bytes you happen to draw. - -In our setting those bytes come from a seed, and the seed is a deterministic function -of the **plaintext**. So the number of loop iterations — and therefore the time the -shuffle takes — depends, weakly, on the secret being encrypted. That is a textbook -**timing side-channel**: secret-dependent control flow. - -How exploitable is it, really? Honestly: not very. The variation is tens of -nanoseconds, buried in noise, smeared across many blocks, and an attacker would need an -enormous number of timed samples of chosen or known plaintexts to extract anything. We -are not aware of a practical attack. - -But "we couldn't find an attack" is not the bar we hold cryptographic code to. -Constant-time execution — runtime independent of secret data — is a *discipline*, not a -cost-benefit calculation, precisely because timing channels have a long history of -going from "theoretically interesting" to "practically devastating" when someone finds -the right amplification. A shuffle whose duration depends on the plaintext violates the -discipline. So we fixed it — and the fix happened to make it dramatically faster too. - -## The fix: wide draws and Lemire's multiply-high - -Two ideas, both about *how you draw the random index*, neither touching the shuffle -itself. - -**Idea 1 — draw wide.** Instead of a single byte, draw a full 64-bit value. The -irreducible bias from squeezing a power-of-two range into `n` buckets is always "at -most one bucket is one element bigger than the others." What matters is that ±1 -*relative to the bucket size*: - -- A byte (256 values) split into 63 buckets: 256 = 4·63 + 4, so 4 buckets are - over-represented. Bias ≈ 4/256 ≈ **1.5%**. Significant. -- A 64-bit value split into 63 buckets: still ~63 buckets over-represented, but out of - 2⁶⁴. Bias ≈ 63/2⁶⁴ ≈ **2⁻⁵⁸**. A rounding error. - -The wide draw doesn't remove the bias — it makes it so small that you no longer need -rejection sampling to hide it. That's the unlock: drop the retry loop, and the draw -count becomes **fixed**. No secret-dependent branches, no timing channel. - -**Idea 2 — Lemire's multiply-high.** That still leaves *how* to map a 64-bit value `x` -into `0..=i` without a division (modulo is not only biased but also a variable-latency -instruction on many CPUs). Daniel Lemire's trick: treat `x` as a fraction of the way -through the 64-bit range and scale it into the target range with a single widening -multiply and a shift: +# A timing channel in a random shuffle — and how we made ORE faster by removing it + +*Draft for the CipherStash research blog — 2026-06-13. Follows +`cipherstash-js-suite/prompts/_shared/writing-guidelines.md`. Code samples are Rust +(the library is `ore-rs`), not the TypeScript default — this is an engine-internals +post, so the product-doc conventions for TS examples and CTAs are adapted accordingly.* + +**Title options** +1. A timing channel in a random shuffle — and how we made ORE faster by removing it +2. Rejection sampling leaks time: a constant-time fix for our ORE permutation +3. Faster *and* safer: replacing rejection sampling in order-revealing encryption + +**Meta description** (152 chars) +> A rejection-sampling shuffle in our order-revealing encryption leaked timing. The fix — fixed-count wide draws — is constant-time and ~9× faster. Here's how. + +--- + +## What this is about + +We rewrote `ore-rs`, the order-revealing encryption (ORE) library behind CipherStash's +searchable encryption, for speed. While benchmarking we found a small **timing +side-channel inside a random shuffle**, and a build setting that was running our AES in +software — **60× slower** than the hardware path — without warning. + +This post covers the shuffle. The fix is a good reminder that the textbook "correct" +way to draw an unbiased random number (rejection sampling) can be both slower *and* +less safe than a slightly smarter one — and that the smarter one has a cleaner security +proof. + +> **Note:** The cryptographic constructions here are under internal review before +> release. This is an engineering write-up, not a security advisory — we have no +> evidence of a practical attack, and we fixed the channel as a matter of discipline. + +## Background: the shuffle inside ORE + +ORE lets you compare encrypted values — "is A < B?" — without decrypting them. We use +the Lewi–Wu construction: it splits a plaintext into small blocks and, per block, +applies a **pseudo-random permutation (PRP)** — a keyed, secret reordering of the +block's value space. + +We build that PRP with a **Fisher–Yates shuffle** (what Knuth popularized as +Algorithm P). To shuffle `n` items you walk from the top down and, at each position `i`, +draw a uniform index `j` in `0..=i` and swap. So the whole construction depends on one +operation: **draw a uniform integer in a range.** + +## Why it matters: drawing a bounded integer can leak time + +Your randomness gives you uniform *bits*. But `i+1` is rarely a power of two, so folding, +say, 256 equally likely bytes into 3 equally likely outcomes doesn't divide evenly — +`byte % 3` is biased. For a permutation, that bias makes some orderings more likely than +others, which a PRP must not do. + +The standard fix is **rejection sampling**: draw, and if the value lands in the biased +tail, throw it away and draw again. It's unbiased and correct. It also draws a +**variable** number of values — and how many depends on the bytes you happen to get. + +Here is the problem. Those bytes come from a seed, and the seed is derived from the +**plaintext**. So the time the shuffle takes depends, weakly, on the secret being +encrypted. + +> **Warning:** A loop whose iteration count depends on secret-derived data is a timing +> side-channel. The leak here is tiny and noisy, and we know of no practical attack — +> but constant-time execution is a discipline, not a cost-benefit call. Timing channels +> have a habit of going from "theoretical" to "exploited" once someone finds the right +> amplification. + +## How the fix works: wide draws + Lemire reduction + +Two ideas, both about *how you draw the index* — the shuffle itself doesn't change. + +**1. Draw wide.** Use a full 64-bit value instead of a byte. The leftover bias from +splitting a power-of-two range into `n` buckets is always "at most one bucket is one +element larger." What matters is that ±1 *relative to bucket size*: + +- A byte (256 values) into 63 buckets: 4 buckets over-represented → bias ≈ **1.5%**. +- A 64-bit value into 63 buckets: ~63 buckets over-represented out of 2⁶⁴ → bias ≈ **2⁻⁵⁸**. + +The wide draw doesn't remove the bias; it shrinks it to a rounding error — small enough +that you no longer need rejection to hide it. Drop the retry loop, and the draw count +becomes **fixed**. + +**2. Reduce with Lemire's multiply-high.** Map a 64-bit `x` into `0..range` with one +widening multiply and a shift — no division, no branch: ```rust -// uniform-ish index in [0, range), no division, no rejection +// uniform index in [0, range): no rejection loop, no modulo let j = ((x as u128 * range as u128) >> 64) as u64; ``` -This is `floor(range · x / 2⁶⁴)` — it drops `x` into one of `range` near-equal buckets -using a multiply and a shift, both constant-latency. No loop, no `%`, no branch. - -Put together: a fixed sequence of 63 wide draws, each reduced with a multiply-high, -replaces a variable-length loop of byte draws with rejection. The shuffle is identical; -only the randomness plumbing changed. - -## The security argument: trading "exact" for "constant-time + provable" - -Here's the part a cryptographer cares about. Rejection sampling gives you an *exactly* -uniform permutation. Our replacement gives you one that is uniform up to a **statistical -distance of about 2⁻⁵⁵** from perfectly uniform (we computed the exact bound by summing -the per-draw deviations; the power-of-two ranges contribute exactly zero). - -Is giving up "exact" a loss? No — and this is the elegant part. The Lewi–Wu security -analysis already models each block's permutation as a **uniformly random permutation** -(realized via a PRF). Our construction instantiates exactly that object, up to a -2⁻⁵⁵ statistical term. So the change adds a single, tiny number to the existing -security bound — **no new assumption, no new idealized model, no round-count to argue -about.** A reviewer consumes it as one inequality. - -We were tempted by a fancier option, and rejecting it is instructive. - -## The road not taken: swap-or-not - -For small domains there's a beautiful constant-time PRP called **swap-or-not** -(Hoang–Morris–Rogaway). It's branch-free by construction and vectorizes wonderfully — -on paper it's the "right" cryptographic object for an enciphering problem like ours. We -prototyped it at several round counts. - -We rejected it, and not on speed (though our fixed-draw shuffle was actually faster). -We rejected it on the *proof*. The swap-or-not security bound is excellent when the -adversary can query only a small fraction of the domain. But in ORE, the right-hand -ciphertext of a block effectively exposes the **entire codebook** of that block's -permutation: a single ciphertext reveals an ordered constraint for every value, and -across encryptions sharing a prefix an adversary can reconstruct the whole table. So -the honest query budget is "the adversary sees all N points" — and at full codebook the -swap-or-not bound becomes *vacuous* for any practical number of rounds on a 64-element -domain. The known fix (the "sometimes-recurse" shuffle) reintroduces data-dependent -control flow — a timing channel — which defeats the entire reason we liked it. - -So: a more sophisticated primitive, slower in practice, that we'd have had to ship on a -*heuristic* security argument. Versus a humble shuffle with fixed-count wide draws, -faster, with a one-line proof that plugs into the model we already use. The humble -option wins. (We kept the swap-or-not prototype around as a strictly-constant-time -fallback in case review ever rejects the cache-line argument for the shuffle's one -remaining secret-indexed memory access — but that's a story for another post.) - -## The other finding: your hardware AES might be asleep - -While benchmarking, we noticed our AES was running suspiciously slowly on Apple Silicon -and other ARM64 hardware. The cause: the Rust `aes` crate (v0.8) does **not** -automatically use the ARMv8 cryptography extensions. You have to opt in with a build -flag (`RUSTFLAGS="--cfg aes_armv8"`). Without it, you silently get a software AES -implementation that is roughly **60× slower per block** — and nothing warns you. - -For a u64 encryption in `ore-rs`, just setting that flag took us from 381 µs to 39 µs — -a 9.7× speedup before we changed a single line of algorithm. If you ship cryptography -that runs on ARM servers (gravitons, Apple CI, etc.), check whether your AES is -actually hardware-accelerated. It's an easy and enormous win, and an easy and enormous -thing to miss. +Together: a fixed sequence of wide draws, each reduced with a multiply-high, replaces a +variable-length rejection loop. + +```rust +// Before: variable draw count — leaks time +let mut v = rng.next_u32() % cap; +while v > max { v = rng.next_u32() % cap; } // retries depend on the seed + +// After: exactly one draw per index — constant-time +let j = ((rng.next_u64() as u128 * (i as u128 + 1)) >> 64) as usize; +``` + +**Expected outcome:** identical shuffle, seed-independent runtime, branch-free, and no +division. + +## The security trade is in our favour + +Rejection sampling gives an *exactly* uniform permutation. The wide-draw version is +uniform to within a **statistical distance of ~2⁻⁵⁵** of perfect. + +That's not a loss, because the Lewi–Wu analysis already models each block's permutation +as a uniformly random permutation. Our construction instantiates exactly that object, up +to a 2⁻⁵⁵ term — so the change adds **one small number to the existing security bound**: +no new assumption, no new model, nothing to argue about. + +> **Tip:** For secret-dependent draws, "uniform to 2⁻⁵⁵ in constant time" is a better +> property than "exactly uniform in variable time." Reach for a fixed-count method +> whenever you draw bounded random integers near secret data. + +## We tried the fancy option and rejected it + +For small domains there's an elegant constant-time PRP, **swap-or-not**. It vectorizes +beautifully and looks like the "right" primitive. We prototyped it — and rejected it on +the *proof*, not on speed (our shuffle was faster anyway). + +Swap-or-not's security bound is strong only when an attacker can query a small fraction +of the value space. But in ORE, a block's ciphertext effectively exposes its **entire** +permutation, so the honest assumption is "the attacker sees everything" — and there the +bound becomes meaningless on a 64-element domain at any practical setting. The known fix +reintroduces data-dependent control flow (a timing channel), defeating the point. A +humble shuffle with a one-line proof beat the sophisticated primitive. + +## The other finding: check that your AES is hardware AES + +While benchmarking we found the Rust `aes` crate (v0.8) does **not** auto-enable the +ARMv8 crypto extensions — you opt in with `RUSTFLAGS="--cfg aes_armv8"`. Without it you +silently get software AES, ~60× slower per block. Setting the flag alone took a `u64` +encryption from 381 µs to 39 µs. + +> **Tip:** If you ship cryptography on ARM servers (Graviton, Apple CI), confirm your +> AES is hardware-accelerated. It's an easy 60× to leave on the table. ## Results -Combining the hardware-AES fix, an allocation-free bulk encoding rewrite, SIMD -kernels, the new 6-bit block scheme, and the constant-time PRP, a `u64` encryption went -from **381 µs to 8.6 µs** on an M1 Max, with a further drop to a projected ~3.3 µs once -the PRP keystream is derived without a per-block key schedule (a change that rides along -with other work in flight). The PRP construction alone went from ~1.36 µs to 153 ns — -about **9× faster** — while *removing* a timing channel rather than adding risk. +| Change | `u64` encrypt (M1 Max) | +|---|---:| +| Starting point (software AES) | 381 µs | +| Hardware AES enabled | 39 µs | +| Full v2 rewrite + constant-time PRP | **8.6 µs** | + +The PRP construction alone dropped from ~1.36 µs to 153 ns — about **9× faster** — +while *removing* a timing channel rather than adding risk. + +## We found the same pattern in our own code + +Once we'd named it, we went looking. The same rejection-sampling shape lives in +`vitaminc`, our Rust cryptography toolkit, in its bounded-random helper (which feeds a +permutation-key generator and a password generator). Same root cause, same fix — plus +two correctness bugs the audit surfaced along the way. We've filed an issue and are +fixing it. + +## Why this matters for CipherStash + +Constant-time discipline is part of what "searchable encryption you can trust" means. +This work makes ORE measurably faster — helping searchable-encryption queries meet +real-time performance needs — and removes a side-channel before it can ever matter. +Finding and fixing the same class of issue across two of our libraries is the kind of +continuous, proactive security posture our customers are buying, not a point-in-time +checkbox. -## Responsible disclosure, internally +## Related -The rejection-sampling-as-timing-channel pattern is general. Once we'd named it, we went -looking — and found the same shape in a second CipherStash library, `vitaminc`, in its -bounded-random helper (which feeds both a permutation key generator and a password -generator). Same root cause; same fix. While there we also found that the helper's -power-of-two code path silently broke its own "inclusive range" contract — biasing the -permutation — and that the inclusive bound could drive a reachable out-of-bounds panic -in password generation. All three are resolved by the same move: a single fixed-count, -half-open, wide-draw helper. We've filed an issue and are fixing it. +- `ore-rs` (the ORE library) and its v2 architecture plan +- CipherStash searchable encryption: [cipherstash.com](https://cipherstash.com) +- Daniel Lemire, "Fast Random Integer Generation in an Interval" (2019) +- Lewi–Wu, "Order-Revealing Encryption: New Constructions, Applications, and Lower + Bounds" (2016) ## Takeaways -1. **Rejection sampling is a timing channel** whenever the rejection count depends on - secret-derived randomness. If you draw bounded random integers anywhere near secret - data, prefer a fixed-count method. -2. **Wide draws + Lemire multiply-high** give you fixed-count, branch-free, division-free - bounded integers, biased only to ~2⁻⁵⁵ — negligible, and a clean statistical term in - a security proof rather than a heuristic. -3. **"Exact uniform via rejection" is not automatically the safer choice.** For - secret-dependent draws, "uniform-to-2⁻⁵⁵ in constant time" beats "exactly uniform in - variable time." -4. **The most sophisticated primitive is not always the right one.** Swap-or-not is - lovely; its proof doesn't fit our threat model, so the humble shuffle wins. -5. **Check that your AES is actually hardware AES.** A build flag can be worth 60×. - -*The constant-time PRP and the wide-draw helper are in the `ore-rs` v2 work; the -`vitaminc` fix is tracked in its issue tracker. The cryptographic constructions -described here are under internal review before release.* +1. **Rejection sampling is a timing channel** when the rejection count depends on + secret-derived randomness. +2. **Wide draws + Lemire multiply-high** give fixed-count, branch-free, bounded integers + biased only to ~2⁻⁵⁵ — negligible, and a clean term in a proof. +3. **"Exact via rejection" isn't automatically safer** for secret-dependent draws. +4. **The most sophisticated primitive isn't always right** — swap-or-not's proof didn't + fit our threat model. +5. **Check that your AES is hardware AES.** A build flag can be worth 60×. From a5f6b0de1a7b69671b041f40a2981ec84c3586f9 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 13 Jun 2026 16:15:43 +1000 Subject: [PATCH 06/19] docs: rewrite blog draft to the blog-writing-voice skill (Dan Draper profile) Redone against cipherstash-js-suite .claude/skills/blog-writing-voice (the canonical voice skill, which post-dated the stale local checkout I first wrote to). Narrative detective-story arc, hook opening instead of a definition, recurring 'old code I was proud of' motif bookended, first- person Dan voice, evocative headers, sparing em-dashes, US English, italic closing aphorism, and the :wq sign-off. --- ...2026-06-13-the-shuffle-that-leaked-time.md | 190 +++++------------- 1 file changed, 52 insertions(+), 138 deletions(-) diff --git a/docs/blog/2026-06-13-the-shuffle-that-leaked-time.md b/docs/blog/2026-06-13-the-shuffle-that-leaked-time.md index 0c991ea..172adcc 100644 --- a/docs/blog/2026-06-13-the-shuffle-that-leaked-time.md +++ b/docs/blog/2026-06-13-the-shuffle-that-leaked-time.md @@ -1,187 +1,101 @@ -# A timing channel in a random shuffle — and how we made ORE faster by removing it + -*Draft for the CipherStash research blog — 2026-06-13. Follows -`cipherstash-js-suite/prompts/_shared/writing-guidelines.md`. Code samples are Rust -(the library is `ore-rs`), not the TypeScript default — this is an engine-internals -post, so the product-doc conventions for TS examples and CTAs are adapted accordingly.* +# The shuffle that told the time -**Title options** -1. A timing channel in a random shuffle — and how we made ORE faster by removing it -2. Rejection sampling leaks time: a constant-time fix for our ORE permutation -3. Faster *and* safer: replacing rejection sampling in order-revealing encryption +Years ago I wrote a little function to shuffle 64 numbers into a secret order. It was part of the first real Rust I ever shipped, the order-revealing encryption that lets CipherStash compare encrypted values without decrypting them. I was proud of it. It was textbook correct. -**Meta description** (152 chars) -> A rejection-sampling shuffle in our order-revealing encryption leaked timing. The fix — fixed-count wide draws — is constant-time and ~9× faster. Here's how. +Last week I set out to make it faster, and discovered it had been quietly telling anyone who cared to measure roughly how long it took to encrypt your data. Which, it turns out, is a function of your data. ---- +This is the story of that bug, the surprisingly elegant fix, and a second surprise that was costing us 60x on half our hardware. There's a moral at the end about reading your own old code. -## What this is about +## The secret reshuffle at the heart of ORE -We rewrote `ore-rs`, the order-revealing encryption (ORE) library behind CipherStash's -searchable encryption, for speed. While benchmarking we found a small **timing -side-channel inside a random shuffle**, and a build setting that was running our AES in -software — **60× slower** than the hardware path — without warning. +Quick orientation, then we'll get to the crime scene. -This post covers the shuffle. The fix is a good reminder that the textbook "correct" -way to draw an unbiased random number (rejection sampling) can be both slower *and* -less safe than a slightly smarter one — and that the smarter one has a cleaner security -proof. +Order-revealing encryption lets you ask "is A less than B?" on ciphertexts, without ever holding the plaintext. We use the Lewi-Wu construction. It chops a value into small blocks and, for each block, scrambles its possible values into a secret keyed order. That scramble is a pseudo-random permutation, a PRP, and we build it with a Fisher-Yates shuffle. It's the same shuffle Knuth taught a generation of us: walk down the array, and at each spot `i` pick a random index `j` somewhere at or below it, then swap. -> **Note:** The cryptographic constructions here are under internal review before -> release. This is an engineering write-up, not a security advisory — we have no -> evidence of a practical attack, and we fixed the channel as a matter of discipline. +So the whole edifice rests on one humble operation. Pick a uniform random number in a range. That's where the leak was hiding. -## Background: the shuffle inside ORE +## ...wait, what's wrong with picking a random number? -ORE lets you compare encrypted values — "is A < B?" — without decrypting them. We use -the Lewi–Wu construction: it splits a plaintext into small blocks and, per block, -applies a **pseudo-random permutation (PRP)** — a keyed, secret reordering of the -block's value space. +Your randomness arrives as uniform *bits*. A byte is a number from 0 to 255. But the range you want, say 0 to 62, almost never divides 256 evenly, so if you just take `byte % 63` some answers come up slightly more often than others. That skew is called modulo bias, and for a permutation it's poison. It makes some orderings more likely than others, and a secret shuffle that prefers certain orders isn't very secret. -We build that PRP with a **Fisher–Yates shuffle** (what Knuth popularized as -Algorithm P). To shuffle `n` items you walk from the top down and, at each position `i`, -draw a uniform index `j` in `0..=i` and swap. So the whole construction depends on one -operation: **draw a uniform integer in a range.** +The proper fix, the one every textbook teaches, is rejection sampling. Draw a byte. If it landed in the biased tail, throw it away and draw another. Keep going until you get a clean one. It's correct, it's unbiased, and I used it without a second thought. -## Why it matters: drawing a bounded integer can leak time +The counterintuitive part kept me up at night. Rejection sampling, the proper unbiased method, was the bug. -Your randomness gives you uniform *bits*. But `i+1` is rarely a power of two, so folding, -say, 256 equally likely bytes into 3 equally likely outcomes doesn't divide evenly — -`byte % 3` is biased. For a permutation, that bias makes some orderings more likely than -others, which a PRP must not do. +## What the timing whispered -The standard fix is **rejection sampling**: draw, and if the value lands in the biased -tail, throw it away and draw again. It's unbiased and correct. It also draws a -**variable** number of values — and how many depends on the bytes you happen to get. +Think about what "keep going until you get a clean one" actually does. It draws a *variable* number of times. Sometimes one draw, sometimes five, depending entirely on the random bytes it happens to see. -Here is the problem. Those bytes come from a seed, and the seed is derived from the -**plaintext**. So the time the shuffle takes depends, weakly, on the secret being -encrypted. +And where do those bytes come from? A seed. And the seed is derived from the plaintext you're encrypting. -> **Warning:** A loop whose iteration count depends on secret-derived data is a timing -> side-channel. The leak here is tiny and noisy, and we know of no practical attack — -> but constant-time execution is a discipline, not a cost-benefit call. Timing channels -> have a habit of going from "theoretical" to "exploited" once someone finds the right -> amplification. +Follow the thread. The number of loops depends on the bytes, the bytes depend on the seed, the seed depends on your data. So the *time* the shuffle takes depends, faintly, on the secret it's meant to protect. That is the textbook definition of a timing side-channel: secret data steering how long the code runs. -## How the fix works: wide draws + Lemire reduction +How exploitable is it really? Barely. The wobble is tens of nanoseconds, smeared across many blocks and buried in noise, and I know of no practical attack against it. But "I couldn't break it" is not the bar. Constant-time execution is a discipline you keep precisely because timing leaks have a long history of looking harmless right up until someone finds the lever. A shuffle whose duration tracks the plaintext breaks that discipline. So it had to go. -Two ideas, both about *how you draw the index* — the shuffle itself doesn't change. +The lovely thing is that closing the channel also made it nearly nine times faster. -**1. Draw wide.** Use a full 64-bit value instead of a byte. The leftover bias from -splitting a power-of-two range into `n` buckets is always "at most one bucket is one -element larger." What matters is that ±1 *relative to bucket size*: +## Drawing dice from a firehose -- A byte (256 values) into 63 buckets: 4 buckets over-represented → bias ≈ **1.5%**. -- A 64-bit value into 63 buckets: ~63 buckets over-represented out of 2⁶⁴ → bias ≈ **2⁻⁵⁸**. +The fix is two ideas, and neither one touches the shuffle. They change only how it draws a number. -The wide draw doesn't remove the bias; it shrinks it to a rounding error — small enough -that you no longer need rejection to hide it. Drop the retry loop, and the draw count -becomes **fixed**. +**First, draw wide.** Instead of a single byte, take a full 64-bit value. The leftover bias from squeezing a power-of-two range into 63 buckets is always at most "one bucket is one item bigger than the rest". What matters is how big that one item is *relative* to the bucket: -**2. Reduce with Lemire's multiply-high.** Map a 64-bit `x` into `0..range` with one -widening multiply and a shift — no division, no branch: +- A byte into 63 buckets: about a 1.5% lean toward some values. +- A 64-bit value into 63 buckets: about a 2⁻⁵⁸ lean. A rounding error. + +The wide draw doesn't erase the bias. It shrinks it until it's too small to bother hiding, which means you can throw away the rejection loop entirely. No loop, no variable timing. + +**Second, reduce with a multiply, not a modulo.** Daniel Lemire's trick maps a 64-bit `x` into a range with one widening multiply and a shift. Picture `x` as how far along the number line you landed, then scale that fraction up into your range: ```rust -// uniform index in [0, range): no rejection loop, no modulo +// uniform in [0, range): no loop, no modulo, no branch let j = ((x as u128 * range as u128) >> 64) as u64; ``` -Together: a fixed sequence of wide draws, each reduced with a multiply-high, replaces a -variable-length rejection loop. +Put together, a fixed run of wide draws each reduced by a multiply replaces a ragged loop of byte draws and retries. Same shuffle. Fixed timing. Faster. ```rust -// Before: variable draw count — leaks time +// Before: the number of iterations depends on the seed, which depends on the plaintext let mut v = rng.next_u32() % cap; -while v > max { v = rng.next_u32() % cap; } // retries depend on the seed +while v > max { v = rng.next_u32() % cap; } -// After: exactly one draw per index — constant-time +// After: exactly one draw, every time, whatever the data let j = ((rng.next_u64() as u128 * (i as u128 + 1)) >> 64) as usize; ``` -**Expected outcome:** identical shuffle, seed-independent runtime, branch-free, and no -division. - -## The security trade is in our favour - -Rejection sampling gives an *exactly* uniform permutation. The wide-draw version is -uniform to within a **statistical distance of ~2⁻⁵⁵** of perfect. - -That's not a loss, because the Lewi–Wu analysis already models each block's permutation -as a uniformly random permutation. Our construction instantiates exactly that object, up -to a 2⁻⁵⁵ term — so the change adds **one small number to the existing security bound**: -no new assumption, no new model, nothing to argue about. - -> **Tip:** For secret-dependent draws, "uniform to 2⁻⁵⁵ in constant time" is a better -> property than "exactly uniform in variable time." Reach for a fixed-count method -> whenever you draw bounded random integers near secret data. - -## We tried the fancy option and rejected it - -For small domains there's an elegant constant-time PRP, **swap-or-not**. It vectorizes -beautifully and looks like the "right" primitive. We prototyped it — and rejected it on -the *proof*, not on speed (our shuffle was faster anyway). +## Giving up "perfect" to gain "constant" -Swap-or-not's security bound is strong only when an attacker can query a small fraction -of the value space. But in ORE, a block's ciphertext effectively exposes its **entire** -permutation, so the honest assumption is "the attacker sees everything" — and there the -bound becomes meaningless on a 64-element domain at any practical setting. The known fix -reintroduces data-dependent control flow (a timing channel), defeating the point. A -humble shuffle with a one-line proof beat the sophisticated primitive. +A cryptographer reading this just flinched. Rejection sampling gives you an *exactly* uniform shuffle. My version is only uniform to within about 2⁻⁵⁵ of perfect. Did I just trade away rigor for speed? -## The other finding: check that your AES is hardware AES +No, and this is the part I find genuinely satisfying. The Lewi-Wu security proof already models each block's permutation as a uniformly random one. My shuffle *is* that object, give or take 2⁻⁵⁵. So the change adds a single tiny number to a bound that was already there. No new assumption, no new model, nothing to argue about in review. For a draw that touches secret data, "uniform to 2⁻⁵⁵ in constant time" is a better thing to own than "exactly uniform, in variable time". -While benchmarking we found the Rust `aes` crate (v0.8) does **not** auto-enable the -ARMv8 crypto extensions — you opt in with `RUSTFLAGS="--cfg aes_armv8"`. Without it you -silently get software AES, ~60× slower per block. Setting the flag alone took a `u64` -encryption from 381 µs to 39 µs. +## The fancy option I threw away -> **Tip:** If you ship cryptography on ARM servers (Graviton, Apple CI), confirm your -> AES is hardware-accelerated. It's an easy 60× to leave on the table. +I'll admit I wanted to use something cleverer. There's a beautiful constant-time PRP for small domains called swap-or-not. It vectorizes like a dream and looks like the *proper* cryptographic answer. I built it. -## Results +Then I read its security proof more carefully and threw it away. Swap-or-not is strong only when an attacker can see a small slice of the value space. But in ORE a block's ciphertext effectively hands over the *whole* permutation, so the honest assumption is that the attacker sees everything, and at that point the proof gives you nothing on a 64-element domain at any practical setting. The known patch reintroduces data-dependent branching, the very timing channel I was trying to kill. A humble shuffle with a one-line proof beat the elegant primitive with a vacuous one. There's a lesson in that I keep relearning. -| Change | `u64` encrypt (M1 Max) | -|---|---:| -| Starting point (software AES) | 381 µs | -| Hardware AES enabled | 39 µs | -| Full v2 rewrite + constant-time PRP | **8.6 µs** | +## Your AES might be asleep -The PRP construction alone dropped from ~1.36 µs to 153 ns — about **9× faster** — -while *removing* a timing channel rather than adding risk. +While I had the profiler open, a second thing nagged at me. AES was crawling on Apple Silicon and our ARM servers. -## We found the same pattern in our own code +The cause was almost insulting in its simplicity. The Rust `aes` crate doesn't switch on the ARMv8 hardware crypto instructions unless you ask, with a build flag. Leave it off, and you silently get a software fallback that runs about 60x slower per block, with nothing to warn you. Setting one flag took a `u64` encryption from 381µs to 39µs, before I'd improved a single line of algorithm. If you ship crypto onto ARM, go and check this today. It's the cheapest 60x you'll ever find. 🎉 -Once we'd named it, we went looking. The same rejection-sampling shape lives in -`vitaminc`, our Rust cryptography toolkit, in its bounded-random helper (which feeds a -permutation-key generator and a password generator). Same root cause, same fix — plus -two correctness bugs the audit surfaced along the way. We've filed an issue and are -fixing it. +## Where it landed -## Why this matters for CipherStash +Stack it all up, the hardware AES, an allocation-free rewrite, SIMD, and the constant-time shuffle, and a `u64` encryption went from 381µs to 8.6µs on an M1 Max. The shuffle alone dropped from roughly 1.36µs to 153ns, about nine times quicker, while *losing* a side-channel rather than gaining a risk. -Constant-time discipline is part of what "searchable encryption you can trust" means. -This work makes ORE measurably faster — helping searchable-encryption queries meet -real-time performance needs — and removes a side-channel before it can ever matter. -Finding and fixing the same class of issue across two of our libraries is the kind of -continuous, proactive security posture our customers are buying, not a point-in-time -checkbox. +## Then I went looking in our own house -## Related +Once you've named a pattern you start seeing it everywhere. So I went hunting through our other Rust crates, and found the same rejection-sampling shape in vitaminc, our cryptography toolkit, feeding both a permutation generator and a password generator. Same root cause. The audit turned up two more bugs riding alongside it for free, including a draw that could index one past the end of an array and panic. Same fix closes all of it, and it's filed. -- `ore-rs` (the ORE library) and its v2 architecture plan -- CipherStash searchable encryption: [cipherstash.com](https://cipherstash.com) -- Daniel Lemire, "Fast Random Integer Generation in an Interval" (2019) -- Lewi–Wu, "Order-Revealing Encryption: New Constructions, Applications, and Lower - Bounds" (2016) +That's the bit I'd underline. The original shuffle was correct by the textbook, written by someone (me) who cared, and it still carried a quiet flaw for years. Speeding it up was the accident that made me look closely enough to see it. Performance work and security work are far more often the same work than we pretend, and the code most worth re-reading with fresh eyes is usually your own. -## Takeaways +*Go back and read the thing you were proud of. It has more to teach you than you think.* -1. **Rejection sampling is a timing channel** when the rejection count depends on - secret-derived randomness. -2. **Wide draws + Lemire multiply-high** give fixed-count, branch-free, bounded integers - biased only to ~2⁻⁵⁵ — negligible, and a clean term in a proof. -3. **"Exact via rejection" isn't automatically safer** for secret-dependent draws. -4. **The most sophisticated primitive isn't always right** — swap-or-not's proof didn't - fit our threat model. -5. **Check that your AES is hardware AES.** A build flag can be worth 60×. +:wq From c5135ee1399adc8fc66954c72ff54e4151cdf3a5 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 13 Jun 2026 16:46:01 +1000 Subject: [PATCH 07/19] docs: move blog draft to cipherstash-js-suite The post now lives in the marketing site content (cipherstash/cipherstash-js-suite#548); it doesn't belong in the library repo. --- ...2026-06-13-the-shuffle-that-leaked-time.md | 101 ------------------ 1 file changed, 101 deletions(-) delete mode 100644 docs/blog/2026-06-13-the-shuffle-that-leaked-time.md diff --git a/docs/blog/2026-06-13-the-shuffle-that-leaked-time.md b/docs/blog/2026-06-13-the-shuffle-that-leaked-time.md deleted file mode 100644 index 172adcc..0000000 --- a/docs/blog/2026-06-13-the-shuffle-that-leaked-time.md +++ /dev/null @@ -1,101 +0,0 @@ - - -# The shuffle that told the time - -Years ago I wrote a little function to shuffle 64 numbers into a secret order. It was part of the first real Rust I ever shipped, the order-revealing encryption that lets CipherStash compare encrypted values without decrypting them. I was proud of it. It was textbook correct. - -Last week I set out to make it faster, and discovered it had been quietly telling anyone who cared to measure roughly how long it took to encrypt your data. Which, it turns out, is a function of your data. - -This is the story of that bug, the surprisingly elegant fix, and a second surprise that was costing us 60x on half our hardware. There's a moral at the end about reading your own old code. - -## The secret reshuffle at the heart of ORE - -Quick orientation, then we'll get to the crime scene. - -Order-revealing encryption lets you ask "is A less than B?" on ciphertexts, without ever holding the plaintext. We use the Lewi-Wu construction. It chops a value into small blocks and, for each block, scrambles its possible values into a secret keyed order. That scramble is a pseudo-random permutation, a PRP, and we build it with a Fisher-Yates shuffle. It's the same shuffle Knuth taught a generation of us: walk down the array, and at each spot `i` pick a random index `j` somewhere at or below it, then swap. - -So the whole edifice rests on one humble operation. Pick a uniform random number in a range. That's where the leak was hiding. - -## ...wait, what's wrong with picking a random number? - -Your randomness arrives as uniform *bits*. A byte is a number from 0 to 255. But the range you want, say 0 to 62, almost never divides 256 evenly, so if you just take `byte % 63` some answers come up slightly more often than others. That skew is called modulo bias, and for a permutation it's poison. It makes some orderings more likely than others, and a secret shuffle that prefers certain orders isn't very secret. - -The proper fix, the one every textbook teaches, is rejection sampling. Draw a byte. If it landed in the biased tail, throw it away and draw another. Keep going until you get a clean one. It's correct, it's unbiased, and I used it without a second thought. - -The counterintuitive part kept me up at night. Rejection sampling, the proper unbiased method, was the bug. - -## What the timing whispered - -Think about what "keep going until you get a clean one" actually does. It draws a *variable* number of times. Sometimes one draw, sometimes five, depending entirely on the random bytes it happens to see. - -And where do those bytes come from? A seed. And the seed is derived from the plaintext you're encrypting. - -Follow the thread. The number of loops depends on the bytes, the bytes depend on the seed, the seed depends on your data. So the *time* the shuffle takes depends, faintly, on the secret it's meant to protect. That is the textbook definition of a timing side-channel: secret data steering how long the code runs. - -How exploitable is it really? Barely. The wobble is tens of nanoseconds, smeared across many blocks and buried in noise, and I know of no practical attack against it. But "I couldn't break it" is not the bar. Constant-time execution is a discipline you keep precisely because timing leaks have a long history of looking harmless right up until someone finds the lever. A shuffle whose duration tracks the plaintext breaks that discipline. So it had to go. - -The lovely thing is that closing the channel also made it nearly nine times faster. - -## Drawing dice from a firehose - -The fix is two ideas, and neither one touches the shuffle. They change only how it draws a number. - -**First, draw wide.** Instead of a single byte, take a full 64-bit value. The leftover bias from squeezing a power-of-two range into 63 buckets is always at most "one bucket is one item bigger than the rest". What matters is how big that one item is *relative* to the bucket: - -- A byte into 63 buckets: about a 1.5% lean toward some values. -- A 64-bit value into 63 buckets: about a 2⁻⁵⁸ lean. A rounding error. - -The wide draw doesn't erase the bias. It shrinks it until it's too small to bother hiding, which means you can throw away the rejection loop entirely. No loop, no variable timing. - -**Second, reduce with a multiply, not a modulo.** Daniel Lemire's trick maps a 64-bit `x` into a range with one widening multiply and a shift. Picture `x` as how far along the number line you landed, then scale that fraction up into your range: - -```rust -// uniform in [0, range): no loop, no modulo, no branch -let j = ((x as u128 * range as u128) >> 64) as u64; -``` - -Put together, a fixed run of wide draws each reduced by a multiply replaces a ragged loop of byte draws and retries. Same shuffle. Fixed timing. Faster. - -```rust -// Before: the number of iterations depends on the seed, which depends on the plaintext -let mut v = rng.next_u32() % cap; -while v > max { v = rng.next_u32() % cap; } - -// After: exactly one draw, every time, whatever the data -let j = ((rng.next_u64() as u128 * (i as u128 + 1)) >> 64) as usize; -``` - -## Giving up "perfect" to gain "constant" - -A cryptographer reading this just flinched. Rejection sampling gives you an *exactly* uniform shuffle. My version is only uniform to within about 2⁻⁵⁵ of perfect. Did I just trade away rigor for speed? - -No, and this is the part I find genuinely satisfying. The Lewi-Wu security proof already models each block's permutation as a uniformly random one. My shuffle *is* that object, give or take 2⁻⁵⁵. So the change adds a single tiny number to a bound that was already there. No new assumption, no new model, nothing to argue about in review. For a draw that touches secret data, "uniform to 2⁻⁵⁵ in constant time" is a better thing to own than "exactly uniform, in variable time". - -## The fancy option I threw away - -I'll admit I wanted to use something cleverer. There's a beautiful constant-time PRP for small domains called swap-or-not. It vectorizes like a dream and looks like the *proper* cryptographic answer. I built it. - -Then I read its security proof more carefully and threw it away. Swap-or-not is strong only when an attacker can see a small slice of the value space. But in ORE a block's ciphertext effectively hands over the *whole* permutation, so the honest assumption is that the attacker sees everything, and at that point the proof gives you nothing on a 64-element domain at any practical setting. The known patch reintroduces data-dependent branching, the very timing channel I was trying to kill. A humble shuffle with a one-line proof beat the elegant primitive with a vacuous one. There's a lesson in that I keep relearning. - -## Your AES might be asleep - -While I had the profiler open, a second thing nagged at me. AES was crawling on Apple Silicon and our ARM servers. - -The cause was almost insulting in its simplicity. The Rust `aes` crate doesn't switch on the ARMv8 hardware crypto instructions unless you ask, with a build flag. Leave it off, and you silently get a software fallback that runs about 60x slower per block, with nothing to warn you. Setting one flag took a `u64` encryption from 381µs to 39µs, before I'd improved a single line of algorithm. If you ship crypto onto ARM, go and check this today. It's the cheapest 60x you'll ever find. 🎉 - -## Where it landed - -Stack it all up, the hardware AES, an allocation-free rewrite, SIMD, and the constant-time shuffle, and a `u64` encryption went from 381µs to 8.6µs on an M1 Max. The shuffle alone dropped from roughly 1.36µs to 153ns, about nine times quicker, while *losing* a side-channel rather than gaining a risk. - -## Then I went looking in our own house - -Once you've named a pattern you start seeing it everywhere. So I went hunting through our other Rust crates, and found the same rejection-sampling shape in vitaminc, our cryptography toolkit, feeding both a permutation generator and a password generator. Same root cause. The audit turned up two more bugs riding alongside it for free, including a draw that could index one past the end of an array and panic. Same fix closes all of it, and it's filed. - -That's the bit I'd underline. The original shuffle was correct by the textbook, written by someone (me) who cared, and it still carried a quiet flaw for years. Speeding it up was the accident that made me look closely enough to see it. Performance work and security work are far more often the same work than we pretend, and the code most worth re-reading with fresh eyes is usually your own. - -*Go back and read the thing you were proud of. It has more to teach you than you think.* - -:wq From dec7faf23ba0876c5c206a8721933317535425d3 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 14 Jun 2026 19:04:13 +1000 Subject: [PATCH 08/19] docs: scope string common-prefix leakage to query-time/online Lewi-Wu is a left/right scheme: a comparison is only evaluated between a left and a right ciphertext, and a right ciphertext in isolation reveals nothing about order. With right-only-at-rest storage (the default deployment), an offline attacker recovers nothing -- not order, and a fortiori not common-prefix length. The first-differing-block disclosure surfaces only at query time (to the legitimate operator) or to an online adversary observing query traffic. Rewrite the plan's string-leakage discussion to lead with this left/right asymmetry and the three threat tiers, and narrow the security-checklist item accordingly. The product decision (B1) is thus about acceptable query-time/online leakage, not at-rest leakage. --- docs/plans/2026-06-12-ore-v2-architecture.md | 30 ++++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/docs/plans/2026-06-12-ore-v2-architecture.md b/docs/plans/2026-06-12-ore-v2-architecture.md index ddc5221..10912d5 100644 --- a/docs/plans/2026-06-12-ore-v2-architecture.md +++ b/docs/plans/2026-06-12-ore-v2-architecture.md @@ -332,10 +332,29 @@ instance, so right-encryption throughput stays comparable to the packed scheme. **String semantics and leakage.** Strings are encoded as their UTF-8 bytes (optionally case-folded/normalised upstream — out of scope here), decomposed by the chosen width. -Lewi-Wu leaks the index of the first differing block; for strings that is **the length -of the common prefix**, which is materially more revealing than for fixed-width numerics. -This must be documented prominently on the string API, and is a product-level decision -about acceptable leakage, not something the library can engineer away. +A comparison reveals the index of the first differing block; for strings that is **the +length of the common prefix**, which is materially more revealing than for fixed-width +numerics. But this leakage is scoped to the **comparison operation**, not to stored +data, because Lewi-Wu is a left/right scheme: a comparison is only ever evaluated +between a left ciphertext and a right ciphertext, and a right ciphertext in isolation +reveals nothing about order. Three threat tiers follow: + +- **Offline / at rest (right-only storage, the default deployment):** an attacker who + exfiltrates the database holds only right ciphertexts, has no left ciphertext to + compare against, and recovers nothing — not order, and a fortiori not common-prefix + length. The offline case is clean. +- **Query time (legitimate operator):** running a query emits a left ciphertext, and + each comparison against the stored rights reveals first-differing-block (= + common-prefix length for strings) for exactly the pairs that query touches. +- **Online adversary observing queries:** an attacker who can watch enough query + traffic accumulates those per-comparison leakages and can reconstruct prefix + structure across the touched set. + +So the common-prefix disclosure is bounded to the **in-use / online** setting and never +applies to data at rest. This must still be documented prominently on the string API, +and the residual query-time/online leakage is a product-level decision about acceptable +leakage — but it is a narrower decision than the unscoped framing suggests, and not +something the library can engineer away. ### 6. Random-oracle instantiation (the 1-bit hash H) @@ -411,7 +430,8 @@ PR 2's trait change, which should be called out in the changelog). - [ ] Accumulator chain state treated as key material: zeroized, never serialized, 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). +- [ ] String leakage profile documented and acknowledged at product level — scoped to + query-time/online (right-only-at-rest reveals nothing); see §5(b) (PR 6). ## Decisions taken (revisit if needed) From 771b78e4a7ff3db6eae3f11d9c62b862fefe3f47 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 14 Jun 2026 20:00:05 +1000 Subject: [PATCH 09/19] docs: add crypto review brief for v2 decisions A1-A4 Standalone sign-off brief a reviewer can act on without reading the full plan or codebase. Covers: A1 (1-bit hash H instantiation -- shipped FixedPiZ2Hash), A2 (CMAC cached-state accumulator for PR 6), A3 (PRP keystream as a CMAC branch family, shape ii), A4 (secret-indexed Fisher-Yates swap). Sequencing: A1+A4 gate Bit6 vector pinning (both change ciphertexts), A2+A3 gate PR 6. Flags a discrepancy found in the shipped code: LemireFyPrp is not repr(align(64)), so the one-cache-line argument the plan claims for the secret-indexed permutation.swap(i,j) does not hold as written -- either add the alignment or take the constant-time fallback. --- .../2026-06-14-ore-v2-crypto-review-brief.md | 324 ++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md diff --git a/docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md b/docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md new file mode 100644 index 0000000..2688a53 --- /dev/null +++ b/docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md @@ -0,0 +1,324 @@ +# ORE v2 — crypto review brief (decisions A1–A4) + +**Date:** 2026-06-14 +**Author:** Dan Draper (via Claude Code) +**Audience:** crypto sign-off reviewer (internal) +**Source of truth:** `docs/plans/2026-06-12-ore-v2-architecture.md` (§5b, §6, Open Q1). +This brief is self-contained; section/line refs let you drill in. + +--- + +## 0. What you're being asked to sign off (TL;DR) + +Four crypto decisions gate the v2 work. Two block a PR that is already open +(#82); two block PR 6 (not yet written). + +| # | Decision | Model claimed | Status in code | Blocks | +|---|----------|---------------|----------------|--------| +| **A1** | 1-bit hash `H` instantiation | random-permutation (option 3) / ideal-cipher (option 2 fallback) | **shipped as default in #82** (`FixedPiZ2Hash`) | #82 merge + Bit6 vector pinning | +| **A2** | Chained-prefix accumulator = AES-CMAC cached-state | CMAC PRF (standard) + 3 auditable claims | designed, not yet coded | PR 6 (variable-length / strings) | +| **A3** | PRP keystream from the accumulator (shape ii) | statistical (≤2⁻⁵⁵) + branch-family soundness | shape (i) shipped; (ii) deferred | PR 6 perf; couples to A2 | +| **A4** | Secret-indexed swap in PRP key-gen — accept the one-cache-line argument? | constant-time / cache-line | shipped **without** the claimed `repr(align(64))` mitigation | Bit6 vector pinning (a CT-PRP swap changes outputs) | + +**Recommended sequencing:** do **A1 + A4 first** (they gate freezing Bit6 and +its test vectors), then **A2 + A3 as one pass** (they gate PR 6 and A3 only +exists inside A2's accumulator). A1 and A4 are both coupled to vector pinning, +so resolve them before any Bit6 byte vectors are committed. + +**What is explicitly *not* in scope:** the legacy Bit8 scheme is wire-frozen and +byte-identical to v1 (`tests/compat_vectors`); it keeps all status-quo +constructions forever and is out of review. Everything below concerns *new, +not-yet-frozen* schemes only. + +--- + +## 1. Construction primer (the parts these decisions touch) + +ORE here is **Lewi-Wu (2016) small-domain "BlockORE"**, a *left/right* scheme: + +- A plaintext is decomposed into blocks. Bit8 → blocks over `DOMAIN = 256`; + the new **Bit6** scheme → `DOMAIN = 64`. +- Per block, a **PRP** `π` permutes the `DOMAIN` symbols (keyed per block from a + prefix-dependent seed — this is **A3/A4**). +- The **right ciphertext** of a block is a length-`DOMAIN` vector: for each + permuted symbol `j`, a comparison result `cmp(π⁻¹(j), x)` masked by a 1-bit + value `H(ro_key(i,j), nonce)` (this is **A1**). +- The **left ciphertext** of a block is the permuted index of the plaintext + symbol plus a published tag. +- **Comparison** is evaluated by a *keyless* comparator (e.g. in Postgres) + between one left and one right ciphertext; it recomputes `H` from public + material only — hence H may use the nonce and published tags but **no + long-term secret** (this constraint drives **A1**). +- For plaintexts longer than one packed AES block (~14 Bit6 blocks), the + per-block secrets must be derived from an **accumulator** over the prefix + (this is **A2**, which also hosts **A3**). + +Leakage (for completeness, not under review): a comparison reveals the index of +the first differing block. For strings that is common-prefix length. This is a +property of *comparison*, not stored data — right-only-at-rest reveals nothing; +the disclosure is query-time/online only (plan §5b, "String semantics and +leakage"). + +--- + +## 2. A1 — the 1-bit hash `H` (§6; blocks #82) + +### The question +`H(x, r) → Z₂` where `x` = RO key / left tag (public), `r` = per-ciphertext +nonce (public). It must be computable by a keyless comparator from public +ciphertext material. Which construction? + +### What is shipped (the default to ratify or change) +`packages/ore-rs/src/primitives/hash.rs:50-124`, `FixedPiZ2Hash`: + +``` +H(x, r) = LSB( π(x ⊕ r) ⊕ x ), π = AES-128_{K₀}, K₀ public constant +``` + +- `K₀ = PI_KEY = b"ORE-rs.v2.H-pi.1"` — nothing-up-my-sleeve, **deliberately + public**; security rests on AES being a good *public random permutation*, not + on key secrecy (`hash.rs:66-71`). Expanded once per process. +- This is **§6 option 3**: fixed-key-AES MMO hashing, analysed in the + **random-permutation model** (cf. BHKR13; GKWY20). +- The feedforward `⊕ x` is implemented as: capture `LSB(x)` before overwriting, + then XOR with `LSB(π(x⊕r))` (`hash.rs:96-123`; SIMD `lsb_mask` path verified + equivalent to scalar). +- A conservative fallback is also coded — **§6 option 2**, `Aes128Z2Hash` = + `LSB(AES_r(x) ⊕ x)` (MMO with nonce-as-key, **ideal-cipher model**). Switching + is a one-line `type Z2Hash = …` flip in the Bit6 scheme. + +### The candidate menu (full table, plan §6) +| # | Construction | Model | Cost vs today | +|---|---|---|---| +| 1 | `LSB(AES_r(x))` (status quo, Bit8) | ideal cipher | — | +| 2 | `LSB(AES_r(x) ⊕ x)` MMO feedforward (**fallback**) | ideal cipher | +1 XOR | +| 3 | `LSB(π(x⊕r) ⊕ x)`, fixed public `π` (**shipped default**) | random permutation | **faster — zero key schedules** | +| 4 | `LSB(AES_x(r))` RO-key-as-key | **standard model (PRF)** | ~2–4× right-encryption | +| 5 | SHA-256/Blake3 over `x‖r` | random oracle | 2–5× encrypt path | + +### What needs scrutiny +1. **The fixed-key-AES-hashing argument in *our* setting.** Mine GKWY20 for the + known `x ⊕ r` tweaking pitfalls. The claim to confirm: our requirements are + *weaker* than garbling's (no circularity, no correlated keys), so the + construction is sound here. Is that right, and are there ORE-specific + correlations between `x` (RO keys / left tags) and `r` (nonce) that break the + random-permutation reduction? +2. **Is option 3 worth it over option 2?** Option 3 is faster (no key schedule) + but assumes a random *permutation*; option 2 is the "minimal upgrade" in the + ideal-cipher model. If the random-permutation argument is shaky, fall back. +3. **Standard-model alternative (option 4):** do we want to pay 2–4× for + standard-model PRF security instead of an idealised-model assertion? Cost is + measured and on the table by design. +4. **The 1-bit LSB truncation** (LSB of a pseudorandom block) — believed + uncontroversial in every model; confirm. +5. **Nothing-up-my-sleeve constant** `K₀` — acceptable as-is? + +### Decision & what it unblocks +Pick the H for new schemes (default option 3, or fall back to 2, or escalate to +4). **#82 must not merge and Bit6 byte vectors must not be pinned until this is +signed off** — the choice changes every Bit6 right ciphertext. Implementation is +already a one-line type flip either way. + +--- + +## 3. A2 — chained-prefix accumulator = CMAC cached-state (§5b; blocks PR 6) + +### The question +PR 6 (variable-length / strings) needs an accumulator that absorbs the prefix +incrementally and, at each block position, derives the per-block secrets/outputs +(PRP seed, left tag, RO keys). **Candidate B — AES-CMAC with cached prefix +state** was selected by a benchmark gate; sign off its design before PR 6 is +written. + +### Why B (the gate result, durable record in §5b) +- **Candidate A (Cascade/GGM, chain through the AES key slot)** — cleanest + security story (constrained-PRF/GGM hybrid, no related-key assumptions) but + **rejected on performance**: on Apple M1 Max with hardware AES, an AES-128 key + expansion costs ~172 ns ≈ 160 batched block encryptions (no key-schedule + instruction on aarch64), making per-block overhead **~84%** at Bit6 — ~6× over + the ~10–15% threshold. (Spike at `/tmp/ore-keyexp-spike`.) +- **Candidate C (XE-style GF(2¹²⁸) masked single-key CBC)** — fastest, but the + many-outputs-per-state argument is bespoke (PMAC-lineage analyses publish only + a final tag). Held in reserve. +- **Candidate B** — control measured ~0% overhead and has a citable standards + basis. + +### The design to review +Every published value is a *bona fide* **AES-CMAC (NIST SP 800-38B)** tag of an +injectively encoded message. For block `i`: + +``` +msg(branch, i) = enc(x₀‖0) ‖ enc(x₁‖1) ‖ … ‖ enc(x_{i-1}‖i−1) ‖ final(branch, value, i) +tag = CMAC_k( msg(branch, i) ) +``` + +with separate **branch families**: chain step, `prp_seed` (this hosts A3), +left tag `f`, and `ro_key(i, j)` for `j ∈ 0..DOMAIN`. The per-prefix +chaining-state cache (clone-CBC-state-then-finalize) is **purely an +implementation optimization** — incremental CMAC, not a new primitive. + +Security reduces to **CMAC's PRF security** plus three auditable claims: +1. **Encoding injectivity** — `msg(branch, i)` is injective across blocks, + branches, and block widths (so no two distinct logical outputs share a CMAC + input). +2. **Implementation faithfulness** — the incremental/cached implementation is + bit-identical to one-shot CMAC (will be tested against the `cmac` crate's + vectors). +3. **Zeroization** — cached secret states are zeroized; never serialized; never + reachable from `Left`/`Right` types. + +### What needs scrutiny +1. **The injective encoding is currently a sketch** — it must be specified + exactly (field widths, the `enc(·)` block format, the `final(branch,…)` + block, domain-separation tags between the 4 branch families) and then checked + injective. This is the load-bearing claim. +2. **Many outputs per prefix state.** Standard CMAC analyses publish one tag; + here we publish chain + seed + tag + `DOMAIN` RO keys per position. This is + sound iff all inputs are distinct (claim 1) and CMAC is used purely as a PRF — + confirm there's no subtlety in deriving the *next chain state* from a tag that + is itself published (the GGM design made this vanish structurally; CMAC needs + the chain-state branch domain-separated from published branches). +3. **PRF₂ disposition** — does PRF₂ remain a separate key or become another + branch family? (Either is claimed fine; pick one.) + +### Decision & what it unblocks +Sign off the CMAC design (after the encoding is fully specified) → PR 6 can be +written. Fixed-N schemes never touch the accumulator, so this does **not** block +goals 1–3 or #82. + +--- + +## 4. A3 — PRP keystream from the accumulator, "shape (ii)" (Open Q1; rides with A2) + +### Background: the PRP question is otherwise resolved +Open Q1 (cheaper PRP for new schemes) is **resolved by spike** +(`/tmp/ore-prp-spike`, RESULTS.md). Winner: **fixed-draw Fisher–Yates with +Lemire-reduced wide draws** — `N−1` fixed draws, each a 64-bit value reduced by +multiply-high `((x·m) >> 64)`, zero rejection sampling, branch-free. Security: +exact statistical distance **≤ 2⁻⁵⁵** (for N=64) from a uniformly random +permutation — the object Lewi-Wu already models — so it adds a *pure statistical +term*, no new assumption. + +This is **shipped as `LemireFyPrp<64>`** for Bit6 (PR 5), +`packages/ore-rs/src/primitives/prp.rs:135-229`. It already (a) closes a +plaintext-dependent timing channel in the old rejection-sampled PRNG and (b) +removes a power-of-two modulo bias, and takes Bit6 u64 encrypt 11.5 → 8.6 µs. +**Swap-or-not was rejected** (its `8N^{3/2}/(r+4)` bound is vacuous at q=N, and +ORE exposes a block PRP's full codebook). Bit8 stays on the Knuth shuffle +(wire-frozen). + +### The narrow question for review +Shipped **shape (i)** keys a *fresh AES-128 key schedule per block* to produce +the FY keystream (`prp.rs:164-169`: `Aes128::new(seed)` then 32 CTR blocks). +**Shape (ii)** produces the same keystream from the *already-scheduled +accumulator cipher* (a `prp_seed` branch family of A2's CMAC), eliminating the +per-block key schedule — the ~1.9 µs that separates 8.6 µs from the projected +≈3.3 µs. + +The math (FY + Lemire, ≤2⁻⁵⁵) is unchanged. The **only new question** is whether +sourcing the PRP keystream from the accumulator's PRF output is sound: +1. The `prp_seed` branch must be **domain-separated** from the chain/tag/ro_key + branches (no keystream reuse across roles). +2. The keystream-as-PRP-randomness reuse pattern must not interact badly with + the same cipher's other outputs. + +This is **why A3 is reviewed inside A2** rather than as a bespoke key-reuse hack +bolted onto PR 5. + +### Decision & what it unblocks +Approve (or reject) deriving the PRP stream as a CMAC branch family. Unblocks the +PR 6 PRP perf target (~3.3 µs). No effect on #82 (which ships shape (i)). + +--- + +## 5. A4 — secret-indexed swap in PRP key generation (Open Q1; couples to Bit6 vectors) + +### The question +FY key generation performs a swap whose **address is secret-derived**: + +`prp.rs:189-196`: +```rust +for i in (1..$domain).rev() { + let d = $domain - 1 - i; + let x = u64::from_le_bytes(stream[d*8 .. d*8+8]); + let j = ((x as u128 * (i as u128 + 1)) >> 64) as usize; // secret j + perm.permutation.swap(i, j); // secret-indexed write +} +``` + +`j` depends on the (secret, prefix-derived) keystream, so the *memory address +written* is secret. Do we accept the **one-cache-line argument** (the whole +permutation table fits in a single cache line, so the access pattern leaks +nothing through the cache), or require the strictly-constant-time fallback? + +### ⚠️ Discrepancy found in the shipped code +The plan (Open Q1) states the mitigation is a **64-byte `#[repr(align(64))]` +table** so the table provably occupies one cache line. **The shipped struct has +no such alignment** (`prp.rs:135-139`): + +```rust +#[derive(Zeroize)] +pub struct LemireFyPrp { + permutation: [u8; N], // 64 bytes for Bit6, but NOT cache-line aligned + inverse: [u8; N], +} +``` + +With default alignment (1), the 64-byte `permutation` array can **straddle two +cache lines**, which weakens — does not establish — the one-cache-line argument. +**Action regardless of the verdict:** either add `#[repr(align(64))]` (and ensure +`permutation` sits first / isolated within its own line) so the claimed +mitigation actually holds, or the argument must be re-derived for an unaligned +table. + +### Context +- The **read** paths are already constant-time: `permute`/`invert` are table + lookups returning the value, and `indicator_mask_xor` scans the *entire* + permutation table via a branch-free `gt_mask` kernel (`prp.rs:225-228`). Only + the **key-generation swap** is secret-indexed. +- Same class of issue exists in **vitaminc** (filed cipherstash/vitaminc#198) + and in the legacy Bit8 Knuth path (wire-frozen — documented, not fixed). +- The **strictly-constant-time fallback** (a swap-or-not / oblivious-swap form) + is preserved in the PRP spike if the cache-line argument is rejected. + +### Why this couples to vector pinning +If review rejects the cache-line argument and mandates a different +constant-time PRP construction, the *permutation it produces changes*, hence +every Bit6 right ciphertext changes. So A4 must be settled **before Bit6 byte +vectors are pinned** — same gate as A1. + +### Decision & what it unblocks +Either: (a) accept the one-cache-line argument **and** require the +`#[repr(align(64))]` fix so it's true; or (b) mandate the CT fallback (and +re-pin vectors against it). Settle before freezing Bit6 vectors. + +--- + +## 6. Sign-off checklist + +**Gate 1 — before #82 merges / Bit6 vectors pinned:** +- [ ] **A1** H construction selected (default option 3 ratified, or fall back to + 2 / escalate to 4); fixed-key-AES tweaking pitfalls (GKWY20) cleared for + our setting. +- [ ] **A4** swap verdict given; if cache-line argument accepted, + `#[repr(align(64))]` fix landed; if rejected, CT-fallback PRP chosen. +- [ ] Bit6 byte vectors regenerated and pinned **after** A1 + A4. + +**Gate 2 — before PR 6 is written:** +- [ ] **A2** CMAC encoding fully specified and checked injective; many-outputs + / chain-state-from-published-tag interaction cleared; impl-faithfulness + + zeroization test plan agreed. +- [ ] **A3** PRP-stream-as-CMAC-branch-family domain separation approved. + +--- + +## 7. References / pointers +- Plan: `docs/plans/2026-06-12-ore-v2-architecture.md` — §5b (accumulator), + §6 (H), Open Q1 (PRP), Security checklist. +- Code: `primitives/hash.rs` (A1), `primitives/prp.rs` (A3/A4), + `scheme/bit2_w6.rs` (Bit6 wiring), `tests/compat_vectors` (Bit8 frozen). +- Spikes: `/tmp/ore-keyexp-spike` (A2 gate), `/tmp/ore-prp-spike` (A3/A4). +- Benchmarks: `docs/benchmarks/2026-06-13-*.md`. +- Lit: Lewi-Wu 2016 (BlockORE); BHKR13 / GKWY20 (fixed-key AES hashing); + NIST SP 800-38B (CMAC); HMR12 + Morris-Rogaway 2014 (swap-or-not); Lemire 2019 + (nearly-divisionless bounded random). From 40f6a54bbe4ca1890eefafdcbf3ac0141fbeae59 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 14 Jun 2026 21:53:35 +1000 Subject: [PATCH 10/19] ore: harden PRP/comparator constant-time, add N<=64 guard A4 hardening from the crypto review brief. None of these change ciphertexts (compat + comparison vectors unchanged). - prp.rs: LemireFyPrp is now repr(C, align(64)) so each [u8;N] table is one 64-byte cache line (permutation@0, inverse@64) -- covers both secret-indexed key-gen writes (the FY swap and the inverse fill) for the one-cache-line argument. Add a const assert!(domain <= 64) in impl_lemire_fy_prp! so a larger instantiation, which would span multiple lines and lose the property, is a compile error. - width.rs: add oblivious ct_select_byte(block, idx) -- scans the whole block and constant-time-selects the byte, so the access address is independent of the secret index. - bit2/bit2_w6 comparators: route all four get_bit sites through ct_select_byte. The right-block byte read was indexed by a[l] (the secret permuted symbol); the oblivious read closes that cache-line channel and, by touching every byte, the sub-line (MemJam) channel too -- chosen over mere alignment for that reason. Cost is <=32 byte-ops per comparison. - review brief: add the MemJam analysis (4K-aliasing, 4-byte granularity, Intel-wide scope incl. SGX, ARM/AMD out, SMT co-residence required), the oblivious-swap-FY vs swap-or-not wire-compatibility distinction, and the consequence that A4 no longer gates Bit6 vector pinning (the fixes are byte-stable; A1/H is the sole remaining gate). --- .../2026-06-14-ore-v2-crypto-review-brief.md | 197 ++++++++++++++---- packages/ore-rs/src/primitives/prp.rs | 26 +++ packages/ore-rs/src/scheme/bit2.rs | 9 +- .../ore-rs/src/scheme/bit2/block_types.rs | 9 +- packages/ore-rs/src/scheme/bit2_w6.rs | 9 +- .../ore-rs/src/scheme/bit2_w6/block_types.rs | 9 +- packages/ore-rs/src/scheme/width.rs | 28 +++ 7 files changed, 226 insertions(+), 61 deletions(-) diff --git a/docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md b/docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md index 2688a53..88a3939 100644 --- a/docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md +++ b/docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md @@ -18,12 +18,13 @@ Four crypto decisions gate the v2 work. Two block a PR that is already open | **A1** | 1-bit hash `H` instantiation | random-permutation (option 3) / ideal-cipher (option 2 fallback) | **shipped as default in #82** (`FixedPiZ2Hash`) | #82 merge + Bit6 vector pinning | | **A2** | Chained-prefix accumulator = AES-CMAC cached-state | CMAC PRF (standard) + 3 auditable claims | designed, not yet coded | PR 6 (variable-length / strings) | | **A3** | PRP keystream from the accumulator (shape ii) | statistical (≤2⁻⁵⁵) + branch-family soundness | shape (i) shipped; (ii) deferred | PR 6 perf; couples to A2 | -| **A4** | Secret-indexed swap in PRP key-gen — accept the one-cache-line argument? | constant-time / cache-line | shipped **without** the claimed `repr(align(64))` mitigation | Bit6 vector pinning (a CT-PRP swap changes outputs) | +| **A4** | Secret-indexed swap in PRP key-gen — ratify alignment + MemJam posture? | constant-time / cache-line (sub-line = oblivious tier) | `#[repr(C, align(64))]` + oblivious compare read + `N≤64` guard **applied** (uncommitted) | nothing — fixes are byte-stable; A1 alone gates vectors | -**Recommended sequencing:** do **A1 + A4 first** (they gate freezing Bit6 and -its test vectors), then **A2 + A3 as one pass** (they gate PR 6 and A3 only -exists inside A2's accumulator). A1 and A4 are both coupled to vector pinning, -so resolve them before any Bit6 byte vectors are committed. +**Recommended sequencing:** do **A1 first** — it is now the sole gate on freezing +Bit6 and its test vectors. **A4 is a ratification** of changes already applied +(none of which alter ciphertexts), plus a posture call on MemJam; it no longer +couples to vector pinning. Then **A2 + A3 as one pass** (they gate PR 6, and A3 +only exists inside A2's accumulator). **What is explicitly *not* in scope:** the legacy Bit8 scheme is wire-frozen and byte-identical to v1 (`tests/compat_vectors`); it keeps all status-quo @@ -251,46 +252,152 @@ written* is secret. Do we accept the **one-cache-line argument** (the whole permutation table fits in a single cache line, so the access pattern leaks nothing through the cache), or require the strictly-constant-time fallback? -### ⚠️ Discrepancy found in the shipped code -The plan (Open Q1) states the mitigation is a **64-byte `#[repr(align(64))]` -table** so the table provably occupies one cache line. **The shipped struct has -no such alignment** (`prp.rs:135-139`): - -```rust -#[derive(Zeroize)] -pub struct LemireFyPrp { - permutation: [u8; N], // 64 bytes for Bit6, but NOT cache-line aligned - inverse: [u8; N], -} -``` - -With default alignment (1), the 64-byte `permutation` array can **straddle two -cache lines**, which weakens — does not establish — the one-cache-line argument. -**Action regardless of the verdict:** either add `#[repr(align(64))]` (and ensure -`permutation` sits first / isolated within its own line) so the claimed -mitigation actually holds, or the argument must be re-derived for an unaligned -table. +### Discrepancy found, now fixed (pending scrutiny) +The plan (Open Q1) stated the mitigation is a **64-byte cache-line-aligned +table**, but the originally shipped struct had **no alignment** (default +alignment 1), so the 64-byte `permutation` array could straddle two lines — +weakening, not establishing, the one-cache-line argument. There are in fact +**two** secret-indexed writes during key generation that the argument must +cover, not one: +- the Fisher–Yates `permutation.swap(i, j)` — secret `j` (`prp.rs:195`); +- the inverse fill `inverse[val] = …` — secret `val` (`prp.rs:198-200`). + +**Fix applied** (uncommitted, pending this review): `#[repr(C, align(64))]` on +`LemireFyPrp` (`prp.rs:135-139`). `repr(C)` pins field order so `permutation` is +at offset 0; `align(64)` puts the struct on a line boundary. At `N = 64` each +`[u8; 64]` table is exactly one line — `permutation` → line 0, `inverse` → +line 1 — so both secret-indexed writes are line-uniform. The argument holds only +for **N ≤ 64**; the sole instantiation is `LemireFyPrp<64>`. A `const _ = +assert!(N <= 64)` guard is proposed to make a larger instantiation a compile +error. ### Context - The **read** paths are already constant-time: `permute`/`invert` are table lookups returning the value, and `indicator_mask_xor` scans the *entire* permutation table via a branch-free `gt_mask` kernel (`prp.rs:225-228`). Only - the **key-generation swap** is secret-indexed. + the **key-generation writes** (above) are secret-indexed. - Same class of issue exists in **vitaminc** (filed cipherstash/vitaminc#198) and in the legacy Bit8 Knuth path (wire-frozen — documented, not fixed). -- The **strictly-constant-time fallback** (a swap-or-not / oblivious-swap form) - is preserved in the PRP spike if the cache-line argument is rejected. - -### Why this couples to vector pinning -If review rejects the cache-line argument and mandates a different -constant-time PRP construction, the *permutation it produces changes*, hence -every Bit6 right ciphertext changes. So A4 must be settled **before Bit6 byte -vectors are pinned** — same gate as A1. +- **Two fallback forms, only one is wire-compatible** (this matters — see "MemJam + & the oblivious tier" below): an **oblivious-swap Fisher–Yates** (same FY, same + Lemire draws, constant-time `swap`/inverse-fill via full-scan conditional + select) produces the **identical permutation** → identical ciphertext → + drop-in, vectors unchanged, ~O(N²) swap cost. A **different construction** + (swap-or-not) produces a *different* permutation → incompatible wire format, + and was rejected on the q=N proof anyway. The oblivious-swap form is the one to + reach for. +- **MemJam caveat (full analysis below):** cache-line alignment defends only at + *line* granularity; the sub-line (4-byte) MemJam channel on SMT-enabled Intel + is closed only by an oblivious construction. + +### Block-width asymmetry — the one-cache-line property is Bit6-only +The construction-time argument does **not** scale to a hypothetical +`LemireFyPrp<256>` (8-bit width), and the asymmetry is a positive argument for +Bit6 as the default (open question 3): +- **Construction (encryptor host) — strictly worse at D = 256.** The table is + 256 bytes = **4 cache lines**, so each swap leaks ~2 bits (which-of-4-lines). + And there are more swaps per block (D−1 = **255 vs 63**): a u64 is **8 blocks × + 255 = 2040** secret-indexed swaps at Bit8 vs **11 × 63 = 693** at Bit6. Larger + domain grows swaps faster than it shrinks block count, so the "fewer blocks" + intuition inverts. No offsetting benefit on this axis. +- **Comparison (comparator host) — tied.** The compare-side secret-indexed + access is `get_bit(target_block, a[l])` (next subsection). The right block is + 256 bits = **32 bytes** at Bit8 and 64 bits = **8 bytes** at Bit6; both are + ≤ 64, so both fit within one cache line given alignment. Bit8 is **not** better + here — both widths are line-uniform. +- These two leaks are on **different machines** (encryptor vs comparator) and + different trust domains, so neither offsets the other; each is evaluated + per-host. Net: 8-bit would be worse on construction and tied on compare — Bit6 + has the stronger constant-time story end to end. + +### Compare-side: oblivious `get_bit` — FIXED (uncommitted, pending review) +Comparison has its own secret-indexed access on the comparator host. After the +constant-time scan finds the first differing block `l` (`bit2.rs:235-242`, done +with `Choice`/`conditional_assign`), it reads byte `a[l] / 8` of the right block, +where `a[l]` (the permuted symbol) is sensitive. The block base was at an +arbitrary buffer offset, so the read could straddle a line. + +**Fix applied:** all four `get_bit` sites (`bit2.rs`, `bit2_w6.rs`, and the +`RightBlock32`/`RightBlock8` inherent methods) now route the byte read through a +new oblivious helper `width::ct_select_byte`, which scans the *entire* block and +constant-time-selects the target byte, so the access address is independent of +`a[l]`. This was chosen over mere alignment deliberately: an aligned direct index +still leaks at sub-line (MemJam) granularity, whereas the full scan closes both +line and sub-line channels. Cost is ≤ 32 byte-ops per comparison — negligible +beside the per-comparison AES hash. Results are unchanged (compat + comparison +vectors pass), so it does **not** touch the wire format. The block-*selection* +index `l` is left as a direct index because `l` (first-differing-block) is leaked +by ORE's definition anyway. +- **Severity it addressed was low** (both `l` and `a[l]` are already in the + ciphertexts the comparator holds; the channel only matters to an attacker who + can time the comparator's cache but not read its memory), but the oblivious + read is cheap and removes the question entirely. +- **Scope note:** production comparison runs in the Postgres extension / proxy + (separate codebase) and must adopt the same oblivious read (or `ore.rs`'s + comparator) — tracked separately; this repo's `compare_raw_slices` and typed + `cmp` are now fixed. + +### MemJam & the oblivious tier — risk, CPU scope, and wire compatibility +This is the one open judgement call on the construction-side (encryptor) swap. +The shipped `#[repr(C, align(64))]` fix makes the swap **line-uniform**, which +closes the broad cache-line channel for everyone (AMD, ARM, and non-SMT Intel). +It does **not** close MemJam. + +**What MemJam is.** 4K aliasing: Intel's memory disambiguation predicts +store→load dependencies from only the low address bits, so an attacker who writes +to an aliasing address forces a false read-after-write dependency and times the +victim's load at **4-byte granularity within a cache line**. That is exactly why +alignment (a 64-byte property) is insufficient. + +**CPU scope.** +- **Intel x86 — effectively all generations, incl. modern parts and SGX.** The + MemJam paper's headline is that it applies to "all major Intel processors + including the latest generations," unlike its predecessor. +- **Older Intel (pre-Haswell)** also fall to **CacheBleed** (cache-bank + conflicts, sub-line). Between the two, treat "intra-line is safe on Intel" as + false across the line. +- **AMD** — not the MemJam target; no demonstrated equivalent, but not provably + immune. +- **ARM (Apple Silicon, AWS Graviton)** — this mechanism does not apply. + Relevant: our benchmarks (M1 Max) and likely Graviton production are unaffected. + +**Threat model — co-residence required.** MemJam is **not remote**: the spy must +run on the **sibling hyperthread (same physical core, SMT enabled)** of the +victim, hammering the aliasing address throughout the computation. So the surface +is: Intel **+** SMT on **+** attacker code on the same core as the *encryptor* +**+** an attacker who cannot already read the victim's memory. Disabling SMT or +core-isolation neutralizes it with no code change; running the encryptor on ARM +sidesteps it entirely. Marginal leakage is small regardless — the swap addresses +reveal partial info about a permutation whose codebook the ciphertext already +largely exposes (the same fact that sank swap-or-not). + +**Recommendation.** +1. **Default:** ship the `#[repr(C, align(64))]` fix; document that the + constant-time guarantee is at **cache-line granularity**, and that sub-line + (MemJam/CacheBleed) resistance on SMT-enabled Intel needs either SMT-off / + core-isolation **or** the oblivious build. +2. **High-assurance tier:** offer the **oblivious-swap Fisher–Yates** builder + (~3× slower, already spiked) for threat models that include a malicious + co-tenant on Intel with SMT. + +This matches field practice — aligned-constant-time is the standard bar, +oblivious is the paranoid tier — and lets the deployment, not the library, pay +the 3× only when it needs to. + +### Relationship to vector pinning (revised) +The MemJam tier does **not** gate Bit6 vector pinning, *provided the oblivious +fallback is the oblivious-swap FY form* (recommended): it yields the identical +permutation, so ciphertexts are byte-stable whether or not it is enabled. Vectors +can be pinned after the A1/H decision, and the oblivious builder added later as a +build option with no re-pin. (Only a *different-construction* fallback such as +swap-or-not would change ciphertexts and force a re-pin — another reason to +prefer oblivious-swap FY.) ### Decision & what it unblocks -Either: (a) accept the one-cache-line argument **and** require the -`#[repr(align(64))]` fix so it's true; or (b) mandate the CT fallback (and -re-pin vectors against it). Settle before freezing Bit6 vectors. +Ratify: (a) `#[repr(C, align(64))]` as the default construction-side fix; (b) the +oblivious compare-side read; (c) the `N≤64` compile guard; and (d) the MemJam +posture (document scope + offer oblivious-swap FY as the high-assurance build). +None of (a)–(d) changes ciphertexts, so Bit6 vectors are gated only by A1. --- @@ -299,10 +406,17 @@ re-pin vectors against it). Settle before freezing Bit6 vectors. **Gate 1 — before #82 merges / Bit6 vectors pinned:** - [ ] **A1** H construction selected (default option 3 ratified, or fall back to 2 / escalate to 4); fixed-key-AES tweaking pitfalls (GKWY20) cleared for - our setting. -- [ ] **A4** swap verdict given; if cache-line argument accepted, - `#[repr(align(64))]` fix landed; if rejected, CT-fallback PRP chosen. -- [ ] Bit6 byte vectors regenerated and pinned **after** A1 + A4. + our setting. **This is the sole gate on Bit6 vector pinning.** +- [ ] **A4** ratify the applied construction-side fix `#[repr(C, align(64))]` + (covers both secret-indexed writes; line-uniform at N ≤ 64). +- [ ] **A4** ratify the `const _ = assert!(N <= 64)` compile guard (applied). +- [ ] **A4** ratify the oblivious compare-side read `width::ct_select_byte` + (applied; all four `get_bit` sites; results unchanged). +- [ ] **A4** MemJam posture: accept "default = line-granularity CT; sub-line + resistance via SMT-off / core-isolation or the oblivious-swap-FY build." + Decide whether to build the oblivious-swap-FY tier now or on demand. +- [ ] Bit6 byte vectors regenerated and pinned **after A1** (all A4 fixes are + byte-stable; production comparator adopts `ct_select_byte` separately). **Gate 2 — before PR 6 is written:** - [ ] **A2** CMAC encoding fully specified and checked injective; many-outputs @@ -321,4 +435,5 @@ re-pin vectors against it). Settle before freezing Bit6 vectors. - Benchmarks: `docs/benchmarks/2026-06-13-*.md`. - Lit: Lewi-Wu 2016 (BlockORE); BHKR13 / GKWY20 (fixed-key AES hashing); NIST SP 800-38B (CMAC); HMR12 + Morris-Rogaway 2014 (swap-or-not); Lemire 2019 - (nearly-divisionless bounded random). + (nearly-divisionless bounded random); Moghimi et al. CT-RSA 2018 (MemJam, + arXiv:1711.08002) + Yarom et al. 2016 (CacheBleed) for the sub-line channels. diff --git a/packages/ore-rs/src/primitives/prp.rs b/packages/ore-rs/src/primitives/prp.rs index 81b6e15..70e5855 100644 --- a/packages/ore-rs/src/primitives/prp.rs +++ b/packages/ore-rs/src/primitives/prp.rs @@ -132,7 +132,23 @@ impl_knuth_shuffle_prp!(256, crate::primitives::simd::gt_mask_xor_256); /// new assumption, no new idealised model. /// /// New (non-wire-frozen) schemes only. +/// +/// Layout note (constant-time): key generation performs two secret-indexed +/// writes — the Fisher–Yates `permutation.swap(i, j)` (secret `j`) and the +/// `inverse[val] = …` fill (secret `val`). The one-cache-line argument that +/// defends these (any access within a single 64-byte line leaks nothing +/// through the cache) requires each table to occupy exactly one line. At +/// `N = 64` each `[u8; N]` is 64 bytes, so `#[repr(C, align(64))]` places +/// `permutation` at offset 0 (line 0) and `inverse` at offset 64 (line 1): +/// `repr(C)` pins field order (default `repr(Rust)` may reorder), `align(64)` +/// puts the struct on a cache-line boundary. The argument only holds for +/// `N ≤ 64`; the sole instantiation is `LemireFyPrp<64>`. +/// +/// **Under review** — see `docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md` +/// (A4). If the cache-line argument is rejected, this is replaced by a strictly +/// constant-time (oblivious-swap) construction. #[derive(Zeroize)] +#[repr(C, align(64))] pub struct LemireFyPrp { permutation: [u8; N], inverse: [u8; N], @@ -152,6 +168,16 @@ impl ZeroizeOnDrop for LemireFyPrp {} /// bulk indicator kernel for the domain. macro_rules! impl_lemire_fy_prp { ($domain:literal, $stream_blocks:literal, $gt_mask:path) => { + // The one-cache-line constant-time argument for the secret-indexed + // key-generation writes (see the struct docs / review brief A4) holds + // only when each `[u8; N]` table fits a single 64-byte cache line. A + // larger domain silently spans multiple lines and loses the property, + // so make it a compile error rather than a comment. + const _: () = assert!( + $domain <= 64, + "LemireFyPrp: the one-cache-line constant-time argument requires domain <= 64" + ); + impl Prp for LemireFyPrp<$domain> { fn new(key: &[u8]) -> PrpResult { if key.len() < 16 { diff --git a/packages/ore-rs/src/scheme/bit2.rs b/packages/ore-rs/src/scheme/bit2.rs index 6a607fb..a4ef59b 100644 --- a/packages/ore-rs/src/scheme/bit2.rs +++ b/packages/ore-rs/src/scheme/bit2.rs @@ -280,11 +280,10 @@ fn right_block(input: &[u8], n: usize) -> &[u8] { fn get_bit(block: &[u8], bit: usize) -> u8 { debug_assert!(block.len() == RightBlock32::BLOCK_SIZE); debug_assert!(bit < 256); - let byte_index = bit / 8; - let position = bit % 8; - let v = 1 << position; - - (block[byte_index] & v) >> position + // `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); + (byte >> (bit % 8)) & 1 } impl PartialEq for CipherText { diff --git a/packages/ore-rs/src/scheme/bit2/block_types.rs b/packages/ore-rs/src/scheme/bit2/block_types.rs index 8502541..a4e0a78 100644 --- a/packages/ore-rs/src/scheme/bit2/block_types.rs +++ b/packages/ore-rs/src/scheme/bit2/block_types.rs @@ -40,11 +40,10 @@ impl RightBlock32 { #[inline] pub fn get_bit(&self, bit: usize) -> u8 { debug_assert!(bit < 256); - let byte_index = bit / 8; - let position = bit % 8; - let v = 1 << position; - - (self.data[byte_index] & v) >> position + // `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(&self.data, bit / 8); + (byte >> (bit % 8)) & 1 } } diff --git a/packages/ore-rs/src/scheme/bit2_w6.rs b/packages/ore-rs/src/scheme/bit2_w6.rs index 9b1d831..c4f7def 100644 --- a/packages/ore-rs/src/scheme/bit2_w6.rs +++ b/packages/ore-rs/src/scheme/bit2_w6.rs @@ -289,11 +289,10 @@ fn right_block(input: &[u8], n: usize) -> &[u8] { fn get_bit(block: &[u8], bit: usize) -> u8 { debug_assert!(block.len() == RightBlock8::BLOCK_SIZE); debug_assert!(bit < 64); - let byte_index = bit / 8; - let position = bit % 8; - let v = 1 << position; - - (block[byte_index] & v) >> position + // `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); + (byte >> (bit % 8)) & 1 } impl PartialEq for CipherText { 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 403a4d7..6128271 100644 --- a/packages/ore-rs/src/scheme/bit2_w6/block_types.rs +++ b/packages/ore-rs/src/scheme/bit2_w6/block_types.rs @@ -18,11 +18,10 @@ impl RightBlock8 { #[inline] pub fn get_bit(&self, bit: usize) -> u8 { debug_assert!(bit < 64); - let byte_index = bit / 8; - let position = bit % 8; - let v = 1 << position; - - (self.data[byte_index] & v) >> position + // `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(&self.data, bit / 8); + (byte >> (bit % 8)) & 1 } } diff --git a/packages/ore-rs/src/scheme/width.rs b/packages/ore-rs/src/scheme/width.rs index 754a262..d7e30c3 100644 --- a/packages/ore-rs/src/scheme/width.rs +++ b/packages/ore-rs/src/scheme/width.rs @@ -57,6 +57,34 @@ macro_rules! impl_aes_block_buf { impl_aes_block_buf!(256); impl_aes_block_buf!(64); +/// Oblivious byte read: returns `block[idx]` while touching **every** byte of +/// `block`, so the memory-access address is independent of `idx`. +/// +/// The comparators read the right-ciphertext block at `bit / 8` where `bit` is +/// the secret permuted symbol (`a[l]`, the left ciphertext's permuted index at +/// the first differing block). A direct `block[bit / 8]` index makes the +/// touched address secret-dependent, which is a cache-line timing channel — and +/// because the block fits within a line, even an aligned direct index would +/// still leak at sub-line (4-byte) granularity to a MemJam-class attacker on +/// SMT-enabled Intel. Scanning the whole block removes the data-dependent +/// address entirely, closing both. The block is ≤ 32 bytes, so the scan is +/// cheap relative to the per-comparison AES hash. +/// +/// See `docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md` (A4, compare +/// side). The bit-position shift (`>> (bit % 8)`) is a register shift, not a +/// memory access, so it is not a cache channel and is left as-is. +#[inline] +pub(crate) fn ct_select_byte(block: &[u8], idx: usize) -> u8 { + use subtle_ng::{ConditionallySelectable, ConstantTimeEq}; + let mut acc = 0u8; + for (i, &b) in block.iter().enumerate() { + // `i` is the public loop counter; `idx` is secret. Both index a block + // of ≤ 32 bytes, so the u8 cast is lossless. + acc.conditional_assign(&b, (i as u8).ct_eq(&(idx as u8))); + } + acc +} + /// Per-block bitvector operations on a Right ciphertext block, one bit per /// value in the block domain. pub trait RightBitVec { From 84c1b7689f11d32c1665045ded3551efd09db466 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 15 Jun 2026 10:48:24 +1000 Subject: [PATCH 11/19] docs: frame block width as a leakage decision, resolve default-scheme Q Add a "Block width is a leakage decision" subsection to plan section 5(b) and rewrite open question 3. Lewi-Wu leaks the first-differing-block index, so larger blocks leak less (Bit8 < Bit6 < CLWW): a u64's first differing bit is localised to an 8-bit window at Bit8 vs a 6-bit window at Bit6. That online prefix-leakage axis is the one the library cannot fix; it pulls against Bit6's encrypt-side one-cache-line CT advantage, which is only a cost difference (full oblivious CT is available at both widths, ~12x cheaper at Bit6). Conclusion: width is a per-domain/per-deployment policy keyed on target data and threat model, not a global default. Default numerics to Bit8 (lower leakage + wire-compat); Bit6 is opt-in for at-rest-dominated, size/perf, or encryptor-hostile deployments. Supersedes the earlier 'Bit6 as default' lean. --- docs/plans/2026-06-12-ore-v2-architecture.md | 45 +++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-06-12-ore-v2-architecture.md b/docs/plans/2026-06-12-ore-v2-architecture.md index 10912d5..d96884f 100644 --- a/docs/plans/2026-06-12-ore-v2-architecture.md +++ b/docs/plans/2026-06-12-ore-v2-architecture.md @@ -356,6 +356,41 @@ and the residual query-time/online leakage is a product-level decision about acc leakage — but it is a narrower decision than the unscoped framing suggests, and not something the library can engineer away. +**Block width is a leakage decision, not just a size/perf one.** In Lewi-Wu, the +comparison leaks the index of the first differing block, so **larger blocks leak less**: +Bit8 (8-bit) < Bit6 (6-bit) < CLWW (1-bit, the full first-differing-*bit*). For a u64, +the first differing bit is localised to an **8-bit window** under Bit8 (8 blocks) but a +**6-bit window** under Bit6 (11 blocks) — Bit6 sharpens an online inference adversary's +divergence-point/density estimation by ~1.33×. It is an incremental sharpening, not the +categorical jump to CLWW, but it is real and it is the one axis the library cannot fix. +This sits opposite the **encrypt-side** advantage of Bit6 (the one-cache-line PRP; see +Open Q1 / the A4 review brief): cheap constant-time key generation that Bit8 cannot get +for free. The two pull in opposite directions and live in different threat models: + +- They do **not** net out: the leakage axis is an *online/query-time* property against an + inference adversary; the encrypt-side axis is a *side-channel* property against an + attacker co-resident with the encryptor. At rest, the leakage axis is a **tie** (both + reveal nothing), so there Bit6's win is free. +- The encrypt-side axis is a **cost** difference, not "constant-time vs not": full + oblivious constant-time is available at *both* widths via oblivious-swap Fisher–Yates, + just ~12× cheaper at Bit6 (≈44k ct-ops/u64 vs ≈522k at Bit8). The leakage axis is the + **fundamental, unfixable** one. + +Because leakage is unfixable and encrypt-side CT is purchasable at either width, the +lower-leakage width (Bit8) is the conservative **default**, with Bit6 an explicit opt-in. +Width is therefore a **per-domain / per-deployment policy** keyed on the target data and +threat model, not a global default: + +| Dominant threat | Encryptor environment | Choose | +|---|---|---| +| At-rest exfiltration (right-only) | any | **Bit6** — leakage tie, take the smaller ciphertext + cheap CT | +| Online inference on the plaintext distribution | trusted / dedicated | **Bit8** — encrypt-side moot, take the lower leakage | +| Online inference | hostile / multi-tenant | **Bit8 + oblivious-swap-FY** (low leakage *and* CT, ~522k ct-ops), or Bit6 if perf-bound and 2 bits of resolution is acceptable | + +Default numerics to Bit8 (lower leakage, and it is the wire-frozen compatible scheme); +strings pick width per the size/leakage trade in the PR 6 design. This supersedes the +earlier lean toward "Bit6 as the default" (Open Q3). + ### 6. Random-oracle instantiation (the 1-bit hash H) Lewi-Wu models the right-ciphertext mask as a random oracle `H(ro_key, nonce) → Z₂`. @@ -499,8 +534,14 @@ PR 2's trait change, which should be called out in the changelog). key-reuse pattern bolted onto PR 5. 2. **`u16` vs `u8` block count in the v2 header:** u16 chosen for strings; confirm no need for >65 535 blocks (≈48 KiB plaintext at Bit6). -3. **Should Bit6 become the default scheme** recommended in the README once shipped, with - Bit8 positioned as the legacy/compat scheme? Affects docs tone in PR 7. +3. **Block width is a per-domain / per-deployment choice, not a global default + (RESOLVED — see §5(b) "Block width is a leakage decision").** Earlier framing asked + "should Bit6 be the default"; the answer is no, because width trades online + prefix-leakage (Bit8 leaks less — the unfixable axis) against encrypt-side + constant-time cost (Bit6 cheaper — a fixable axis). Default numerics to **Bit8** + (lower leakage + wire-compat); position **Bit6** as an opt-in for at-rest-dominated, + size/perf-sensitive, or encryptor-hostile (with CT budget) deployments. PR 7 docs + present the decision rule (the §5(b) table), not a single recommended scheme. 4. **Pending review outcomes:** the §5(b) accumulator and §6 H selections await the NEON benchmark spike (PR 5) and internal crypto review; decision rules and candidate write-ups are inline in those sections. From d721c4eaf46855248d911d0be58b45ab9b6a2256 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 15 Jun 2026 12:26:57 +1000 Subject: [PATCH 12/19] docs: add numeric encodings (fixed-point vs log/scientific) note to plan Record mantissa+exponent / log-domain encoding as an encoding-layer option for wide-dynamic-range numerics (currency, measurements, high-range decimals): bounds block count, uniform relative precision, deliberate magnitude-band leakage; composes with the variable-block machinery. Caveats: Benford leading-digit skew (relative != flat), magnitude-band is a conscious leak, and parameters must be fixed per-domain as public (Parameter-Hiding ORE, Cash et al. 2018). Explicitly scope it as NOT a low-entropy / narrow-domain mitigation: it is order-preserving, so it cannot touch the order floor, and for a narrow domain like DOB the exponent is near-constant (increases high-order skew). DOB-class fields are mitigated by coarsening the plaintext to the queried granularity, not by re-encoding. Keeps the two ideas from being conflated later. --- docs/plans/2026-06-12-ore-v2-architecture.md | 40 ++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/docs/plans/2026-06-12-ore-v2-architecture.md b/docs/plans/2026-06-12-ore-v2-architecture.md index d96884f..dfb2d01 100644 --- a/docs/plans/2026-06-12-ore-v2-architecture.md +++ b/docs/plans/2026-06-12-ore-v2-architecture.md @@ -391,6 +391,46 @@ Default numerics to Bit8 (lower leakage, and it is the wire-frozen compatible sc strings pick width per the size/leakage trade in the PR 6 design. This supersedes the earlier lean toward "Bit6 as the default" (Open Q3). +**Numeric encodings: fixed-point vs log/scientific (an encoding-layer option, not a +low-entropy mitigation).** Block ORE is parameterised by *how* a value maps to blocks, +not just by block width. For **wide-dynamic-range numerics** — currency, scientific / +sensor measurements, high-range decimals — a **mantissa+exponent (a.k.a. log-domain) +encoding** is a good fit: write `x ≈ f · base^e`, lay the exponent in the high-order +blocks and the mantissa in the low-order blocks (order-preserving), then encode as +usual. This is the same family as the "scientific notation" encoding used with CLWW ORE +and as `round(scale · log_base(x))`. Benefits, all of which compose with the §5 +variable-block machinery: + +- **Bounds block count over a wide range** (cents-to-billions becomes exponent + a + fixed-width mantissa, not a 60-bit fixed-point integer). +- **Uniform relative precision** — small and large values get the same significant-figure + blocks. (IEEE-754 already has this shape; the existing `f64` path exploits the + exponent-first layout.) +- **Makes prefix leakage a deliberate choice** — "magnitude band + N significant + figures" rather than an accident of fixed-point width. + +Caveats to design in: +- **Benford's law:** leading significant digits of natural numeric data are non-uniform + (1 ≈ 30%, 9 ≈ 5%), so the mantissa's top block stays skewed and inference-exposed. + Log encoding makes leakage *relative*, not *flat*. +- **You are electing to leak the magnitude band** — usually acceptable, but a conscious + leakage decision. +- **Parameters are a leakage surface:** per *Parameter-Hiding ORE* (Cash–Liu–O'Neill– + Zhang, ASIACRYPT 2018), the base/precision/scale leak distribution info if chosen + per value or per dataset. Fix them **per domain**, treat them as public constants, and + document them. + +**Explicit scope — do not conflate with the width/leakage decision above.** This encoding +solves *dynamic range + relative precision + block count*. It is **order-preserving**, so +it does **not** touch the order-leakage floor and is **not** a mitigation for low-entropy / +narrow-domain fields (DOB, names, etc.). Those fail because their *high-order bits are +skewed and ORE exposes them first*, plus the order floor (NKW sorting/cumulative); an +order-preserving re-encoding — log or otherwise — cannot help, and for a narrow domain like +DOB the exponent is near-constant (it *increases* high-order skew). The only lever there is +coarsening the plaintext to the granularity actually queried (year / age-band). Keep the +two ideas distinct: log/scientific encoding is for *wide-range numerics*; plaintext +coarsening is for *low-entropy narrow domains*. + ### 6. Random-oracle instantiation (the 1-bit hash H) Lewi-Wu models the right-ciphertext mask as a random oracle `H(ro_key, nonce) → Z₂`. From 1cff0bc8c9679a94d29a70261f0356cd78c6bc08 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 15 Jun 2026 14:29:57 +1000 Subject: [PATCH 13/19] =?UTF-8?q?ore:=20resolve=20A1=20=E2=80=94=20adopt?= =?UTF-8?q?=20BHKR=20sigma-MMO=20for=20the=201-bit=20hash=20H?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the fixed public-key AES construction (option 3) but upgrade it with the BHKR/Zahur orthomorphism: H(x, r) = LSB( pi(sigma(x) XOR r) XOR sigma(x) XOR r ) with pi = AES-128 under public key K0 and sigma(x) = 2x in GF(2^128) (the 'multiply by x' / CMAC-subkey doubling; reduction constant 0x87). FixedPiZ2Hash (Bit6 only) updated in both the scalar comparator path and the bulk scalar/SIMD encryption path; added gf128_double plus tests (fixed_pi_scalar_matches_bulk, gf128_double_reduction). Legacy Bit8 keeps Aes128Z2Hash unchanged. Justification (review brief A1, plan section 6): - The known fixed-key-MMO attacks (GKWY; the half-gates multi-instance attack of eprint 2019/1168) require *known* hash inputs and a recoverable global Free-XOR offset. ORE has independent *secret* PRF inputs and no global offset, so the O(p*C/2^k) degradation does not arise. - The tight tweak-as-key variant (2019/1168 Thm 2) is declined: rekeying per evaluation breaks the keyless-comparator / performance requirement and fixes a degradation ORE does not suffer. - eprint 2025/792 cryptanalyses collision/preimage/one-wayness (not the 1-bit correlation-robustness we rely on) and only round-reduced AES (7/10 collision on AES-MMO/MP), leaving full AES-128's margin intact. - The orthomorphism is cheap defense-in-depth: security holds by matching the named BHKR/Zahur construction rather than by a usage argument. This changes Bit6 right ciphertexts; A1 was the gate holding Bit6 vector pinning and is now cleared. Docs mark A1 RESOLVED. --- docs/plans/2026-06-12-ore-v2-architecture.md | 42 ++++-- .../2026-06-14-ore-v2-crypto-review-brief.md | 124 +++++++++-------- packages/ore-rs/src/primitives/hash.rs | 131 +++++++++++++++--- 3 files changed, 205 insertions(+), 92 deletions(-) diff --git a/docs/plans/2026-06-12-ore-v2-architecture.md b/docs/plans/2026-06-12-ore-v2-architecture.md index dfb2d01..8817385 100644 --- a/docs/plans/2026-06-12-ore-v2-architecture.md +++ b/docs/plans/2026-06-12-ore-v2-architecture.md @@ -433,6 +433,14 @@ coarsening is for *low-entropy narrow domains*. ### 6. Random-oracle instantiation (the 1-bit hash H) +> **RESOLVED 2026-06-15 (A1).** Keep the fixed public-key AES construction +> (option 3), upgraded with the BHKR orthomorphism: `H(x, r) = LSB(π(σ(x) ⊕ r) ⊕ +> σ(x) ⊕ r)` with `π = AES_{K₀}` (public `K₀`) and `σ(x) = 2x` in GF(2^128) (the +> BHKR/Zahur σ-MMO). Rationale below; full write-up in the crypto review brief +> A1 (`docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md`). Tweak-as-key +> (eprint 2019/1168 Thm 2) was **declined** (rekeying breaks the keyless/fast +> requirement, and fixes a multi-instance degradation ORE doesn't suffer). + Lewi-Wu models the right-ciphertext mask as a random oracle `H(ro_key, nonce) → Z₂`. Today it is instantiated as `LSB(AES_nonce(ro_key))` — the **nonce as the AES key** — and the code has carried a TODO questioning that construction since the beginning @@ -451,14 +459,25 @@ key / left tag, `r` = nonce): |---|---|---|---|---| | 1 | `LSB(AES_r(x))` — status quo | ideal cipher | — | key is public, so AES's standard PRP assumption gives nothing; security is an ideal-cipher assertion | | 2 | `LSB(AES_r(x) ⊕ x)` — MMO feedforward | ideal cipher | +1 XOR | matches the analyzed blockcipher-hashing shape; feedforward removes the invertible-public-permutation structure; the minimal upgrade | -| 3 | `LSB(π(x ⊕ r) ⊕ x)`, `π = AES_{K₀}`, K₀ public constant | random permutation | **faster** — zero key schedules ever | fixed-key-AES hashing (BHKR13); mine GKWY20 for known `x ⊕ r` tweaking pitfalls (our requirements are weaker than garbling's — no circularity, no correlated keys) | +| 3 | **SELECTED:** `LSB(π(σ(x) ⊕ r) ⊕ σ(x) ⊕ r)`, `π = AES_{K₀}`, `σ(x)=2x` | random permutation | **faster** — zero key schedules ever | BHKR/Zahur σ-MMO. GKWY/half-gates attacks (eprint 2019/1168) need *known* inputs + a global offset; ORE has independent *secret* PRF inputs and no offset, so they don't port. σ is cheap defense-in-depth. 2025/792 attacks (collision/preimage) target unused properties and are round-reduced | | 4 | `LSB(AES_x(r))` — RO key as AES key | **standard model** (PRF) | ~2–4× right-encryption: one key schedule per `(i, j)` | what the old TODO was reaching for; the honest price of standard-model security; composes poorly with accumulator Candidate A (both pay per-block schedules) | | 5 | SHA-256 (HW) / Blake3 over `x ‖ r` | random oracle | 2–5× encrypt path | comparator computes H once per comparison, so query latency is unaffected — only encryption throughput pays | -Proposal into review: **#3, with #2 as the conservative fallback**, and #4 written up -with its *measured* cost so the standard-model option is accepted or declined with the -price visible. The 1-bit truncation (LSB of a pseudorandom block) is uncontroversial -in every model. +Decision (2026-06-15): **#3 with the BHKR orthomorphism `σ(x)=2x`** — `H(x,r) = +LSB(π(σ(x)⊕r) ⊕ σ(x)⊕r)`. The known fixed-key-MMO attacks (GKWY; the half-gates +multi-instance attack of eprint 2019/1168) require the adversary to know the hash +inputs and recover a global Free-XOR offset — ORE's inputs are independent *secret* +PRF outputs and there is no global offset, so neither precondition holds and the +`O(p·C/2^k)` degradation does not arise. The orthomorphism is not strictly needed +in this setting; it is adopted as nearly-free defense-in-depth so security holds by +matching the named BHKR/Zahur construction rather than by a usage argument. The +tight tweak-as-key variant (2019/1168 Thm 2) is declined: rekeying per evaluation +conflicts with the keyless-comparator / performance requirement and addresses a +degradation absent here. AES-hashing cryptanalysis (eprint 2025/792) targets +collision/preimage/one-wayness — not the 1-bit correlation-robustness we rely on — +and reaches only round-reduced AES (7/10 collision), leaving full AES-128's margin. +The 1-bit truncation is uncontroversial in every model. Options #2/#4/#5 remain in +the table as the considered alternatives. ## PR roadmap @@ -499,8 +518,8 @@ PR 2's trait change, which should be called out in the changelog). - [ ] PRP seeds (PRF₂ outputs) structurally separated from serializable `Left` state; no code path can write seed material into a ciphertext (PR 2). - [ ] Domain separation between Bit8/Bit6/chained schemes under shared keys (PR 5, 6). -- [ ] H instantiation (§6) selected and signed off, with its security model - (ideal-cipher / random-permutation / standard) recorded (before PR 5). +- [x] H instantiation (§6) selected and signed off (2026-06-15): BHKR σ-MMO, + random-permutation model; see §6 and review brief A1. - [ ] Selected §5(b) accumulator candidate reviewed and signed off (before PR 6). - [ ] Accumulator chain state treated as key material: zeroized, never serialized, never reachable from `Left`/`Right` types (PR 6). @@ -521,10 +540,11 @@ PR 2's trait change, which should be called out in the changelog). `u128`/`Decimal` on Bit6 arrive with PR 6 if wanted. 5. **Strings use the chained-prefix variable-length scheme regardless of width**; width choice (6 vs 8) for strings is a ciphertext-size trade-off left to the PR 6 design. -6. **H decision is pulled forward to before PR 5** (not PR 6): Bit6 is a new scheme and - should ship with the chosen H rather than inherit nonce-as-key for compatibility's - sake. Proposal: fixed-public-permutation MMO (§6 option 3), conservative fallback - MMO-with-nonce-key (option 2). +6. **H = BHKR σ-MMO with fixed public AES key (§6, A1 RESOLVED 2026-06-15):** + `LSB(π(σ(x)⊕r) ⊕ σ(x)⊕r)`, `σ(x)=2x`. Bit6 ships with this rather than inheriting + nonce-as-key. GKWY/half-gates attacks don't port (secret independent inputs, no + global offset); tweak-as-key declined (rekeying); 2025/792 hits only unused + properties on round-reduced AES. Legacy Bit8 keeps the status quo forever. 7. **Accumulator choice is a decision rule, not a fixed pick:** cascade/GGM if NEON key-expansion overhead measures under ~10–15% on Bit6 strings, else CMAC with cached state; XE only if profiling eliminates both (§5(b)). diff --git a/docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md b/docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md index 88a3939..b8c0f7e 100644 --- a/docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md +++ b/docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md @@ -15,16 +15,16 @@ Four crypto decisions gate the v2 work. Two block a PR that is already open | # | Decision | Model claimed | Status in code | Blocks | |---|----------|---------------|----------------|--------| -| **A1** | 1-bit hash `H` instantiation | random-permutation (option 3) / ideal-cipher (option 2 fallback) | **shipped as default in #82** (`FixedPiZ2Hash`) | #82 merge + Bit6 vector pinning | +| **A1** | 1-bit hash `H` instantiation | random-permutation (BHKR σ-MMO) | ✅ **RESOLVED** — `FixedPiZ2Hash` = `LSB(π(σ(x)⊕r)⊕σ(x)⊕r)`, σ(x)=2x | — (was the Bit6-vector gate; now cleared) | | **A2** | Chained-prefix accumulator = AES-CMAC cached-state | CMAC PRF (standard) + 3 auditable claims | designed, not yet coded | PR 6 (variable-length / strings) | | **A3** | PRP keystream from the accumulator (shape ii) | statistical (≤2⁻⁵⁵) + branch-family soundness | shape (i) shipped; (ii) deferred | PR 6 perf; couples to A2 | | **A4** | Secret-indexed swap in PRP key-gen — ratify alignment + MemJam posture? | constant-time / cache-line (sub-line = oblivious tier) | `#[repr(C, align(64))]` + oblivious compare read + `N≤64` guard **applied** (uncommitted) | nothing — fixes are byte-stable; A1 alone gates vectors | -**Recommended sequencing:** do **A1 first** — it is now the sole gate on freezing -Bit6 and its test vectors. **A4 is a ratification** of changes already applied -(none of which alter ciphertexts), plus a posture call on MemJam; it no longer -couples to vector pinning. Then **A2 + A3 as one pass** (they gate PR 6, and A3 -only exists inside A2's accumulator). +**Recommended sequencing:** **A1 is resolved** (BHKR σ-MMO) — Bit6 vectors can now +be generated and pinned against it. **A4 is a ratification** of changes already +applied (none of which alter ciphertexts), plus a posture call on MemJam. Then +**A2 + A3 as one pass** (they gate PR 6, and A3 only exists inside A2's +accumulator). **What is explicitly *not* in scope:** the legacy Bit8 scheme is wire-frozen and byte-identical to v1 (`tests/compat_vectors`); it keeps all status-quo @@ -62,63 +62,67 @@ leakage"). --- -## 2. A1 — the 1-bit hash `H` (§6; blocks #82) +## 2. A1 — the 1-bit hash `H` (§6) — ✅ RESOLVED 2026-06-15 ### The question -`H(x, r) → Z₂` where `x` = RO key / left tag (public), `r` = per-ciphertext -nonce (public). It must be computable by a keyless comparator from public -ciphertext material. Which construction? +`H(x, r) → Z₂` where `x` = RO key / left tag, `r` = per-ciphertext nonce. It must +be computable by a **keyless** comparator from public ciphertext material (no +long-term secret), so any "PRF under a third key" design is out. -### What is shipped (the default to ratify or change) -`packages/ore-rs/src/primitives/hash.rs:50-124`, `FixedPiZ2Hash`: +### Resolution +**Keep option 3 (fixed public-key AES), upgraded with the BHKR orthomorphism.** +Shipped construction (`packages/ore-rs/src/primitives/hash.rs`, `FixedPiZ2Hash`): ``` -H(x, r) = LSB( π(x ⊕ r) ⊕ x ), π = AES-128_{K₀}, K₀ public constant +H(x, r) = LSB( π(σ(x) ⊕ r) ⊕ σ(x) ⊕ r ), π = AES-128_{K₀}, σ(x) = 2·x in GF(2^128) ``` -- `K₀ = PI_KEY = b"ORE-rs.v2.H-pi.1"` — nothing-up-my-sleeve, **deliberately - public**; security rests on AES being a good *public random permutation*, not - on key secrecy (`hash.rs:66-71`). Expanded once per process. -- This is **§6 option 3**: fixed-key-AES MMO hashing, analysed in the - **random-permutation model** (cf. BHKR13; GKWY20). -- The feedforward `⊕ x` is implemented as: capture `LSB(x)` before overwriting, - then XOR with `LSB(π(x⊕r))` (`hash.rs:96-123`; SIMD `lsb_mask` path verified - equivalent to scalar). -- A conservative fallback is also coded — **§6 option 2**, `Aes128Z2Hash` = - `LSB(AES_r(x) ⊕ x)` (MMO with nonce-as-key, **ideal-cipher model**). Switching - is a one-line `type Z2Hash = …` flip in the Bit6 scheme. - -### The candidate menu (full table, plan §6) -| # | Construction | Model | Cost vs today | -|---|---|---|---| -| 1 | `LSB(AES_r(x))` (status quo, Bit8) | ideal cipher | — | -| 2 | `LSB(AES_r(x) ⊕ x)` MMO feedforward (**fallback**) | ideal cipher | +1 XOR | -| 3 | `LSB(π(x⊕r) ⊕ x)`, fixed public `π` (**shipped default**) | random permutation | **faster — zero key schedules** | -| 4 | `LSB(AES_x(r))` RO-key-as-key | **standard model (PRF)** | ~2–4× right-encryption | -| 5 | SHA-256/Blake3 over `x‖r` | random oracle | 2–5× encrypt path | - -### What needs scrutiny -1. **The fixed-key-AES-hashing argument in *our* setting.** Mine GKWY20 for the - known `x ⊕ r` tweaking pitfalls. The claim to confirm: our requirements are - *weaker* than garbling's (no circularity, no correlated keys), so the - construction is sound here. Is that right, and are there ORE-specific - correlations between `x` (RO keys / left tags) and `r` (nonce) that break the - random-permutation reduction? -2. **Is option 3 worth it over option 2?** Option 3 is faster (no key schedule) - but assumes a random *permutation*; option 2 is the "minimal upgrade" in the - ideal-cipher model. If the random-permutation argument is shaky, fall back. -3. **Standard-model alternative (option 4):** do we want to pay 2–4× for - standard-model PRF security instead of an idealised-model assertion? Cost is - measured and on the table by design. -4. **The 1-bit LSB truncation** (LSB of a pseudorandom block) — believed - uncontroversial in every model; confirm. -5. **Nothing-up-my-sleeve constant** `K₀` — acceptable as-is? - -### Decision & what it unblocks -Pick the H for new schemes (default option 3, or fall back to 2, or escalate to -4). **#82 must not merge and Bit6 byte vectors must not be pinned until this is -signed off** — the choice changes every Bit6 right ciphertext. Implementation is -already a one-line type flip either way. +i.e. the **BHKR/Zahur fixed-key σ-MMO** with output truncated to 1 bit. + +- `K₀ = PI_KEY = b"ORE-rs.v2.H-pi.1"` — nothing-up-my-sleeve, deliberately + public; security rests on AES as a good *public random permutation*, not key + secrecy. Expanded once per process. +- `σ(x) = 2x` is the GF(2^128) doubling orthomorphism (same "multiply by x" as + CMAC subkeys; constant `0x87`), constant-time, branch-free (`gf128_double`). + Both `σ` and `σ⊕id` are permutations. +- `hash` (comparator) and `hash_all_into` (encryptor, scalar + SIMD `lsb_mask`) + verified equivalent (`fixed_pi_scalar_matches_bulk`); doubling checked against + the textbook shift/0x87 rule (`gf128_double_reduction`). + +### Why this is sound for ORE (the core justification) +The known attacks on fixed-key MMO **do not port to ORE**, and we now have the +exact reason from the literature: + +- **eprint 2019/1168** (Guo–Katz–Wang–Weng–Yu, *Better Concrete Security for + Half-Gates*) attacks the fixed-key construction `π(2x⊕i)⊕2x⊕i` in the + multi-instance garbling setting with success `O(p·C/2^k)` — but the attack + works by **recovering a global Free-XOR offset `R` from *known* hash inputs** + (the evaluator holds wire labels `Wa` and gate ids `j`, learns + `H(Wa⊕R, j)`, then meet-in-the-middles over `π`). **ORE has neither + precondition:** its `H` inputs are independent **secret** PRF outputs (the + adversary never learns them, sees only 1-bit masks), and there is **no global + offset**. So the multi-instance degradation mechanism is structurally absent. +- Their tight fix (Theorem 2, `E(i, σ(x))⊕σ(x)` — tweak as the AES *key*) is + **deliberately not adopted**: it requires rekeying per evaluation, which breaks + the keyless-comparator/performance requirement, and it fixes a degradation ORE + doesn't suffer. **Do not chase Theorem 2.** +- **eprint 2025/792** (Chen–Guo–List–Shi–Zhang, *Scrutinizing AES-based Hashing*) + is cryptanalysis of **collision / preimage / one-wayness** — properties this + 1-bit hash does not rely on — and its best AES-128 results are **round-reduced** + (7/10 collision on AES-MMO/MP at 2⁶⁰, 4/10 on AES-DM), never reaching full AES. + Mild evidence *for* the random-permutation assumption at full rounds. + +### Why the orthomorphism (the only change from the original draft) +Plain MMO (`σ = id`) would already be fine for ORE's independent-secret-input +setting. `σ(x)=2x` is adopted as **cheap defense-in-depth** (a few branch-free +ops) so security holds **by matching the named BHKR/Zahur construction** rather +than by a usage argument about input independence — robust-by-construction vs +robust-by-argument. The 1-bit truncation is uncontroversial in every model. + +### Status +Resolved. `FixedPiZ2Hash` updated. This **changes Bit6 right ciphertexts**, so +Bit6 byte vectors are (re)generated against this construction when pinned — A1 is +the gate that was holding that, and it is now cleared. --- @@ -404,9 +408,11 @@ None of (a)–(d) changes ciphertexts, so Bit6 vectors are gated only by A1. ## 6. Sign-off checklist **Gate 1 — before #82 merges / Bit6 vectors pinned:** -- [ ] **A1** H construction selected (default option 3 ratified, or fall back to - 2 / escalate to 4); fixed-key-AES tweaking pitfalls (GKWY20) cleared for - our setting. **This is the sole gate on Bit6 vector pinning.** +- [x] **A1** H construction resolved (2026-06-15): keep fixed public-key AES, + upgraded to the BHKR σ-MMO `LSB(π(σ(x)⊕r)⊕σ(x)⊕r)`, σ(x)=2x. GKWY/half-gates + attacks shown not to port (secret independent inputs, no global offset); + tweak-as-key (2019/1168 Thm 2) explicitly declined; 2025/792 targets + properties we don't use and is round-reduced. - [ ] **A4** ratify the applied construction-side fix `#[repr(C, align(64))]` (covers both secret-indexed writes; line-uniform at N ≤ 64). - [ ] **A4** ratify the `const _ = assert!(N <= 64)` compile guard (applied). diff --git a/packages/ore-rs/src/primitives/hash.rs b/packages/ore-rs/src/primitives/hash.rs index e233bfc..07d5698 100644 --- a/packages/ore-rs/src/primitives/hash.rs +++ b/packages/ore-rs/src/primitives/hash.rs @@ -47,18 +47,28 @@ impl Hash for Aes128Z2Hash { } } -/// Z2 hash instantiated as `LSB(π(x ⊕ r) ⊕ x)` with `π` a *fixed public* -/// AES-128 permutation and `r` the per-ciphertext nonce — the -/// fixed-key-AES MMO construction proposed in the v2 plan §6 (option 3), -/// analysed in the random-permutation model (cf. BHKR13 / GKWY20). +/// Z2 hash instantiated as the BHKR fixed-key-AES **σ-MMO** construction: +/// `H(x, r) = LSB(π(σ(x) ⊕ r) ⊕ σ(x) ⊕ r)`, where `π` is a *fixed public* +/// AES-128 permutation (public key [`PI_KEY`]), `r` is the per-ciphertext +/// nonce, and `σ(x) = 2·x` is the GF(2^128) doubling orthomorphism (the +/// BHKR/Zahur linear orthomorphism; both `σ` and `σ ⊕ id` are permutations). +/// v2 plan §6 option 3 — **A1 resolved 2026-06-15** — analysed in the +/// random-permutation model (BHKR13; GKWY20; Guo–Katz–Wang–Weng–Yu, eprint +/// 2019/1168). /// -/// The `Hash::new` "key" parameter carries the **nonce** (same calling -/// convention as [`Aes128Z2Hash`], which uses the nonce as an AES key); -/// the AES key here is the public constant [`PI_KEY`] and is expanded -/// once per process. +/// The orthomorphism `σ` is the only departure from plain MMO and is adopted +/// as cheap defense-in-depth, not to fix a present weakness: the known attacks +/// on fixed-key MMO (GKWY; the half-gates attack of eprint 2019/1168) require +/// *known, Free-XOR-correlated* hash inputs plus a recoverable global offset — +/// neither of which ORE has, since its `H` inputs are independent **secret** +/// PRF outputs and there is no global offset. `σ` makes the construction +/// secure by matching the named BHKR/Zahur hash rather than by a usage +/// argument. The cryptanalysis of round-reduced AES hashing (eprint 2025/792) +/// targets collision/preimage/one-wayness — properties this 1-bit hash does +/// not rely on — and never reaches full-round AES-128. /// -/// **Status: pending crypto review** (v2 plan §6) — used only by post-v2 -/// schemes whose wire format is not yet frozen. +/// The `Hash::new` "key" parameter carries the **nonce** `r`; the AES key is +/// the public constant [`PI_KEY`], expanded once per process. pub struct FixedPiZ2Hash { nonce: AesBlock, } @@ -76,6 +86,24 @@ fn pi() -> &'static Aes128 { PI.get_or_init(|| Aes128::new(GenericArray::from_slice(&PI_KEY))) } +/// In-place GF(2^128) doubling `b ← 2·b` — the BHKR/Zahur orthomorphism `σ` +/// (the same "multiply by x" used for CMAC subkey derivation; reduction +/// polynomial x^128 + x^7 + x^2 + x + 1, constant `0x87`). Constant-time: +/// fixed trip count, branch-free reduction, no secret-dependent control flow. +/// `b` must be exactly 16 bytes (big-endian field element). +#[inline] +fn gf128_double(b: &mut [u8]) { + debug_assert_eq!(b.len(), 16); + let msb = b[0] >> 7; // bit shifted out of the top; capture before mutating + let mut carry = 0u8; + for i in (0..16).rev() { + let next = (b[i] << 1) | carry; + carry = b[i] >> 7; + b[i] = next; + } + b[15] ^= msb.wrapping_mul(0x87); // conditional reduction, branch-free +} + impl Hash for FixedPiZ2Hash { fn new(nonce: &HashKey) -> Self { Self { nonce: *nonce } @@ -83,33 +111,40 @@ impl Hash for FixedPiZ2Hash { fn hash(&self, data: &[u8]) -> u8 { assert_eq!(data.len(), 16); - let x_lsb = data[0] & 1u8; + // BHKR σ-MMO: m = σ(x) ⊕ r; return lsb(π(m) ⊕ m). let mut block = [0u8; 16]; - for (slot, (&x, &r)) in block.iter_mut().zip(data.iter().zip(self.nonce.iter())) { - *slot = x ^ r; + block.copy_from_slice(data); + gf128_double(&mut block); // σ(x) = 2x + for (slot, &r) in block.iter_mut().zip(self.nonce.iter()) { + *slot ^= r; // m = σ(x) ⊕ r } + let m_lsb = block[0] & 1u8; let block = GenericArray::from_mut_slice(&mut block); pi().encrypt_block(block); - (block[0] & 1u8) ^ x_lsb + (block[0] & 1u8) ^ m_lsb } fn hash_all_into(&self, data: &mut [AesBlock], out: &mut [u8]) { debug_assert_eq!(out.len() * 8, data.len()); - // Feedforward: capture the x LSBs before overwriting, then - // out = lsb(x) ^ lsb(π(x ⊕ r)). + // BHKR σ-MMO: m = σ(x) ⊕ r, then out = lsb(m) ^ lsb(π(m)), with + // σ(x) = 2x in GF(2^128). Form m in place first so the feedforward + // captures lsb(m) rather than lsb(x). + for block in data.iter_mut() { + gf128_double(block.as_mut_slice()); // σ(x) + for (slot, &r) in block.iter_mut().zip(self.nonce.iter()) { + *slot ^= r; // m = σ(x) ⊕ r + } + } + + // feedforward lsb(m) if data.len() == 256 { crate::primitives::simd::lsb_mask_256(data, out); } else { crate::primitives::simd::scalar::lsb_mask(data, out); } - for block in data.iter_mut() { - for (slot, &r) in block.iter_mut().zip(self.nonce.iter()) { - *slot ^= r; - } - } - pi().encrypt_blocks(data); + pi().encrypt_blocks(data); // π(m) let mut pi_mask = [0u8; 32]; let pi_mask = &mut pi_mask[..out.len()]; @@ -168,4 +203,56 @@ mod tests { hash.hash(&input); } + + // The comparator uses the scalar `hash`; encryption uses the bulk + // `hash_all_into`. For the BHKR σ-MMO they must agree bit-for-bit, over + // both the scalar (n=64, the Bit6 domain) and SIMD (n=256) `lsb_mask` + // backends. + #[test] + fn fixed_pi_scalar_matches_bulk() { + let nonce: [u8; 16] = hex!("0f0e0d0c 0b0a0908 07060504 03020100"); + let h: FixedPiZ2Hash = Hash::new(GenericArray::from_slice(&nonce)); + + for &n in &[64usize, 256usize] { + let mut blocks: Vec = (0..n) + .map(|i| { + let mut b = [0u8; 16]; + for (j, slot) in b.iter_mut().enumerate() { + *slot = (i.wrapping_mul(31).wrapping_add(j)) as u8; + } + *GenericArray::from_slice(&b) + }) + .collect(); + + let mut expected = vec![0u8; n / 8]; + for (i, b) in blocks.iter().enumerate() { + expected[i / 8] |= h.hash(b.as_slice()) << (i % 8); + } + + let mut out = vec![0u8; n / 8]; + h.hash_all_into(&mut blocks, &mut out); + assert_eq!(out, expected, "scalar vs bulk mismatch for n={}", n); + } + } + + // σ(x) = 2x must be an orthomorphism: both σ and σ⊕id are permutations. + // Spot-check the GF(2^128) doubling against the textbook shift/0x87 rule. + #[test] + fn gf128_double_reduction() { + // High bit clear: pure left shift. + let mut b = [0u8; 16]; + b[15] = 0x01; + gf128_double(&mut b); + let mut want = [0u8; 16]; + want[15] = 0x02; + assert_eq!(b, want); + + // High bit set: shift then XOR 0x87 into the low byte. + let mut b = [0u8; 16]; + b[0] = 0x80; + gf128_double(&mut b); + let mut want = [0u8; 16]; + want[15] = 0x87; + assert_eq!(b, want); + } } From 461abd83383e591eef7b79077c37107b85080d51 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 15 Jun 2026 16:10:28 +1000 Subject: [PATCH 14/19] test(ore-rs): pin v2 Bit6 wire-format vectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Freeze the OreAes128Bit6 (bit2_w6) wire format now that A1 (the H construction) is resolved — BHKR σ-MMO. Mirrors compat_vectors.rs (Bit8): deterministic TestRng nonce, pinned left + full ciphertext bytes, and comparison/order fixtures over the pinned bytes. Adds a signed-int (i64) order vector since Bit6 is a new scheme; cross-checks confirm the v2 header (0x02/0x02 + u16 block count) and the orderable sign-flip equivalence (i64::MIN ↔ 0u64, i64::MAX ↔ u64::MAX). Regeneratable via: cargo test --test compat_w6_vectors -- --ignored --nocapture generate --- packages/ore-rs/tests/compat_w6_vectors.rs | 287 ++++++++++++++++++ .../ore-rs/tests/compat_w6_vectors/vectors.rs | 32 ++ 2 files changed, 319 insertions(+) create mode 100644 packages/ore-rs/tests/compat_w6_vectors.rs create mode 100644 packages/ore-rs/tests/compat_w6_vectors/vectors.rs diff --git a/packages/ore-rs/tests/compat_w6_vectors.rs b/packages/ore-rs/tests/compat_w6_vectors.rs new file mode 100644 index 0000000..7a4c58f --- /dev/null +++ b/packages/ore-rs/tests/compat_w6_vectors.rs @@ -0,0 +1,287 @@ +//! Wire-format vectors for the `OreAes128Bit6` (bit2_w6) scheme — the v2 +//! 6-bit-block scheme with the v2 wire header and the BHKR σ-MMO hash `H` +//! (plan §6 / review brief A1, resolved 2026-06-15). +//! +//! These tests pin the exact serialised bytes produced for fixed keys and +//! plaintexts, plus comparison results over those bytes. They freeze the v2 +//! Bit6 wire format introduced by this PR: once released, any change that +//! alters these bytes is a wire-format break for stored Bit6 ciphertexts and +//! must fail here. (Mirror of `compat_vectors.rs`, which does the same for the +//! legacy Bit8 `OreAes128` scheme.) +//! +//! Left ciphertexts are deterministic given the keys. Full ciphertexts include +//! a random nonce drawn from the cipher's internal RNG, which `OreCipher::init` +//! seeds via `SeedableRng::from_entropy`; [`TestRng`] overrides `from_entropy` +//! to a fixed seed so full-ciphertext bytes are reproducible. +//! +//! To regenerate (only legitimate if the wire format is *deliberately* changed): +//! +//! ```text +//! cargo test --test compat_w6_vectors -- --ignored --nocapture generate +//! ``` + +use ore_rs::{ + scheme::bit2_w6::{OreAes128Bit6, OreAes128Bit6ChaCha20}, + CipherText, OreCipher, OreEncrypt, OreOutput, +}; +use rand::{RngCore, SeedableRng}; +use rand_chacha::ChaCha20Rng; +use std::cmp::Ordering; + +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, +]; + +/// RNG whose `from_entropy` is deterministic, so that `OreCipher::init` +/// (which calls `from_entropy` internally) produces a reproducible nonce stream. +struct TestRng(ChaCha20Rng); + +impl RngCore for TestRng { + fn next_u32(&mut self) -> u32 { + self.0.next_u32() + } + fn next_u64(&mut self) -> u64 { + self.0.next_u64() + } + fn fill_bytes(&mut self, dest: &mut [u8]) { + self.0.fill_bytes(dest) + } + fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand::Error> { + self.0.try_fill_bytes(dest) + } +} + +impl SeedableRng for TestRng { + type Seed = [u8; 32]; + fn from_seed(seed: Self::Seed) -> Self { + Self(ChaCha20Rng::from_seed(seed)) + } + fn from_entropy() -> Self { + Self::from_seed([SEED; 32]) + } +} + +type OreA = OreAes128Bit6>; +type OreB = OreAes128Bit6>; + +fn cipher_a() -> OreA { + OreCipher::init(&K1, &K2).unwrap() +} + +fn cipher_b() -> OreB { + OreCipher::init(&K1, &K2).unwrap() +} + +fn cipher_left() -> OreAes128Bit6ChaCha20 { + OreCipher::init(&K1, &K2).unwrap() +} + +#[path = "compat_w6_vectors/vectors.rs"] +mod vectors; +use vectors::*; + +// --------------------------------------------------------------------------- +// Left ciphertexts (fully deterministic) +// --------------------------------------------------------------------------- + +#[test] +fn left_u64_456() { + let ore = cipher_left(); + let left = 456u64.encrypt_left(&ore).unwrap(); + assert_eq!(hex::encode(left.to_bytes()), LEFT_U64_456); +} + +#[test] +fn left_u64_zero() { + let ore = cipher_left(); + let left = 0u64.encrypt_left(&ore).unwrap(); + assert_eq!(hex::encode(left.to_bytes()), LEFT_U64_0); +} + +#[test] +fn left_u32_1000() { + let ore = cipher_left(); + let left = 1000u32.encrypt_left(&ore).unwrap(); + assert_eq!(hex::encode(left.to_bytes()), LEFT_U32_1000); +} + +// --------------------------------------------------------------------------- +// Full ciphertexts (deterministic via TestRng nonce stream) +// --------------------------------------------------------------------------- + +#[test] +fn full_u64_vectors() { + for (value, expected) in FULL_U64 { + let ct = value.encrypt(&cipher_a()).unwrap(); + assert_eq!( + hex::encode(ct.to_bytes()), + *expected, + "full ciphertext mismatch for u64 {value}" + ); + } +} + +#[test] +fn full_u32_vectors() { + for (value, expected) in FULL_U32 { + let ct = value.encrypt(&cipher_a()).unwrap(); + assert_eq!( + hex::encode(ct.to_bytes()), + *expected, + "full ciphertext mismatch for u32 {value}" + ); + } +} + +#[test] +fn full_i64_vectors() { + for (value, expected) in FULL_I64 { + let ct = value.encrypt(&cipher_a()).unwrap(); + assert_eq!( + hex::encode(ct.to_bytes()), + *expected, + "full ciphertext mismatch for i64 {value}" + ); + } +} + +#[test] +fn full_f64_vector() { + let ct = 1.5f64.encrypt(&cipher_a()).unwrap(); + assert_eq!(hex::encode(ct.to_bytes()), FULL_F64_1_5); +} + +#[test] +fn full_u64_456_alternate_nonce() { + let ct = 456u64.encrypt(&cipher_b()).unwrap(); + assert_eq!(hex::encode(ct.to_bytes()), FULL_U64_456_SEED_B); +} + +// --------------------------------------------------------------------------- +// Comparison fixtures over pinned bytes (no RNG involved) +// --------------------------------------------------------------------------- + +fn pinned(hex_str: &str) -> Vec { + hex::decode(hex_str).unwrap() +} + +#[test] +fn compare_raw_slices_total_order_u64() { + // FULL_U64 is listed in ascending plaintext order. + for (i, (_, a)) in FULL_U64.iter().enumerate() { + for (j, (_, b)) in FULL_U64.iter().enumerate() { + let expected = i.cmp(&j); + assert_eq!( + OreAes128Bit6ChaCha20::compare_raw_slices(&pinned(a), &pinned(b)), + Some(expected), + "u64 vector order mismatch at ({i}, {j})" + ); + } + } +} + +#[test] +fn compare_raw_slices_total_order_i64() { + // FULL_I64 is listed in ascending plaintext order (incl. negatives). + for (i, (_, a)) in FULL_I64.iter().enumerate() { + for (j, (_, b)) in FULL_I64.iter().enumerate() { + let expected = i.cmp(&j); + assert_eq!( + OreAes128Bit6ChaCha20::compare_raw_slices(&pinned(a), &pinned(b)), + Some(expected), + "i64 vector order mismatch at ({i}, {j})" + ); + } + } +} + +#[test] +fn compare_raw_slices_equality_across_nonces() { + // Same plaintext (456u64) encrypted under two different nonce streams + // must compare equal. + let (_, seed_a) = FULL_U64[2]; + assert_eq!( + OreAes128Bit6ChaCha20::compare_raw_slices(&pinned(seed_a), &pinned(FULL_U64_456_SEED_B)), + Some(Ordering::Equal) + ); +} + +#[test] +fn typed_comparison_of_pinned_bytes() { + // u64 is 11 blocks at 6-bit width. + let a = CipherText::::from_slice(&pinned(FULL_U64[1].1)).unwrap(); + let b = CipherText::::from_slice(&pinned(FULL_U64[3].1)).unwrap(); + assert!(a < b); + assert!(b > a); + assert_eq!(a.cmp(&a), Ordering::Equal); +} + +// --------------------------------------------------------------------------- +// Generator (run manually; see module docs) +// --------------------------------------------------------------------------- + +#[test] +#[ignore = "generator: prints the contents of tests/compat_w6_vectors/vectors.rs"] +fn generate() { + println!("// Contents of tests/compat_w6_vectors/vectors.rs"); + println!( + "// Generated by `cargo test --test compat_w6_vectors -- --ignored --nocapture generate`" + ); + println!("// DO NOT regenerate unless deliberately breaking the wire format (see plan doc)."); + println!(); + + let ore = cipher_left(); + let print_left = |name: &str, bytes: Vec| { + println!("pub const {}: &str = \"{}\";", name, hex::encode(bytes)); + }; + + print_left( + "LEFT_U64_456", + 456u64.encrypt_left(&ore).unwrap().to_bytes(), + ); + print_left("LEFT_U64_0", 0u64.encrypt_left(&ore).unwrap().to_bytes()); + print_left( + "LEFT_U32_1000", + 1000u32.encrypt_left(&ore).unwrap().to_bytes(), + ); + println!(); + + println!("pub const FULL_U64: &[(u64, &str)] = &["); + for value in [0u64, 1, 456, 1 << 32, u64::MAX] { + let ct = value.encrypt(&cipher_a()).unwrap(); + println!(" ({}, \"{}\"),", value, hex::encode(ct.to_bytes())); + } + println!("];"); + println!(); + + println!("pub const FULL_U32: &[(u32, &str)] = &["); + for value in [0u32, 1000, u32::MAX] { + let ct = value.encrypt(&cipher_a()).unwrap(); + println!(" ({}, \"{}\"),", value, hex::encode(ct.to_bytes())); + } + println!("];"); + println!(); + + println!("pub const FULL_I64: &[(i64, &str)] = &["); + for value in [i64::MIN, -1i64, 0, 1, i64::MAX] { + let ct = value.encrypt(&cipher_a()).unwrap(); + println!(" ({}, \"{}\"),", value, hex::encode(ct.to_bytes())); + } + println!("];"); + println!(); + + let f = 1.5f64.encrypt(&cipher_a()).unwrap(); + println!( + "pub const FULL_F64_1_5: &str = \"{}\";", + hex::encode(f.to_bytes()) + ); + + let alt = 456u64.encrypt(&cipher_b()).unwrap(); + println!( + "pub const FULL_U64_456_SEED_B: &str = \"{}\";", + hex::encode(alt.to_bytes()) + ); +} diff --git a/packages/ore-rs/tests/compat_w6_vectors/vectors.rs b/packages/ore-rs/tests/compat_w6_vectors/vectors.rs new file mode 100644 index 0000000..9f39d90 --- /dev/null +++ b/packages/ore-rs/tests/compat_w6_vectors/vectors.rs @@ -0,0 +1,32 @@ +// Contents of tests/compat_w6_vectors/vectors.rs +// Generated by `cargo test --test compat_w6_vectors -- --ignored --nocapture generate` +// DO NOT regenerate unless deliberately breaking the wire format (see plan doc). + +pub const LEFT_U64_456: &str = "0202000b30303030303030303033066da76fd0a92c30514fc8f00ca98a2eb2028fbe301e641f72490894e363ce589e8df6ec476a5ed8796830fbea6ce0624832dae399c351fef149a537f2b8eb08c0cdff4dbcd6c0e38276f78b576550fdd5a0ea9626f42573d61d1ba8372269ef9e6f489c0d208424eddcb113d397481361c3d1ad37b414893fd1bff4b92ba4216833321ae4cc88b8d44b2808a6fd3f8b6469a7a2426c58a491c8399caf81b7c41b23d8b8de7fc61f1cd531a0851aa2ffe2"; +pub const LEFT_U64_0: &str = "0202000b30303030303030303030306da76fd0a92c30514fc8f00ca98a2eb2028fbe301e641f72490894e363ce589e8df6ec476a5ed8796830fbea6ce0624832dae399c351fef149a537f2b8eb08c0cdff4dbcd6c0e38276f78b576550fdd5a0ea9626f42573d61d1ba8372269ef9e6f489c0d208424eddcb113d397481361c3d1ad37b414893fd1bff4b92ba4216833321ae4cc88b8d44b2808a6fd3f8b642190f0d1cfcf729be71b0090e1fbea72829007cdf8c5a5f97d531ff760162a09"; +pub const LEFT_U32_1000: &str = "02020006010101181d3ade4ea5650330e73613b528889d22317305e80fc6fb8b873c039f66bd6452a68b50741e00096f4478a8a0a486a4f875b89b822e0419aefb1379d152350c76be43caaedd9680f4c08792120ec0aa765ef0ca184280fbb109fac7487520d9a920cd"; + +pub const FULL_U64: &[(u64, &str)] = &[ + (0, "0202000b30303030303030303030306da76fd0a92c30514fc8f00ca98a2eb2028fbe301e641f72490894e363ce589e8df6ec476a5ed8796830fbea6ce0624832dae399c351fef149a537f2b8eb08c0cdff4dbcd6c0e38276f78b576550fdd5a0ea9626f42573d61d1ba8372269ef9e6f489c0d208424eddcb113d397481361c3d1ad37b414893fd1bff4b92ba4216833321ae4cc88b8d44b2808a6fd3f8b642190f0d1cfcf729be71b0090e1fbea72829007cdf8c5a5f97d531ff760162a0998191f46e5830216445436978803697a1709ad580000c09f8a67ab89b7e38f00b47a03f4e2ce32a6b10f3a3844133e1e5a64f007c189512be33d0c024336ae384f4573f4a7bdf0d53d0d36054fb7baa78b83614e752c7554396f703222948e6912bfe2f3cc7fd451"), + (1, "0202000b303030303030303030301c6da76fd0a92c30514fc8f00ca98a2eb2028fbe301e641f72490894e363ce589e8df6ec476a5ed8796830fbea6ce0624832dae399c351fef149a537f2b8eb08c0cdff4dbcd6c0e38276f78b576550fdd5a0ea9626f42573d61d1ba8372269ef9e6f489c0d208424eddcb113d397481361c3d1ad37b414893fd1bff4b92ba4216833321ae4cc88b8d44b2808a6fd3f8b642190f0d1cfcf729be71b0090e1fbea72fc5fbd1caacd687846292edaeb697f2298191f46e5830216445436978803697a1709ad580000c09f8a67ab89b7e38f00b47a03f4e2ce32a6b10f3a3844133e1e5a64f007c189512be33d0c024336ae384f4573f4a7bdf0d53d0d36054fb7baa78b83614e752c7554396f703222948e6912afe2e3cc5ed451"), + (456, "0202000b30303030303030303033066da76fd0a92c30514fc8f00ca98a2eb2028fbe301e641f72490894e363ce589e8df6ec476a5ed8796830fbea6ce0624832dae399c351fef149a537f2b8eb08c0cdff4dbcd6c0e38276f78b576550fdd5a0ea9626f42573d61d1ba8372269ef9e6f489c0d208424eddcb113d397481361c3d1ad37b414893fd1bff4b92ba4216833321ae4cc88b8d44b2808a6fd3f8b6469a7a2426c58a491c8399caf81b7c41b23d8b8de7fc61f1cd531a0851aa2ffe298191f46e5830216445436978803697a1709ad580000c09f8a67ab89b7e38f00b47a03f4e2ce32a6b10f3a3844133e1e5a64f007c189512be33d0c024336ae384f4573f4a7bdf0d53d0d36054fb7baa78b83614e752c75548b5b342931f946cf19b1e81aaf869ec8"), + (4294967296, "0202000b3030303030122d2d2d2d2d6da76fd0a92c30514fc8f00ca98a2eb2028fbe301e641f72490894e363ce589e8df6ec476a5ed8796830fbea6ce0624832dae399c351fef149a537f2b8eb08c0cdff4dbcd6c0e38276f78b576550fdd5d88b8766243e3bc0c33b9cd7fb8177fac0bf790914e1c6611f3288661f8d74b037a7e2824f80f249f4f51bb83ba425c81fb3358cda4168948680343b5f4916211ca82a58d8ccdffdc6cab511f23e50477a721591d1b27130df58c8883633d9f798191f46e5830216445436978803697a1709ad580000c09f8a67ab89b7e38f00b47a03f4e2ce32a6b10f3a3844133e1e5a64f007c189512b630d081942532ebab0281c1a21c623371c377fbcfaf9cc1524ec2e4efd3bf561206e95cfc6e6bcdcfdae60c9e4c5d14d"), + (18446744073709551615, "0202000b14131f0e1916352e1427184fc2a5344bd8e4d2267f574f3d125a637f1581b95aabcaa9bf9e75797176b79566143e89a02280f629ab517274541299fc070a178c8e68945d1d8b36ff6ba3affe08ca245c43c210b5a225fa70195653a22759ebe35ae2d27917643f631e2a22646e96893bc6b0fdfbb52eea30f22f9abe59715690cb342e3d4ac8d80876d714c5c09b40480e0080494590f47cf26e6a0dde5d186dc7ac26537622a20bbcae5ac965c3164bb16726d8e5bddbd6edc81598191f46e5830216445436978803697ae8f652a7ffff3e6061b846c45e3c34e8f5e53518be0ddecacfa949ef167231a30706d11d46f0007c03f40f02881963b1eb5e34b7ca7bc1091de83498ba65b8c0873046c35bce173ffef584423cb8b7e3b3fd0fdcca073fa2"), +]; + +pub const FULL_U32: &[(u32, &str)] = &[ + (0, "02020006010101010101de4ea5650330e73613b528889d22317305e80fc6fb8b873c039f66bd6452a68b50741e00096f4478a8a0a486a4f875b814e6fcb930e233b386cbd9dda45497b53b02654308e9ee83954fd229beb70e81d8997d55bed3ce83df28fd023b7d89c898191f46e5830216445436978803697abf6a57a701024b498d595df2d1092c3433343a5107cee8bd1a67d7b140c6550e9d89ce918486c2b7f90852f47463b6b3"), + (1000, "02020006010101181d3ade4ea5650330e73613b528889d22317305e80fc6fb8b873c039f66bd6452a68b50741e00096f4478a8a0a486a4f875b89b822e0419aefb1379d152350c76be43caaedd9680f4c08792120ec0aa765ef0ca184280fbb109fac7487520d9a920cd98191f46e5830216445436978803697abf6a57a701024b498d595df2d1092c3433343a5107cee8bd1e67d7b040d6550e442ee01bef272b62112b2b64dc7df53e"), + (4294967295, "02020006030c291101194562a690d4a461836bc2e78c902fe1e89e465fcf9a24f99375152ead2c7e7111d8ee74340910533979a2eb64c7831b3f5af19be28c4060ede8fe719854dfb35dbd00700a83fe373edf77e4baa9dc92dacf0e4b56c7bd1f9362beec6ae7f862a498191f46e5830216445436978803697a4295a858fefdb4b68318576386da1b05f1d9054cb3bc8418136231333cf4efca5effa190912c5b0ecd4f98680d17dd7d"), +]; + +pub const FULL_I64: &[(i64, &str)] = &[ + (-9223372036854775808, "0202000b30303030303030303030306da76fd0a92c30514fc8f00ca98a2eb2028fbe301e641f72490894e363ce589e8df6ec476a5ed8796830fbea6ce0624832dae399c351fef149a537f2b8eb08c0cdff4dbcd6c0e38276f78b576550fdd5a0ea9626f42573d61d1ba8372269ef9e6f489c0d208424eddcb113d397481361c3d1ad37b414893fd1bff4b92ba4216833321ae4cc88b8d44b2808a6fd3f8b642190f0d1cfcf729be71b0090e1fbea72829007cdf8c5a5f97d531ff760162a0998191f46e5830216445436978803697a1709ad580000c09f8a67ab89b7e38f00b47a03f4e2ce32a6b10f3a3844133e1e5a64f007c189512be33d0c024336ae384f4573f4a7bdf0d53d0d36054fb7baa78b83614e752c7554396f703222948e6912bfe2f3cc7fd451"), + (-1, "0202000b2c2c1c1814213729260f3ea6a46104d8f7d85dd41bb6559481ecc537880628b958b1bd1b7670def64969f54e66bf47b998ea099e036df26be762171ad2556e9702534e1ed7a1d9518876f02764a05137ce3e95e36dfd56bd97c893fa96a0e85f163bb53cbc9c81776ff8e14fa605f2f91e8fc4d8a6d074af62b9437b4b6fc577f046ab20dffb2de8e77a4b1752b6dd47e9e917a9bab7f0fa28fea47df435316fcda9b141d82228470b5c4c6e81021facd21a11ad4131029349531d98191f46e5830216445436978803697aad7de943137d0839d15d4c607487b53a499ab76891cd84076c3d06b3fd508d3ac1887d5a16bed6ca02fd897be402e0410e33e36db414fc02d87209f003771de8b5c97daf75e82155e3c0e4e6ce4f1c7b73707af2841d0ebe"), + (0, "0202000b113c3c3c3c3c3c3c3c3c3cd3da521cdaabfbbf9fcc45c6174a429eda2a3a399c7dc329cb98274b1ab0b39895a9a5fe76973ace13f485d0027a7fcd7c0551155d2d39e1733cb1c16a156d599591638b440006c7d9ffd5e8b05e1d75d2ef94795c749afd728d9210140be472f291216313f526e35c350faaa8e1b84c2d1d6f2e63fec795db2c4e6814a438c875523815387d80a728388e55673f019be16ccd05adce99755f7f0d147ffc5d2d61d2d61c8429df0c5ea84d61ee6bd81b98191f46e5830216445436978803697aad7deb43137d083976351a1289df61856a1283efce71991c35f487702c6d82454715ad108497b7a7c2a18690bce12a4ec81fefc82525b3b14958a5a02ef64f57b6c4b0c0678fc978997015e7cf5184524eb6c61d22c864c7"), + (1, "0202000b113c3c3c3c3c3c3c3c3c1dd3da521cdaabfbbf9fcc45c6174a429eda2a3a399c7dc329cb98274b1ab0b39895a9a5fe76973ace13f485d0027a7fcd7c0551155d2d39e1733cb1c16a156d599591638b440006c7d9ffd5e8b05e1d75d2ef94795c749afd728d9210140be472f291216313f526e35c350faaa8e1b84c2d1d6f2e63fec795db2c4e6814a438c875523815387d80a728388e55673f019be16ccd05adce99755f7f0d147ffc5d2d74c0c308762f6501242d2e466d85b32098191f46e5830216445436978803697aad7deb43137d083976351a1289df61856a1283efce71991c35f487702c6d82454715ad108497b7a7c2a18690bce12a4ec81fefc82525b3b14958a5a02ef64f57b6c4b0c0678fc978997015e7cf51845207b6c63d22c864c7"), + (9223372036854775807, "0202000b14131f0e1916352e1427184fc2a5344bd8e4d2267f574f3d125a637f1581b95aabcaa9bf9e75797176b79566143e89a02280f629ab517274541299fc070a178c8e68945d1d8b36ff6ba3affe08ca245c43c210b5a225fa70195653a22759ebe35ae2d27917643f631e2a22646e96893bc6b0fdfbb52eea30f22f9abe59715690cb342e3d4ac8d80876d714c5c09b40480e0080494590f47cf26e6a0dde5d186dc7ac26537622a20bbcae5ac965c3164bb16726d8e5bddbd6edc81598191f46e5830216445436978803697ae8f652a7ffff3e6061b846c45e3c34e8f5e53518be0ddecacfa949ef167231a30706d11d46f0007c03f40f02881963b1eb5e34b7ca7bc1091de83498ba65b8c0873046c35bce173ffef584423cb8b7e3b3fd0fdcca073fa2"), +]; + +pub const FULL_F64_1_5: &str = "0202000b221e212f2f2f2f2f2f2f2f10177b7f6092ae411ace1de82037f902570e2a33d92a1c8a854e6e82bdfb2bf298aa9f2c5fdce55f7b0f9654cba0f3139f1d9ca41590aa16ebf58d9e5b8f5702d208e17ef3d6c784b86c56f057242a431da5157e3c55339080d6c76b4712472e86d88847df9c4b078697f872b83a044191813b9e4dfe63a130407a4594d7a4ff326f814160865f7bc92c15a62e943f1b10ee79e46a79f6e459ed2cf79411772108bddc07c03d3e390c7299ee38b6073798191f46e5830216445436978803697aa9744ae7977d086064353890312503bd81457c13a6383df0f441a5469cc86eaa10b9da6132823f0f4cf570b4faf4d2a3132fde62df4571cfde9f2e46aa8c8be8dbaa84de3cec940c177e32abeecc8be1bae00b227d0b492c"; +pub const FULL_U64_456_SEED_B: &str = "0202000b30303030303030303033066da76fd0a92c30514fc8f00ca98a2eb2028fbe301e641f72490894e363ce589e8df6ec476a5ed8796830fbea6ce0624832dae399c351fef149a537f2b8eb08c0cdff4dbcd6c0e38276f78b576550fdd5a0ea9626f42573d61d1ba8372269ef9e6f489c0d208424eddcb113d397481361c3d1ad37b414893fd1bff4b92ba4216833321ae4cc88b8d44b2808a6fd3f8b6469a7a2426c58a491c8399caf81b7c41b23d8b8de7fc61f1cd531a0851aa2ffe252f6f5b7a04d1eeff2d0f12880ed826963e803a735bb7e164404b383b468f182908199026bf8e6fb99c720d36978679a1fe2932d5f30672c4d0a74563a7e4ea6da1cbf7ac0b3817221b88df27425b9f702d1b212fd593ea483a3e49a4058c6e22c9e930f84542c8e"; From ef6e3d409dff3b9373dbfcd1b783150546c00076 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 15 Jun 2026 16:24:05 +1000 Subject: [PATCH 15/19] perf(ore-rs): vectorize GF(2^128) doubling in the sigma-MMO hash Replace the per-byte carry loop in the BHKR sigma-MMO with a single u128 word op: 2x = (x << 1) ^ ((x >> 127) * 0x87) over the big-endian field element. In the bulk encrypt path, fold the doubling and the nonce XOR into one u128 pass per block (the ~704-evals/u64 hot loop). Byte-identical output (sigma is unchanged), so the pinned Bit6 vectors and the scalar<->bulk consistency test still pass; still constant-time (branch-free, no secret-dependent control flow). Recovers the A1 sigma-MMO regression: Bit6 u64 encrypt ~12.3us -> ~8.9us (Apple M1 Max), back to the pre-orthomorphism shape-i level. --- packages/ore-rs/src/primitives/hash.rs | 48 ++++++++++++++++---------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/packages/ore-rs/src/primitives/hash.rs b/packages/ore-rs/src/primitives/hash.rs index 07d5698..eeef30c 100644 --- a/packages/ore-rs/src/primitives/hash.rs +++ b/packages/ore-rs/src/primitives/hash.rs @@ -86,22 +86,30 @@ fn pi() -> &'static Aes128 { PI.get_or_init(|| Aes128::new(GenericArray::from_slice(&PI_KEY))) } -/// In-place GF(2^128) doubling `b ← 2·b` — the BHKR/Zahur orthomorphism `σ` -/// (the same "multiply by x" used for CMAC subkey derivation; reduction -/// polynomial x^128 + x^7 + x^2 + x + 1, constant `0x87`). Constant-time: -/// fixed trip count, branch-free reduction, no secret-dependent control flow. -/// `b` must be exactly 16 bytes (big-endian field element). +/// GF(2^128) doubling `2·x` on a big-endian field element packed in a `u128` +/// — the BHKR/Zahur orthomorphism `σ` ("multiply by x"; reduction polynomial +/// x^128 + x^7 + x^2 + x + 1, constant `0x87`). A single shift plus a +/// branch-free conditional XOR, replacing the per-byte carry loop that +/// dominated the σ-MMO hot path (≈704 doublings per Bit6 u64 encrypt). +/// Constant-time: no secret-dependent control flow. +#[inline] +fn gf128_double_u128(x: u128) -> u128 { + // `x << 1` discards the top bit (the GF reduction trigger); fold 0x87 into + // the low byte iff that bit was set. `x >> 127` is 0 or 1. + (x << 1) ^ ((x >> 127) * 0x87) +} + +/// In-place GF(2^128) doubling `b ← 2·b`; `b` must be exactly 16 bytes +/// (big-endian field element). Used by the scalar comparator path; the bulk +/// encrypt path folds the doubling and nonce XOR into one `u128` pass (see +/// `hash_all_into`). #[inline] fn gf128_double(b: &mut [u8]) { debug_assert_eq!(b.len(), 16); - let msb = b[0] >> 7; // bit shifted out of the top; capture before mutating - let mut carry = 0u8; - for i in (0..16).rev() { - let next = (b[i] << 1) | carry; - carry = b[i] >> 7; - b[i] = next; - } - b[15] ^= msb.wrapping_mul(0x87); // conditional reduction, branch-free + let mut arr = [0u8; 16]; + arr.copy_from_slice(b); + let doubled = gf128_double_u128(u128::from_be_bytes(arr)); + b.copy_from_slice(&doubled.to_be_bytes()); } impl Hash for FixedPiZ2Hash { @@ -129,12 +137,16 @@ impl Hash for FixedPiZ2Hash { // BHKR σ-MMO: m = σ(x) ⊕ r, then out = lsb(m) ^ lsb(π(m)), with // σ(x) = 2x in GF(2^128). Form m in place first so the feedforward - // captures lsb(m) rather than lsb(x). + // captures lsb(m) rather than lsb(x). Doubling and nonce XOR are fused + // into one u128 pass per block — this is the 704-evals/u64 hot loop. + let mut nonce_arr = [0u8; 16]; + nonce_arr.copy_from_slice(self.nonce.as_slice()); + let nonce = u128::from_be_bytes(nonce_arr); for block in data.iter_mut() { - gf128_double(block.as_mut_slice()); // σ(x) - for (slot, &r) in block.iter_mut().zip(self.nonce.iter()) { - *slot ^= r; // m = σ(x) ⊕ r - } + let mut arr = [0u8; 16]; + arr.copy_from_slice(block.as_slice()); + let m = gf128_double_u128(u128::from_be_bytes(arr)) ^ nonce; + block.copy_from_slice(&m.to_be_bytes()); } // feedforward lsb(m) From ac0fad7c9764c0a1c0fe00eec4469e0154b33d92 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 15 Jun 2026 16:40:36 +1000 Subject: [PATCH 16/19] docs: draft A2 chained-prefix CMAC accumulator design spec The detailed, review-ready spec for the PR 6 accumulator (the A2 gate), replacing the sketch in the review brief. Covers: - Construction: CMAC (NIST SP 800-38B) over an injective prefix-block + final-block encoding; cached CBC state = incremental CMAC. Subkeys reuse the (vectorized) gf128_double. - Injective message encoding (byte layouts) + injectivity argument. - Per-block algorithm; the left tag f[n] = ro(n, xt[n]) is the RO_KEY branch at the permuted symbol (not a separate output), preserving mask cancellation at compare time. - NO total-length binding -- required so prefix-sharing strings of different lengths compare correctly; length comparability enforced at the comparator. - One dedicated KDF'd key unifies the old prf1/prf2 via branch tags (RO_KEY/PRP_STREAM); PRF2 subsumed. - Shape-(ii) PRP (A3): keystream derived as CMAC tags, no per-block key schedule (needs a LemireFyPrp::from_stream ctor). - Security: reduction to CMAC PRF + the three auditable claims (injectivity, incremental faithfulness, zeroization) + birthday budget; the chain state is never published, so the cascade/GGM interaction does not arise. - Test plan + open questions for the reviewer. Linked from review brief A2 and plan section 5(b). --- docs/plans/2026-06-12-ore-v2-architecture.md | 3 +- ...2026-06-15-ore-v2-cmac-accumulator-spec.md | 263 ++++++++++++++++++ .../2026-06-14-ore-v2-crypto-review-brief.md | 4 + 3 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-06-15-ore-v2-cmac-accumulator-spec.md diff --git a/docs/plans/2026-06-12-ore-v2-architecture.md b/docs/plans/2026-06-12-ore-v2-architecture.md index 8817385..dac381e 100644 --- a/docs/plans/2026-06-12-ore-v2-architecture.md +++ b/docs/plans/2026-06-12-ore-v2-architecture.md @@ -281,7 +281,8 @@ software on aarch64 (no key-schedule instruction); estimated 5–20% overhead ag Bit6's ~130 batched AES ops per block. **Benchmark gate:** measure NEON key-expansion overhead before committing. -**Candidate B — CMAC with cached prefix state.** Every published value is a *bona +**Candidate B — CMAC with cached prefix state.** *(SELECTED; full design spec: +`docs/plans/2026-06-15-ore-v2-cmac-accumulator-spec.md`.)* Every published value is a *bona fide* AES-CMAC (NIST SP 800-38B) tag of an injectively encoded message — for block `i`: `enc(x₀‖0) ‖ … ‖ enc(x_{i-1}‖i−1) ‖ final_block(branch, value, i)` — and the per-prefix chaining-state cache is purely an implementation optimization (CMAC is CBC diff --git a/docs/plans/2026-06-15-ore-v2-cmac-accumulator-spec.md b/docs/plans/2026-06-15-ore-v2-cmac-accumulator-spec.md new file mode 100644 index 0000000..fd99e49 --- /dev/null +++ b/docs/plans/2026-06-15-ore-v2-cmac-accumulator-spec.md @@ -0,0 +1,263 @@ +# ORE v2 — chained-prefix CMAC accumulator: design spec (A2) + +**Date:** 2026-06-15 +**Status:** DRAFT for crypto review — the **A2 gate** that must pass before PR 6 +(chained prefix / variable-length / strings) is written. +**Companions:** plan §5(b) (`docs/plans/2026-06-12-ore-v2-architecture.md`), +review brief A2/A3 (`docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md`). + +This is the precise specification the review brief flagged as "still a sketch." +It pins the message encoding (the injectivity-critical part), the per-block +algorithm, the security argument, and the test plan. + +--- + +## 1. What this replaces, and why + +The fixed-N Bit6 scheme (#82) derives every per-block secret by packing the +**prefix** `x[0..n-1]` into one 16-byte AES block and encrypting it: + +- **PRP seed** (`derive_prp_seeds`, key `prf2`): `seed_n = E_{prf2}( x[0..n-1] ‖ 0… ‖ N@[15] )` +- **RO key / left tag** (key `prf1`): `ro(n, v) = E_{prf1}( x[0..n-1] ‖ v@[n] ‖ n@[N] ‖ N@[15] )`, + where `v = j` for the right-vector mask at domain position `j`, and `v = xt[n]` + (the permuted current symbol) for the published left tag `f[n]`. **So + `f[n] = ro(n, xt[n])`** — the same function at the value's permuted position; + this is what makes the masks cancel at compare time. + +Packing the raw prefix into one block caps the plaintext at **≤ 14 blocks** +(`MAX_BLOCKS`). PR 6 needs arbitrary length (strings; also `u128`/`Decimal` +which exceed 14 Bit6 blocks). The fix is an **accumulator**: absorb the prefix +incrementally into a fixed 16-byte state, and derive the per-block secrets by +**CMAC-finalising** that state with a per-output final block — instead of +packing the raw prefix. Candidate B (CMAC, NIST SP 800-38B) was selected by the +§5(b) benchmark gate (cascade/GGM rejected on aarch64 key-expansion cost; XE +held in reserve). + +--- + +## 2. Construction + +Let `E_k` be AES-128 under the accumulator key `k` (§3). Standard CMAC: + +``` +L = E_k(0^128) +K1 = dbl(L) # dbl = GF(2^128) "multiply by x" — the same gf128_double +K2 = dbl(K1) # (reused from the σ-MMO hash; constant 0x87) +``` + +All accumulator messages are an exact multiple of 16 bytes, so **only K1 is ever +used** (K2/padding never occurs). Define, over a running CBC state `S`: + +``` +absorb(S, B) = E_k(S ⊕ B) # extend the prefix chain (no subkey) +finalize(S, F) = E_k(S ⊕ F ⊕ K1) # a published CMAC tag (full last block) +``` + +For a plaintext of blocks `x[0..N-1]`, maintain `S_n` = the CBC chain over the +prefix blocks `P_0 ‖ … ‖ P_{n-1}` (`S_0 = 0^128`). Every per-block secret is a +`finalize(S_n, F)` for a final block `F` that injectively names the output. The +prefix chain is cached and extended incrementally — `clone-state-then-finalize` +*is* incremental CMAC, so each published value is a *bona fide* one-shot CMAC tag +of `P_0 ‖ … ‖ P_{n-1} ‖ F`. + +--- + +## 3. Key derivation & domain separation + +The accumulator uses a **dedicated key `k`**, derived from the master ORE key by +a labelled KDF distinct from every other use (the fixed-N `prf1`/`prf2`, H's +public `π`, the nonce RNG): + +``` +k = E_{k_master}( "ORE.v2.chain.acc\x00" ) # 16-byte ASCII label, single AES call +``` + +(`k_master` = one of the `init(k1, k2)` keys; concrete slot TBD in review.) A +dedicated key makes the accumulator's PRF security self-contained: no cross-use +collisions with the fixed-N schemes or with H. The chained scheme therefore +**unifies the old `prf1` and `prf2`** into one key with branch separation +(below) — PRF₂ is subsumed. + +--- + +## 4. Message encoding (injectivity-critical) + +Every block is exactly 16 bytes. Two block types, distinguished by byte 0. + +**Prefix block `P_t`** (absorbed into the chain; carries symbol `x[t]`): + +| byte | 0 | 1–2 | 3 | 4–15 | +|------|---|-----|---|------| +| value | `0x00` (TYPE_PREFIX) | `t` (u16 BE) | `x[t]` | `0x00` | + +**Final block `F(branch, n, s)`** (the last block of a `finalize` message): + +| byte | 0 | 1–2 | 3–4 | 5 | 6–15 | +|------|---|-----|-----|---|------| +| value | branch tag | `n` (u16 BE) | `s` (u16 BE) | `width` (`0x06`) | `0x00` | + +- **branch tag** ∈ `{ 0x01 = RO_KEY, 0x02 = PRP_STREAM }` (both ≠ `0x00`). +- **`n`** = block position being derived. +- **`s`** = sub-index: for `RO_KEY`, the domain value `j` (or `xt[n]` for the + left tag); for `PRP_STREAM`, the keystream counter `c`. +- **`width`** = block width (6) → domain separation from any future Bit8-chained + scheme. + +**No total-length (`N`) binding** — deliberately, and unlike the fixed-N packed +scheme (§7). + +### Injectivity +Every prefix block has `byte0 = 0x00`; every final block has `byte0 ∈ {0x01, +0x02}`. In a message `P_0 ‖ … ‖ P_{i-1} ‖ F`, the final block is the unique +non-`0x00`-byte0 block (and it is last), so the message parses unambiguously into +`((x[0],0),…,(x[i-1],i-1), (branch,n,s,width))`. Hence the map + +``` +(prefix values x[0..i-1], branch, n, s, width) ⟼ message bytes +``` + +is **injective**: distinct logical inputs ⇒ distinct messages. (Claim 1.) + +--- + +## 5. Per-block algorithm + +`STREAM_BLOCKS` = ⌈(DOMAIN−1)·8 / 16⌉ = 32 for Bit6 (63 wide draws → 504 B → 32 +× 16 B). `nonce` is a fresh per-ciphertext random value (unchanged from fixed-N). + +``` +S ← 0^128 +for n in 0..N: + # PRP for block n (shape (ii), A3): keystream straight from the accumulator + stream ← ‖_{c=0}^{STREAM_BLOCKS-1} finalize(S, F(PRP_STREAM, n, c)) + π_n ← LemireFyPrp::from_stream(stream) # new ctor; FY math unchanged + xt[n] ← π_n.permute(x[n]) + + # left tag f[n] = ro(n, xt[n]) — RO_KEY branch at s = xt[n] + f[n] ← finalize(S, F(RO_KEY, n, xt[n])) + + # right block: ro_key per domain value, then mask exactly as fixed-N + for j in 0..DOMAIN: + ro[j] ← finalize(S, F(RO_KEY, n, j)) + encode_right_block(right[n], π_n, x[n], H_nonce, ro) # unchanged: H-mask ⊕ indicator + + # extend the prefix chain + S ← absorb(S, P_n(x[n])) # S_{n+1} +zeroize(S, stream, ro, K1, K2, L, k) +``` + +- `encode_right_block`, `H` (the BHKR σ-MMO, A1), `indicator_mask_xor`, and the + comparator are **unchanged** — only the derivation of `ro`/`f`/PRP changes from + packed-AES to CMAC. `f[n]` reuses the `RO_KEY` finalize at `s = xt[n]`, so the + left/right masks cancel exactly as today. +- Left-only encryption (queries) runs the `PRP_STREAM` + `f[n]` steps only. +- Per-block AES count ≈ `1 (absorb) + 32 (stream) + DOMAIN (ro) ≈ 97` at Bit6, + comparable to the packed scheme's ~130; **no per-block key schedule** (this is + the shape-(ii) win, A3). + +--- + +## 6. Incremental CMAC ↔ one-shot equivalence (Claim 2) + +`finalize(S_n, F)` with `S_n` the CBC chain of `P_0..P_{n-1}` equals one-shot +`CMAC_k(P_0 ‖ … ‖ P_{n-1} ‖ F)` because `F` is a full 16-byte final block (→ K1) +and `S_n` is the standard CBC state. The chain extension `absorb(S_n, P_n)` is a +CBC step with **no subkey**, so it is *not* a published tag. **Test:** assert the +incremental implementation byte-matches the `cmac` crate's one-shot output over +the exact `P/F` messages, across positions/branches/sub-indices. + +--- + +## 7. No length binding — required for cross-length comparison + +The fixed-N scheme binds `N` (byte 15) into every derivation; that is safe +because only equal-length (same-type) ciphertexts are ever compared. The chained +scheme **must not** bind `N`: two strings sharing a prefix (`"app"` vs `"apple"`) +must produce **identical** per-block secrets for the shared blocks so the +comparator finds the first differing block correctly (shorter sorts first). The +per-block secrets therefore depend on `(prefix values, position n, branch, s)` +but **not** total length. + +Length **comparability** is enforced at the comparator, not in the derivation: + +- **Strings:** different lengths allowed; scan `min(len_a, len_b)` blocks; if + equal throughout, the shorter sorts first (lexicographic). Common-prefix-length + leakage is the intended, query-time-scoped leakage (plan §5(b)). +- **Fixed-length types** (`u128`/`Decimal` via the accumulator): the header + carries the block count; the comparator **rejects** mismatched lengths + (cross-type comparison), exactly as fixed-N does. + +--- + +## 8. Security argument + +1. **`k` is a uniform AES key** used *only* by the accumulator (§3), so its PRF + security is self-contained. +2. **CMAC is a secure variable-input-length PRF** in the PRP model (NIST SP + 800-38B; Iwata–Kurosawa OMAC), advantage `≈ (σ)² / 2^128` for `σ` total + blocks processed under `k`, plus the AES PRP term. +3. **Every published secret is `CMAC_k(msg)`** for an injectively-encoded `msg` + (Claim 1). Distinct logical outputs ⇒ distinct messages ⇒ jointly + indistinguishable from independent uniform values. These are precisely the + independent PRF outputs the Lewi-Wu analysis assumes for `ro`/`f`/PRP; ORE + security then follows from the existing Lewi-Wu argument. +4. **The chain state is never published.** `S_n` is an internal CBC value; the + only outputs are `finalize(S_n, F)` with the K1-treated final block. So the + "published-outputs-vs-chaining-value" interaction that complicates cascade/GGM + does **not** arise — it is subsumed by the CMAC PRF abstraction. (`absorb` + and `finalize` from the same `S_n` are E_k at different points: `S_n⊕P_n` vs + `S_n⊕F⊕K1`, with `P_n` byte0=`0x00` and `F` byte0≠`0x00`.) +5. **Many outputs per state** (32 stream + DOMAIN ro per block) is sound for the + same reason: distinct final blocks ⇒ distinct messages ⇒ independent tags. +6. **Birthday budget.** `σ ≈ q · L · (DOMAIN + STREAM + 1)`. For `q = 2^32` + ciphertexts, `L = 14`, Bit6: `σ ≈ 2^44`, term `≈ 2^{88-128} = 2^{-40}` — + comfortable. `log()` the assumption if a deployment expects `q ≫ 2^32`. + +Auditable claims for sign-off: **(1)** encoding injectivity (§4), **(2)** +incremental-vs-one-shot faithfulness (§6), **(3)** zeroization (§9). + +--- + +## 9. State hygiene (Claim 3) + +`k`, `L`, `K1`, `K2`, the cached chain states `S_n`, the `stream` buffer, and the +`ro` buffer are all secret key-equivalent material (anyone holding `S_n` can +derive every output for that prefix). All must be: zeroized on drop; never +serialized; never reachable from `Left`/`Right`/`CipherText` types. (Mirrors the +existing `SeedBuf`/template zeroization in #82.) + +--- + +## 10. Test plan + +- **CMAC faithfulness:** incremental impl == `cmac` crate one-shot, over many + `(prefix, branch, n, s)` (§6). +- **Subkey KAT:** `L/K1/K2` against NIST SP 800-38B AES-128 CMAC test vectors + (also exercises the shared `gf128_double`). +- **Cross-length comparison:** strings sharing a prefix of every length compare + correctly (shorter sorts first); fixed-length mismatches are rejected (§7). +- **Order/roundtrip quickcheck** for the chained scheme (as bit2_w6 has). +- **Shape-(ii) PRP equivalence:** `from_stream(accumulator stream)` yields the + same permutation as the spec's FY over that stream; statistical-distance bound + unchanged (A3). +- **Wire vectors pinned** once the encoding is signed off (mirror + `compat_w6_vectors`), incl. a variable-length string set. +- **Zeroization** assertions / `Drop` coverage. + +--- + +## 11. Open questions for review + +1. **Key slot for `k`** (§3): which `init` key + exact label; confirm the KDF + (single labelled AES call) is acceptable. +2. **`width`/scheme tag** (§4): is a 1-byte width enough domain separation, or + should the final block also carry a scheme id (as the wire header does)? +3. **PRP_STREAM vs a single seed:** spec derives the full FY stream as 32 CMAC + tags (shape ii). Alternative: one `PRP_SEED` tag that keys a fresh AES-CTR + (shape i) — simpler, but reintroduces a per-block key schedule. Confirm shape + (ii) is wanted here (it is the A3 perf rationale). +4. **Birthday budget** (§8.6): acceptable `q` ceiling; whether to document a + re-key guidance for very large datasets. +5. **`u128`/`Decimal`** ride this accumulator purely to exceed 14 blocks — they + are fixed-length; confirm the comparator's length-rejection (§7) is the right + place to keep them non-comparable with strings/other types. diff --git a/docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md b/docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md index b8c0f7e..1dd41e3 100644 --- a/docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md +++ b/docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md @@ -128,6 +128,10 @@ the gate that was holding that, and it is now cleared. ## 3. A2 — chained-prefix accumulator = CMAC cached-state (§5b; blocks PR 6) +> **Full design spec (2026-06-15):** `docs/plans/2026-06-15-ore-v2-cmac-accumulator-spec.md` +> — pins the injective message encoding, per-block algorithm, security argument, +> and test plan that this section summarises. + ### The question PR 6 (variable-length / strings) needs an accumulator that absorbs the prefix incrementally and, at each block position, derives the per-block secrets/outputs From 3f4bc4c2e0fdd6795862099b933cec0a81b2c823 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 15 Jun 2026 17:18:50 +1000 Subject: [PATCH 17/19] docs: define 'branch' up front in the A2 CMAC spec The final-block signature F(branch, n, s) and section 3's 'branch separation' referenced 'branch' before it was named. Add an explicit definition at the top of section 4 (RO_KEY / PRP_STREAM output families, carried as the byte-0 branch tag) and point section 3 at it. --- .../2026-06-15-ore-v2-cmac-accumulator-spec.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-06-15-ore-v2-cmac-accumulator-spec.md b/docs/plans/2026-06-15-ore-v2-cmac-accumulator-spec.md index fd99e49..e2a746c 100644 --- a/docs/plans/2026-06-15-ore-v2-cmac-accumulator-spec.md +++ b/docs/plans/2026-06-15-ore-v2-cmac-accumulator-spec.md @@ -75,13 +75,24 @@ k = E_{k_master}( "ORE.v2.chain.acc\x00" ) # 16-byte ASCII label, single AE (`k_master` = one of the `init(k1, k2)` keys; concrete slot TBD in review.) A dedicated key makes the accumulator's PRF security self-contained: no cross-use collisions with the fixed-N schemes or with H. The chained scheme therefore -**unifies the old `prf1` and `prf2`** into one key with branch separation -(below) — PRF₂ is subsumed. +**unifies the old `prf1` and `prf2`** into one key, domain-separated by the +**branch tag** (the `RO_KEY` / `PRP_STREAM` output families defined in §4) — +PRF₂ is subsumed. --- ## 4. Message encoding (injectivity-critical) +A **branch** names which output family a `finalize` derives. There are two: + +- **`RO_KEY`** (tag `0x01`) — the right-vector mask values `ro(n, j)`, and the + left tag `f[n] = ro(n, xt[n])`; +- **`PRP_STREAM`** (tag `0x02`) — the Fisher–Yates keystream that builds `π_n`. + +These replace the fixed-N scheme's separate `prf1` (ro/f) and `prf2` (PRP) keys +(§3); the branch is carried as the **byte-0 branch tag** of the final block, and +is the `branch` argument in `F(branch, n, s)` below. + Every block is exactly 16 bytes. Two block types, distinguished by byte 0. **Prefix block `P_t`** (absorbed into the chain; carries symbol `x[t]`): From f92344c5adb1beff4bd07b8b354f4cf442ee3c52 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 15 Jun 2026 17:23:15 +1000 Subject: [PATCH 18/19] docs: add key inventory to A2 CMAC spec (single secret key) Make explicit that the chained scheme has exactly one secret key (k), producing both branches via the branch tag; branch-tag domain separation under a good PRF is equivalent to independent per-branch keys but cheaper (one key schedule + one subkey pair). Note H's pi is public (not secret) and the nonce is not a key; contrast with fixed-N (#82, two keys) and the init(k1,k2) API (k2 redundant here); flag the single-key choice for explicit sign-off. --- .../2026-06-15-ore-v2-cmac-accumulator-spec.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/plans/2026-06-15-ore-v2-cmac-accumulator-spec.md b/docs/plans/2026-06-15-ore-v2-cmac-accumulator-spec.md index e2a746c..68f6172 100644 --- a/docs/plans/2026-06-15-ore-v2-cmac-accumulator-spec.md +++ b/docs/plans/2026-06-15-ore-v2-cmac-accumulator-spec.md @@ -79,6 +79,22 @@ collisions with the fixed-N schemes or with H. The chained scheme therefore **branch tag** (the `RO_KEY` / `PRP_STREAM` output families defined in §4) — PRF₂ is subsumed. +**Key inventory.** The chained scheme has exactly **one secret key — `k`** — +which produces *both* branches. Branch-tag domain separation under a good PRF is +equivalent to independent per-branch keys (the injectivity argument of §4 + the +CMAC-PRF reduction of §8), and is cheaper: one AES key schedule and one CMAC +subkey pair (`L, K1, K2`) rather than two. The only other key-shaped material is +**public** (H's fixed `π` constant `K₀`) or non-key (the per-ciphertext nonce). +Two consequences for review: + +- **vs fixed-N (#82):** that scheme keeps two secret keys (`prf1`/`prf2`); the + unification is a *chained-scheme* choice, not retroactive. +- **vs the `init(k1, k2)` API:** `k` is KDF-derived, so a single master input + suffices here — `k2` is redundant for this scheme unless retained for API + compatibility (open question 1). The alternative design — two keys, one per + branch, no branch tag — is equivalent in security but costs a second key + schedule/subkey pair; the single-key choice should be explicitly blessed. + --- ## 4. Message encoding (injectivity-critical) From 92833dd170f156aa69a7be46f61d082b3198071d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 16 Jun 2026 15:58:39 +1000 Subject: [PATCH 19/19] harden(bit6): count=0 guard, shared encoder, ct bit-extract, opaque Debug (#82 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review follow-ups on the Bit6 / v2-wire PR: - compare_raw_slices: reject a degenerate count=0 header. No OreEncrypt path produces zero blocks; without this, two crafted 0-block ciphertexts (header + nonce only) compare Equal because the scan loop never runs. (C2) - Deduplicate encode_right_block: make the bit2 helper generic over the hash (encode_right_block) and have the Bit6 scheme call it instead of keeping a near-verbatim copy. (D1) - Add width::ct_bit and route all four get_bit sites through it: extract the target bit with constant shift amounts + a constant-time select, instead of "byte >> (bit % 8)" (a shift by a secret amount — constant-time on x86_64/aarch64 but not guaranteed on every target). Pairs with ct_select_byte for a fully oblivious, data-independent bit read. (D3 mitigation) - Replace #[derive(Debug)] on OreAes128 / OreAes128Bit6 with an explicit opaque Debug impl (finish_non_exhaustive) so key material can never be rendered. (Used a manual impl rather than vitaminc::OpaqueDebug — vitaminc is a 0.2.0 pre-release and ore-rs is a published crate; see PR discussion.) No wire-format change: bit2 and bit6 compat vectors remain byte-identical. --- packages/ore-rs/src/scheme/bit2.rs | 18 ++++++--- .../ore-rs/src/scheme/bit2/block_types.rs | 2 +- packages/ore-rs/src/scheme/bit2_w6.rs | 40 +++++++++++-------- .../ore-rs/src/scheme/bit2_w6/block_types.rs | 2 +- packages/ore-rs/src/scheme/width.rs | 28 ++++++++++++- 5 files changed, 64 insertions(+), 26 deletions(-) diff --git a/packages/ore-rs/src/scheme/bit2.rs b/packages/ore-rs/src/scheme/bit2.rs index a4ef59b..0a3ff98 100644 --- a/packages/ore-rs/src/scheme/bit2.rs +++ b/packages/ore-rs/src/scheme/bit2.rs @@ -30,7 +30,7 @@ pub use self::block_types::*; /// nonces. The two PRF instances are keyed at construction; the RNG is held /// in a `RefCell` so encryption can take `&self` while still drawing fresh /// randomness. Keys are zeroised on drop. -#[derive(Debug, ZeroizeOnDrop)] +#[derive(ZeroizeOnDrop)] pub struct OreAes128 { prf1: Aes128Prf, prf2: Aes128Prf, @@ -38,6 +38,14 @@ pub struct OreAes128 { rng: RefCell, } +// Opaque Debug: never render key material. (`Aes128`'s own Debug is already +// opaque, but spell it out so the guarantee can't regress.) +impl std::fmt::Debug for OreAes128 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OreAes128").finish_non_exhaustive() + } +} + /// Convenience alias for [`OreAes128`] backed by `ChaCha20Rng` — the RNG /// most callers will want. pub type OreAes128ChaCha20 = OreAes128; @@ -81,11 +89,11 @@ fn derive_prp_seeds(prf2: &Aes128Prf, x: &PlainText) -> SeedB /// one linear pass of its inverse table — no per-bit `invert` lookups, no /// heap allocation. `ro_blocks` holds the PRF₁-encrypted RO keys and is /// trashed by the hash pass. -fn encode_right_block( +pub(crate) fn encode_right_block( block: &mut W::RightBlock, prp: &W::Prp, x: u8, - hasher: &Aes128Z2Hash, + hasher: &H, ro_blocks: &mut [AesBlock], ) { debug_assert_eq!(ro_blocks.len(), W::DOMAIN); @@ -199,7 +207,7 @@ 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, x[n], &hasher, &mut work); } self.prf1.encrypt_all(&mut left.f); @@ -283,7 +291,7 @@ fn get_bit(block: &[u8], bit: usize) -> u8 { // `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); - (byte >> (bit % 8)) & 1 + crate::scheme::width::ct_bit(byte, (bit % 8) as u8) } impl PartialEq for CipherText { diff --git a/packages/ore-rs/src/scheme/bit2/block_types.rs b/packages/ore-rs/src/scheme/bit2/block_types.rs index a4e0a78..6b4a55c 100644 --- a/packages/ore-rs/src/scheme/bit2/block_types.rs +++ b/packages/ore-rs/src/scheme/bit2/block_types.rs @@ -43,7 +43,7 @@ impl RightBlock32 { // `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(&self.data, bit / 8); - (byte >> (bit % 8)) & 1 + crate::scheme::width::ct_bit(byte, (bit % 8) as u8) } } diff --git a/packages/ore-rs/src/scheme/bit2_w6.rs b/packages/ore-rs/src/scheme/bit2_w6.rs index c4f7def..62bb29d 100644 --- a/packages/ore-rs/src/scheme/bit2_w6.rs +++ b/packages/ore-rs/src/scheme/bit2_w6.rs @@ -24,7 +24,7 @@ use crate::{ primitives::{ hash::FixedPiZ2Hash, prf::Aes128Prf, AesBlock, Hash, HashKey, Prf, Prp, NONCE_SIZE, }, - scheme::width::{AesBlockBuf, Bit6, BlockWidth, RightBitVec}, + scheme::width::{AesBlockBuf, Bit6, BlockWidth}, OreCipher, OreError, PlainText, }; @@ -51,7 +51,7 @@ pub const MAX_BLOCKS: usize = 14; /// AES-128 BlockORE cipher over 6-bit blocks, generic over the RNG used /// for per-encryption nonces. Keys are zeroised on drop. -#[derive(Debug, ZeroizeOnDrop)] +#[derive(ZeroizeOnDrop)] pub struct OreAes128Bit6 { prf1: Aes128Prf, prf2: Aes128Prf, @@ -59,6 +59,13 @@ pub struct OreAes128Bit6 { rng: RefCell, } +// Opaque Debug: never render key material (OpaqueDebug discipline). +impl std::fmt::Debug for OreAes128Bit6 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OreAes128Bit6").finish_non_exhaustive() + } +} + /// Convenience alias for [`OreAes128Bit6`] backed by `ChaCha20Rng`. pub type OreAes128Bit6ChaCha20 = OreAes128Bit6; @@ -102,18 +109,8 @@ impl OreAes128Bit6 { } } -fn encode_right_block( - block: &mut ::RightBlock, - prp: &::Prp, - x: u8, - hasher: &Z2Hash, - ro_blocks: &mut [AesBlock], -) { - debug_assert_eq!(ro_blocks.len(), ::DOMAIN); - let out = block.as_mut_bytes(); - hasher.hash_all_into(ro_blocks, out); - prp.indicator_mask_xor(x, out); -} +// Right-block encoding is the width-/hash-generic helper shared with the +// legacy scheme: `crate::scheme::bit2::encode_right_block::`. impl OreCipher for OreAes128Bit6 { type LeftBlockType = LeftBlock16; @@ -201,7 +198,13 @@ impl OreCipher for OreAes128Bit6 { 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); + crate::scheme::bit2::encode_right_block::( + &mut right.data[n], + &prp, + x[n], + &hasher, + &mut work, + ); } self.prf1.encrypt_all(&mut left.f); @@ -226,7 +229,10 @@ impl OreCipher for OreAes128Bit6 { return None; } let num_blocks = header_a.2; - if num_blocks > MAX_BLOCKS { + // Reject a degenerate count=0 header: no OreEncrypt path produces zero + // blocks, and an empty scan would otherwise return Equal for any pair + // of crafted 0-block ciphertexts. + if num_blocks == 0 || num_blocks > MAX_BLOCKS { return None; } @@ -292,7 +298,7 @@ fn get_bit(block: &[u8], bit: usize) -> u8 { // `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); - (byte >> (bit % 8)) & 1 + crate::scheme::width::ct_bit(byte, (bit % 8) as u8) } impl PartialEq for CipherText { 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 6128271..510c975 100644 --- a/packages/ore-rs/src/scheme/bit2_w6/block_types.rs +++ b/packages/ore-rs/src/scheme/bit2_w6/block_types.rs @@ -21,7 +21,7 @@ impl RightBlock8 { // `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(&self.data, bit / 8); - (byte >> (bit % 8)) & 1 + crate::scheme::width::ct_bit(byte, (bit % 8) as u8) } } diff --git a/packages/ore-rs/src/scheme/width.rs b/packages/ore-rs/src/scheme/width.rs index d7e30c3..6e06610 100644 --- a/packages/ore-rs/src/scheme/width.rs +++ b/packages/ore-rs/src/scheme/width.rs @@ -71,8 +71,8 @@ impl_aes_block_buf!(64); /// cheap relative to the per-comparison AES hash. /// /// See `docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md` (A4, compare -/// side). The bit-position shift (`>> (bit % 8)`) is a register shift, not a -/// memory access, so it is not a cache channel and is left as-is. +/// side). The bit within the selected byte is then extracted with [`ct_bit`], +/// which avoids a shift by the secret amount. #[inline] pub(crate) fn ct_select_byte(block: &[u8], idx: usize) -> u8 { use subtle_ng::{ConditionallySelectable, ConstantTimeEq}; @@ -85,6 +85,30 @@ pub(crate) fn ct_select_byte(block: &[u8], idx: usize) -> u8 { acc } +/// Oblivious extraction of bit `pos` (`0..8`) of `byte` — used right after +/// [`ct_select_byte`] to read the target bit of the selected right-block byte. +/// +/// `byte >> pos` would be a shift by a *secret* amount; that is constant-time +/// on x86_64/aarch64 (the targets ore.rs ships to) but not guaranteed so on +/// every architecture. Here every candidate shift is a compile-time constant +/// and the result is chosen with a constant-time select, so the timing is +/// data-independent on all targets — defence-in-depth matching the oblivious +/// byte read above. +#[inline] +pub(crate) fn ct_bit(byte: u8, pos: u8) -> u8 { + use subtle_ng::{ConditionallySelectable, ConstantTimeEq}; + let mut out = 0u8; + out.conditional_assign(&(byte & 1), pos.ct_eq(&0)); + out.conditional_assign(&((byte >> 1) & 1), pos.ct_eq(&1)); + out.conditional_assign(&((byte >> 2) & 1), pos.ct_eq(&2)); + out.conditional_assign(&((byte >> 3) & 1), pos.ct_eq(&3)); + out.conditional_assign(&((byte >> 4) & 1), pos.ct_eq(&4)); + out.conditional_assign(&((byte >> 5) & 1), pos.ct_eq(&5)); + out.conditional_assign(&((byte >> 6) & 1), pos.ct_eq(&6)); + out.conditional_assign(&((byte >> 7) & 1), pos.ct_eq(&7)); + out +} + /// Per-block bitvector operations on a Right ciphertext block, one bit per /// value in the block domain. pub trait RightBitVec {