diff --git a/Cargo.lock b/Cargo.lock index bf77b9dc..607b1d90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -342,12 +342,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "lazy_static" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" - [[package]] name = "libc" version = "0.2.155" @@ -472,7 +466,6 @@ dependencies = [ "criterion", "hex", "hex-literal", - "lazy_static", "num", "orderable-bytes", "quickcheck", diff --git a/docs/plans/2026-06-12-ore-v2-architecture.md b/docs/plans/2026-06-12-ore-v2-architecture.md index aceb03c1..96439efd 100644 --- a/docs/plans/2026-06-12-ore-v2-architecture.md +++ b/docs/plans/2026-06-12-ore-v2-architecture.md @@ -18,6 +18,34 @@ - **No wire-format header.** `compare_raw_slices` infers the block count from the slice length; ciphertexts of different schemes/sizes are distinguishable only by length. +### Known leakage of the legacy scheme + +The 8-bit scheme derives the PRP seed for block `n` as `PRF₂(x[0..n] ‖ 0…)`. The input +binds neither `n` nor the block count, so the seeds for blocks `n < m` are equal when +`x[n..m]` is all zero. A run of equal non-zero bytes does not collide (`[5, 5, 5]` gives +three seeds). Blocks with equal seeds share a permutation, so every zero byte in a run +encrypts to the same `xt` value, `π(0)`. The pinned vectors (`tests/compat_vectors`) show +it: + +- `0u64` gives `xt = 90 90 90 90 90 90 90 90`; +- `1u64 << 32` gives `xt = 90 90 90 27 d5 d5 d5 d5`; +- `1.5f64` gives `xt = 92 52 6a 6a 6a 6a 6a 6a`. + +One ciphertext is enough (a Left alone shows it). It gives away: + +- the number of leading zero bytes of an integer, which is its order of magnitude: the + leading-zero seed is `PRF₂(0…)` in every ciphertext, so its `π(0)` is the same value + (`0x90` above) in every ciphertext under a key; +- runs of zero bytes elsewhere, such as the trailing zero mantissa bytes of a "round" + float; +- equal `xt` values across positions and across ciphertexts when their seeds match + (`xt[7]` of `1u64` and `xt[3]` of `1u64 << 32` are both `0x27`). + +Lewi-Wu ORE leaks only the order and the first differing block of a compared pair; this +is more. The wire format is frozen (see Non-goals), so the legacy scheme keeps it, and it +is documented in the crate README. New schemes must bind the block index and the block +count into the seed input. + This plan covers four goals, structured as a sequence of PRs: 1. **Efficient unary encoding** — replace the per-bit iterative right-block construction @@ -399,6 +427,9 @@ PR 2's trait change, which should be called out in the changelog). never reachable from `Left`/`Right` types (PR 6). - [ ] GF(2^128) doubling constant-time, if Candidate C is chosen (PR 6). - [ ] String leakage profile documented and acknowledged at product level (PR 6). +- [ ] Legacy zero-run seed leakage (Context, "Known leakage of the legacy scheme") + documented in the README; new schemes bind the block index and count into their + PRP seeds (PR 2, 5). ## Decisions taken (revisit if needed) diff --git a/packages/ore-rs/Cargo.toml b/packages/ore-rs/Cargo.toml index 4adfe178..d865b63b 100644 --- a/packages/ore-rs/Cargo.toml +++ b/packages/ore-rs/Cargo.toml @@ -45,7 +45,6 @@ num = "0.4.0" hex = "0.4.3" subtle-ng = "2.5.0" zeroize = { version = "1.5.7", features = ["zeroize_derive"] } -lazy_static = "1.4.0" thiserror = "1.0.38" [[bench]] diff --git a/packages/ore-rs/README.md b/packages/ore-rs/README.md index 121f107d..6db224cf 100644 --- a/packages/ore-rs/README.md +++ b/packages/ore-rs/README.md @@ -13,7 +13,7 @@ It makes the following improvements on the original scheme: * Exclusive use of AES as a Random Oracle * Pipeline optimisations, for higher throughput * Hardware AES acceleration on `x86_64` and ARM (via the [`aes`](https://crates.io/crates/aes) crate's runtime detection, on stable Rust) -* Inclusion of the block number in block prefixes, to avoid repeated prefixes +* Inclusion of the block number in the left-tag (PRF₁) input, to avoid repeated tags. The PRP seed input does not include it: see [Known leakage](#known-leakage-of-oreaes128) ## Usage Documentation @@ -62,6 +62,15 @@ Hardware AES is provided by the [`aes`](https://crates.io/crates/aes) crate, whi The underlying scheme (Lewi-Wu Block-ORE) has been well studied, but this implementation has not had a public third-party audit. Evaluate it against your own threat model before using it in production. +### Known leakage of `OreAes128` + +Beyond the order and the first differing block of a compared pair, an `OreAes128` ciphertext shows its runs of zero bytes. The PRP seed for block `n` is derived from the bytes before it, padded with zeros, and binds neither the block index nor the block count. Blocks inside a run of zero bytes therefore share a permutation and encrypt to the same value. One ciphertext (a Left alone is enough) gives away: + +* the number of leading zero bytes of an integer, which is its order of magnitude, as the same value in every ciphertext under a key; +* other runs of zero bytes, such as the trailing zero mantissa bytes of a round float. + +The wire format of `OreAes128` is frozen, so this cannot change for existing data. See "Known leakage of the legacy scheme" in [the v2 plan](https://github.com/cipherstash/ore.rs/blob/main/docs/plans/2026-06-12-ore-v2-architecture.md). + To report a security issue, see [SECURITY.md](https://github.com/cipherstash/ore.rs/blob/main/SECURITY.md) or email security@cipherstash.com. ## License diff --git a/packages/ore-rs/src/scheme.rs b/packages/ore-rs/src/scheme.rs index 1a83cb38..33198b49 100644 --- a/packages/ore-rs/src/scheme.rs +++ b/packages/ore-rs/src/scheme.rs @@ -6,3 +6,6 @@ /// 2-bit-indicator BlockORE with AES-128 PRF and Knuth-shuffle PRP. pub mod bit2; + +pub(crate) mod decompose; +pub(crate) mod width; diff --git a/packages/ore-rs/src/scheme/bit2.rs b/packages/ore-rs/src/scheme/bit2.rs index 96631016..92f9fb52 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,84 @@ 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]); + // Known leakage, kept because the wire format is frozen: the seed + // input binds neither the block index nor the block count, so the + // seeds for blocks n < m are equal when x[n..m] is all zero. Those + // blocks share one permutation, so a zero run repeats π(0) in `xt` + // and a single Left ciphertext shows it. See "Known leakage of the + // legacy scheme" in the v2 plan. New schemes bind the block index + // and count into the seed: + // https://github.com/cipherstash/ore.rs/pull/82 + } + prf2.encrypt_all(&mut seeds); + SeedBuf(seeds) +} + +/// 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 +148,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 +162,65 @@ impl OreCipher for OreAes128 { // Generate a 16-byte random nonce self.rng.borrow_mut().try_fill(&mut right.nonce)?; - // Build the prefixes - // TODO: Don't modify struct values directly - use a function on a "Left" - left.f.iter_mut().enumerate().for_each(|(n, block)| { - block[0..n].clone_from_slice(&x[0..n]); - }); - - self.prf2.encrypt_all(&mut left.f); - - // To make zeroizing / resetting the RO keys - // Since the AesBlock type is stack allocated this should get optimised to a single memcpy - lazy_static! { - static ref ZEROED_RO_KEYS: [AesBlock; 256] = [Default::default(); 256]; + let seeds = derive_prp_seeds(&self.prf2, x); + + /* TODO: This seems to work but it is technically using the nonce as the key + * (instead of using it as the plaintext). This appears to be how the original + * ORE implementation does it but it feels a bit wonky to me. + * Alternatives are catalogued in the v2 plan §6 and will be settled + * there for new schemes; this scheme keeps the construction for + * wire compatibility. + */ + let hasher: Aes128Z2Hash = Hash::new(AesBlock::from_slice(&right.nonce)); + + // RO key template: entry `j` of the template holds the *input* + // `(x[0..n] ‖ j ‖ n)` for the current block. Between blocks only + // three bytes per entry change (the prefix byte just fixed, the + // candidate byte position, and the block index), so the template is + // maintained incrementally; `work` receives a copy each block for + // the in-place PRF/hash passes. Template and work hold secret + // intermediate state and are zeroized at the end. + let mut template = ::RoKeyBuf::zeroed(); + for (j, entry) in template.iter_mut().enumerate() { + entry[0] = j as u8; } - - let mut ro_keys = *ZEROED_RO_KEYS; + let mut work = ::RoKeyBuf::zeroed(); for n in 0..N { - // Set prefix and create PRP for the block - let prp: KnuthShufflePRP = Prp::new(&left.f[n])?; - + let prp: ::Prp = Prp::new(&seeds.0[n])?; left.xt[n] = prp.permute(x[n])?; - // Reset the f block - left.f[n].default_in_place(); - left.f[n][0..n].clone_from_slice(&x[0..n]); left.f[n][n] = left.xt[n]; // Include the block number in the value passed to the Random Oracle left.f[n][N] = n as u8; - for (j, ro_key) in ro_keys.iter_mut().enumerate() { - /* - * The output of F in H(F(k1, y|i-1||j), r) - */ - ro_key[0..n].clone_from_slice(&x[0..n]); - ro_key[n] = j as u8; - ro_key[N] = n as u8; + if n > 0 { + // Extend the prefix over the previous candidate position and + // move the candidate byte and block index along. + for (j, entry) in template.iter_mut().enumerate() { + entry[n - 1] = x[n - 1]; + entry[n] = j as u8; + entry[N] = n as u8; + } } - self.prf1.encrypt_all(&mut ro_keys); - - /* TODO: This seems to work but it is technically using the nonce as the key - * (instead of using it as the plaintext). This appears to be how the original - * ORE implementation does it but it feels a bit wonky to me. Should check with David. - * It is useful though because the AES crate makes it easy to encrypt groups of 8 - * plaintexts under the same key. We really want the ability to encrypt the same - * plaintext (i.e. the nonce) under different keys but this may be an acceptable - * approximation. - * - * If not, we will probably need to implement our own parallel encrypt using intrisics - * like in the AES crate: https://github.com/RustCrypto/block-ciphers/blob/master/aes/src/ni/aes128.rs#L26 - */ - let hasher: Aes128Z2Hash = Hash::new(AesBlock::from_slice(&right.nonce)); - let hashes = hasher.hash_all(&mut ro_keys); - - // FIXME: force casting to u8 from usize could cause a panic - for (j, h) in hashes.iter().enumerate() { - let jstar = prp.invert(j as u8)?; - let indicator = cmp(jstar, x[n]); - right.data[n].set_bit(j, indicator ^ h); - } + work.copy_from(&template); + self.prf1.encrypt_all(work.as_mut_slice()); - // Zeroize / reset the RO keys before the next loop iteration - ro_keys.clone_from_slice(&*ZEROED_RO_KEYS); + let hashes = hasher.hash_all(work.as_mut_slice()); + encode_right_block::(&mut right.data[n], &prp, x[n], &hashes)?; } self.prf1.encrypt_all(&mut left.f); + for entry in template.iter_mut() { + entry.zeroize(); + } + for entry in work.as_mut_slice() { + entry.zeroize(); + } + Ok(CipherText { left, right }) } diff --git a/packages/ore-rs/src/scheme/decompose.rs b/packages/ore-rs/src/scheme/decompose.rs new file mode 100644 index 00000000..f49b8af4 --- /dev/null +++ b/packages/ore-rs/src/scheme/decompose.rs @@ -0,0 +1,112 @@ +//! Decomposition of canonical plaintext bytes into per-block values. +//! +//! Plaintext canonicalisation (making a value's natural order match +//! lexicographic byte order) lives in `orderable-bytes`. This module handles +//! the next step: splitting those bytes into block values `< DOMAIN` for a +//! given [`super::width::BlockWidth`]. +//! +//! - 8-bit: identity — one byte per block. +//! - 6-bit: MSB-first bit-packing — `N` bytes become `ceil(8N / 6)` blocks, +//! with the final block zero-padded in its low bits. MSB-first packing +//! preserves lexicographic order, and for fixed-length inputs the trailing +//! zero padding is order-neutral. +//! +//! The 6-bit packing ships ahead of the Bit6 scheme (v2 plan, PR 5) because +//! it is pure bit logic that can be pinned by property tests now. + +// The 6-bit functions are consumed by the Bit6 scheme (v2 plan, PR 5); they +// land early so the packing is pinned by tests independent of that scheme. +#[allow(dead_code)] +/// Number of 6-bit blocks needed for `n` plaintext bytes. +pub(crate) const fn num_blocks_6bit(n: usize) -> usize { + (8 * n).div_ceil(6) +} + +#[allow(dead_code)] +/// Decompose `bytes` MSB-first into 6-bit block values, writing them to +/// `out`. `out.len()` must be exactly `num_blocks_6bit(bytes.len())`. +/// +/// Block `i` holds plaintext bits `[6i, 6i + 6)` (bit 0 = MSB of byte 0), +/// so lexicographic order over block values equals lexicographic order over +/// the input bytes. +pub(crate) fn decompose_6bit(bytes: &[u8], out: &mut [u8]) { + debug_assert_eq!(out.len(), num_blocks_6bit(bytes.len())); + + for (i, slot) in out.iter_mut().enumerate() { + let bit = 6 * i; + let byte = bit / 8; + let offset = bit % 8; + + // Take 6 bits starting at `offset` within `bytes[byte]`, spilling + // into the next byte (or zero padding past the end). + let hi = bytes[byte] as u16; + let lo = *bytes.get(byte + 1).unwrap_or(&0) as u16; + let window = (hi << 8) | lo; + + *slot = ((window >> (10 - offset)) & 0x3f) as u8; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + quickcheck! { + /// MSB-first 6-bit packing preserves lexicographic order for + /// equal-length inputs. + fn order_preserved_u64(x: u64, y: u64) -> bool { + let xb = x.to_be_bytes(); + let yb = y.to_be_bytes(); + let mut xq = [0u8; num_blocks_6bit(8)]; + let mut yq = [0u8; num_blocks_6bit(8)]; + decompose_6bit(&xb, &mut xq); + decompose_6bit(&yb, &mut yq); + + xb.cmp(&yb) == xq.cmp(&yq) + } + + /// Every block value is in the 6-bit domain. + fn blocks_in_domain(bytes: Vec) -> bool { + let mut out = vec![0u8; num_blocks_6bit(bytes.len())]; + decompose_6bit(&bytes, &mut out); + out.iter().all(|&b| b < 64) + } + + /// Decomposition is injective: it can be inverted by re-concatenating + /// the 6-bit values and truncating to the original bit length. + fn roundtrip(bytes: Vec) -> bool { + let mut out = vec![0u8; num_blocks_6bit(bytes.len())]; + decompose_6bit(&bytes, &mut out); + + let mut rebuilt = vec![0u8; bytes.len()]; + for (i, &v) in out.iter().enumerate() { + let bit = 6 * i; + let byte = bit / 8; + let offset = bit % 8; + let window = (v as u16) << (10 - offset); + rebuilt[byte] |= (window >> 8) as u8; + if byte + 1 < rebuilt.len() { + rebuilt[byte + 1] |= (window & 0xff) as u8; + } + } + rebuilt == bytes + } + } + + #[test] + fn block_counts() { + assert_eq!(num_blocks_6bit(4), 6); // u32: 32 bits -> 6 blocks + assert_eq!(num_blocks_6bit(8), 11); // u64: 64 bits -> 11 blocks + assert_eq!(num_blocks_6bit(14), 19); // Decimal + assert_eq!(num_blocks_6bit(16), 22); // u128 + assert_eq!(num_blocks_6bit(0), 0); + } + + #[test] + fn known_packing() { + // Bit stream 11111100 00001111 -> 111111 | 000000 | 1111 + 2 pad bits + let mut out = [0u8; 3]; + decompose_6bit(&[0b1111_1100, 0b0000_1111], &mut out); + assert_eq!(out, [0b111111, 0b000000, 0b111100]); + } +} diff --git a/packages/ore-rs/src/scheme/width.rs b/packages/ore-rs/src/scheme/width.rs new file mode 100644 index 00000000..43557337 --- /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. Block values are `0..DOMAIN`. Derived from + /// [`BITS`](Self::BITS) so the two cannot disagree. + const DOMAIN: usize = 1 << Self::BITS; + /// Right-ciphertext block: a `DOMAIN`-bit masked truth-table row. + type RightBlock: CipherTextBlock + RightBitVec; + /// PRP over the block domain. + 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; + type RightBlock = RightBlock32; + type Prp = crate::primitives::prp::KnuthShufflePRP; + type RoKeyBuf = [AesBlock; 256]; +}