diff --git a/Cargo.lock b/Cargo.lock index 8672fd2..6fceb67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -342,12 +342,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "lazy_static" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" - [[package]] name = "libc" version = "0.2.155" @@ -472,7 +466,6 @@ dependencies = [ "criterion", "hex", "hex-literal", - "lazy_static", "num", "orderable-bytes", "quickcheck", diff --git a/packages/ore-rs/Cargo.toml b/packages/ore-rs/Cargo.toml index 8bb37e0..34850c6 100644 --- a/packages/ore-rs/Cargo.toml +++ b/packages/ore-rs/Cargo.toml @@ -43,7 +43,6 @@ num = "0.4.0" hex = "0.4.3" subtle-ng = "2.5.0" zeroize = { version = "1.5.7", features = ["zeroize_derive"] } -lazy_static = "1.4.0" thiserror = "1.0.38" [[bench]] diff --git a/packages/ore-rs/src/scheme.rs b/packages/ore-rs/src/scheme.rs index 1a83cb3..33198b4 100644 --- a/packages/ore-rs/src/scheme.rs +++ b/packages/ore-rs/src/scheme.rs @@ -6,3 +6,6 @@ /// 2-bit-indicator BlockORE with AES-128 PRF and Knuth-shuffle PRP. pub mod bit2; + +pub(crate) mod decompose; +pub(crate) mod width; diff --git a/packages/ore-rs/src/scheme/bit2.rs b/packages/ore-rs/src/scheme/bit2.rs index 9663101..36c8594 100644 --- a/packages/ore-rs/src/scheme/bit2.rs +++ b/packages/ore-rs/src/scheme/bit2.rs @@ -7,20 +7,19 @@ use crate::{ ciphertext::*, primitives::{ - hash::Aes128Z2Hash, prf::Aes128Prf, prp::KnuthShufflePRP, AesBlock, Hash, HashKey, Prf, - Prp, NONCE_SIZE, + hash::Aes128Z2Hash, prf::Aes128Prf, AesBlock, Hash, HashKey, Prf, Prp, NONCE_SIZE, }, + scheme::width::{AesBlockBuf, Bit8, BlockWidth, RightBitVec}, OreCipher, OreError, PlainText, }; use aes::cipher::generic_array::GenericArray; -use lazy_static::lazy_static; use rand::{Rng, SeedableRng}; use rand_chacha::ChaCha20Rng; use std::cell::RefCell; use std::cmp::Ordering; use subtle_ng::{Choice, ConditionallySelectable, ConstantTimeEq}; -use zeroize::ZeroizeOnDrop; +use zeroize::{Zeroize, ZeroizeOnDrop}; /// Per-block ciphertext component types ([`LeftBlock16`], [`RightBlock32`]) /// used by this scheme. @@ -51,6 +50,79 @@ fn cmp(a: u8, b: u8) -> u8 { u8::from(a > b) } +/// Derive the per-block PRP seeds for `x` under `prf2`: seed `n` is +/// `PRF₂(x[0..n] ‖ 0…)`. The seeds are **key-equivalent material** — anyone +/// holding seed `n` can rebuild that block's permutation and invert `xt[n]` +/// — so they live in their own buffer, never in the (serialisable) `Left`, +/// and the caller must consume them via [`SeedBuf`]'s zeroize-on-drop. +struct SeedBuf([AesBlock; N]); + +impl Drop for SeedBuf { + fn drop(&mut self) { + for seed in self.0.iter_mut() { + seed.zeroize(); + } + } +} + +fn derive_prp_seeds(prf2: &Aes128Prf, x: &PlainText) -> SeedBuf { + let mut seeds = [AesBlock::default(); N]; + for (n, block) in seeds.iter_mut().enumerate() { + block[0..n].clone_from_slice(&x[0..n]); + // TODO (tracked in v2 plan, fixed for new schemes by the §5(b) + // accumulator): the block index is not bound here, so a run of + // identical prefix bytes yields identical seeds. + } + prf2.encrypt_all(&mut seeds); + SeedBuf(seeds) +} + +/// Build the right-ciphertext bitvector for one block: for every candidate +/// value `j`, bit `j` is `(π⁻¹(j) > x) ⊕ h[j]`. +/// +/// This is the naive per-bit form, retained in this refactor for risk +/// staging; the permutation-direct bulk form replaces it in the next PR +/// (v2 plan §2) without changing the output bytes. +fn encode_right_block( + block: &mut W::RightBlock, + prp: &W::Prp, + x: u8, + hashes: &[u8], +) -> Result<(), OreError> { + debug_assert_eq!(hashes.len(), W::DOMAIN); + for (j, h) in hashes.iter().enumerate() { + let jstar = prp.invert(j as u8)?; + let indicator = cmp(jstar, x); + block.set_bit(j, indicator ^ h); + } + Ok(()) +} + +impl OreAes128 { + /// Fill `left` with the PRF₁ tag inputs and permuted values for `x`, + /// using the given PRP seeds. After this returns, `left.f[n]` holds the + /// *unencrypted* tag input `(x[0..n] ‖ xt[n] ‖ n)`; the caller runs the + /// final PRF₁ pass once any other use of the inputs (the RO key + /// template) is done. + fn build_left( + &self, + left: &mut Left, + seeds: &SeedBuf, + x: &PlainText, + ) -> Result<(), OreError> { + for n in 0..N { + let prp: ::Prp = Prp::new(&seeds.0[n])?; + left.xt[n] = prp.permute(x[n])?; + + left.f[n][0..n].clone_from_slice(&x[0..n]); + left.f[n][n] = left.xt[n]; + // Include the block number in the value passed to the Random Oracle + left.f[n][N] = n as u8; + } + Ok(()) + } +} + impl OreCipher for OreAes128 { type LeftBlockType = LeftBlock16; type RightBlockType = RightBlock32; @@ -71,37 +143,8 @@ impl OreCipher for OreAes128 { fn encrypt_left(&self, x: &PlainText) -> EncryptLeftResult { let mut output = Left::::init(); - // Build the prefixes - // TODO: Don't modify struct values directly - use a function on a "Left" trait - output.f.iter_mut().enumerate().for_each(|(n, block)| { - block[0..n].clone_from_slice(&x[0..n]); - // TODO: Include the block number in the prefix to avoid repeating values for common - // blocks in a long prefix - // e.g. when plaintext is 4700 (2-bytes/blocks) - // xt = [17, 17, 17, 17, 17, 17, 223, 76] - }); - - self.prf2.encrypt_all(&mut output.f); - - for (n, xn) in x.iter().enumerate().take(N) { - // Set prefix and create PRP for the block - let prp: KnuthShufflePRP = Prp::new(&output.f[n])?; - - output.xt[n] = prp.permute(*xn)?; - } - - // Reset the f block - // We don't actually need to clear sensitive data here, we - // just need fast "zero set". Reassigning the value will drop the old one and allocate new - // data to the stack - output.f = [Default::default(); N]; - - for n in 0..N { - output.f[n][0..n].clone_from_slice(&x[0..n]); - output.f[n][n] = output.xt[n]; - // Include the block number in the value passed to the Random Oracle - output.f[n][N] = n as u8; - } + let seeds = derive_prp_seeds(&self.prf2, x); + self.build_left(&mut output, &seeds, x)?; self.prf1.encrypt_all(&mut output.f); Ok(output) @@ -114,74 +157,65 @@ impl OreCipher for OreAes128 { // Generate a 16-byte random nonce self.rng.borrow_mut().try_fill(&mut right.nonce)?; - // Build the prefixes - // TODO: Don't modify struct values directly - use a function on a "Left" - left.f.iter_mut().enumerate().for_each(|(n, block)| { - block[0..n].clone_from_slice(&x[0..n]); - }); - - self.prf2.encrypt_all(&mut left.f); - - // To make zeroizing / resetting the RO keys - // Since the AesBlock type is stack allocated this should get optimised to a single memcpy - lazy_static! { - static ref ZEROED_RO_KEYS: [AesBlock; 256] = [Default::default(); 256]; + let seeds = derive_prp_seeds(&self.prf2, x); + + /* TODO: This seems to work but it is technically using the nonce as the key + * (instead of using it as the plaintext). This appears to be how the original + * ORE implementation does it but it feels a bit wonky to me. + * Alternatives are catalogued in the v2 plan §6 and will be settled + * there for new schemes; this scheme keeps the construction for + * wire compatibility. + */ + let hasher: Aes128Z2Hash = Hash::new(AesBlock::from_slice(&right.nonce)); + + // RO key template: entry `j` of the template holds the *input* + // `(x[0..n] ‖ j ‖ n)` for the current block. Between blocks only + // three bytes per entry change (the prefix byte just fixed, the + // candidate byte position, and the block index), so the template is + // maintained incrementally; `work` receives a copy each block for + // the in-place PRF/hash passes. Template and work hold secret + // intermediate state and are zeroized at the end. + let mut template = ::RoKeyBuf::zeroed(); + for (j, entry) in template.iter_mut().enumerate() { + entry[0] = j as u8; } - - let mut ro_keys = *ZEROED_RO_KEYS; + let mut work = ::RoKeyBuf::zeroed(); for n in 0..N { - // Set prefix and create PRP for the block - let prp: KnuthShufflePRP = Prp::new(&left.f[n])?; - + let prp: ::Prp = Prp::new(&seeds.0[n])?; left.xt[n] = prp.permute(x[n])?; - // Reset the f block - left.f[n].default_in_place(); - left.f[n][0..n].clone_from_slice(&x[0..n]); left.f[n][n] = left.xt[n]; // Include the block number in the value passed to the Random Oracle left.f[n][N] = n as u8; - for (j, ro_key) in ro_keys.iter_mut().enumerate() { - /* - * The output of F in H(F(k1, y|i-1||j), r) - */ - ro_key[0..n].clone_from_slice(&x[0..n]); - ro_key[n] = j as u8; - ro_key[N] = n as u8; + if n > 0 { + // Extend the prefix over the previous candidate position and + // move the candidate byte and block index along. + for (j, entry) in template.iter_mut().enumerate() { + entry[n - 1] = x[n - 1]; + entry[n] = j as u8; + entry[N] = n as u8; + } } - self.prf1.encrypt_all(&mut ro_keys); - - /* TODO: This seems to work but it is technically using the nonce as the key - * (instead of using it as the plaintext). This appears to be how the original - * ORE implementation does it but it feels a bit wonky to me. Should check with David. - * It is useful though because the AES crate makes it easy to encrypt groups of 8 - * plaintexts under the same key. We really want the ability to encrypt the same - * plaintext (i.e. the nonce) under different keys but this may be an acceptable - * approximation. - * - * If not, we will probably need to implement our own parallel encrypt using intrisics - * like in the AES crate: https://github.com/RustCrypto/block-ciphers/blob/master/aes/src/ni/aes128.rs#L26 - */ - let hasher: Aes128Z2Hash = Hash::new(AesBlock::from_slice(&right.nonce)); - let hashes = hasher.hash_all(&mut ro_keys); - - // FIXME: force casting to u8 from usize could cause a panic - for (j, h) in hashes.iter().enumerate() { - let jstar = prp.invert(j as u8)?; - let indicator = cmp(jstar, x[n]); - right.data[n].set_bit(j, indicator ^ h); - } + work.copy_from(&template); + self.prf1.encrypt_all(work.as_mut_slice()); - // Zeroize / reset the RO keys before the next loop iteration - ro_keys.clone_from_slice(&*ZEROED_RO_KEYS); + let hashes = hasher.hash_all(work.as_mut_slice()); + encode_right_block::(&mut right.data[n], &prp, x[n], &hashes)?; } self.prf1.encrypt_all(&mut left.f); + for entry in template.iter_mut() { + entry.zeroize(); + } + for entry in work.as_mut_slice() { + entry.zeroize(); + } + Ok(CipherText { left, right }) } diff --git a/packages/ore-rs/src/scheme/decompose.rs b/packages/ore-rs/src/scheme/decompose.rs new file mode 100644 index 0000000..f49b8af --- /dev/null +++ b/packages/ore-rs/src/scheme/decompose.rs @@ -0,0 +1,112 @@ +//! Decomposition of canonical plaintext bytes into per-block values. +//! +//! Plaintext canonicalisation (making a value's natural order match +//! lexicographic byte order) lives in `orderable-bytes`. This module handles +//! the next step: splitting those bytes into block values `< DOMAIN` for a +//! given [`super::width::BlockWidth`]. +//! +//! - 8-bit: identity — one byte per block. +//! - 6-bit: MSB-first bit-packing — `N` bytes become `ceil(8N / 6)` blocks, +//! with the final block zero-padded in its low bits. MSB-first packing +//! preserves lexicographic order, and for fixed-length inputs the trailing +//! zero padding is order-neutral. +//! +//! The 6-bit packing ships ahead of the Bit6 scheme (v2 plan, PR 5) because +//! it is pure bit logic that can be pinned by property tests now. + +// The 6-bit functions are consumed by the Bit6 scheme (v2 plan, PR 5); they +// land early so the packing is pinned by tests independent of that scheme. +#[allow(dead_code)] +/// Number of 6-bit blocks needed for `n` plaintext bytes. +pub(crate) const fn num_blocks_6bit(n: usize) -> usize { + (8 * n).div_ceil(6) +} + +#[allow(dead_code)] +/// Decompose `bytes` MSB-first into 6-bit block values, writing them to +/// `out`. `out.len()` must be exactly `num_blocks_6bit(bytes.len())`. +/// +/// Block `i` holds plaintext bits `[6i, 6i + 6)` (bit 0 = MSB of byte 0), +/// so lexicographic order over block values equals lexicographic order over +/// the input bytes. +pub(crate) fn decompose_6bit(bytes: &[u8], out: &mut [u8]) { + debug_assert_eq!(out.len(), num_blocks_6bit(bytes.len())); + + for (i, slot) in out.iter_mut().enumerate() { + let bit = 6 * i; + let byte = bit / 8; + let offset = bit % 8; + + // Take 6 bits starting at `offset` within `bytes[byte]`, spilling + // into the next byte (or zero padding past the end). + let hi = bytes[byte] as u16; + let lo = *bytes.get(byte + 1).unwrap_or(&0) as u16; + let window = (hi << 8) | lo; + + *slot = ((window >> (10 - offset)) & 0x3f) as u8; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + quickcheck! { + /// MSB-first 6-bit packing preserves lexicographic order for + /// equal-length inputs. + fn order_preserved_u64(x: u64, y: u64) -> bool { + let xb = x.to_be_bytes(); + let yb = y.to_be_bytes(); + let mut xq = [0u8; num_blocks_6bit(8)]; + let mut yq = [0u8; num_blocks_6bit(8)]; + decompose_6bit(&xb, &mut xq); + decompose_6bit(&yb, &mut yq); + + xb.cmp(&yb) == xq.cmp(&yq) + } + + /// Every block value is in the 6-bit domain. + fn blocks_in_domain(bytes: Vec) -> bool { + let mut out = vec![0u8; num_blocks_6bit(bytes.len())]; + decompose_6bit(&bytes, &mut out); + out.iter().all(|&b| b < 64) + } + + /// Decomposition is injective: it can be inverted by re-concatenating + /// the 6-bit values and truncating to the original bit length. + fn roundtrip(bytes: Vec) -> bool { + let mut out = vec![0u8; num_blocks_6bit(bytes.len())]; + decompose_6bit(&bytes, &mut out); + + let mut rebuilt = vec![0u8; bytes.len()]; + for (i, &v) in out.iter().enumerate() { + let bit = 6 * i; + let byte = bit / 8; + let offset = bit % 8; + let window = (v as u16) << (10 - offset); + rebuilt[byte] |= (window >> 8) as u8; + if byte + 1 < rebuilt.len() { + rebuilt[byte + 1] |= (window & 0xff) as u8; + } + } + rebuilt == bytes + } + } + + #[test] + fn block_counts() { + assert_eq!(num_blocks_6bit(4), 6); // u32: 32 bits -> 6 blocks + assert_eq!(num_blocks_6bit(8), 11); // u64: 64 bits -> 11 blocks + assert_eq!(num_blocks_6bit(14), 19); // Decimal + assert_eq!(num_blocks_6bit(16), 22); // u128 + assert_eq!(num_blocks_6bit(0), 0); + } + + #[test] + fn known_packing() { + // Bit stream 11111100 00001111 -> 111111 | 000000 | 1111 + 2 pad bits + let mut out = [0u8; 3]; + decompose_6bit(&[0b1111_1100, 0b0000_1111], &mut out); + assert_eq!(out, [0b111111, 0b000000, 0b111100]); + } +} diff --git a/packages/ore-rs/src/scheme/width.rs b/packages/ore-rs/src/scheme/width.rs new file mode 100644 index 0000000..40a9dc6 --- /dev/null +++ b/packages/ore-rs/src/scheme/width.rs @@ -0,0 +1,96 @@ +//! Block-width abstraction for BlockORE schemes. +//! +//! A [`BlockWidth`] fixes the number of plaintext bits consumed per ORE +//! block and everything that follows from it: the block domain size, the +//! right-ciphertext bitvector type, the PRP over the domain, and the +//! random-oracle key buffer. Widths are sealed: the comparator and wire +//! format are width-specific, so downstream crates must not add widths. +//! +//! Stable Rust cannot express `[u8; 1 << BITS]` for a generic width +//! (`generic_const_exprs`), so the width carries its domain-sized buffers +//! as associated types instead. + +use crate::ciphertext::CipherTextBlock; +use crate::primitives::{AesBlock, Prp}; +use crate::scheme::bit2::block_types::RightBlock32; + +mod sealed { + pub trait Sealed {} + impl Sealed for super::Bit8 {} + impl Sealed for [super::AesBlock; 256] {} +} + +/// A domain-sized buffer of AES blocks used for per-block random-oracle +/// keys. Implemented for `[AesBlock; DOMAIN]` arrays (which have no +/// `Default` for large N on stable Rust). +pub trait AesBlockBuf: sealed::Sealed { + /// A zeroed buffer. + fn zeroed() -> Self; + /// View as a mutable slice for batched PRF passes. (Concrete `[T; N]` + /// callers resolve to the inherent array method; this exists for + /// width-generic code, which arrives with the Bit6 scheme.) + #[allow(dead_code)] + fn as_mut_slice(&mut self) -> &mut [AesBlock]; + /// Overwrite `self` with `other` (a domain-sized memcpy). + fn copy_from(&mut self, other: &Self); +} + +impl AesBlockBuf for [AesBlock; 256] { + fn zeroed() -> Self { + [AesBlock::default(); 256] + } + fn as_mut_slice(&mut self) -> &mut [AesBlock] { + self + } + fn copy_from(&mut self, other: &Self) { + self.clone_from_slice(other); + } +} + +/// Per-block bitvector operations on a Right ciphertext block, one bit per +/// value in the block domain. +pub trait RightBitVec { + /// Set bit `bit` to `value` (`0` or `1`). + fn set_bit(&mut self, bit: usize, value: u8); + /// Read bit `bit`. (The width-generic comparator lands with the Bit6 + /// scheme; the legacy comparator calls the inherent method.) + #[allow(dead_code)] + fn get_bit(&self, bit: usize) -> u8; +} + +impl RightBitVec for RightBlock32 { + fn set_bit(&mut self, bit: usize, value: u8) { + RightBlock32::set_bit(self, bit, value) + } + fn get_bit(&self, bit: usize) -> u8 { + RightBlock32::get_bit(self, bit) + } +} + +/// The number of plaintext bits consumed per ORE block, and the types that +/// depend on it. See the module docs. +pub trait BlockWidth: sealed::Sealed + 'static { + /// Bits of plaintext per block. + const BITS: usize; + /// Block domain size: `1 << BITS`. Block values are `0..DOMAIN`. + const DOMAIN: usize; + /// Right-ciphertext block: a `DOMAIN`-bit masked truth-table row. + type RightBlock: CipherTextBlock + RightBitVec; + /// PRP over the block domain. + type Prp: Prp; + /// Buffer holding `DOMAIN` random-oracle keys. + type RoKeyBuf: AesBlockBuf; +} + +/// The 8-bit block width used by the legacy [`crate::scheme::bit2`] scheme: +/// one plaintext byte per block, domain 256. +#[derive(Debug)] +pub struct Bit8; + +impl BlockWidth for Bit8 { + const BITS: usize = 8; + const DOMAIN: usize = 256; + type RightBlock = RightBlock32; + type Prp = crate::primitives::prp::KnuthShufflePRP; + type RoKeyBuf = [AesBlock; 256]; +}