diff --git a/docs/benchmarks/2026-06-15-chained-results.md b/docs/benchmarks/2026-06-15-chained-results.md new file mode 100644 index 0000000..ea6b66f --- /dev/null +++ b/docs/benchmarks/2026-06-15-chained-results.md @@ -0,0 +1,36 @@ +# Chained variable-length scheme — benchmark results + +**Date:** 2026-06-15 +**Machine:** Apple M1 Max, hardware AES (`aes_armv8`) +**Scheme:** `OreAes128Bit6Chained` (PR 6) — 6-bit blocks, AES-CMAC accumulator, +shape-(ii) PRP (keystream from the accumulator, no per-block key schedule). +**Command:** `cargo bench -p ore-rs --bench chained` + +| benchmark | input | blocks | time | +|---|---|---:|---:| +| `chained-encrypt-str-5` | `"alice"` | 7 | **5.05 µs** | +| `chained-encrypt-str-17` | `"alice@example.com"` | 23 | **15.97 µs** | +| `chained-encrypt-str-43` | 43-char sentence | 58 | **40.18 µs** | +| `chained-encrypt-left-str-17` | 17 chars | 23 | 10.09 µs | +| `chained-compare-str-17` | 17 chars | 23 | **402 ns** | + +## Reading + +- **~0.69 µs/block** for full encryption, scaling linearly with length + (5.05/7, 15.97/23, 40.18/58 all ≈ 0.69). +- This is *below* fixed-N Bit6's **~0.81 µs/block** (≈8.9 µs for an 11-block + u64) — the shape-(ii) payoff: the accumulator is keyed once per ciphertext, so + there is **no per-block AES key schedule**. Per-block work is + ~32 (`PRP_STREAM`) + 64 (`RO_KEY`) + 1 (`absorb`) ≈ 97 AES ops + the 64 + σ-MMO H evals, all under the already-scheduled accumulator cipher. +- `encrypt_left` is ~63% of full encrypt (it skips the 64 `RO_KEY` finalizes and + the right-block masking per block, keeping only the PRP + the single left tag). +- Comparison (~402 ns at 23 blocks) is a constant-time prefix scan plus one H + eval and one oblivious right-block read. + +## Caveats + +- CMAC subkey `K1` and the per-block doubling reuse the vectorized + `gf128_double`; the accumulator XORs are `u128` word ops. +- Numbers are from a 3 s criterion measurement on a developer machine (some + thermal variance); treat as indicative, not a regression gate. diff --git a/docs/plans/2026-06-12-ore-v2-architecture.md b/docs/plans/2026-06-12-ore-v2-architecture.md index dac381e..ed3e643 100644 --- a/docs/plans/2026-06-12-ore-v2-architecture.md +++ b/docs/plans/2026-06-12-ore-v2-architecture.md @@ -496,6 +496,16 @@ PRs 3/4 and 5 are independent after PR 2, so they can proceed in parallel. Each `main` releasable; release-plz handles version bumps (all semver-minor under 0.x except PR 2's trait change, which should be called out in the changelog). +> **PR 6 status (2026-06-15): IMPLEMENTED** on `feat/ore-v2-chained` against the A2 +> design (preliminary crypto sign-off given; detailed review in progress). Ships the +> AES-CMAC accumulator (`primitives/cmac.rs`, validated vs NIST SP 800-38B), +> `LemireFyPrp::from_stream` (shape ii), and `OreAes128Bit6Chained` (`scheme/chained.rs`) +> with variable-length `Var*` types and string encryption. Lexicographic cross-length +> comparison, equality-across-nonces, and >14-block plaintexts are tested; perf +> `docs/benchmarks/2026-06-15-chained-results.md` (~0.69 µs/block, below fixed-N's ~0.81). +> Not yet in: `u128`/`Decimal` via the const-N + accumulator path; pinned wire vectors +> (await final A2 sign-off, like Bit6 awaited A1). + ## Testing strategy - **Vectors (PR 1) as the refactor contract:** every later PR runs them unchanged for diff --git a/packages/ore-rs/Cargo.toml b/packages/ore-rs/Cargo.toml index 3ddf561..96afe00 100644 --- a/packages/ore-rs/Cargo.toml +++ b/packages/ore-rs/Cargo.toml @@ -63,5 +63,9 @@ name = "chrono" harness = false required-features = ["chrono"] +[[bench]] +name = "chained" +harness = false + [[example]] name = "encrypt" diff --git a/packages/ore-rs/benches/chained.rs b/packages/ore-rs/benches/chained.rs new file mode 100644 index 0000000..a3bfc9d --- /dev/null +++ b/packages/ore-rs/benches/chained.rs @@ -0,0 +1,41 @@ +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use hex_literal::hex; +use ore_rs::scheme::chained::{OreAes128Bit6Chained, OreAes128Bit6ChainedChaCha20}; + +fn init_ore() -> OreAes128Bit6ChainedChaCha20 { + let k1: [u8; 16] = hex!("00010203 04050607 08090a0b 0c0d0e0f"); + OreAes128Bit6Chained::init(&k1).unwrap() +} + +fn criterion_benchmark(c: &mut Criterion) { + let ore = init_ore(); + + // Short (~8 chars ≈ 11 blocks) and long (~40 chars ≈ 54 blocks) strings. + let short = "alice"; + let medium = "alice@example.com"; + let long = "the quick brown fox jumps over the lazy dog"; + + c.bench_function("chained-encrypt-str-5", |b| { + b.iter(|| ore.encrypt_str(black_box(short)).unwrap()) + }); + c.bench_function("chained-encrypt-str-17", |b| { + b.iter(|| ore.encrypt_str(black_box(medium)).unwrap()) + }); + c.bench_function("chained-encrypt-str-43", |b| { + b.iter(|| ore.encrypt_str(black_box(long)).unwrap()) + }); + c.bench_function("chained-encrypt-left-str-17", |b| { + b.iter(|| ore.encrypt_left_str(black_box(medium)).unwrap()) + }); + + let a = ore.encrypt_str(medium).unwrap().to_bytes(); + let b_ct = ore.encrypt_str("alice@example.org").unwrap().to_bytes(); + c.bench_function("chained-compare-str-17", |bench| { + bench.iter(|| { + OreAes128Bit6ChainedChaCha20::compare_raw_slices(black_box(&a), black_box(&b_ct)) + }) + }); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/packages/ore-rs/src/lib.rs b/packages/ore-rs/src/lib.rs index c7e1ca5..a994dad 100644 --- a/packages/ore-rs/src/lib.rs +++ b/packages/ore-rs/src/lib.rs @@ -170,6 +170,10 @@ pub enum OreError { /// per-ciphertext nonce). #[error("Randomness Error")] RandError(#[from] rand::Error), + /// The plaintext exceeds the maximum block count the wire format can + /// represent (the chained scheme encodes the block count as a `u16`). + #[error("Too many blocks")] + TooManyBlocks, } /// A BlockORE cipher: a key-bound object that can encrypt fixed-N plaintexts diff --git a/packages/ore-rs/src/primitives.rs b/packages/ore-rs/src/primitives.rs index 9a0f189..d58b605 100644 --- a/packages/ore-rs/src/primitives.rs +++ b/packages/ore-rs/src/primitives.rs @@ -1,3 +1,4 @@ +pub mod cmac; pub mod hash; pub mod prf; pub mod prp; diff --git a/packages/ore-rs/src/primitives/cmac.rs b/packages/ore-rs/src/primitives/cmac.rs new file mode 100644 index 0000000..59820a6 --- /dev/null +++ b/packages/ore-rs/src/primitives/cmac.rs @@ -0,0 +1,260 @@ +//! Chained-prefix AES-CMAC accumulator for the variable-length ORE scheme. +//! +//! Implements the design in `docs/plans/2026-06-15-ore-v2-cmac-accumulator-spec.md` +//! (plan §5b, the A2 gate). Every per-block secret of the chained scheme is a +//! `finalize(prefix-state, final_block)` — a bona fide AES-CMAC (NIST SP +//! 800-38B) tag of `P_0 ‖ … ‖ P_{n-1} ‖ final_block`, where the prefix CBC +//! state is cached and extended incrementally (`clone-state-then-finalize` +//! *is* incremental CMAC). +//! +//! All accumulator messages are exact 16-byte multiples, so only subkey `K1` +//! is ever used (the `K2`/padding path never occurs). `K1 = dbl(E_k(0))` reuses +//! the σ-MMO GF(2^128) doubling. + +use crate::primitives::hash::gf128_double_u128; +use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit}; +use aes::Aes128; +use zeroize::Zeroize; + +/// Block-width tag in the final block (domain separation; see the spec §4). +pub(crate) const WIDTH_BIT6: u8 = 6; + +/// Output-family ("branch") tag carried in byte 0 of a final block. Encoded as +/// a non-zero enum: prefix blocks use byte 0 = `0x00`, so making this a type +/// (rather than a `u8`) removes any way to construct a final block whose byte 0 +/// is `0x00` and collides with a prefix block — the disjointness is the basis +/// of the encoding's injectivity (spec §4), now guaranteed at compile time. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub(crate) enum Branch { + RoKey = 0x01, + PrpStream = 0x02, +} + +/// Prefix block `P_t` carrying symbol `sym` at position `pos` (spec §4): +/// `[0x00 ‖ pos(u16 BE) ‖ sym ‖ 0…]`. +#[inline] +pub(crate) fn prefix_block(pos: u16, sym: u8) -> [u8; 16] { + let mut b = [0u8; 16]; + b[1..3].copy_from_slice(&pos.to_be_bytes()); + b[3] = sym; + b +} + +/// Final block `F(branch, n, s)` (spec §4): +/// `[branch ‖ n(u16 BE) ‖ s(u16 BE) ‖ width ‖ 0…]`. `s` is the domain value +/// `j` (or the permuted symbol `xt[n]` for the left tag) for `RoKey`, and the +/// keystream counter `c` for `PrpStream`. `branch` is a [`Branch`] (always +/// non-zero), so byte 0 can never collide with a prefix block. +#[inline] +pub(crate) fn final_block(branch: Branch, n: u16, s: u16, width: u8) -> [u8; 16] { + let mut b = [0u8; 16]; + b[0] = branch as u8; + b[1..3].copy_from_slice(&n.to_be_bytes()); + b[3..5].copy_from_slice(&s.to_be_bytes()); + b[5] = width; + b +} + +/// Incremental AES-CMAC over a prefix of 16-byte blocks. +/// +/// `state` is the CBC-MAC chain over the prefix blocks absorbed so far (no +/// subkey applied — those are never the last block). [`finalize`](Self::finalize) +/// produces the CMAC tag of `prefix ‖ final_block` without mutating the chain; +/// [`absorb`](Self::absorb) extends the prefix. +pub(crate) struct CmacAccumulator { + cipher: Aes128, + k1: [u8; 16], + state: [u8; 16], +} + +impl CmacAccumulator { + /// Schedule AES under `key`, derive `K1 = dbl(E_k(0))`, and start the chain + /// at the all-zero CBC IV (empty prefix). + pub(crate) fn new(key: &[u8; 16]) -> Self { + let cipher = Aes128::new(GenericArray::from_slice(key)); + let mut acc = Self { + cipher, + k1: [0u8; 16], + state: [0u8; 16], + }; + let mut l = acc.encrypt([0u8; 16]); // L = E_k(0) + acc.k1 = gf128_double_u128(u128::from_be_bytes(l)).to_be_bytes(); + l.zeroize(); // L is K1's source; don't leave it on the stack (spec §9) + acc + } + + #[inline] + fn encrypt(&self, mut b: [u8; 16]) -> [u8; 16] { + self.cipher + .encrypt_block(GenericArray::from_mut_slice(&mut b)); + b + } + + /// Extend the prefix chain: `S ← E_k(S ⊕ block)` (a CBC step, no subkey — + /// not a published tag). + #[inline] + pub(crate) fn absorb(&mut self, block: &[u8; 16]) { + let mixed = u128::from_be_bytes(self.state) ^ u128::from_be_bytes(*block); + self.state = self.encrypt(mixed.to_be_bytes()); + } + + /// CMAC tag of `prefix-so-far ‖ final_block`: `E_k(S ⊕ final_block ⊕ K1)`. + /// Does not change the chain, so all outputs at a given position finalize + /// from the one cached `S`. + #[inline] + pub(crate) fn finalize(&self, final_block: &[u8; 16]) -> [u8; 16] { + let mixed = u128::from_be_bytes(self.state) + ^ u128::from_be_bytes(*final_block) + ^ u128::from_be_bytes(self.k1); + self.encrypt(mixed.to_be_bytes()) + } + + #[cfg(test)] + pub(crate) fn subkey1(&self) -> [u8; 16] { + self.k1 + } +} + +impl Drop for CmacAccumulator { + fn drop(&mut self) { + // `cipher` zeroizes its own key schedule (aes "zeroize" feature). + self.k1.zeroize(); + self.state.zeroize(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use hex_literal::hex; + use quickcheck::TestResult; + + // NIST SP 800-38B, AES-128 CMAC examples (Appendix D.1). Validates that + // this is real CMAC: the K1 subkey, the single-full-block tag, and the + // multi-block CBC chain + finalize. + #[test] + fn nist_sp800_38b_aes128() { + let k = hex!("2b7e151628aed2a6abf7158809cf4f3c"); + + let acc = CmacAccumulator::new(&k); + assert_eq!(acc.subkey1(), hex!("fbeed618357133667c85e08f7236a8de")); + + // CMAC of one full block (the block is the final block; empty prefix). + let m1 = hex!("6bc1bee22e409f96e93d7e117393172a"); + assert_eq!(acc.finalize(&m1), hex!("070a16b46b4d4144f79bdd9dd04a287c")); + + // CMAC of four full blocks: absorb the first three, finalize the last. + let mut acc = CmacAccumulator::new(&k); + acc.absorb(&hex!("6bc1bee22e409f96e93d7e117393172a")); + acc.absorb(&hex!("ae2d8a571e03ac9c9eb76fac45af8e51")); + acc.absorb(&hex!("30c81c46a35ce411e5fbc1191a0a52ef")); + assert_eq!( + acc.finalize(&hex!("f69f2445df4f9b17ad2b417be66c3710")), + hex!("51f0bebf7e3b9d92fc49741779363cfe") + ); + } + + #[test] + fn encoding_is_injective_on_block_type() { + // Prefix blocks have byte 0 == 0x00; final blocks have byte 0 != 0x00. + assert_eq!(prefix_block(7, 0x3f)[0], 0x00); + assert_ne!(final_block(Branch::RoKey, 7, 9, WIDTH_BIT6)[0], 0x00); + assert_ne!(final_block(Branch::PrpStream, 7, 9, WIDTH_BIT6)[0], 0x00); + // Distinct branches / positions / sub-indices give distinct blocks. + assert_ne!( + final_block(Branch::RoKey, 1, 2, WIDTH_BIT6), + final_block(Branch::PrpStream, 1, 2, WIDTH_BIT6) + ); + assert_ne!( + final_block(Branch::RoKey, 1, 2, WIDTH_BIT6), + final_block(Branch::RoKey, 1, 3, WIDTH_BIT6) + ); + assert_ne!(prefix_block(1, 2), prefix_block(2, 2)); + } + + // finalize must not disturb the cached chain: many outputs from one state. + #[test] + fn finalize_does_not_mutate_state() { + let mut acc = CmacAccumulator::new(&[0x11u8; 16]); + acc.absorb(&prefix_block(0, 5)); + let a = acc.finalize(&final_block(Branch::RoKey, 1, 0, WIDTH_BIT6)); + let _ = acc.finalize(&final_block(Branch::PrpStream, 1, 0, WIDTH_BIT6)); + let a_again = acc.finalize(&final_block(Branch::RoKey, 1, 0, WIDTH_BIT6)); + assert_eq!(a, a_again); + } + + /// Independent from-scratch CMAC of a whole-block message: CBC-MAC chain + /// over all but the last block, then `E_k(state ⊕ last ⊕ K1)`. Mirrors the + /// NIST construction without reusing the accumulator's state threading, so + /// it can validate that threading for any block count (not just t∈{1,4}). + fn reference_cmac(key: &[u8; 16], blocks: &[[u8; 16]]) -> [u8; 16] { + let cipher = Aes128::new(GenericArray::from_slice(key)); + let enc = |mut b: [u8; 16]| { + cipher.encrypt_block(GenericArray::from_mut_slice(&mut b)); + b + }; + let k1 = gf128_double_u128(u128::from_be_bytes(enc([0u8; 16]))).to_be_bytes(); + let (last, prefix) = blocks.split_last().expect("at least one block"); + let mut state = 0u128; + for blk in prefix { + state = u128::from_be_bytes(enc((state ^ u128::from_be_bytes(*blk)).to_be_bytes())); + } + enc((state ^ u128::from_be_bytes(*last) ^ u128::from_be_bytes(k1)).to_be_bytes()) + } + + quickcheck! { + /// Incremental absorb-then-finalize over an arbitrary number of full + /// blocks equals a from-scratch CMAC. Validates the chain threading and + /// subkey use for every block count, well beyond the two fixed vectors. + fn prop_incremental_matches_reference(key: Vec, msg: Vec) -> TestResult { + if key.len() < 16 { + return TestResult::discard(); + } + let mut k = [0u8; 16]; + k.copy_from_slice(&key[..16]); + + // 1..=8 full blocks, derived from `msg` (deterministic, never empty). + let nblocks = 1 + (msg.len() % 8); + let mut blocks = Vec::with_capacity(nblocks); + for i in 0..nblocks { + let mut b = [0u8; 16]; + for (j, slot) in b.iter_mut().enumerate() { + *slot = msg + .get(i * 16 + j) + .copied() + .unwrap_or((i as u8).wrapping_mul(31).wrapping_add(j as u8)); + } + blocks.push(b); + } + + let mut acc = CmacAccumulator::new(&k); + let (last, prefix) = blocks.split_last().unwrap(); + for blk in prefix { + acc.absorb(blk); + } + TestResult::from_bool(acc.finalize(last) == reference_cmac(&k, &blocks)) + } + + /// Final blocks injectively encode `(branch, n, s, width)`: the tuple is + /// recoverable from the bytes and byte 0 is always non-zero, so distinct + /// inputs give distinct blocks (and none collide with a prefix block). + fn prop_final_block_injective(n: u16, s: u16, width: u8) -> bool { + [Branch::RoKey, Branch::PrpStream].iter().copied().all(|branch| { + let fb = final_block(branch, n, s, width); + fb[0] == branch as u8 + && fb[0] != 0x00 + && u16::from_be_bytes([fb[1], fb[2]]) == n + && u16::from_be_bytes([fb[3], fb[4]]) == s + && fb[5] == width + }) + } + + /// Prefix blocks carry byte0 == 0x00 (never colliding with a final + /// block) and injectively encode `(pos, sym)`. + fn prop_prefix_block_injective(pos: u16, sym: u8) -> bool { + let pb = prefix_block(pos, sym); + pb[0] == 0x00 && u16::from_be_bytes([pb[1], pb[2]]) == pos && pb[3] == sym + } + } +} diff --git a/packages/ore-rs/src/primitives/hash.rs b/packages/ore-rs/src/primitives/hash.rs index eeef30c..0ffbd36 100644 --- a/packages/ore-rs/src/primitives/hash.rs +++ b/packages/ore-rs/src/primitives/hash.rs @@ -93,9 +93,10 @@ fn pi() -> &'static Aes128 { /// 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 { +pub(crate) 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. + // the low byte iff that bit was set. `x >> 127` is 0 or 1. Also the CMAC + // subkey doubling (`crate::primitives::cmac`). (x << 1) ^ ((x >> 127) * 0x87) } diff --git a/packages/ore-rs/src/primitives/prp.rs b/packages/ore-rs/src/primitives/prp.rs index 70e5855..137ecf3 100644 --- a/packages/ore-rs/src/primitives/prp.rs +++ b/packages/ore-rs/src/primitives/prp.rs @@ -178,27 +178,17 @@ macro_rules! impl_lemire_fy_prp { "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 { + impl LemireFyPrp<$domain> { + /// Build the permutation directly from a precomputed draw stream + /// (shape (ii)): `stream` must be at least `($domain - 1) * 8` + /// bytes, consumed as `$domain - 1` little-endian u64 draws and + /// Lemire-reduced. The chained scheme feeds the CMAC accumulator's + /// `PRP_STREAM` branch here, avoiding a per-block AES key schedule. + pub(crate) fn from_stream(stream: &[u8]) -> PrpResult { + if stream.len() < ($domain - 1) * 8 { 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], @@ -225,6 +215,34 @@ macro_rules! impl_lemire_fy_prp { perm.inverse[*val as usize] = index as u8; } + Ok(perm) + } + } + + 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. Shape (i): a fresh key schedule + // per call. Shape (ii) skips this via `from_stream`. + 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 perm = Self::from_stream(&stream)?; + // The keystream determined the permutation — wipe it. stream.zeroize(); for b in blocks.iter_mut() { diff --git a/packages/ore-rs/src/scheme.rs b/packages/ore-rs/src/scheme.rs index 25df7c0..6632fca 100644 --- a/packages/ore-rs/src/scheme.rs +++ b/packages/ore-rs/src/scheme.rs @@ -12,5 +12,11 @@ pub mod bit2; /// pending crypto review of the Z2 hash (v2 plan, section 6). pub mod bit2_w6; +/// Variable-length / chained-prefix scheme (6-bit blocks): lifts the packed +/// 14-block cap via an AES-CMAC accumulator, enabling string encryption. Wire +/// format v2 (scheme id 0x03). See +/// `docs/plans/2026-06-15-ore-v2-cmac-accumulator-spec.md`. +pub mod chained; + pub(crate) mod decompose; pub(crate) mod width; diff --git a/packages/ore-rs/src/scheme/bit2.rs b/packages/ore-rs/src/scheme/bit2.rs index 0a3ff98..a4ef59b 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(ZeroizeOnDrop)] +#[derive(Debug, ZeroizeOnDrop)] pub struct OreAes128 { prf1: Aes128Prf, prf2: Aes128Prf, @@ -38,14 +38,6 @@ 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; @@ -89,11 +81,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. -pub(crate) fn encode_right_block( +fn encode_right_block( block: &mut W::RightBlock, prp: &W::Prp, x: u8, - hasher: &H, + hasher: &Aes128Z2Hash, ro_blocks: &mut [AesBlock], ) { debug_assert_eq!(ro_blocks.len(), W::DOMAIN); @@ -207,7 +199,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); @@ -291,7 +283,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); - crate::scheme::width::ct_bit(byte, (bit % 8) as u8) + (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 6b4a55c..a4e0a78 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); - crate::scheme::width::ct_bit(byte, (bit % 8) as u8) + (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 62bb29d..c4f7def 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}, + scheme::width::{AesBlockBuf, Bit6, BlockWidth, RightBitVec}, 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(ZeroizeOnDrop)] +#[derive(Debug, ZeroizeOnDrop)] pub struct OreAes128Bit6 { prf1: Aes128Prf, prf2: Aes128Prf, @@ -59,13 +59,6 @@ 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; @@ -109,8 +102,18 @@ impl OreAes128Bit6 { } } -// Right-block encoding is the width-/hash-generic helper shared with the -// legacy scheme: `crate::scheme::bit2::encode_right_block::`. +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; @@ -198,13 +201,7 @@ impl OreCipher for OreAes128Bit6 { work.copy_from(&template); self.prf1.encrypt_all(work.as_mut_slice()); - crate::scheme::bit2::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); @@ -229,10 +226,7 @@ impl OreCipher for OreAes128Bit6 { return None; } let num_blocks = header_a.2; - // 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 { + if num_blocks > MAX_BLOCKS { return None; } @@ -298,7 +292,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); - crate::scheme::width::ct_bit(byte, (bit % 8) as u8) + (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 510c975..6128271 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); - crate::scheme::width::ct_bit(byte, (bit % 8) as u8) + (byte >> (bit % 8)) & 1 } } diff --git a/packages/ore-rs/src/scheme/chained.rs b/packages/ore-rs/src/scheme/chained.rs new file mode 100644 index 0000000..5b6ebf6 --- /dev/null +++ b/packages/ore-rs/src/scheme/chained.rs @@ -0,0 +1,621 @@ +//! Variable-length / chained-prefix BlockORE over 6-bit blocks. +//! +//! Lifts the fixed-N packed-prefix cap (≤ 14 blocks) by deriving every +//! per-block secret from an incremental **AES-CMAC accumulator** over the +//! prefix, instead of packing the raw prefix into one AES block. This enables +//! arbitrary-length plaintexts — strings in particular. Design: +//! `docs/plans/2026-06-15-ore-v2-cmac-accumulator-spec.md` (the A2 gate). +//! +//! Per block `n` (prefix `x[0..n-1]`, accumulator state `S_n`): +//! - **PRP** `π_n` (shape ii): keystream = `STREAM_BLOCKS` CMAC tags on the +//! `PRP_STREAM` branch → `LemireFyPrp::from_stream` (no per-block key +//! schedule). +//! - **left tag** `f[n] = ro(n, xt[n])` — the `RO_KEY` branch at the permuted +//! symbol (so the left/right masks cancel at compare time). +//! - **right block**: `H(ro(n, j), nonce) ⊕ indicator`, as the fixed-N scheme. +//! +//! Comparison is lexicographic: scan `min(len_a, len_b)` blocks; if a prefix +//! matches throughout, the shorter sorts first. Common-prefix-length leakage is +//! the intended, query-time-scoped leakage (plan §5b). + +use std::cell::RefCell; +use std::cmp::Ordering; + +use rand::{Rng, SeedableRng}; +use rand_chacha::ChaCha20Rng; +use subtle_ng::{Choice, ConditionallySelectable, ConstantTimeEq}; +use zeroize::Zeroize; + +use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit}; +use aes::Aes128; + +use crate::ciphertext::{parse_header, ParseError}; +use crate::primitives::cmac::{final_block, prefix_block, Branch, CmacAccumulator, WIDTH_BIT6}; +use crate::primitives::hash::FixedPiZ2Hash; +use crate::primitives::prp::LemireFyPrp; +use crate::primitives::{AesBlock, Hash, HashKey, Prp}; +use crate::scheme::decompose::{decompose_6bit, num_blocks_6bit}; +use crate::scheme::width::ct_select_byte; +use crate::OreError; + +const VERSION: u8 = 0x02; +const SCHEME_ID: u8 = 0x03; +const DOMAIN: usize = 64; +/// CMAC tags per block for the PRP keystream: ⌈(DOMAIN−1)·8 / 16⌉ = 32. +const STREAM_BLOCKS: usize = 32; +const F_LEN: usize = 16; +const RIGHT_LEN: usize = DOMAIN / 8; // 8 +const HEADER_LEN: usize = 4; +const NONCE_LEN: usize = 16; + +/// Domain-separation label for deriving the accumulator key (spec §3). +const ACC_KEY_LABEL: [u8; 16] = *b"ORE.v2.chain.acc"; + +/// Serialised length of a full ciphertext with `count` blocks. +#[inline] +fn total_len(count: usize) -> usize { + HEADER_LEN + count + count * F_LEN + NONCE_LEN + count * RIGHT_LEN +} + +/// Serialised length of a left-only ciphertext (`header ‖ xt ‖ f`) — the prefix +/// of a full ciphertext, with no nonce/right half. Matches `VarLeft::to_bytes`. +#[inline] +fn left_len(count: usize) -> usize { + HEADER_LEN + count + count * F_LEN +} + +// Raw-byte accessors into a serialised full ciphertext (see `to_bytes`). +#[inline] +fn xt_at(s: &[u8], i: usize) -> u8 { + s[HEADER_LEN + i] +} +#[inline] +fn f_at(s: &[u8], count: usize, i: usize) -> &[u8] { + let o = HEADER_LEN + count + i * F_LEN; + &s[o..o + F_LEN] +} +#[inline] +fn nonce_at(s: &[u8], count: usize) -> &[u8] { + let o = HEADER_LEN + count + count * F_LEN; + &s[o..o + NONCE_LEN] +} +#[inline] +fn right_at(s: &[u8], count: usize, i: usize) -> &[u8] { + let o = HEADER_LEN + count + count * F_LEN + NONCE_LEN + i * RIGHT_LEN; + &s[o..o + RIGHT_LEN] +} + +/// Left half of a chained ciphertext: per-block permuted symbol + tag. +#[derive(Clone, Debug)] +pub struct VarLeft { + xt: Vec, + f: Vec<[u8; F_LEN]>, +} + +/// Right half: per-ciphertext nonce + per-block masked bitvectors. +#[derive(Clone, Debug)] +pub struct VarRight { + nonce: [u8; NONCE_LEN], + blocks: Vec<[u8; RIGHT_LEN]>, +} + +/// Full variable-length ciphertext. +#[derive(Clone, Debug)] +pub struct VarCipherText { + left: VarLeft, + right: VarRight, +} + +impl VarLeft { + /// `header ‖ xt ‖ f` (no nonce/right half). + pub fn to_bytes(&self) -> Vec { + let count = self.xt.len(); + let mut out = Vec::with_capacity(HEADER_LEN + count + count * F_LEN); + out.extend_from_slice(&[VERSION, SCHEME_ID]); + out.extend_from_slice(&(count as u16).to_be_bytes()); + out.extend_from_slice(&self.xt); + for fb in &self.f { + out.extend_from_slice(fb); + } + out + } +} + +impl VarCipherText { + /// `header ‖ xt ‖ f ‖ nonce ‖ right`. + pub fn to_bytes(&self) -> Vec { + let count = self.left.xt.len(); + let mut out = Vec::with_capacity(total_len(count)); + out.extend_from_slice(&[VERSION, SCHEME_ID]); + out.extend_from_slice(&(count as u16).to_be_bytes()); + out.extend_from_slice(&self.left.xt); + for fb in &self.left.f { + out.extend_from_slice(fb); + } + out.extend_from_slice(&self.right.nonce); + for rb in &self.right.blocks { + out.extend_from_slice(rb); + } + out + } + + /// Parse the bytes produced by [`Self::to_bytes`]. + pub fn from_slice(data: &[u8]) -> Result { + let ((ver, sid, count), _body) = parse_header(data)?; + if ver != VERSION || sid != SCHEME_ID { + return Err(ParseError); + } + if data.len() != total_len(count) { + return Err(ParseError); + } + let xt = data[HEADER_LEN..HEADER_LEN + count].to_vec(); + let mut f = Vec::with_capacity(count); + for n in 0..count { + let mut fb = [0u8; F_LEN]; + fb.copy_from_slice(f_at(data, count, n)); + f.push(fb); + } + let mut nonce = [0u8; NONCE_LEN]; + nonce.copy_from_slice(nonce_at(data, count)); + let mut blocks = Vec::with_capacity(count); + for n in 0..count { + let mut rb = [0u8; RIGHT_LEN]; + rb.copy_from_slice(right_at(data, count, n)); + blocks.push(rb); + } + Ok(VarCipherText { + left: VarLeft { xt, f }, + right: VarRight { nonce, blocks }, + }) + } +} + +/// Variable-length / chained-prefix ORE cipher (6-bit blocks). +pub struct OreAes128Bit6Chained { + /// Dedicated CMAC accumulator key (subsumes the fixed-N `prf1`/`prf2`). + k_acc: [u8; 16], + rng: RefCell, +} + +/// Convenience alias backed by `ChaCha20Rng`. +pub type OreAes128Bit6ChainedChaCha20 = OreAes128Bit6Chained; + +impl OreAes128Bit6Chained { + /// Initialise from a single 16-byte key. Unlike the fixed-N schemes (which + /// key two PRFs), the chained scheme is **single-key by design**: the CMAC + /// accumulator derives every per-block secret from one key via branch tags + /// (spec §3), so there is no second key. The accumulator key is `k1` run + /// through a labelled AES call for domain separation. + pub fn init(k1: &[u8; 16]) -> Result { + let cipher = Aes128::new(GenericArray::from_slice(k1)); + let mut k_acc = ACC_KEY_LABEL; + cipher.encrypt_block(GenericArray::from_mut_slice(&mut k_acc)); + Ok(Self { + k_acc, + rng: RefCell::new(R::from_entropy()), + }) + } + + /// Derive the per-block PRP from the accumulator's `PRP_STREAM` branch. + fn prp_at(&self, acc: &CmacAccumulator, n: u16) -> Result, OreError> { + let mut stream = [0u8; STREAM_BLOCKS * 16]; + for (c, chunk) in stream.chunks_mut(16).enumerate() { + chunk.copy_from_slice(&acc.finalize(&final_block( + Branch::PrpStream, + n, + c as u16, + WIDTH_BIT6, + ))); + } + let prp = LemireFyPrp::::from_stream(&stream)?; + stream.zeroize(); + Ok(prp) + } + + /// Full Left+Right ciphertext for `x` (one 6-bit symbol per element). + pub fn encrypt_var(&self, x: &[u8]) -> Result { + let count = x.len(); + debug_assert!(x.iter().all(|&b| (b as usize) < DOMAIN)); + if count > u16::MAX as usize { + return Err(OreError::TooManyBlocks); + } + + let mut acc = CmacAccumulator::new(&self.k_acc); + let mut nonce = [0u8; NONCE_LEN]; + self.rng.borrow_mut().try_fill(&mut nonce)?; + let hasher = FixedPiZ2Hash::new(HashKey::from_slice(&nonce)); + + let mut xt = Vec::with_capacity(count); + let mut f = Vec::with_capacity(count); + let mut blocks = Vec::with_capacity(count); + + for (n, &sym) in x.iter().enumerate() { + let n16 = n as u16; + let prp = self.prp_at(&acc, n16)?; + let permuted = prp.permute(sym)?; + xt.push(permuted); + + // ro_key for every domain value; f[n] = ro(n, xt[n]). + let mut ro = [AesBlock::default(); DOMAIN]; + for (j, blk) in ro.iter_mut().enumerate() { + blk.copy_from_slice(&acc.finalize(&final_block( + Branch::RoKey, + n16, + j as u16, + WIDTH_BIT6, + ))); + } + let mut fb = [0u8; F_LEN]; + fb.copy_from_slice(ro[permuted as usize].as_slice()); + f.push(fb); + + // right block = H-mask ⊕ indicator (trashes `ro`). + let mut rb = [0u8; RIGHT_LEN]; + hasher.hash_all_into(&mut ro, &mut rb); + prp.indicator_mask_xor(sym, &mut rb); + blocks.push(rb); + + // `ro` held the key-derived RO_KEY tags (then the H outputs); wipe + // it before the next block (matches the bit2_w6 scratch-buffer wipe). + for blk in ro.iter_mut() { + blk.as_mut_slice().zeroize(); + } + + acc.absorb(&prefix_block(n16, sym)); + } + + Ok(VarCipherText { + left: VarLeft { xt, f }, + right: VarRight { nonce, blocks }, + }) + } + + /// Left-only ciphertext (smaller; for query plaintexts). + pub fn encrypt_left_var(&self, x: &[u8]) -> Result { + let count = x.len(); + debug_assert!(x.iter().all(|&b| (b as usize) < DOMAIN)); + if count > u16::MAX as usize { + return Err(OreError::TooManyBlocks); + } + + let mut acc = CmacAccumulator::new(&self.k_acc); + let mut xt = Vec::with_capacity(count); + let mut f = Vec::with_capacity(count); + + for (n, &sym) in x.iter().enumerate() { + let n16 = n as u16; + let prp = self.prp_at(&acc, n16)?; + let permuted = prp.permute(sym)?; + xt.push(permuted); + f.push(acc.finalize(&final_block( + Branch::RoKey, + n16, + permuted as u16, + WIDTH_BIT6, + ))); + acc.absorb(&prefix_block(n16, sym)); + } + Ok(VarLeft { xt, f }) + } + + /// Encrypt a string (UTF-8 bytes → MSB-first 6-bit blocks). + pub fn encrypt_str(&self, s: &str) -> Result { + self.encrypt_var(&str_to_blocks(s)) + } + + /// Left-only string ciphertext. + pub fn encrypt_left_str(&self, s: &str) -> Result { + self.encrypt_left_var(&str_to_blocks(s)) + } + + /// Compare two serialised full ciphertexts (lexicographic; shorter prefix + /// sorts first). `None` if either is not a well-formed ciphertext of this + /// scheme. + pub fn compare_raw_slices(a: &[u8], b: &[u8]) -> Option { + let ((va, sa, ca), _) = parse_header(a).ok()?; + let ((vb, sb, cb), _) = parse_header(b).ok()?; + if va != VERSION || sa != SCHEME_ID || vb != VERSION || sb != SCHEME_ID { + return None; + } + if a.len() != total_len(ca) || b.len() != total_len(cb) { + return None; + } + // Only `a`'s left half and `b`'s right half are read (Lewi-Wu + // asymmetry); the left-only query path is `compare_left_to_full`. + Some(Self::compare_views(a, ca, b, cb)) + } + + /// Compare a left-only (query) ciphertext `left` — produced by + /// [`Self::encrypt_left_var`]/[`Self::encrypt_left_str`] — against a stored + /// full ciphertext `full`. This is the Lewi-Wu query path: a comparison + /// needs only the query's left half (`xt`/`f`) and the stored ciphertext's + /// right half, so the smaller left-only artifact suffices and the stored + /// right half reveals nothing at rest. Returns `left`'s order relative to + /// `full` (`Less` ⇒ the query plaintext sorts before the stored one). + /// `None` if either input is malformed. + pub fn compare_left_to_full(left: &[u8], full: &[u8]) -> Option { + let ((vl, sl, cl), _) = parse_header(left).ok()?; + let ((vf, sf, cf), _) = parse_header(full).ok()?; + if vl != VERSION || sl != SCHEME_ID || vf != VERSION || sf != SCHEME_ID { + return None; + } + // `left` is `header ‖ xt ‖ f` (no nonce/right); `full` is complete. + if left.len() != left_len(cl) || full.len() != total_len(cf) { + return None; + } + Some(Self::compare_views(left, cl, full, cf)) + } + + /// Core comparator. `a` supplies the **left view** (`header ‖ xt ‖ f` — the + /// layout is identical for a `VarLeft` and the left prefix of a full + /// ciphertext, so the `xt_at`/`f_at` accessors work on either); `b` supplies + /// the **right half**. Constant-time scan of the shared prefix; if it + /// matches throughout, the shorter sorts first; otherwise the first + /// differing block is resolved via the random oracle against `b`'s right + /// block. `a` reads only `xt`/`f`, `b` reads `xt`/`f`/`nonce`/`right`. + fn compare_views(a: &[u8], ca: usize, b: &[u8], cb: usize) -> Ordering { + let min_count = ca.min(cb); + let mut is_equal = Choice::from(1u8); + let mut l: u64 = 0; + for n in 0..min_count { + let differs = !xt_at(a, n).ct_eq(&xt_at(b, n)) | !f_at(a, ca, n).ct_eq(f_at(b, cb, n)); + l.conditional_assign(&(n as u64), is_equal & differs); + is_equal.conditional_assign(&Choice::from(0u8), is_equal & differs); + } + + if bool::from(is_equal) { + // Shared prefix matches throughout — shorter sorts first. + return ca.cmp(&cb); + } + + let l = l as usize; + let hasher = FixedPiZ2Hash::new(HashKey::from_slice(nonce_at(b, cb))); + let h = hasher.hash(f_at(a, ca, l)); + let permuted = xt_at(a, l); + let byte = ct_select_byte(right_at(b, cb, l), (permuted / 8) as usize); + let test = ((byte >> (permuted % 8)) & 1) ^ h; + if test == 1 { + Ordering::Greater + } else { + Ordering::Less + } + } +} + +impl Drop for OreAes128Bit6Chained { + fn drop(&mut self) { + self.k_acc.zeroize(); + } +} + +fn str_to_blocks(s: &str) -> Vec { + let bytes = s.as_bytes(); + let nb = num_blocks_6bit(bytes.len()); + let mut blocks = vec![0u8; nb]; + if nb > 0 { + decompose_6bit(bytes, &mut blocks); + } + blocks +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ore() -> OreAes128Bit6ChainedChaCha20 { + OreAes128Bit6Chained::init(&[0x11; 16]).unwrap() + } + + fn cmp(c: &OreAes128Bit6ChainedChaCha20, a: &str, b: &str) -> Ordering { + let ca = c.encrypt_str(a).unwrap().to_bytes(); + let cb = c.encrypt_str(b).unwrap().to_bytes(); + OreAes128Bit6ChainedChaCha20::compare_raw_slices(&ca, &cb).unwrap() + } + + #[test] + fn string_total_order_matches_lexicographic() { + let c = ore(); + // Includes shared prefixes of different lengths and beyond 14 blocks. + let mut words = [ + "", + "a", + "ab", + "abc", + "abd", + "ac", + "b", + "banana", + "bananas", + "the quick brown fox jumps over the lazy dog", + "the quick brown fox jumps over the lazy dog.", + "zzz", + ]; + for &a in &words { + for &b in &words { + assert_eq!(cmp(&c, a, b), a.cmp(b), "order mismatch for {a:?} vs {b:?}"); + } + } + words.sort(); + // sanity: sort is stable and the list is what we expect + assert_eq!(words[0], ""); + } + + #[test] + fn equality_same_plaintext_across_nonces() { + let c = ore(); + let a = c.encrypt_str("hello world").unwrap().to_bytes(); + let b = c.encrypt_str("hello world").unwrap().to_bytes(); + assert_ne!(a, b, "nonces should differ"); // different nonce streams + assert_eq!( + OreAes128Bit6ChainedChaCha20::compare_raw_slices(&a, &b), + Some(Ordering::Equal) + ); + } + + #[test] + fn serialize_roundtrip() { + let c = ore(); + let ct = c.encrypt_str("roundtrip me").unwrap(); + let bytes = ct.to_bytes(); + let parsed = VarCipherText::from_slice(&bytes).unwrap(); + assert_eq!(parsed.to_bytes(), bytes); + } + + #[test] + fn beyond_packed_cap() { + // > 14 6-bit blocks (the fixed-N cap) must work and order correctly. + let c = ore(); + let long_a = "aaaaaaaaaaaaaaaaaaaaaaaa"; // 24 bytes -> 32 blocks + let long_b = "aaaaaaaaaaaaaaaaaaaaaaab"; + assert_eq!(cmp(&c, long_a, long_b), Ordering::Less); + assert_eq!(cmp(&c, long_a, long_a), Ordering::Equal); + } + + #[test] + fn cross_scheme_bytes_rejected() { + let c = ore(); + let mut bytes = c.encrypt_str("x").unwrap().to_bytes(); + bytes[1] = 0x02; // pretend it's the fixed-N scheme id + assert_eq!( + OreAes128Bit6ChainedChaCha20::compare_raw_slices(&bytes, &bytes), + None + ); + } + + #[test] + fn left_query_path_and_artifact_rejection() { + let c = ore(); + let left = c.encrypt_left_str("hi").unwrap().to_bytes(); + let full = c.encrypt_str("hi").unwrap().to_bytes(); + // The query path accepts (left, full) and reports the order. + assert_eq!( + OreAes128Bit6ChainedChaCha20::compare_left_to_full(&left, &full), + Some(Ordering::Equal) + ); + // A left-only artifact is not a full ciphertext, and vice versa, so the + // length checks reject the swapped/mismatched cases. + assert_eq!( + OreAes128Bit6ChainedChaCha20::compare_raw_slices(&left, &full), + None + ); + assert_eq!( + OreAes128Bit6ChainedChaCha20::compare_left_to_full(&full, &full), + None + ); + } + + #[test] + fn empty_string_sorts_first() { + let c = ore(); + assert_eq!(cmp(&c, "", "a"), Ordering::Less); + assert_eq!(cmp(&c, "", ""), Ordering::Equal); + } + + // --- Property tests -------------------------------------------------- + // + // The contract under test is "the comparator reproduces the lexicographic + // order of the plaintext", over arbitrary inputs and (independent) lengths — + // not just the hand-picked words above. Lengths are capped so each case + // stays cheap (~97 AES ops/block) while still routinely exceeding the + // 14-block fixed-N packed-prefix cap. + const PROP_MAX_BLOCKS: usize = 32; + + /// Map arbitrary bytes into the 6-bit block domain, length-capped. + fn to_domain(v: &[u8]) -> Vec { + v.iter().take(PROP_MAX_BLOCKS).map(|b| b & 0x3f).collect() + } + + fn enc(c: &OreAes128Bit6ChainedChaCha20, x: &[u8]) -> Vec { + c.encrypt_var(x).unwrap().to_bytes() + } + + quickcheck! { + /// Headline contract: the comparator reproduces the lexicographic order + /// of the underlying 6-bit block sequences, for arbitrary blocks and + /// arbitrary (independent) lengths — the full domain `0..64`, including + /// values that string inputs never produce. + fn prop_block_order_matches_lex(a: Vec, b: Vec) -> bool { + let c = ore(); + let (a, b) = (to_domain(&a), to_domain(&b)); + let (ea, eb) = (enc(&c, &a), enc(&c, &b)); + OreAes128Bit6ChainedChaCha20::compare_raw_slices(&ea, &eb) == Some(a.cmp(&b)) + } + + /// Lewi-Wu query path: a left-only (query) ciphertext compared against a + /// stored full ciphertext reproduces the plaintext order — + /// `compare_left_to_full(left(a), full(b)) == a.cmp(b)`. + fn prop_left_query_matches_full(a: Vec, b: Vec) -> bool { + let c = ore(); + let (a, b) = (to_domain(&a), to_domain(&b)); + let left_a = c.encrypt_left_var(&a).unwrap().to_bytes(); + let full_b = enc(&c, &b); + OreAes128Bit6ChainedChaCha20::compare_left_to_full(&left_a, &full_b) + == Some(a.cmp(&b)) + } + + /// Same, with a forced shared prefix — exercises the constant-time + /// prefix scan and first-differing-block selection at controlled common + /// lengths (independent random inputs almost never share a prefix). + fn prop_shared_prefix_order(prefix: Vec, sa: Vec, sb: Vec) -> bool { + let c = ore(); + let p = to_domain(&prefix); + let mut a = p.clone(); + a.extend(to_domain(&sa)); + a.truncate(PROP_MAX_BLOCKS); + let mut b = p; + b.extend(to_domain(&sb)); + b.truncate(PROP_MAX_BLOCKS); + let (ea, eb) = (enc(&c, &a), enc(&c, &b)); + OreAes128Bit6ChainedChaCha20::compare_raw_slices(&ea, &eb) == Some(a.cmp(&b)) + } + + /// String comparison matches `str::cmp` across arbitrary Unicode and + /// independent lengths: the MSB-first 6-bit packing is order-preserving + /// even with tail zero-padding (cross-length prefix case included). + fn prop_string_order_matches_str(a: String, b: String) -> bool { + let c = ore(); + let a: String = a.chars().take(12).collect(); + let b: String = b.chars().take(12).collect(); + let ea = c.encrypt_str(&a).unwrap().to_bytes(); + let eb = c.encrypt_str(&b).unwrap().to_bytes(); + OreAes128Bit6ChainedChaCha20::compare_raw_slices(&ea, &eb) == Some(a.cmp(&b)) + } + + /// Two encryptions of one plaintext (fresh nonces each) compare Equal, + /// and never produce identical bytes (the nonce streams differ). + fn prop_equality_across_nonces(x: Vec) -> bool { + let c = ore(); + let x = to_domain(&x); + let (a, b) = (enc(&c, &x), enc(&c, &x)); + let equal = OreAes128Bit6ChainedChaCha20::compare_raw_slices(&a, &b) + == Some(Ordering::Equal); + // Empty plaintexts have no right blocks, so their bytes can collide; + // only require nonce divergence when there is masked material. + let distinct = x.is_empty() || a != b; + equal && distinct + } + + /// The left ciphertext is deterministic (it has no nonce) and is + /// byte-identical to the left half embedded in a full ciphertext — the + /// two encrypt paths must not drift. + fn prop_left_deterministic_and_consistent(x: Vec) -> bool { + let c = ore(); + let x = to_domain(&x); + let l1 = c.encrypt_left_var(&x).unwrap().to_bytes(); + let l2 = c.encrypt_left_var(&x).unwrap().to_bytes(); + let full = c.encrypt_var(&x).unwrap(); + l1 == l2 && full.left.to_bytes() == l1 + } + + /// Serialised full ciphertexts round-trip through `from_slice`. + fn prop_serialize_roundtrip(x: Vec) -> bool { + let c = ore(); + let x = to_domain(&x); + let bytes = enc(&c, &x); + match VarCipherText::from_slice(&bytes) { + Ok(ct) => ct.to_bytes() == bytes, + Err(_) => false, + } + } + } +} diff --git a/packages/ore-rs/src/scheme/width.rs b/packages/ore-rs/src/scheme/width.rs index 6e06610..d7e30c3 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 within the selected byte is then extracted with [`ct_bit`], -/// which avoids a shift by the secret amount. +/// 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}; @@ -85,30 +85,6 @@ 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 {