From d3d7b7b7355b52c8c77da062bb0c5a1c0aa68017 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 16 Oct 2026 14:58:28 +1100 Subject: [PATCH 1/7] feat(chained): encrypt u128/i128 on the chained scheme; drop the panicking legacy impls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `OreEncrypt` for `u128`/`i128` on `OreAes128` has panicked on every call since it was added (ad90f0b): the legacy left-tag input puts the block index at byte `N` of one AES block, so the scheme takes at most 15 bytes. At 22 six-bit blocks the types do not fit `OreAes128Bit6`'s 14-block packed prefix either. The chained scheme's CMAC accumulator has no length cap, so they go there. Chained scheme: - `OreEncrypt>` for `u128` and `i128`. Outputs are `ChainedCipherText<22>` / `ChainedLeft<22>` (`U128_BLOCKS`, `I128_BLOCKS`): the same bytes as a `Var*` of that length, with the block count in the type. Their `OreOutput::from_slice`, `TryFrom` and typed `compare_raw_slices` / `compare_left_to_full` accept only that count, so the typed path never orders an integer ciphertext against a string (spec §7). The untyped comparators still order them lexicographically. - `encrypt_fixed` / `encrypt_left_fixed` for any `FixedOrderableBytes` type, and `VarLeft::from_slice`. - Wire vectors for both types pinned in `tests/compat_chained_vectors.rs`; the existing vectors are unchanged. `OreEncrypt` drops its `T: OreCipher` bound so the chained scheme (single key, no const `N`) can implement it. Existing impls and callers are unaffected. Legacy scheme: - The `u128`/`i128` impls are removed and the impl macro asserts `ENCODED_LEN <= bit2::MAX_BLOCKS` (15) at compile time, so a type too wide for the scheme cannot be given an impl again. - `encrypt` / `encrypt_left` return `OreError::TooManyBlocks` for `N > 15` instead of indexing out of bounds. Every `OreEncrypt` impl macro (legacy, Bit6, chained) now emits a round-trip, ordering and equality-across-nonces test for the type it implements, so a type cannot be listed as supported without being exercised. Closes #105. Rebased over #110 (chained padding and the string ceiling). The fixed-length path stays unpadded: a value of one type always has the same length, so there is nothing to hide, and the cipher's `Options` (padding policy, string ceiling) do not apply to it. Its ciphertexts carry the `Padding::None` tag in the 7-byte header; `ChainedCipherText` / `ChainedLeft` accept only that tag and the pinned count, so a padded string of the same block count is refused by the typed path as well as by the policy check in the untyped comparators. The `u128`/`i128` wire vectors are re-pinned for the padded header. --- docs/plans/2026-06-12-ore-v2-architecture.md | 4 +- packages/ore-rs/CHANGELOG.md | 21 + packages/ore-rs/README.md | 4 +- packages/ore-rs/src/encrypt.rs | 90 +++- packages/ore-rs/src/lib.rs | 4 +- packages/ore-rs/src/scheme/bit2.rs | 31 ++ packages/ore-rs/src/scheme/bit2_w6.rs | 81 ++- packages/ore-rs/src/scheme/chained.rs | 472 +++++++++++++++++- .../ore-rs/tests/compat_chained_vectors.rs | 162 +++++- .../tests/compat_chained_vectors/vectors.rs | 30 ++ 10 files changed, 848 insertions(+), 51 deletions(-) diff --git a/docs/plans/2026-06-12-ore-v2-architecture.md b/docs/plans/2026-06-12-ore-v2-architecture.md index 07146ca4..096d039b 100644 --- a/docs/plans/2026-06-12-ore-v2-architecture.md +++ b/docs/plans/2026-06-12-ore-v2-architecture.md @@ -610,7 +610,9 @@ PR 2's trait change, which should be called out in the changelog). > 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. Wire vectors are +> `u128`/`i128` landed 2026-10 as `OreEncrypt` impls on the chained scheme with +> block-count-typed outputs (`ChainedCipherText<22>`), not a separate const-N scheme +> (issue #105). `Decimal` can follow the same way via `encrypt_fixed`. Wire vectors are > pinned (`tests/compat_chained_vectors.rs`). ## Testing strategy diff --git a/packages/ore-rs/CHANGELOG.md b/packages/ore-rs/CHANGELOG.md index 5900168c..c77e6c55 100644 --- a/packages/ore-rs/CHANGELOG.md +++ b/packages/ore-rs/CHANGELOG.md @@ -14,6 +14,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 size and encryption time grow with the padded length and the string is caller-supplied, so a ceiling bounds what one input can cost; it also bounds the number of length buckets a column can show. `init_with_padding` is replaced by `init_with`. +- `u128` and `i128` can be encrypted, on the chained scheme (`scheme::chained`): `OreEncrypt` + impls producing `ChainedCipherText<22>` / `ChainedLeft<22>`, whose typed parsers and + comparators accept only that block count, plus `encrypt_fixed` / `encrypt_left_fixed` for + any `FixedOrderableBytes` type and `VarLeft::from_slice`. Wire vectors are pinned in + `tests/compat_chained_vectors.rs` (#105). +- `scheme::bit2::MAX_BLOCKS` (15): the most bytes the legacy scheme takes. + +### Changed + +- `OreEncrypt` no longer requires `T: OreCipher`, so the chained scheme (which is not an + `OreCipher`) can implement it. Existing impls and callers are unaffected. +- Every `OreEncrypt` impl macro now emits a round-trip and ordering test for the type it + implements, so a type can't be declared supported without being exercised (#105). + +### Fixed + +- `OreAes128` panicked with an index out of bounds on any 16-byte plaintext, which is what + the `u128`/`i128` impls on that scheme had always done (#105). Those impls are removed: the + legacy scheme cannot take 16 bytes, and the impl macro now rejects such a type at compile + time. Encrypting a `PlainText` with `N > 15` directly returns `OreError::TooManyBlocks` + instead of panicking. ## [0.9.0-rc.1] - 2026-10-12 diff --git a/packages/ore-rs/README.md b/packages/ore-rs/README.md index 11626c06..d73d42d5 100644 --- a/packages/ore-rs/README.md +++ b/packages/ore-rs/README.md @@ -21,7 +21,7 @@ Reference documentation is on [docs.rs/ore-rs](https://docs.rs/ore-rs). ## Supported plaintext types -`OreEncrypt` is implemented for `bool`, all integer widths (`u8`–`u128`, `i8`–`i128`), `char`, `f32`, and `f64`. `u128` and `i128` cannot be encrypted yet: `OreAes128` takes at most 15 bytes (its left-tag input puts the block index at byte `N` of one AES block), so encrypting a 16-byte value panics, and `OreAes128Bit6` covers types up to 64 bits. Two optional features extend this via the sibling [`orderable-bytes`](https://crates.io/crates/orderable-bytes) crate: +`OreEncrypt` is implemented for `bool`, integers up to 64 bits (`u8`–`u64`, `i8`–`i64`), `char`, `f32`, and `f64` on both `OreAes128` and `OreAes128Bit6`. `u128` and `i128` are implemented on the chained scheme only (`OreAes128Bit6Chained`, `scheme::chained`): at 16 bytes they exceed both the 15-byte limit of `OreAes128` and the 14-block packed prefix of `OreAes128Bit6`, and the chained scheme's CMAC accumulator has no length cap. Their ciphertexts are `ChainedCipherText<22>` / `ChainedLeft<22>`, which pin the block count so a typed comparator never orders them against a string. Two optional features extend this via the sibling [`orderable-bytes`](https://crates.io/crates/orderable-bytes) crate: - `chrono` — ORE support for `chrono::NaiveDate` and `chrono::DateTime` - `decimal` — ORE support for `rust_decimal::Decimal` @@ -83,7 +83,7 @@ Beyond the order and the first differing block of a compared pair, an `OreAes128 * 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. For new data, use `OreAes128Bit6` (`scheme::bit2_w6`, ORE v2), which binds the block index and the block count into its seeds. It covers types up to 64 bits; `Decimal` stays on `OreAes128` for now. `u128` and `i128` are supported by neither scheme (see [Supported plaintext types](#supported-plaintext-types)). 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). +The wire format of `OreAes128` is frozen, so this cannot change for existing data. For new data, use `OreAes128Bit6` (`scheme::bit2_w6`, ORE v2), which binds the block index and the block count into its seeds. It covers types up to 64 bits; `Decimal` stays on `OreAes128` for now. `u128` and `i128` use the chained scheme (see [Supported plaintext types](#supported-plaintext-types)). 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. diff --git a/packages/ore-rs/src/encrypt.rs b/packages/ore-rs/src/encrypt.rs index 3354a033..e58d4860 100644 --- a/packages/ore-rs/src/encrypt.rs +++ b/packages/ore-rs/src/encrypt.rs @@ -1,12 +1,13 @@ use crate::ciphertext::*; -use crate::scheme::bit2::OreAes128; +use crate::scheme::bit2::{OreAes128, MAX_BLOCKS}; use crate::PlainText; use crate::{OreCipher, OreError}; use orderable_bytes::{FixedOrderableBytes, OrderableBytes}; use rand::{Rng, SeedableRng}; /// Type-directed entry point for encrypting plaintext values with a given -/// [`OreCipher`]. +/// cipher: an [`OreCipher`], or the chained scheme +/// [`crate::scheme::chained::OreAes128Bit6Chained`]. /// /// Each implementation knows how to canonicalise its target type into the /// fixed-size byte plaintext expected by the cipher. For primitives and @@ -16,7 +17,7 @@ use rand::{Rng, SeedableRng}; /// comparison. The associated output types pin the resulting ciphertext /// shape, with `LeftOutput` the query-only half and `FullOutput` the full /// ciphertext suitable for storage. -pub trait OreEncrypt { +pub trait OreEncrypt { /// Output type produced by [`encrypt_left`](Self::encrypt_left). type LeftOutput: OreOutput; /// Output type produced by [`encrypt`](Self::encrypt). @@ -45,8 +46,6 @@ const U32_LEN: usize = ::ENCODED_LEN; const I32_LEN: usize = ::ENCODED_LEN; const U64_LEN: usize = ::ENCODED_LEN; const I64_LEN: usize = ::ENCODED_LEN; -const U128_LEN: usize = ::ENCODED_LEN; -const I128_LEN: usize = ::ENCODED_LEN; const CHAR_LEN: usize = ::ENCODED_LEN; const F32_LEN: usize = ::ENCODED_LEN; const F64_LEN: usize = ::ENCODED_LEN; @@ -58,8 +57,17 @@ const F64_LEN: usize = ::ENCODED_LEN; // were previously blanket impls over `T: OreCipher`; scheme-specific // impls keep coherence simple now that block count != byte count for // some schemes. +// +// Each invocation also emits a test that the type round-trips, orders `$lo` +// below `$hi`, and compares equal across nonces, so a type cannot be listed +// as supported without being exercised (issue #105). macro_rules! impl_ore_encrypt_via_orderable_bytes { - ($type:ty, $len_const:ident) => { + ($type:ty, $len_const:ident, $tests:ident, $lo:expr, $hi:expr) => { + // The legacy left-tag input puts the block index at byte `N` of one + // AES block, so the scheme takes at most 15 bytes. `u128`/`i128` + // (16 bytes) must not get these impls: see `scheme::chained`. + const _: () = assert!($len_const <= MAX_BLOCKS); + impl OreEncrypt> for $type { type LeftOutput = Left, $len_const>; type FullOutput = CipherText, $len_const>; @@ -72,23 +80,65 @@ macro_rules! impl_ore_encrypt_via_orderable_bytes { cipher.encrypt(&self.to_orderable_bytes()) } } + + #[cfg(test)] + mod $tests { + use super::*; + use crate::scheme::bit2::OreAes128ChaCha20; + use std::cmp::Ordering; + + #[test] + fn round_trip_order_and_equality() { + let ore: OreAes128ChaCha20 = OreCipher::init(&[0x11; 16], &[0x22; 16]).unwrap(); + let (lo, hi): ($type, $type) = ($lo, $hi); + assert!(lo < hi); + + let lo_ct = lo.encrypt(&ore).unwrap(); + let hi_ct = hi.encrypt(&ore).unwrap(); + let lo_left = lo.encrypt_left(&ore).unwrap(); + let lo_ct2 = lo.encrypt(&ore).unwrap(); + + let lo_bytes = lo_ct.to_bytes(); + let hi_bytes = hi_ct.to_bytes(); + assert_eq!( + CipherText::::from_slice(&lo_bytes) + .unwrap() + .to_bytes(), + lo_bytes + ); + assert_eq!( + Left::::from_slice(&lo_left.to_bytes()) + .unwrap() + .to_bytes(), + lo_left.to_bytes() + ); + + let cmp = OreAes128ChaCha20::compare_raw_slices; + assert_eq!(cmp(&lo_bytes, &hi_bytes), Some(Ordering::Less)); + assert_eq!(cmp(&hi_bytes, &lo_bytes), Some(Ordering::Greater)); + assert_ne!(lo_bytes, lo_ct2.to_bytes()); + assert_eq!(cmp(&lo_bytes, &lo_ct2.to_bytes()), Some(Ordering::Equal)); + // The typed comparator, where the scheme has one. + assert_eq!(lo_ct.cmp(&hi_ct), Ordering::Less); + } + } }; } -impl_ore_encrypt_via_orderable_bytes!(bool, BOOL_LEN); -impl_ore_encrypt_via_orderable_bytes!(u8, U8_LEN); -impl_ore_encrypt_via_orderable_bytes!(i8, I8_LEN); -impl_ore_encrypt_via_orderable_bytes!(u16, U16_LEN); -impl_ore_encrypt_via_orderable_bytes!(i16, I16_LEN); -impl_ore_encrypt_via_orderable_bytes!(u32, U32_LEN); -impl_ore_encrypt_via_orderable_bytes!(i32, I32_LEN); -impl_ore_encrypt_via_orderable_bytes!(u64, U64_LEN); -impl_ore_encrypt_via_orderable_bytes!(i64, I64_LEN); -impl_ore_encrypt_via_orderable_bytes!(u128, U128_LEN); -impl_ore_encrypt_via_orderable_bytes!(i128, I128_LEN); -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_ore_encrypt_via_orderable_bytes!(bool, BOOL_LEN, bool_tests, false, true); +impl_ore_encrypt_via_orderable_bytes!(u8, U8_LEN, u8_tests, 0u8, u8::MAX); +impl_ore_encrypt_via_orderable_bytes!(i8, I8_LEN, i8_tests, i8::MIN, i8::MAX); +impl_ore_encrypt_via_orderable_bytes!(u16, U16_LEN, u16_tests, 0u16, u16::MAX); +impl_ore_encrypt_via_orderable_bytes!(i16, I16_LEN, i16_tests, i16::MIN, i16::MAX); +impl_ore_encrypt_via_orderable_bytes!(u32, U32_LEN, u32_tests, 0u32, u32::MAX); +impl_ore_encrypt_via_orderable_bytes!(i32, I32_LEN, i32_tests, i32::MIN, i32::MAX); +impl_ore_encrypt_via_orderable_bytes!(u64, U64_LEN, u64_tests, 0u64, u64::MAX); +impl_ore_encrypt_via_orderable_bytes!(i64, I64_LEN, i64_tests, i64::MIN, i64::MAX); +// `u128`/`i128` (16 bytes) exceed this scheme's 15-byte limit and are +// provided by `scheme::chained` instead (issue #105). +impl_ore_encrypt_via_orderable_bytes!(char, CHAR_LEN, char_tests, '\0', char::MAX); +impl_ore_encrypt_via_orderable_bytes!(f32, F32_LEN, f32_tests, f32::MIN, f32::MAX); +impl_ore_encrypt_via_orderable_bytes!(f64, F64_LEN, f64_tests, f64::MIN, f64::MAX); impl OreEncrypt> for PlainText { type LeftOutput = Left, N>; diff --git a/packages/ore-rs/src/lib.rs b/packages/ore-rs/src/lib.rs index 28567270..c8501eba 100644 --- a/packages/ore-rs/src/lib.rs +++ b/packages/ore-rs/src/lib.rs @@ -18,8 +18,10 @@ //! ## Example: Encrypt a number with ORE. //! //! To encrypt a number you need to initialize an [`OreCipher`] as well as `use` the [`OreEncrypt`] trait, -//! which comes with implementations for `bool`, all integer widths (`u8`–`u128`, `i8`–`i128`), `char`, +//! which comes with implementations for `bool`, integers up to 64 bits (`u8`–`u64`, `i8`–`i64`), `char`, //! `f32` and `f64` (plus `chrono` and `rust_decimal` types behind the `chrono`/`decimal` features). +//! `u128` and `i128` are too wide for the fixed-width schemes and are encrypted with the +//! chained scheme instead: see [`scheme::chained`]. //! //! To initialize the Cipher, you must decide on the scheme you want to use. There is only one ORE //! Scheme right now so that's easy but in the future more schemes will become available. diff --git a/packages/ore-rs/src/scheme/bit2.rs b/packages/ore-rs/src/scheme/bit2.rs index 42e0abd4..d8f229ab 100644 --- a/packages/ore-rs/src/scheme/bit2.rs +++ b/packages/ore-rs/src/scheme/bit2.rs @@ -107,6 +107,14 @@ pub(crate) fn encode_right_block( prp.indicator_mask_xor(x, out); } +/// Maximum number of blocks (= plaintext bytes): the left-tag input packs +/// `prefix ‖ xt ‖ index@N` into one 16-byte AES block, so `N ≤ 15`. A +/// 16-byte plaintext (`u128`/`i128`) used to index past the block and panic +/// (issue #105); `encrypt`/`encrypt_left` now return +/// [`OreError::TooManyBlocks`], and the `OreEncrypt` impls refuse such types +/// at compile time. +pub const MAX_BLOCKS: usize = 15; + 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 @@ -150,6 +158,9 @@ impl OreCipher for OreAes128 { } fn encrypt_left(&self, x: &PlainText) -> EncryptLeftResult { + if N > MAX_BLOCKS { + return Err(OreError::TooManyBlocks); + } let mut output = Left::::init(); let seeds = derive_prp_seeds(&self.prf2, x); @@ -160,6 +171,9 @@ impl OreCipher for OreAes128 { } fn encrypt(&self, x: &PlainText) -> EncryptResult { + if N > MAX_BLOCKS { + return Err(OreError::TooManyBlocks); + } let mut left = Left::::init(); let mut right = Right::::init(); @@ -376,6 +390,23 @@ mod tests { type Ore = OreAes128ChaCha20; + /// A 16-byte plaintext does not fit the left-tag input (issue #105): an + /// error, not an out-of-bounds panic. 15 bytes is the most the scheme takes. + #[test] + fn sixteen_bytes_is_an_error_not_a_panic() { + let ore = init_ore(); + assert!(matches!( + ore.encrypt(&[0u8; 16]), + Err(OreError::TooManyBlocks) + )); + assert!(matches!( + ore.encrypt_left(&[0u8; 16]), + Err(OreError::TooManyBlocks) + )); + assert!(ore.encrypt(&[0u8; 15]).is_ok()); + assert!(ore.encrypt_left(&[0u8; 15]).is_ok()); + } + fn init_ore() -> Ore { let mut k1: [u8; 16] = Default::default(); let mut k2: [u8; 16] = Default::default(); diff --git a/packages/ore-rs/src/scheme/bit2_w6.rs b/packages/ore-rs/src/scheme/bit2_w6.rs index e24308fb..0fe4d5a1 100644 --- a/packages/ore-rs/src/scheme/bit2_w6.rs +++ b/packages/ore-rs/src/scheme/bit2_w6.rs @@ -12,8 +12,9 @@ //! the block count in byte 15 must fit one AES block; seeds also use byte //! 14 for the block index), which covers all //! primitives up to 64 bits. `Decimal` stays on the legacy scheme until the -//! chained-prefix construction lands (plan §5). `u128`/`i128` fit neither: -//! the legacy scheme takes at most 15 bytes and panics on 16. +//! chained-prefix construction lands (plan §5). `u128`/`i128` (22 blocks) +//! fit neither this scheme nor the legacy one (15 bytes at most); they are +//! encrypted with [`crate::scheme::chained`], which has no length cap. //! //! **Status: wire format NOT yet frozen.** The Z2 hash is the fixed-π MMO //! construction proposed in plan §6 (option 3), pending crypto review; @@ -424,8 +425,11 @@ mod encrypt_impls { use orderable_bytes::{FixedOrderableBytes, OrderableBytes}; use rand::{Rng, SeedableRng}; + // Each invocation also emits a test that the type round-trips, orders + // `$lo` below `$hi`, and compares equal across nonces, so a type cannot + // be listed as supported without being exercised (issue #105). macro_rules! impl_ore_encrypt_bit6 { - ($type:ty, $blocks_const:ident) => { + ($type:ty, $blocks_const:ident, $tests:ident, $lo:expr, $hi:expr) => { /// Block count for this type at 6-bit width. const $blocks_const: usize = num_blocks_6bit(<$type as FixedOrderableBytes>::ENCODED_LEN); @@ -458,21 +462,68 @@ mod encrypt_impls { }) } } + + #[cfg(test)] + mod $tests { + use super::*; + use crate::scheme::bit2_w6::OreAes128Bit6ChaCha20; + use crate::{OreCipher, OreOutput}; + use std::cmp::Ordering; + + #[test] + fn round_trip_order_and_equality() { + let ore: OreAes128Bit6ChaCha20 = + OreCipher::init(&[0x11; 16], &[0x22; 16]).unwrap(); + let (lo, hi): ($type, $type) = ($lo, $hi); + assert!(lo < hi); + + let lo_ct = lo.encrypt(&ore).unwrap(); + let hi_ct = hi.encrypt(&ore).unwrap(); + let lo_left = lo.encrypt_left(&ore).unwrap(); + let lo_ct2 = lo.encrypt(&ore).unwrap(); + + let lo_bytes = lo_ct.to_bytes(); + let hi_bytes = hi_ct.to_bytes(); + assert_eq!( + CipherText::::from_slice(&lo_bytes) + .unwrap() + .to_bytes(), + lo_bytes + ); + assert_eq!( + Left::::from_slice( + &lo_left.to_bytes() + ) + .unwrap() + .to_bytes(), + lo_left.to_bytes() + ); + + let cmp = OreAes128Bit6ChaCha20::compare_raw_slices; + assert_eq!(cmp(&lo_bytes, &hi_bytes), Some(Ordering::Less)); + assert_eq!(cmp(&hi_bytes, &lo_bytes), Some(Ordering::Greater)); + assert_ne!(lo_bytes, lo_ct2.to_bytes()); + assert_eq!(cmp(&lo_bytes, &lo_ct2.to_bytes()), Some(Ordering::Equal)); + assert_eq!(lo_ct.cmp(&hi_ct), Ordering::Less); + } + } }; } - 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); + impl_ore_encrypt_bit6!(bool, BOOL_BLOCKS, bool_tests, false, true); + impl_ore_encrypt_bit6!(u8, U8_BLOCKS, u8_tests, 0u8, u8::MAX); + impl_ore_encrypt_bit6!(i8, I8_BLOCKS, i8_tests, i8::MIN, i8::MAX); + impl_ore_encrypt_bit6!(u16, U16_BLOCKS, u16_tests, 0u16, u16::MAX); + impl_ore_encrypt_bit6!(i16, I16_BLOCKS, i16_tests, i16::MIN, i16::MAX); + impl_ore_encrypt_bit6!(u32, U32_BLOCKS, u32_tests, 0u32, u32::MAX); + impl_ore_encrypt_bit6!(i32, I32_BLOCKS, i32_tests, i32::MIN, i32::MAX); + impl_ore_encrypt_bit6!(u64, U64_BLOCKS, u64_tests, 0u64, u64::MAX); + impl_ore_encrypt_bit6!(i64, I64_BLOCKS, i64_tests, i64::MIN, i64::MAX); + // `u128`/`i128` (22 blocks) exceed `MAX_BLOCKS` and are provided by + // `scheme::chained` instead (issue #105). + impl_ore_encrypt_bit6!(char, CHAR_BLOCKS, char_tests, '\0', char::MAX); + impl_ore_encrypt_bit6!(f32, F32_BLOCKS, f32_tests, f32::MIN, f32::MAX); + impl_ore_encrypt_bit6!(f64, F64_BLOCKS, f64_tests, f64::MIN, f64::MAX); } #[cfg(test)] diff --git a/packages/ore-rs/src/scheme/chained.rs b/packages/ore-rs/src/scheme/chained.rs index 6243cbdc..fff5acc0 100644 --- a/packages/ore-rs/src/scheme/chained.rs +++ b/packages/ore-rs/src/scheme/chained.rs @@ -67,6 +67,31 @@ //! string never produces, unpadded), but raw symbols cannot be padded with //! zero symbols, since a trailing zero symbol is a real value there, so they //! are not offered as an API (#110). +//! **Fixed-length types.** `u128` and `i128` (22 blocks) are too wide for +//! the packed prefix of `OreAes128Bit6` and for the legacy scheme, so their +//! [`crate::OreEncrypt`] impls live here. The outputs, [`crate::scheme::chained::ChainedCipherText`] and +//! [`crate::scheme::chained::ChainedLeft`], carry the block count in the type: their parsers and +//! comparators take only that count, so an integer ciphertext is never +//! ordered against a string by the typed path. A fixed-length value is +//! not padded (every value of the type has the same length, so there is +//! nothing to hide) and is not subject to the cipher's string ceiling; its +//! ciphertext is tagged [`Padding::None`](crate::scheme::chained::Padding::None), +//! and the typed parsers accept no other tag. +//! +//! ```rust +//! use ore_rs::scheme::chained::{ChainedCipherText, OreAes128Bit6ChainedChaCha20, U128_BLOCKS}; +//! use ore_rs::{OreEncrypt, OreOutput}; +//! use std::cmp::Ordering; +//! +//! let ore = OreAes128Bit6ChainedChaCha20::init(&[0u8; 16]).unwrap(); +//! let a = 1u128.encrypt(&ore).unwrap().to_bytes(); +//! let b = u128::MAX.encrypt(&ore).unwrap().to_bytes(); +//! assert_eq!( +//! ChainedCipherText::::compare_raw_slices(&a, &b), +//! Some(Ordering::Less) +//! ); +//! ``` +//! //! //! **Stored rows compare offline.** A full ciphertext carries its //! deterministic left half (`xt`, `f`), so anyone holding two stored full @@ -77,7 +102,7 @@ use std::cell::RefCell; use std::cmp::Ordering; -use std::convert::TryFrom; +use std::convert::{TryFrom, TryInto}; use rand::{Rng, SeedableRng}; use rand_chacha::ChaCha20Rng; @@ -87,14 +112,16 @@ use zeroize::{Zeroize, Zeroizing}; use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit}; use aes::Aes128Enc; -use crate::ciphertext::{parse_header, ParseError, WireHeader, WIRE_HEADER_LEN}; +use orderable_bytes::FixedOrderableBytes; + +use crate::ciphertext::{parse_header, OreOutput, ParseError, WireHeader, WIRE_HEADER_LEN}; 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::{stack, AesBlock, Hash, HashKey, Prp}; use crate::scheme::decompose::{num_blocks_6bit, Blocks6}; use crate::scheme::width::FirstDiff; -use crate::OreError; +use crate::{OreEncrypt, OreError}; const VERSION: u8 = 0x02; const SCHEME_ID: u8 = 0x03; @@ -406,6 +433,31 @@ impl VarLeft { pub fn padding(&self) -> Padding { self.padding } + + /// Parse the bytes produced by [`Self::to_bytes`]. + pub fn from_slice(data: &[u8]) -> Result { + let (count, padding) = parse_chained_header(data).ok_or(ParseError)?; + if data.len() != left_len(count) { + return Err(ParseError); + } + if !symbols_in_domain(data, count) { + return Err(ParseError); + } + Ok(parse_left_view(data, count, padding)) + } +} + +/// Read `xt` and `f` out of a validated left view (a `VarLeft` or the +/// prefix of a full ciphertext) of `count` blocks, tagged `padding`. +fn parse_left_view(data: &[u8], count: usize, padding: Padding) -> VarLeft { + 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); + } + VarLeft { xt, f, padding } } impl VarCipherText { @@ -441,13 +493,7 @@ impl VarCipherText { if !symbols_in_domain(data, 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 left = parse_left_view(data, count, padding); let mut nonce = [0u8; NONCE_LEN]; nonce.copy_from_slice(nonce_at(data, count)); let mut blocks = Vec::with_capacity(count); @@ -457,7 +503,7 @@ impl VarCipherText { blocks.push(rb); } Ok(VarCipherText { - left: VarLeft { xt, f, padding }, + left, right: VarRight { nonce, blocks }, }) } @@ -711,6 +757,39 @@ impl OreAes128Bit6Chained { }) } + /// Encrypt a fixed-length value (`u128`, `i128`, `Decimal`, …) through + /// its order-preserving [`FixedOrderableBytes`] encoding. This is how + /// types too wide for the packed prefix of `OreAes128Bit6` (more than + /// 14 blocks) get a 6-bit-block ciphertext: the accumulator has no + /// length cap. + /// + /// Every value of one type encodes to the same number of bytes, so the + /// lexicographic comparators order these ciphertexts as the plaintexts. + /// The encoding is not padded (there is no length to hide) and the + /// cipher's [`Options`] do not apply: the ciphertext is tagged + /// [`Padding::None`], and the string ceiling is not checked. + /// The output is an untyped [`VarCipherText`]; to keep a type's + /// ciphertexts from being compared against strings or other types, use + /// the [`OreEncrypt`] impls, whose [`ChainedCipherText`] output pins the + /// block count. + pub fn encrypt_fixed(&self, x: &T) -> Result { + // The encoding is a copy of the plaintext in this frame, so it is + // made inside the scrub. + stack::scrubbed(|| { + let bytes = x.to_fixed_orderable_bytes(); + self.encrypt_blocks(&bytes_to_blocks(bytes.as_ref())?, Padding::None) + }) + } + + /// Left-only ciphertext of a fixed-length value; see + /// [`Self::encrypt_fixed`]. + pub fn encrypt_left_fixed(&self, x: &T) -> Result { + stack::scrubbed(|| { + let bytes = x.to_fixed_orderable_bytes(); + self.encrypt_left_blocks(&bytes_to_blocks(bytes.as_ref())?, Padding::None) + }) + } + /// Compare two serialised full ciphertexts (lexicographic; shorter prefix /// sorts first). `None` if either is not a well-formed ciphertext of this /// scheme, or if they were padded under different policies. @@ -804,6 +883,232 @@ fn symbols_in_domain(view: &[u8], count: usize) -> bool { (0..count).all(|i| usize::from(xt_at(view, i)) < DOMAIN) } +/// The block count of a serialised view whose header is this scheme's and +/// whose padding tag is [`Padding::None`], the only tag the fixed-length +/// path writes; `None` for any other header. +fn unpadded_count(view: &[u8]) -> Option { + let (count, padding) = parse_chained_header(view)?; + (padding == Padding::None).then_some(count) +} + +// --------------------------------------------------------------------------- +// Fixed-length plaintexts: block-count-typed outputs and `OreEncrypt` impls. +// --------------------------------------------------------------------------- + +/// Left half of a chained ciphertext for a fixed-length plaintext of exactly +/// `BLOCKS` 6-bit blocks (a `u128` or `i128` is [`U128_BLOCKS`] = 22). The +/// bytes are those of an unpadded [`VarLeft`] of that length — same wire +/// format, same scheme id, tagged [`Padding::None`] — so the untyped +/// comparators accept them. The type pins the block count: +/// [`OreOutput::from_slice`] and the typed comparators on +/// [`ChainedCipherText`] reject any other count, and any padded ciphertext, +/// which keeps a fixed-length type's ciphertexts out of the lexicographic +/// (shorter-first) ordering the scheme applies across lengths. +#[derive(Clone, Debug)] +pub struct ChainedLeft(VarLeft); + +/// Full chained ciphertext for a fixed-length plaintext of exactly `BLOCKS` +/// 6-bit blocks; see [`ChainedLeft`]. +#[derive(Clone, Debug)] +pub struct ChainedCipherText(VarCipherText); + +impl ChainedLeft { + /// The untyped ciphertext. + pub fn as_var(&self) -> &VarLeft { + &self.0 + } + + /// The untyped ciphertext. + pub fn into_var(self) -> VarLeft { + self.0 + } +} + +impl ChainedCipherText { + /// The untyped ciphertext. + pub fn as_var(&self) -> &VarCipherText { + &self.0 + } + + /// The untyped ciphertext. + pub fn into_var(self) -> VarCipherText { + self.0 + } + + /// Compare two serialised full ciphertexts of exactly `BLOCKS` blocks, + /// both unpadded. `None` if either is malformed, padded, or has another + /// block count, where [`OreAes128Bit6Chained::compare_raw_slices`] + /// would order them lexicographically. An unpadded 16-byte string is + /// also 22 blocks: the count is a guard against mixing types, not a + /// proof of the plaintext type. + pub fn compare_raw_slices(a: &[u8], b: &[u8]) -> Option { + if unpadded_count(a)? != BLOCKS || unpadded_count(b)? != BLOCKS { + return None; + } + OreAes128Bit6ChainedChaCha20::compare_raw_slices(a, b) + } + + /// Compare a serialised [`ChainedLeft`] against a serialised full + /// ciphertext, both of exactly `BLOCKS` blocks; see + /// [`Self::compare_raw_slices`] and + /// [`OreAes128Bit6Chained::compare_left_to_full`]. + pub fn compare_left_to_full(left: &[u8], full: &[u8]) -> Option { + if unpadded_count(left)? != BLOCKS || unpadded_count(full)? != BLOCKS { + return None; + } + OreAes128Bit6ChainedChaCha20::compare_left_to_full(left, full) + } +} + +impl From> for VarLeft { + fn from(left: ChainedLeft) -> Self { + left.0 + } +} + +impl From> for VarCipherText { + fn from(ct: ChainedCipherText) -> Self { + ct.0 + } +} + +/// Succeeds only for an unpadded ciphertext of exactly `BLOCKS` blocks. +impl TryFrom for ChainedLeft { + type Error = ParseError; + fn try_from(left: VarLeft) -> Result { + if left.xt.len() != BLOCKS || left.padding != Padding::None { + return Err(ParseError); + } + Ok(Self(left)) + } +} + +/// Succeeds only for an unpadded ciphertext of exactly `BLOCKS` blocks. +impl TryFrom for ChainedCipherText { + type Error = ParseError; + fn try_from(ct: VarCipherText) -> Result { + if ct.left.xt.len() != BLOCKS || ct.left.padding != Padding::None { + return Err(ParseError); + } + Ok(Self(ct)) + } +} + +impl OreOutput for ChainedLeft { + fn size() -> usize { + left_len(BLOCKS) + } + + fn to_bytes(&self) -> Vec { + self.0.to_bytes() + } + + fn from_slice(data: &[u8]) -> Result { + VarLeft::from_slice(data)?.try_into() + } +} + +impl OreOutput for ChainedCipherText { + fn size() -> usize { + total_len(BLOCKS) + } + + fn to_bytes(&self) -> Vec { + self.0.to_bytes() + } + + fn from_slice(data: &[u8]) -> Result { + VarCipherText::from_slice(data)?.try_into() + } +} + +/// `OreEncrypt` for a fixed-length type on the chained scheme. Each +/// invocation also emits a test that the type round-trips, orders `$lo` +/// below `$hi`, and compares equal across nonces, so a type cannot be +/// listed as supported without being exercised (issue #105). +macro_rules! impl_ore_encrypt_chained { + ($type:ty, $blocks_const:ident, $tests:ident, $lo:expr, $hi:expr) => { + /// Block count of this type on the chained scheme (6-bit blocks). + pub const $blocks_const: usize = + num_blocks_6bit(<$type as FixedOrderableBytes>::ENCODED_LEN); + + impl OreEncrypt> for $type { + type LeftOutput = ChainedLeft<$blocks_const>; + type FullOutput = ChainedCipherText<$blocks_const>; + + fn encrypt_left( + &self, + cipher: &OreAes128Bit6Chained, + ) -> Result { + cipher.encrypt_left_fixed(self).map(ChainedLeft) + } + + fn encrypt( + &self, + cipher: &OreAes128Bit6Chained, + ) -> Result { + cipher.encrypt_fixed(self).map(ChainedCipherText) + } + } + + #[cfg(test)] + mod $tests { + use super::*; + + #[test] + fn round_trip_order_and_equality() { + let ore = OreAes128Bit6ChainedChaCha20::init(&[0x11; 16]).unwrap(); + let (lo, hi): ($type, $type) = ($lo, $hi); + assert!(lo < hi); + + let lo_ct = lo.encrypt(&ore).unwrap(); + let hi_ct = hi.encrypt(&ore).unwrap(); + let lo_left = lo.encrypt_left(&ore).unwrap(); + let lo_ct2 = lo.encrypt(&ore).unwrap(); + + let lo_bytes = lo_ct.to_bytes(); + let hi_bytes = hi_ct.to_bytes(); + assert_eq!(lo_bytes.len(), ChainedCipherText::<$blocks_const>::size()); + assert_eq!( + lo_left.to_bytes().len(), + ChainedLeft::<$blocks_const>::size() + ); + assert_eq!( + ChainedCipherText::<$blocks_const>::from_slice(&lo_bytes) + .unwrap() + .to_bytes(), + lo_bytes + ); + assert_eq!( + ChainedLeft::<$blocks_const>::from_slice(&lo_left.to_bytes()) + .unwrap() + .to_bytes(), + lo_left.to_bytes() + ); + + let cmp = ChainedCipherText::<$blocks_const>::compare_raw_slices; + assert_eq!(cmp(&lo_bytes, &hi_bytes), Some(Ordering::Less)); + assert_eq!(cmp(&hi_bytes, &lo_bytes), Some(Ordering::Greater)); + assert_ne!(lo_bytes, lo_ct2.to_bytes()); + assert_eq!(cmp(&lo_bytes, &lo_ct2.to_bytes()), Some(Ordering::Equal)); + assert_eq!( + ChainedCipherText::<$blocks_const>::compare_left_to_full( + &lo_left.to_bytes(), + &hi_bytes + ), + Some(Ordering::Less) + ); + } + } + }; +} + +// `u128`/`i128` are 22 blocks, past the 14-block packed prefix of +// `OreAes128Bit6` and the 15-byte limit of the legacy scheme; the chained +// scheme is the only one that takes them. +impl_ore_encrypt_chained!(u128, U128_BLOCKS, u128_tests, 0u128, u128::MAX); +impl_ore_encrypt_chained!(i128, I128_BLOCKS, i128_tests, i128::MIN, i128::MAX); + /// Longest string, in UTF-8 bytes, whose block count fits the wire format's /// `u16`: `⌈8n/6⌉ ≤ 65_535` gives `n ≤ 49_151`. pub const MAX_STR_BYTES: usize = u16::MAX as usize * 6 / 8; @@ -845,6 +1150,20 @@ fn str_to_blocks(s: &str, options: &Options) -> Result )) } +/// Decompose `bytes` into 6-bit blocks, unpadded, refusing input too long +/// for the wire format *before* allocating or decomposing it. The fixed- +/// length path: the decomposition is the whole plaintext (it inverts), so +/// the buffer is wiped when dropped. +fn bytes_to_blocks(bytes: &[u8]) -> Result>>, OreError> { + if bytes.len() > MAX_STR_BYTES { + return Err(OreError::TooManyBlocks); + } + Ok(Blocks6::decompose( + bytes, + Zeroizing::new(vec![0u8; num_blocks_6bit(bytes.len())]), + )) +} + /// Refuse a string that ends in NUL: padded, it would encrypt to the same /// blocks as the string without it. One branch on the last byte, which /// reveals only whether the encryption is refused. @@ -1607,6 +1926,119 @@ mod tests { assert_eq!(cmp(&c, long_a, long_a), Ordering::Equal); } + /// A typed (fixed-length) ciphertext is a 22-block `Var*` on the wire, + /// so the untyped comparators order it against anything; the typed + /// parsers and comparators take only the pinned block count. + #[test] + fn fixed_length_outputs_pin_the_block_count() { + let c = ore(); + let n = 7u128.encrypt(&c).unwrap(); + let n_left = 7u128.encrypt_left(&c).unwrap(); + // Strings are unpadded here: a padded one carries another policy + // tag, which every comparator refuses before looking at the count. + let unpadded = ore_with(Padding::None); + let s = unpadded.encrypt_str("alice").unwrap(); // 7 blocks + // 16 bytes = 22 blocks; and the same string padded (to 16 bytes). + let s16 = unpadded.encrypt_str("0123456789abcdef").unwrap(); + let s16_padded = c.encrypt_str("0123456789abcdef").unwrap(); + assert_eq!(s16_padded.left.xt.len(), U128_BLOCKS); + assert_eq!(n.as_var().left.xt.len(), U128_BLOCKS); + assert_eq!(U128_BLOCKS, 22); + assert_eq!(I128_BLOCKS, 22); + + // Same bytes as the untyped artefact of the same value. + assert_eq!( + VarCipherText::from_slice(&n.to_bytes()).unwrap().to_bytes(), + n.to_bytes() + ); + assert_eq!( + VarLeft::from_slice(&n_left.to_bytes()).unwrap().to_bytes(), + n_left.to_bytes() + ); + + // Untyped: lexicographic across lengths. Typed: refused. + assert!( + OreAes128Bit6ChainedChaCha20::compare_raw_slices(&n.to_bytes(), &s.to_bytes()) + .is_some() + ); + let cmp = ChainedCipherText::::compare_raw_slices; + assert_eq!(cmp(&n.to_bytes(), &s.to_bytes()), None); + assert_eq!(cmp(&s.to_bytes(), &n.to_bytes()), None); + assert_eq!( + ChainedCipherText::::compare_left_to_full( + &n_left.to_bytes(), + &s.to_bytes() + ), + None + ); + assert_eq!( + ChainedCipherText::::compare_left_to_full( + &unpadded.encrypt_left_str("alice").unwrap().to_bytes(), + &n.to_bytes() + ), + None + ); + // The count is the guard, not the type: an unpadded 16-byte string + // has 22 blocks too and passes. A padded one of 22 blocks does not. + assert!(cmp(&n.to_bytes(), &s16.to_bytes()).is_some()); + assert_eq!(cmp(&n.to_bytes(), &s16_padded.to_bytes()), None); + assert!(ChainedCipherText::::from_slice(&s16_padded.to_bytes()).is_err()); + assert!(ChainedCipherText::::try_from(s16_padded.clone()).is_err()); + assert!(ChainedCipherText::::from_slice(&s16.to_bytes()).is_ok()); + + // Typed parsers and conversions reject other counts. + assert!(ChainedCipherText::::from_slice(&s.to_bytes()).is_err()); + assert!(ChainedLeft::::from_slice( + &unpadded.encrypt_left_str("alice").unwrap().to_bytes() + ) + .is_err()); + assert!(ChainedCipherText::::try_from(s.clone()).is_err()); + assert!(ChainedCipherText::::try_from(n.clone().into_var()).is_ok()); + assert!(ChainedCipherText::<23>::from_slice(&n.to_bytes()).is_err()); + assert!(ChainedLeft::<23>::from_slice(&n_left.to_bytes()).is_err()); + } + + /// `encrypt_fixed` is what the `OreEncrypt` impls call: same bytes for + /// the left half, and the decomposition of the orderable encoding. + #[test] + fn encrypt_fixed_matches_the_typed_path() { + let c = ore(); + let x = 0x0123_4567_89ab_cdef_u128 << 64 | 0xfedc_ba98_7654_3210; + assert_eq!( + c.encrypt_left_fixed(&x).unwrap().to_bytes(), + x.encrypt_left(&c).unwrap().to_bytes() + ); + let blocks = bytes_to_blocks(&x.to_be_bytes()).unwrap(); + assert_eq!( + c.encrypt_left_var(blocks.as_bytes()).unwrap().to_bytes(), + x.encrypt_left(&c).unwrap().to_bytes() + ); + // Untyped output of the fixed path parses as the typed output. + let ct = c.encrypt_fixed(&x).unwrap(); + assert!(ChainedCipherText::::from_slice(&ct.to_bytes()).is_ok()); + } + + #[test] + fn signed_128_orders_across_zero() { + let c = ore(); + let vals = [i128::MIN, -1, 0, 1, i128::MAX]; + let cts: Vec> = vals + .iter() + .map(|v| v.encrypt(&c).unwrap().to_bytes()) + .collect(); + for (i, a) in cts.iter().enumerate() { + for (j, b) in cts.iter().enumerate() { + assert_eq!( + ChainedCipherText::::compare_raw_slices(a, b), + Some(vals[i].cmp(&vals[j])), + "{} vs {}", + vals[i], + vals[j] + ); + } + } + } + #[test] fn cross_scheme_bytes_rejected() { let c = ore(); @@ -1834,6 +2266,24 @@ mod tests { /// Two encryptions of one plaintext (fresh nonces each) compare Equal, /// and never produce identical bytes (the nonce streams differ). + /// Typed `u128` ciphertexts order as the integers, on both the full + /// and the left-query path. + fn prop_u128_order(a: u128, b: u128) -> bool { + let c = ore(); + let (ca, cb) = (a.encrypt(&c).unwrap().to_bytes(), b.encrypt(&c).unwrap().to_bytes()); + let la = a.encrypt_left(&c).unwrap().to_bytes(); + ChainedCipherText::::compare_raw_slices(&ca, &cb) == Some(a.cmp(&b)) + && ChainedCipherText::::compare_left_to_full(&la, &cb) == Some(a.cmp(&b)) + } + + fn prop_i128_order(a: i128, b: i128) -> bool { + let c = ore(); + let (ca, cb) = (a.encrypt(&c).unwrap().to_bytes(), b.encrypt(&c).unwrap().to_bytes()); + let la = a.encrypt_left(&c).unwrap().to_bytes(); + ChainedCipherText::::compare_raw_slices(&ca, &cb) == Some(a.cmp(&b)) + && ChainedCipherText::::compare_left_to_full(&la, &cb) == Some(a.cmp(&b)) + } + fn prop_equality_across_nonces(x: Vec) -> bool { let c = ore(); let x = to_domain(&x); diff --git a/packages/ore-rs/tests/compat_chained_vectors.rs b/packages/ore-rs/tests/compat_chained_vectors.rs index a4b333c4..e37963c3 100644 --- a/packages/ore-rs/tests/compat_chained_vectors.rs +++ b/packages/ore-rs/tests/compat_chained_vectors.rs @@ -24,8 +24,10 @@ //! ``` use ore_rs::scheme::chained::{ - Options, OreAes128Bit6Chained, OreAes128Bit6ChainedChaCha20, Padding, VarCipherText, + ChainedCipherText, ChainedLeft, Options, OreAes128Bit6Chained, OreAes128Bit6ChainedChaCha20, + Padding, VarCipherText, I128_BLOCKS, U128_BLOCKS, }; +use ore_rs::{OreEncrypt, OreOutput}; use rand::{RngCore, SeedableRng}; use rand_chacha::ChaCha20Rng; use std::cmp::Ordering; @@ -117,6 +119,11 @@ const STRINGS: &[&str] = &[ "bob", ]; +/// Fixed-length integers pinned through the `OreEncrypt` impls (22 blocks +/// each): the domain edges, zero, and a value with every byte distinct. +const U128S: &[u128] = &[0, 1, 0x0123_4567_89ab_cdef_fedc_ba98_7654_3210, u128::MAX]; +const I128S: &[i128] = &[i128::MIN, -1, 0, 1, i128::MAX]; + #[path = "compat_chained_vectors/vectors.rs"] mod vectors; use vectors::*; @@ -183,6 +190,127 @@ fn full_str_vectors_other_policies() { } } +#[test] +fn left_u128_vectors() { + let ore = cipher_left(); + for (x, expected) in LEFT_U128 { + let left = x.encrypt_left(&ore).unwrap(); + assert_eq!( + hex::encode(left.to_bytes()), + *expected, + "left mismatch for {x}" + ); + assert_eq!(left.to_bytes().len(), ChainedLeft::::size()); + } + for (x, expected) in LEFT_I128 { + let left = x.encrypt_left(&ore).unwrap(); + assert_eq!( + hex::encode(left.to_bytes()), + *expected, + "left mismatch for {x}" + ); + assert_eq!(left.to_bytes().len(), ChainedLeft::::size()); + } +} + +#[test] +fn full_u128_vectors() { + for (x, expected) in FULL_U128 { + let ct = x.encrypt(&cipher_a()).unwrap(); + assert_eq!( + hex::encode(ct.to_bytes()), + *expected, + "full mismatch for {x}" + ); + assert_eq!( + ct.to_bytes().len(), + ChainedCipherText::::size() + ); + } + for (x, expected) in FULL_I128 { + let ct = x.encrypt(&cipher_a()).unwrap(); + assert_eq!( + hex::encode(ct.to_bytes()), + *expected, + "full mismatch for {x}" + ); + assert_eq!( + ct.to_bytes().len(), + ChainedCipherText::::size() + ); + } +} + +/// Pinned integer ciphertexts order as the integers under the typed +/// comparators, and parse back through the typed parsers. +#[test] +fn compare_u128_vectors_matches_integer_order() { + for (xa, ha) in FULL_U128 { + for (xb, hb) in FULL_U128 { + assert_eq!( + ChainedCipherText::::compare_raw_slices(&pinned(ha), &pinned(hb)), + Some(xa.cmp(xb)), + "u128 {xa} vs {xb}" + ); + } + for (xl, hl) in LEFT_U128 { + assert_eq!( + ChainedCipherText::::compare_left_to_full(&pinned(hl), &pinned(ha)), + Some(xl.cmp(xa)), + "u128 left {xl} vs {xa}" + ); + } + let ct = ChainedCipherText::::from_slice(&pinned(ha)).unwrap(); + assert_eq!(ct.to_bytes(), pinned(ha)); + } + for (xa, ha) in FULL_I128 { + for (xb, hb) in FULL_I128 { + assert_eq!( + ChainedCipherText::::compare_raw_slices(&pinned(ha), &pinned(hb)), + Some(xa.cmp(xb)), + "i128 {xa} vs {xb}" + ); + } + for (xl, hl) in LEFT_I128 { + assert_eq!( + ChainedCipherText::::compare_left_to_full(&pinned(hl), &pinned(ha)), + Some(xl.cmp(xa)), + "i128 left {xl} vs {xa}" + ); + } + } +} + +/// The typed comparator refuses a string ciphertext of another block count; +/// the untyped one orders an unpadded one lexicographically. A padded +/// string carries another policy tag, which both refuse. +#[test] +fn typed_comparator_rejects_other_block_counts() { + let n = pinned(FULL_U128[0].1); + let (alice, unpadded) = FULL_STR_UNPADDED + .iter() + .find(|(s, _)| *s == "alice") + .map(|(s, h)| (*s, pinned(h))) + .unwrap(); + assert_eq!(alice, "alice"); + assert_eq!( + ChainedCipherText::::compare_raw_slices(&n, &unpadded), + None + ); + assert!(OreAes128Bit6ChainedChaCha20::compare_raw_slices(&n, &unpadded).is_some()); + assert!(ChainedCipherText::::from_slice(&unpadded).is_err()); + + let padded = pinned(full_alice()); + assert_eq!( + ChainedCipherText::::compare_raw_slices(&n, &padded), + None + ); + assert_eq!( + OreAes128Bit6ChainedChaCha20::compare_raw_slices(&n, &padded), + None + ); +} + #[test] fn full_alice_alternate_nonce() { let ct = cipher_b().encrypt_str("alice").unwrap(); @@ -487,4 +615,36 @@ fn generate() { } println!("];"); } + println!(); + + println!("pub const LEFT_U128: &[(u128, &str)] = &["); + for x in U128S { + let left = x.encrypt_left(&ore).unwrap(); + println!(" ({x}, \"{}\"),", hex::encode(left.to_bytes())); + } + println!("];"); + println!(); + + println!("pub const LEFT_I128: &[(i128, &str)] = &["); + for x in I128S { + let left = x.encrypt_left(&ore).unwrap(); + println!(" ({x}, \"{}\"),", hex::encode(left.to_bytes())); + } + println!("];"); + println!(); + + println!("pub const FULL_U128: &[(u128, &str)] = &["); + for x in U128S { + let ct = x.encrypt(&cipher_a()).unwrap(); + println!(" ({x}, \"{}\"),", hex::encode(ct.to_bytes())); + } + println!("];"); + println!(); + + println!("pub const FULL_I128: &[(i128, &str)] = &["); + for x in I128S { + let ct = x.encrypt(&cipher_a()).unwrap(); + println!(" ({x}, \"{}\"),", hex::encode(ct.to_bytes())); + } + println!("];"); } diff --git a/packages/ore-rs/tests/compat_chained_vectors/vectors.rs b/packages/ore-rs/tests/compat_chained_vectors/vectors.rs index 33b80380..9fefc471 100644 --- a/packages/ore-rs/tests/compat_chained_vectors/vectors.rs +++ b/packages/ore-rs/tests/compat_chained_vectors/vectors.rs @@ -69,3 +69,33 @@ pub const FULL_STR_FIXED: &[(&str, &str)] = &[ ("alicf", "020300360200283933281b2b370f1d160003093a28212410393f3c1e2610380d3f06122909090e34362a062f2f221a391f20250a153b32070f291b2321367ae8e098c5628373545e3027b3940019889a97dee6bd3b45d3e9df587803f858651a8d94901d58e061adcbe4a2873f98ce0340bbca2f8c50b6037d68324d54934c1d5eeccca87af1a0ddfabb106717331ae99d5a41b641fe7ea107efd8108e5a463f1ba05316b6c7337631775afaf6fdcbb35f5a17dbe24a8b24e10ac1010e1806002a3d79d789cc4e15656e17bff81e82e676c22f2880eb508d603cb248f7f5b9f1afb088ec7b87dc8574ec0064742c5cc22ecdd321903f5590f1f9dec1c9d1ab73068df42316ad1dbcf6874e41c20880f149c147ce1ae372b7192966683e9185b889a9bd358a91e65f9fdcaeff64b6b6a8c6684e02a8ac3e5089bfb60b4b8626829411da26cb65e224121ea4f38d9156286a4a8b45815ac80ad0310512a1e73c50dab2ec083c32e200040a2ef9b993672af7fb0a10b203eb748e11afe8b01b694f246017e6fe1b7e388bddf3ebe2140fdfba3a5c3cc7c96c2559306c131210aa4a36c4a2b7a0b878c92c1f4224d511d74bc0668526ba125c74c3d8bcc3973e8ae157a45e03d17f964de09b6415905a7ed62bb42e7a60d61de7a2676ddba3ae1fd9aef7c525fd641f0503f8e032d036b508a3c244339c7d88534929526724c4ee659fb0869753aac25ca0a9a0aa14279a5d9f783ad9f813074c8495769b114b053b1dd3260e432393df19cac5afc56916b12b5304e0d6521a361ae035d14f438ded634be98aebac4cb877183f649aa743a87f8d2976284f0d7e1f448e14a82e5ef6ac2bbd66984225c44708f513f45ed566bc7274d237fe728631895c29c9ecaa5f063ea28b08a5c0be2313ff850651ba7666e460176e4a926ac2f23efdf6bb5d71601c3066cdc45271224402aaa4ac461e126a5d136d2f49d6cc77204b2bc95157e590c05d237b48f734c9a5285a410d629324a9ea90295f6cb330a9fe9d4379791e1737e8e73f27fd403a9eb09e04a0a697429209f8e5bd007ada044a6d22a035d19d5fba524e8517c7180a7acb3f5d77bb0d79e38e59fa08df16119eef624824f7deae81dfc8d8ffefe885e9fe5ca052b591967c016587ea4bb0a74288c05062bdbe4c8d910225d9122366aa3e129e835c86f79c98849dae9e03d40f3a3aabcc5bb197fe4d5a8c94368967f4f35034d3a926c216fa5671d3bbfda32f5893a3dfc2863d4575475f0b8a92040e73f1fcb7625fb4d53b7e78761d2302df6598191f46e5830216445436978803697ad909aecea3e41349e02b95ae7093e40add632becdd226d0b708753fab7a69a596e20b624d50e3853b8aa1c1297938c88fe70c4aa4a0a06ec6a2f37440f036e3d0f0e8220787a27e3df58a189ec88cfca3977d74b73aeb564bfed34c2e0c2814622d87e5d55ce2b38716476e65c0661c9b47a2710cb3be0a43062969982e8c68dcdbee03e76d94043b18b4a8777bf99916426da532da5733c18e143a8664e29eef67d8f655956eaf449090cda1c1dfdf14bc197a1a6feac478377014d692e04748bf979c59f4c62fc29d55912ed766eb4b0f69e135f838d6c3cac70f5c8204f90d039e2df60e017f2e5227863b52fd120466e8dc1e4a8ffef685c9a903ca9bb9b73f21023b0596f1073848ac9e285899b853878fcb9c931bd9ec86f635edab6536a5e24209b48508a1a71a90538077170b96d3126cadcb060a129cfe4f013b01ceef97a24a0cc4d3e5c2ce10c8c937b216d51c9846c859a38e91161f5600b908fba3c7010e6d5d8ac9b8cfb1a99862c8c8c7ed2a6ccf4742e303b37151ff9ee7fd65247bc4ad178f2e9ed9be6338552dc02bc32cd6f8c753adc4ff9dabccad04925acb426dcfdd43a8d589ed5fea80d1b"), ("bob", "0203003602002839161537281619211425122f023a3310161b3e173e083c05123506231d393600010a220823320e1f2a241c3a04143c31011213040619367ae8e098c5628373545e3027b394003da2a56461c75d557b49be80ffe925eccb16677fb78c701aec7e9917fcf1ec517ba5f2c16fec4488f1ee9a6d431e2ee65392355338c729e9aef437e524816912235b213478b67f75563f3029d145fbb696dde8f75b8c51bfba6263ad658ca48d4f4cf05a0fdb61eed7a31d992b8413197b4c80909829f0d18cec051ca72af5ca5cf0b6dca340c81cf0e521c04bf556665a96d4fd38686a081f91b7d0020f9a9aad2002f9c680b5c323fa4d3715a7fa8b8f3fb48057c64c5297b72dca1c33ddf0cac2f1b5bab88d904096767217426cd28fbf1b052ff2c3dc7fabe26b6ae36f4d802cc7170908d982574a04cc9f985e5430870b0ccdf5f9e749b806284ab14a7149b89b4789cb01ec5c5c9fec5cba1bfef6ab78126069b782a6813fa464220fe794574f5e1cd720b11f9f96d179ad5ad3d7b64981cf1fd6835354508f03403f6043b2c4a9853d41c8d303d751224f928ef1801c087d7dbba093684920d737492145f340af43c5aa9dda81da0acda49ada07a51114f3f93978cd9b81c13c5c55b65de9d5623482674cd9593e89e454f0949e38e482c116d342dc792dfff932e3381c490f0d06c06e22617cdf9f7ceb796c7991f9bb7fd7d9e0474febbd4e7fde0b6b86831ab486d81873aca9a7d002d1a68174889fb3d847dc583f37260c31e52243ade5ed4f0a4c32dde62fd78a0f5ff0e7d4f2b57625b82f04a5e5d3adb1f0fe872ddda85c34f37d13d1070243d752560c58a7f7ce8795ea5fb6fb6b9d3dde2e858345ae7486428114f3763839c635996258e5595ddca00be97ae347f867b458b39177378763d62048a5b30215508105d608a87adc975ec9c5189c221b4e14944a27b734706db5c6a46bfb655cdb8c4db62799192bdca0b601f6fca11bb101cb0fff7d56e2b0195655c8916187f8b43ad4cef2893e7b983178217fd8978e677222fca083d61b44db033cf3b448780a7803d50f14c678dd247d677e73a95d6a6238c884375bb75cf6de0ecf73e4b5645dfa4e2f118c9e1f0cea643d5dcd8bc894b28d4ab556eb18fb9ce2eb49111fd8e2be2989f5b0da9b4d044afc8758e23b7515cfa4386587cef6c43b00be1b05a253e5df75484892b6255c6d3e20728b49a79f399932e3b1c1cc6a4d253685258eb42a504201b93fcda5b8599b88043bdda6c1c9c6caa17e57925f925543f4f82a7e98191f46e5830216445436978803697ad909aecea3e41349e42bd7ddb591651a358d939875e2204f27ae45ae6d051c061b130a391ad5b1db3c3890dd2408c9b48401f6550698ac14640cb0acf6b9991f5e985d8c785e3937af2b08c3155845e42d232480d90ff884ffb749813cd5ca95c27317bca9d2013bf753669e68ce87551aae698df5eec378fc482691846481620e1fcdc1a1925541e1549716c17a45b7b86fda6951d8d031f9f3387369775d74f1263fb627f2a2a3cdf305228f584a0255b602c736727b62b79756f3f1b77092884dabd44aa4babf4ec9a807ddd635d504a2f9ac7b484c8de8d5f01dc59f004c87086f33b298aea24bd5135e3cb521ea8fa468e1daf0e62fe2e9b0c6a4413c7afe37c5e827d9a34ce0a858a87aa1c0d6d63d84b09639af96a0df51929cdc119d93508161938e0fec08a30e603e9817162e6a5a97b14e56af23e2cc4176f67dc6b75c062026a45311eb7d3058a37ca8bf676bbc9537f6b2d44056543b77068c78133d1891a597a4be85b14f38f92a04168abc1ab88f17de8c0d16fc1adacb923a68e6d6a39a631270c2644457ba2b07276be17144af45a1eac73c6903a750e1ba5ddac2d1dce783763fbc9b256d3ab310"), ]; + +pub const LEFT_U128: &[(u128, &str)] = &[ + (0, "020300160000001d3d170022252220062e182e1c39373a1b153e0410065d826fb3dfbb8090bbcde5ed24acacf0fbc06a3f7f2751d6db18b546cc98c3d0aeaaf841bf3cb2fba85d7b1d1b9d4157d3ffa2a4eb52058bf81e171b324b052c576f57bda58f63f43dda07e9a2a2638c4d5600fe23e1db08227d32b1328e8463aca6e93a4c0d5481cd5e68a39eb917f1517fc3d8df957fc13fa29e15db6822a7cf179133bf7055c301cfdc3adfc65bf4473b18c877aa37a474f4152c95e9aaf1a1ee1a7d20836c27f65070e3fae7b2102d73eaf9bb4a6fba39a353266082419f9f9b12d3286a06e71b596aad71cf7733cf20f779d28a3326093d8ba86567d38dd6c776c38126c319860eb749c07e5ae7014b2dd3c6014bfd87619070f1ba368867e7f6aff17d6936f08d41cfbc7f9297666eee56d7e5a99be4a57785e01943d18d3319e36bc425946471c8d8e8a544fde353aa5a875bebd0752b398e413480b55bee6bfdb5975960f5c236889fdef4fea52d96d3623339f50a37d87ee1c032c7"), + (1, "020300160000001d3d170022252220062e182e1c39373a1b153e0410145d826fb3dfbb8090bbcde5ed24acacf0fbc06a3f7f2751d6db18b546cc98c3d0aeaaf841bf3cb2fba85d7b1d1b9d4157d3ffa2a4eb52058bf81e171b324b052c576f57bda58f63f43dda07e9a2a2638c4d5600fe23e1db08227d32b1328e8463aca6e93a4c0d5481cd5e68a39eb917f1517fc3d8df957fc13fa29e15db6822a7cf179133bf7055c301cfdc3adfc65bf4473b18c877aa37a474f4152c95e9aaf1a1ee1a7d20836c27f65070e3fae7b2102d73eaf9bb4a6fba39a353266082419f9f9b12d3286a06e71b596aad71cf7733cf20f779d28a3326093d8ba86567d38dd6c776c38126c319860eb749c07e5ae7014b2dd3c6014bfd87619070f1ba368867e7f6aff17d6936f08d41cfbc7f9297666eee56d7e5a99be4a57785e01943d18d3319e36bc425946471c8d8e8a544fde353aa5a875bebd0752b398e413480b55bee6bfdb5975960f5c236889fdef4fe11fad85af517f3cd8969d53f11d6123d"), + (1512366075204170947332355369683137040, "020300160000001d090d20183b2e3e343a13143f2e2f050f032b063d235d826fb3dfbb8090bbcde5ed24acacf033cb63848c1924d21d62b4b4ba876321468321cbc63ba6d5efd0b2aa07dd8d22bd07c39b601817b88d35c07bfef5bb0def33e3635ddb322dc2abe3c8139df8983b7d202307e223c83dd20fc97de402dcd83207bf3128d085d365955ccb6025020ed684d07e6178988244c546ffe4898ef29758ae4c493390124d7ec88d96753a37e95bf98d41c9fbba972e6ad56f44734d8f13c405003ca48fa94d94b03c1ed7cd8c09aff62e1512bf57698a51909f1e4742fc434bfe3e300b63a2b7632299667dcd2fe8f1178c0d3c01d7880f8727162ddc7ae70f63e7153eaec6c38f153482fbcfed35454e2220dfd2832fa8c9b45e6aea49bcc57b0d2181409cbb630c9a82d53ee803ef1bb3b6d400cf83458214b744ffedd4cd3b249f44c989f67a8f1fdcaf0c0f0d729f57b6e8ccd1b2439317208bd659c1fde343bbb8c8763bd5d9e88750b04dd187a2f0b5c1206323d234536e"), + (340282366920938463463374607431768211455, "020300160000001c012537140012141c25342e373a0a2e10090d302f1bfc9c0ae91c66774477c4a9846a8f65aa7fb660a84a5e7f8872d29acbc1557e78279399c19d0b74d9460f6f5620b4eb57ce4a0b8d36ed95c277071ca46b389c8e5682e02633bcd397cfbe45d1dcc7d3c3c4ebe08e3a21605fdd12869e152ae90f286f71b03daa32b35f79930a45fc0971741d2c815362a51cfdea0a2d6198e902e3620b9b3a0a00220073eb4a204417dd314d2fc13a260eb4afb685fce8822bb8568871da673a29aba2b8be25604d98cb72bc44ae9f1129070837ef62f971692f7f4e6c460aa533d95498ddcd5953af3b470b785f15d711572e9658386f5024e8f5e234f33374e030ce055673193f308d07d2ac7992d735f617460368c2cc74afa65f070f3e1ef92a640bda8140273a7914f18d0911709ab8fcc5544a10002e46444b551a0dc2f633be0513f6945a6913df84624165ff15ce7f3211c4c46ef0961b4b05e1af6d21695c776cf62d8ac2e5a72072c7d94562999daa115609ecdc59"), +]; + +pub const LEFT_I128: &[(i128, &str)] = &[ + (-170141183460469231731687303715884105728, "020300160000001d3d170022252220062e182e1c39373a1b153e0410065d826fb3dfbb8090bbcde5ed24acacf0fbc06a3f7f2751d6db18b546cc98c3d0aeaaf841bf3cb2fba85d7b1d1b9d4157d3ffa2a4eb52058bf81e171b324b052c576f57bda58f63f43dda07e9a2a2638c4d5600fe23e1db08227d32b1328e8463aca6e93a4c0d5481cd5e68a39eb917f1517fc3d8df957fc13fa29e15db6822a7cf179133bf7055c301cfdc3adfc65bf4473b18c877aa37a474f4152c95e9aaf1a1ee1a7d20836c27f65070e3fae7b2102d73eaf9bb4a6fba39a353266082419f9f9b12d3286a06e71b596aad71cf7733cf20f779d28a3326093d8ba86567d38dd6c776c38126c319860eb749c07e5ae7014b2dd3c6014bfd87619070f1ba368867e7f6aff17d6936f08d41cfbc7f9297666eee56d7e5a99be4a57785e01943d18d3319e36bc425946471c8d8e8a544fde353aa5a875bebd0752b398e413480b55bee6bfdb5975960f5c236889fdef4fea52d96d3623339f50a37d87ee1c032c7"), + (-1, "020300160000002e1d000f0728193803273b2d3a14312f2b1d0c2c393e8d45d702c3baa9c11320c59cd64fb0170fc7a3ce698c5c5934bc587e835d6f85a0b52cf2a9ddc0e65872d3b9cd9b2938e0b6311eb0951d18bb160569e6e503a5b647c51918f2a0bf0821256b542fbf1a3119490b9e26e50d6cdead3f3584961c95a0fc83ef6359515dc0ec3664b258d5426ed1d68e4e32812415174cd77c01feda8937b26861246650f221f22354d011b160bc61684388670c68cda90d04fd7118b682bed95ee03679db942fcd4c3a6a76c6ec7882a97d41790050e7e7a3e97d5c0d01fedca787be884577ba52a20fb71fbe8128f3b0762424264512837ff37c13947be67871e76d47ae575580adc5e7d3a5a53db5fae68b524857aad5bbf68f272480b24cecc7d9e52bafb692810d7219e95fb9c0edd2ea378f9ed71d65c975aedb9bb46362eaf166ebf4f43c3a32001cfaaeba007e83995a1439762b5b1db03c9b4421843711098eb44d58985955a72a5eed9d458ebfee0ad29fcf127eadbe"), + (0, "0203001600000038183a122336323d2630320e202c0536232825281f01da70c42b151372073e985ba41fe3e6730d53f1ca868eea4bacfe6e3df5fdfa7d67083f40ff96506ad8c90ed73e54c84b6783875efb5f1fd08256f50dbad14a0205a48c31d0772f6e888d8848f56fac843975810b92085a17dd02ecc7a5bc1dd5fe8841b5925d2145731ff1271f3ead53dd05f81f121a15c00a300aa5f15b8fccd371e36cb04b6482014b5586819016296e6aac921b387f2a56da40c4833085bac88f76bfed67327d73fbdc7d98513ca8fd90c8b0d681f61a668521b7c9a7fa13781d4d664e0e1dff52d103b7f3ebe12c168ff0400c5fcf22f57c02aa831a02bbc6a99cb2f2529b7394a410935bfb388a7a0c9aa7b45e1f416b023cacc24a5b0372b08746d94557c2168ae483c27d7f334acc8eb15c7fc0884851af37dac14fd6fd903c93c5b464c9193704288ebf07dcd7333708bd94491fc5797729f48ffee13050fc27f48c7330054d352e913b4c7c76f17cbf56881f4525c35965cc62da59"), + (1, "0203001600000038183a122336323d2630320e202c0536232825281f22da70c42b151372073e985ba41fe3e6730d53f1ca868eea4bacfe6e3df5fdfa7d67083f40ff96506ad8c90ed73e54c84b6783875efb5f1fd08256f50dbad14a0205a48c31d0772f6e888d8848f56fac843975810b92085a17dd02ecc7a5bc1dd5fe8841b5925d2145731ff1271f3ead53dd05f81f121a15c00a300aa5f15b8fccd371e36cb04b6482014b5586819016296e6aac921b387f2a56da40c4833085bac88f76bfed67327d73fbdc7d98513ca8fd90c8b0d681f61a668521b7c9a7fa13781d4d664e0e1dff52d103b7f3ebe12c168ff0400c5fcf22f57c02aa831a02bbc6a99cb2f2529b7394a410935bfb388a7a0c9aa7b45e1f416b023cacc24a5b0372b08746d94557c2168ae483c27d7f334acc8eb15c7fc0884851af37dac14fd6fd903c93c5b464c9193704288ebf07dcd7333708bd94491fc5797729f48ffee13050fc27f48c7330054d352e913b4c7c76aa6d4f5c784620f006640e134e76ad"), + (170141183460469231731687303715884105727, "020300160000001c012537140012141c25342e373a0a2e10090d302f1bfc9c0ae91c66774477c4a9846a8f65aa7fb660a84a5e7f8872d29acbc1557e78279399c19d0b74d9460f6f5620b4eb57ce4a0b8d36ed95c277071ca46b389c8e5682e02633bcd397cfbe45d1dcc7d3c3c4ebe08e3a21605fdd12869e152ae90f286f71b03daa32b35f79930a45fc0971741d2c815362a51cfdea0a2d6198e902e3620b9b3a0a00220073eb4a204417dd314d2fc13a260eb4afb685fce8822bb8568871da673a29aba2b8be25604d98cb72bc44ae9f1129070837ef62f971692f7f4e6c460aa533d95498ddcd5953af3b470b785f15d711572e9658386f5024e8f5e234f33374e030ce055673193f308d07d2ac7992d735f617460368c2cc74afa65f070f3e1ef92a640bda8140273a7914f18d0911709ab8fcc5544a10002e46444b551a0dc2f633be0513f6945a6913df84624165ff15ce7f3211c4c46ef0961b4b05e1af6d21695c776cf62d8ac2e5a72072c7d94562999daa115609ecdc59"), +]; + +pub const FULL_U128: &[(u128, &str)] = &[ + (0, "020300160000001d3d170022252220062e182e1c39373a1b153e0410065d826fb3dfbb8090bbcde5ed24acacf0fbc06a3f7f2751d6db18b546cc98c3d0aeaaf841bf3cb2fba85d7b1d1b9d4157d3ffa2a4eb52058bf81e171b324b052c576f57bda58f63f43dda07e9a2a2638c4d5600fe23e1db08227d32b1328e8463aca6e93a4c0d5481cd5e68a39eb917f1517fc3d8df957fc13fa29e15db6822a7cf179133bf7055c301cfdc3adfc65bf4473b18c877aa37a474f4152c95e9aaf1a1ee1a7d20836c27f65070e3fae7b2102d73eaf9bb4a6fba39a353266082419f9f9b12d3286a06e71b596aad71cf7733cf20f779d28a3326093d8ba86567d38dd6c776c38126c319860eb749c07e5ae7014b2dd3c6014bfd87619070f1ba368867e7f6aff17d6936f08d41cfbc7f9297666eee56d7e5a99be4a57785e01943d18d3319e36bc425946471c8d8e8a544fde353aa5a875bebd0752b398e413480b55bee6bfdb5975960f5c236889fdef4fea52d96d3623339f50a37d87ee1c032c798191f46e5830216445436978803697a848f024abe74230b12db314a9699cd84d8ea9b4af271d79c82d9db07a87b3081c9a78eeb955a4a50a938e7bf92bbab93f61dd99079b86c7bd81e350f5141776e185307a33b8589f5bc0ac63cf9dc5195d30933b341948da34df9c3893753b72c8e1b407b9d5906f5337e878c708699c1fc1c70cd65f7f28fff1f879a07940768535c6619f795b5eb7454917c2e14f95b4b5513ff6689c07ce1ab5730ef9b261a2b4a787d29890e590a1b42bf275e19a7"), + (1, "020300160000001d3d170022252220062e182e1c39373a1b153e0410145d826fb3dfbb8090bbcde5ed24acacf0fbc06a3f7f2751d6db18b546cc98c3d0aeaaf841bf3cb2fba85d7b1d1b9d4157d3ffa2a4eb52058bf81e171b324b052c576f57bda58f63f43dda07e9a2a2638c4d5600fe23e1db08227d32b1328e8463aca6e93a4c0d5481cd5e68a39eb917f1517fc3d8df957fc13fa29e15db6822a7cf179133bf7055c301cfdc3adfc65bf4473b18c877aa37a474f4152c95e9aaf1a1ee1a7d20836c27f65070e3fae7b2102d73eaf9bb4a6fba39a353266082419f9f9b12d3286a06e71b596aad71cf7733cf20f779d28a3326093d8ba86567d38dd6c776c38126c319860eb749c07e5ae7014b2dd3c6014bfd87619070f1ba368867e7f6aff17d6936f08d41cfbc7f9297666eee56d7e5a99be4a57785e01943d18d3319e36bc425946471c8d8e8a544fde353aa5a875bebd0752b398e413480b55bee6bfdb5975960f5c236889fdef4fe11fad85af517f3cd8969d53f11d6123d98191f46e5830216445436978803697a848f024abe74230b12db314a9699cd84d8ea9b4af271d79c82d9db07a87b3081c9a78eeb955a4a50a938e7bf92bbab93f61dd99079b86c7bd81e350f5141776e185307a33b8589f5bc0ac63cf9dc5195d30933b341948da34df9c3893753b72c8e1b407b9d5906f5337e878c708699c1fc1c70cd65f7f28fff1f879a07940768535c6619f795b5eb7454917c2e14f95b4b5513ff6689c07ce1ab5730ef9b261a2b4a787d29890e590a19dcd7b61e19a0"), + (1512366075204170947332355369683137040, "020300160000001d090d20183b2e3e343a13143f2e2f050f032b063d235d826fb3dfbb8090bbcde5ed24acacf033cb63848c1924d21d62b4b4ba876321468321cbc63ba6d5efd0b2aa07dd8d22bd07c39b601817b88d35c07bfef5bb0def33e3635ddb322dc2abe3c8139df8983b7d202307e223c83dd20fc97de402dcd83207bf3128d085d365955ccb6025020ed684d07e6178988244c546ffe4898ef29758ae4c493390124d7ec88d96753a37e95bf98d41c9fbba972e6ad56f44734d8f13c405003ca48fa94d94b03c1ed7cd8c09aff62e1512bf57698a51909f1e4742fc434bfe3e300b63a2b7632299667dcd2fe8f1178c0d3c01d7880f8727162ddc7ae70f63e7153eaec6c38f153482fbcfed35454e2220dfd2832fa8c9b45e6aea49bcc57b0d2181409cbb630c9a82d53ee803ef1bb3b6d400cf83458214b744ffedd4cd3b249f44c989f67a8f1fdcaf0c0f0d729f57b6e8ccd1b2439317208bd659c1fde343bbb8c8763bd5d9e88750b04dd187a2f0b5c1206323d234536e98191f46e5830216445436978803697a848f024abe74230b800931e2bfdbe18402ae082c300b4e5ae6f1449a863109859c9c7b968f15cf17b6214ddd607af67a3d786e69744b783947559ef818c9363b0572b68e5c241954a6e088793fa1e1b830117ae5c8911c70de44876eebad39deb38537125d0ae38aecc63d5d25863458acad22772885c65a875cf5a5e6d24c6e027a308945d56469fcee2aec1f66176f9abb14a1a20d858642b60d80fd1edd9bbd3d14f98ebcc2dcf2eeeb2045265200"), + (340282366920938463463374607431768211455, "020300160000001c012537140012141c25342e373a0a2e10090d302f1bfc9c0ae91c66774477c4a9846a8f65aa7fb660a84a5e7f8872d29acbc1557e78279399c19d0b74d9460f6f5620b4eb57ce4a0b8d36ed95c277071ca46b389c8e5682e02633bcd397cfbe45d1dcc7d3c3c4ebe08e3a21605fdd12869e152ae90f286f71b03daa32b35f79930a45fc0971741d2c815362a51cfdea0a2d6198e902e3620b9b3a0a00220073eb4a204417dd314d2fc13a260eb4afb685fce8822bb8568871da673a29aba2b8be25604d98cb72bc44ae9f1129070837ef62f971692f7f4e6c460aa533d95498ddcd5953af3b470b785f15d711572e9658386f5024e8f5e234f33374e030ce055673193f308d07d2ac7992d735f617460368c2cc74afa65f070f3e1ef92a640bda8140273a7914f18d0911709ab8fcc5544a10002e46444b551a0dc2f633be0513f6945a6913df84624165ff15ce7f3211c4c46ef0961b4b05e1af6d21695c776cf62d8ac2e5a72072c7d94562999daa115609ecdc5998191f46e5830216445436978803697a7b70fd95418bdcf4cfb11b544705455dbb6844800bcb1b9ccd3819c53fef6a4189e58af413f65514cd3650ea26949a9f2b907c24223f8b72256a7a75f143feb8cca225340fd23c93ada449d4bd701f7a612dce51421688804b19c8ad847421f9bb2bc59919ca1709b6bdca22268c3e4d9f49b10e0806bee4916b724430c34b514a031e9f1e04a3971273d3bb13e902f156ddba6deff95d82ad5e5993771d6d65bcaf06a9237ae21dcbd2d1e665bef033"), +]; + +pub const FULL_I128: &[(i128, &str)] = &[ + (-170141183460469231731687303715884105728, "020300160000001d3d170022252220062e182e1c39373a1b153e0410065d826fb3dfbb8090bbcde5ed24acacf0fbc06a3f7f2751d6db18b546cc98c3d0aeaaf841bf3cb2fba85d7b1d1b9d4157d3ffa2a4eb52058bf81e171b324b052c576f57bda58f63f43dda07e9a2a2638c4d5600fe23e1db08227d32b1328e8463aca6e93a4c0d5481cd5e68a39eb917f1517fc3d8df957fc13fa29e15db6822a7cf179133bf7055c301cfdc3adfc65bf4473b18c877aa37a474f4152c95e9aaf1a1ee1a7d20836c27f65070e3fae7b2102d73eaf9bb4a6fba39a353266082419f9f9b12d3286a06e71b596aad71cf7733cf20f779d28a3326093d8ba86567d38dd6c776c38126c319860eb749c07e5ae7014b2dd3c6014bfd87619070f1ba368867e7f6aff17d6936f08d41cfbc7f9297666eee56d7e5a99be4a57785e01943d18d3319e36bc425946471c8d8e8a544fde353aa5a875bebd0752b398e413480b55bee6bfdb5975960f5c236889fdef4fea52d96d3623339f50a37d87ee1c032c798191f46e5830216445436978803697a848f024abe74230b12db314a9699cd84d8ea9b4af271d79c82d9db07a87b3081c9a78eeb955a4a50a938e7bf92bbab93f61dd99079b86c7bd81e350f5141776e185307a33b8589f5bc0ac63cf9dc5195d30933b341948da34df9c3893753b72c8e1b407b9d5906f5337e878c708699c1fc1c70cd65f7f28fff1f879a07940768535c6619f795b5eb7454917c2e14f95b4b5513ff6689c07ce1ab5730ef9b261a2b4a787d29890e590a1b42bf275e19a7"), + (-1, "020300160000002e1d000f0728193803273b2d3a14312f2b1d0c2c393e8d45d702c3baa9c11320c59cd64fb0170fc7a3ce698c5c5934bc587e835d6f85a0b52cf2a9ddc0e65872d3b9cd9b2938e0b6311eb0951d18bb160569e6e503a5b647c51918f2a0bf0821256b542fbf1a3119490b9e26e50d6cdead3f3584961c95a0fc83ef6359515dc0ec3664b258d5426ed1d68e4e32812415174cd77c01feda8937b26861246650f221f22354d011b160bc61684388670c68cda90d04fd7118b682bed95ee03679db942fcd4c3a6a76c6ec7882a97d41790050e7e7a3e97d5c0d01fedca787be884577ba52a20fb71fbe8128f3b0762424264512837ff37c13947be67871e76d47ae575580adc5e7d3a5a53db5fae68b524857aad5bbf68f272480b24cecc7d9e52bafb692810d7219e95fb9c0edd2ea378f9ed71d65c975aedb9bb46362eaf166ebf4f43c3a32001cfaaeba007e83995a1439762b5b1db03c9b4421843711098eb44d58985955a72a5eed9d458ebfee0ad29fcf127eadbe98191f46e5830216445436978803697adb49afce01a413493ecccc99282b69cec2417b13755c88ea7d8dc5762a900d689a4d01d5c790493f85d25d89db887d928af09111bd02119ad5aa967907f09f723ac5c4a07ded1d0bb3c576b20f7983e82b7a00af4b1f3e2c98b92074bc16e5c5d6b83a0d5f132ae0dfc88b4ee54c17e8996fb9e8cb29a1ad4c9a03bf06bfa4df862d47775debcb5080c10908fde917cc9504f8813e617784dda38d7bd7b90524c3bfaa7654532bccbf2af91da8419c12"), + (0, "0203001600000038183a122336323d2630320e202c0536232825281f01da70c42b151372073e985ba41fe3e6730d53f1ca868eea4bacfe6e3df5fdfa7d67083f40ff96506ad8c90ed73e54c84b6783875efb5f1fd08256f50dbad14a0205a48c31d0772f6e888d8848f56fac843975810b92085a17dd02ecc7a5bc1dd5fe8841b5925d2145731ff1271f3ead53dd05f81f121a15c00a300aa5f15b8fccd371e36cb04b6482014b5586819016296e6aac921b387f2a56da40c4833085bac88f76bfed67327d73fbdc7d98513ca8fd90c8b0d681f61a668521b7c9a7fa13781d4d664e0e1dff52d103b7f3ebe12c168ff0400c5fcf22f57c02aa831a02bbc6a99cb2f2529b7394a410935bfb388a7a0c9aa7b45e1f416b023cacc24a5b0372b08746d94557c2168ae483c27d7f334acc8eb15c7fc0884851af37dac14fd6fd903c93c5b464c9193704288ebf07dcd7333708bd94491fc5797729f48ffee13050fc27f48c7330054d352e913b4c7c76f17cbf56881f4525c35965cc62da5998191f46e5830216445436978803697adb49afce01a41348d82e9915dc320cb46258438a78fde4099ea912092910bb935baf4e0f8fa5d1b45ad7de790c61b068e3f814ebd57a82fed4867ed1c53fab0d5d18508c7cf716a82cacfda317bce31532672351f2e07b6de8a6bbcb79cf05289a16950be9ac3f3733b81d75f3cb1db68ca497568488d5df938ae1e53f528a8b37697ea9f9508f69dbe4ae3cf7560a4a598d0e39da59a610d7aa0073e97ceb9f7bc6eff11faa8092c6c1601ccab2964c"), + (1, "0203001600000038183a122336323d2630320e202c0536232825281f22da70c42b151372073e985ba41fe3e6730d53f1ca868eea4bacfe6e3df5fdfa7d67083f40ff96506ad8c90ed73e54c84b6783875efb5f1fd08256f50dbad14a0205a48c31d0772f6e888d8848f56fac843975810b92085a17dd02ecc7a5bc1dd5fe8841b5925d2145731ff1271f3ead53dd05f81f121a15c00a300aa5f15b8fccd371e36cb04b6482014b5586819016296e6aac921b387f2a56da40c4833085bac88f76bfed67327d73fbdc7d98513ca8fd90c8b0d681f61a668521b7c9a7fa13781d4d664e0e1dff52d103b7f3ebe12c168ff0400c5fcf22f57c02aa831a02bbc6a99cb2f2529b7394a410935bfb388a7a0c9aa7b45e1f416b023cacc24a5b0372b08746d94557c2168ae483c27d7f334acc8eb15c7fc0884851af37dac14fd6fd903c93c5b464c9193704288ebf07dcd7333708bd94491fc5797729f48ffee13050fc27f48c7330054d352e913b4c7c76aa6d4f5c784620f006640e134e76ad98191f46e5830216445436978803697adb49afce01a41348d82e9915dc320cb46258438a78fde4099ea912092910bb935baf4e0f8fa5d1b45ad7de790c61b068e3f814ebd57a82fed4867ed1c53fab0d5d18508c7cf716a82cacfda317bce31532672351f2e07b6de8a6bbcb79cf05289a16950be9ac3f3733b81d75f3cb1db68ca497568488d5df938ae1e53f528a8b37697ea9f9508f69dbe4ae3cf7560a4a598d0e39da59a610d7aa0073e97ceb9f7bc6eff11faa809282cd401c9cb22058"), + (170141183460469231731687303715884105727, "020300160000001c012537140012141c25342e373a0a2e10090d302f1bfc9c0ae91c66774477c4a9846a8f65aa7fb660a84a5e7f8872d29acbc1557e78279399c19d0b74d9460f6f5620b4eb57ce4a0b8d36ed95c277071ca46b389c8e5682e02633bcd397cfbe45d1dcc7d3c3c4ebe08e3a21605fdd12869e152ae90f286f71b03daa32b35f79930a45fc0971741d2c815362a51cfdea0a2d6198e902e3620b9b3a0a00220073eb4a204417dd314d2fc13a260eb4afb685fce8822bb8568871da673a29aba2b8be25604d98cb72bc44ae9f1129070837ef62f971692f7f4e6c460aa533d95498ddcd5953af3b470b785f15d711572e9658386f5024e8f5e234f33374e030ce055673193f308d07d2ac7992d735f617460368c2cc74afa65f070f3e1ef92a640bda8140273a7914f18d0911709ab8fcc5544a10002e46444b551a0dc2f633be0513f6945a6913df84624165ff15ce7f3211c4c46ef0961b4b05e1af6d21695c776cf62d8ac2e5a72072c7d94562999daa115609ecdc5998191f46e5830216445436978803697a7b70fd95418bdcf4cfb11b544705455dbb6844800bcb1b9ccd3819c53fef6a4189e58af413f65514cd3650ea26949a9f2b907c24223f8b72256a7a75f143feb8cca225340fd23c93ada449d4bd701f7a612dce51421688804b19c8ad847421f9bb2bc59919ca1709b6bdca22268c3e4d9f49b10e0806bee4916b724430c34b514a031e9f1e04a3971273d3bb13e902f156ddba6deff95d82ad5e5993771d6d65bcaf06a9237ae21dcbd2d1e665bef033"), +]; From 730220e2735f811a517d8e9afa70c831e64c4a25 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 16 Oct 2026 16:35:34 +1100 Subject: [PATCH 2/7] fix(chained): check a fixed type's length before copying its encoding; say what the count guards Review of #113 (Copilot): - `encrypt_fixed` / `encrypt_left_fixed` made the owned encoding before `bytes_to_blocks` could refuse it, and `[u8; N]` is a `FixedOrderableBytes` for any `N`, so a wide type copied its whole encoding onto the stack before returning `TooManyBlocks`. Both now refuse `T::ENCODED_LEN > MAX_STR_BYTES` first, from the type alone. - The module docs, `encrypt_fixed` docs and README said the typed outputs keep an integer from being compared against a string. They pin the block count, which is a length guard: a 16-byte string and an `i128` are 22 blocks too, and under one key equal encodings encrypt identically. The docs now say so and tell callers to use one key per column. --- packages/ore-rs/README.md | 2 +- packages/ore-rs/src/scheme/chained.rs | 65 ++++++++++++++++++++++----- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/packages/ore-rs/README.md b/packages/ore-rs/README.md index d73d42d5..0f27b252 100644 --- a/packages/ore-rs/README.md +++ b/packages/ore-rs/README.md @@ -21,7 +21,7 @@ Reference documentation is on [docs.rs/ore-rs](https://docs.rs/ore-rs). ## Supported plaintext types -`OreEncrypt` is implemented for `bool`, integers up to 64 bits (`u8`–`u64`, `i8`–`i64`), `char`, `f32`, and `f64` on both `OreAes128` and `OreAes128Bit6`. `u128` and `i128` are implemented on the chained scheme only (`OreAes128Bit6Chained`, `scheme::chained`): at 16 bytes they exceed both the 15-byte limit of `OreAes128` and the 14-block packed prefix of `OreAes128Bit6`, and the chained scheme's CMAC accumulator has no length cap. Their ciphertexts are `ChainedCipherText<22>` / `ChainedLeft<22>`, which pin the block count so a typed comparator never orders them against a string. Two optional features extend this via the sibling [`orderable-bytes`](https://crates.io/crates/orderable-bytes) crate: +`OreEncrypt` is implemented for `bool`, integers up to 64 bits (`u8`–`u64`, `i8`–`i64`), `char`, `f32`, and `f64` on both `OreAes128` and `OreAes128Bit6`. `u128` and `i128` are implemented on the chained scheme only (`OreAes128Bit6Chained`, `scheme::chained`): at 16 bytes they exceed both the 15-byte limit of `OreAes128` and the 14-block packed prefix of `OreAes128Bit6`, and the chained scheme's CMAC accumulator has no length cap. Their ciphertexts are `ChainedCipherText<22>` / `ChainedLeft<22>`, which pin the block count: the typed parsers and comparators refuse a ciphertext of another length. That is a length guard, not a type guard; a 16-byte string or an `i128` is 22 blocks too, and under one key equal encodings (`0u128`, `i128::MIN`) encrypt identically, so use one key per column. Two optional features extend this via the sibling [`orderable-bytes`](https://crates.io/crates/orderable-bytes) crate: - `chrono` — ORE support for `chrono::NaiveDate` and `chrono::DateTime` - `decimal` — ORE support for `rust_decimal::Decimal` diff --git a/packages/ore-rs/src/scheme/chained.rs b/packages/ore-rs/src/scheme/chained.rs index fff5acc0..5ff7174c 100644 --- a/packages/ore-rs/src/scheme/chained.rs +++ b/packages/ore-rs/src/scheme/chained.rs @@ -71,12 +71,16 @@ //! the packed prefix of `OreAes128Bit6` and for the legacy scheme, so their //! [`crate::OreEncrypt`] impls live here. The outputs, [`crate::scheme::chained::ChainedCipherText`] and //! [`crate::scheme::chained::ChainedLeft`], carry the block count in the type: their parsers and -//! comparators take only that count, so an integer ciphertext is never -//! ordered against a string by the typed path. A fixed-length value is -//! not padded (every value of the type has the same length, so there is -//! nothing to hide) and is not subject to the cipher's string ceiling; its -//! ciphertext is tagged [`Padding::None`](crate::scheme::chained::Padding::None), -//! and the typed parsers accept no other tag. +//! comparators take only that count, so a `u128` ciphertext is never put +//! into the shorter-sorts-first order with a ciphertext of another length. +//! The count is a length guard, not a type guard: a 16-byte string and an +//! `i128` are 22 blocks too, and under one key an equal encoding gives an +//! equal ciphertext (`0u128`, `i128::MIN`, sixteen NUL bytes). Use one key +//! per column. A fixed-length value is not padded (every value of the type +//! has the same length, so there is nothing to hide) and is not subject to +//! the cipher's string ceiling; its ciphertext is tagged +//! [`Padding::None`](crate::scheme::chained::Padding::None), and the typed +//! parsers accept no other tag. //! //! ```rust //! use ore_rs::scheme::chained::{ChainedCipherText, OreAes128Bit6ChainedChaCha20, U128_BLOCKS}; @@ -768,11 +772,19 @@ impl OreAes128Bit6Chained { /// The encoding is not padded (there is no length to hide) and the /// cipher's [`Options`] do not apply: the ciphertext is tagged /// [`Padding::None`], and the string ceiling is not checked. - /// The output is an untyped [`VarCipherText`]; to keep a type's - /// ciphertexts from being compared against strings or other types, use - /// the [`OreEncrypt`] impls, whose [`ChainedCipherText`] output pins the - /// block count. + /// The output is an untyped [`VarCipherText`]; the [`OreEncrypt`] impls + /// wrap it in [`ChainedCipherText`], which pins the block count so the + /// typed parsers and comparators refuse a ciphertext of another length + /// or a padded one. That is a length guard, not a type guard: + /// ciphertexts of two types with equal-length encodings (`u128` and + /// `i128`, or an unpadded 16-byte string) compare, and equal encodings + /// under one key give equal ciphertexts. Use one key per column. + /// + /// A type whose encoding is longer than [`MAX_STR_BYTES`] returns + /// [`OreError::TooManyBlocks`] before the encoding is made (`[u8; N]` + /// is a `FixedOrderableBytes` for any `N`, and the encoding is a copy). pub fn encrypt_fixed(&self, x: &T) -> Result { + check_fixed_len::()?; // The encoding is a copy of the plaintext in this frame, so it is // made inside the scrub. stack::scrubbed(|| { @@ -784,6 +796,7 @@ impl OreAes128Bit6Chained { /// Left-only ciphertext of a fixed-length value; see /// [`Self::encrypt_fixed`]. pub fn encrypt_left_fixed(&self, x: &T) -> Result { + check_fixed_len::()?; stack::scrubbed(|| { let bytes = x.to_fixed_orderable_bytes(); self.encrypt_left_blocks(&bytes_to_blocks(bytes.as_ref())?, Padding::None) @@ -1116,6 +1129,16 @@ pub const MAX_STR_BYTES: usize = u16::MAX as usize * 6 / 8; const _: () = assert!(num_blocks_6bit(MAX_STR_BYTES) <= u16::MAX as usize); const _: () = assert!(num_blocks_6bit(MAX_STR_BYTES + 1) > u16::MAX as usize); +/// Refuse a fixed-length type whose encoding would not fit the wire format, +/// before [`OreAes128Bit6Chained::encrypt_fixed`] copies the encoding onto +/// the stack. Decided by the type, so it never depends on the value. +fn check_fixed_len() -> Result<(), OreError> { + if T::ENCODED_LEN > MAX_STR_BYTES { + return Err(OreError::TooManyBlocks); + } + Ok(()) +} + /// Check raw symbols for the tests' `encrypt_var` and `encrypt_left_var`, /// refusing more blocks than the wire format can number *before* scanning /// them. @@ -2018,6 +2041,28 @@ mod tests { assert!(ChainedCipherText::::from_slice(&ct.to_bytes()).is_ok()); } + /// `[u8; N]` is a fixed-length type for any `N`: one past the wire + /// limit is refused by the type's length, before its encoding is + /// copied, and the largest that fits is accepted. + #[test] + fn fixed_types_past_the_wire_limit_are_refused_by_length() { + let c = ore(); + let too_wide = Box::new([0u8; MAX_STR_BYTES + 1]); + assert!(matches!( + c.encrypt_fixed(&*too_wide), + Err(OreError::TooManyBlocks) + )); + assert!(matches!( + c.encrypt_left_fixed(&*too_wide), + Err(OreError::TooManyBlocks) + )); + assert!(matches!(check_fixed_len::<[u8; MAX_STR_BYTES]>(), Ok(()))); + assert!(matches!( + check_fixed_len::<[u8; MAX_STR_BYTES + 1]>(), + Err(OreError::TooManyBlocks) + )); + } + #[test] fn signed_128_orders_across_zero() { let c = ore(); From 8712cf379fadeb417d5a19c89f4d46542a9702c9 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 18 Oct 2026 22:45:02 +1100 Subject: [PATCH 3/7] fix(chained): borrow a fixed type's encoding so a large array is not copied past the scrub `encrypt_fixed` and `encrypt_left_fixed` called `to_fixed_orderable_bytes`, which copies a `[u8; N]` onto the stack. Up to `MAX_STR_BYTES` (48 KiB) is accepted, more than the 16 KiB the optimised stack scrub zeroes, so most of the plaintext stayed in dead stack. Both now take `to_orderable_bytes`, which borrows a byte array and gives the same bytes; integers still build their 16 bytes inside the scrub. Residue tests encrypt a `MAX_STR_BYTES` array from the heap through each entry point and search the dead stack for it, with a positive control showing the owned copy survives the scrub in optimised builds. Both fail against the previous code under `--release`. --- packages/ore-rs/src/scheme/chained.rs | 66 +++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 5 deletions(-) diff --git a/packages/ore-rs/src/scheme/chained.rs b/packages/ore-rs/src/scheme/chained.rs index 5ff7174c..5c12ef83 100644 --- a/packages/ore-rs/src/scheme/chained.rs +++ b/packages/ore-rs/src/scheme/chained.rs @@ -782,13 +782,17 @@ impl OreAes128Bit6Chained { /// /// A type whose encoding is longer than [`MAX_STR_BYTES`] returns /// [`OreError::TooManyBlocks`] before the encoding is made (`[u8; N]` - /// is a `FixedOrderableBytes` for any `N`, and the encoding is a copy). + /// is a `FixedOrderableBytes` for any `N`). The encoding is borrowed + /// where the type allows it, so a byte array is never copied onto the + /// stack. pub fn encrypt_fixed(&self, x: &T) -> Result { check_fixed_len::()?; - // The encoding is a copy of the plaintext in this frame, so it is - // made inside the scrub. + // The borrowed encoding: a byte array is not copied, so a large one + // leaves no plaintext past the scrub's reach. A type that does + // build its encoding (an integer's 16 bytes) builds it in this + // frame, so it is made inside the scrub. stack::scrubbed(|| { - let bytes = x.to_fixed_orderable_bytes(); + let bytes = x.to_orderable_bytes(); self.encrypt_blocks(&bytes_to_blocks(bytes.as_ref())?, Padding::None) }) } @@ -798,7 +802,7 @@ impl OreAes128Bit6Chained { pub fn encrypt_left_fixed(&self, x: &T) -> Result { check_fixed_len::()?; stack::scrubbed(|| { - let bytes = x.to_fixed_orderable_bytes(); + let bytes = x.to_orderable_bytes(); self.encrypt_left_blocks(&bytes_to_blocks(bytes.as_ref())?, Padding::None) }) } @@ -2424,6 +2428,58 @@ mod tests { ); } + /// A byte array as large as the wire format allows, on the heap: the + /// fixed-length encryptors must borrow it. A copy on the stack would be + /// larger than the optimised scrub and leave plaintext in dead stack. + fn assert_no_fixed_plaintext(encrypt: fn(&OreAes128Bit6ChainedChaCha20, &[u8; MAX_STR_BYTES])) { + use crate::primitives::stack::{residue, SCRUB_BYTES}; + fn plaintext() -> Box<[u8; MAX_STR_BYTES]> { + let v: Vec = (0..MAX_STR_BYTES).map(|i| (i * 37 + 11) as u8).collect(); + v.into_boxed_slice().try_into().unwrap() + } + // Windows from the start, middle and end of the array; a copy + // that outgrew the scrub leaves at least its deepest part behind. + let p = plaintext(); + let needles: Vec> = [0, MAX_STR_BYTES / 2, MAX_STR_BYTES - 64] + .iter() + .map(|&at| p[at..at + 64].to_vec()) + .collect(); + + // Positive control: the owned encoding, made under the scrub, + // survives it when it is larger than the scrub (optimised builds). + if MAX_STR_BYTES > SCRUB_BYTES { + let dead = residue::after(|| { + let p = plaintext(); + stack::scrubbed(|| { + core::hint::black_box(p.to_fixed_orderable_bytes()); + }); + }); + assert!(needles.iter().any(|n| residue::contains(&dead, n))); + } + + let dead = residue::after(move || encrypt(&ore(), &plaintext())); + for needle in &needles { + assert!( + !residue::contains(&dead, needle), + "plaintext left in dead stack" + ); + } + } + + #[test] + fn encrypt_fixed_leaves_no_large_array_on_the_stack() { + assert_no_fixed_plaintext(|ore, x| { + core::hint::black_box(ore.encrypt_fixed(x).unwrap()); + }); + } + + #[test] + fn encrypt_left_fixed_leaves_no_large_array_on_the_stack() { + assert_no_fixed_plaintext(|ore, x| { + core::hint::black_box(ore.encrypt_left_fixed(x).unwrap()); + }); + } + #[test] fn encrypt_left_str_leaves_no_prp_tables_on_the_stack() { assert_no_block_tables( From 88b46e0d111aae2fe508a337e5e0ea719e3cfbce Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 19 Oct 2026 10:29:42 +1100 Subject: [PATCH 4/7] docs(chained): split the fixed-length paragraph; put test docs back; list the removed impls Review of #113 (non-blocking nits): - The module docs' fixed-length paragraph had no blank line before it, so rustdoc ran it into the raw-symbols paragraph; one of the two blank lines after the example is gone. - A comment rustfmt had aligned at column 57 is on its own line. - The doc comment of `prop_equality_across_nonces` had ended up on `prop_u128_order`; each test has its own again. - The CHANGELOG lists the dropped `OreEncrypt` impls for `u128`/`i128` under Removed, as a breaking change, not only under Fixed. --- packages/ore-rs/CHANGELOG.md | 12 +++++++++--- packages/ore-rs/src/scheme/chained.rs | 11 ++++++----- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/ore-rs/CHANGELOG.md b/packages/ore-rs/CHANGELOG.md index c77e6c55..87b3ab72 100644 --- a/packages/ore-rs/CHANGELOG.md +++ b/packages/ore-rs/CHANGELOG.md @@ -28,12 +28,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Every `OreEncrypt` impl macro now emits a round-trip and ordering test for the type it implements, so a type can't be declared supported without being exercised (#105). +### Removed + +- **Breaking:** `OreEncrypt` for `u128` and `i128`. Every call panicked (see + Fixed), and code that called `1u128.encrypt(&legacy_ore)` no longer compiles. Encrypt + 128-bit integers with the chained scheme (`scheme::chained`) instead (#105). + ### Fixed - `OreAes128` panicked with an index out of bounds on any 16-byte plaintext, which is what - the `u128`/`i128` impls on that scheme had always done (#105). Those impls are removed: the - legacy scheme cannot take 16 bytes, and the impl macro now rejects such a type at compile - time. Encrypting a `PlainText` with `N > 15` directly returns `OreError::TooManyBlocks` + the `u128`/`i128` impls on that scheme had always done (#105). Those impls are removed (see + Removed): the legacy scheme cannot take 16 bytes, and the impl macro now rejects such a type + at compile time. Encrypting a `PlainText` with `N > 15` directly returns `OreError::TooManyBlocks` instead of panicking. ## [0.9.0-rc.1] - 2026-10-12 diff --git a/packages/ore-rs/src/scheme/chained.rs b/packages/ore-rs/src/scheme/chained.rs index 5c12ef83..d518d317 100644 --- a/packages/ore-rs/src/scheme/chained.rs +++ b/packages/ore-rs/src/scheme/chained.rs @@ -67,6 +67,7 @@ //! string never produces, unpadded), but raw symbols cannot be padded with //! zero symbols, since a trailing zero symbol is a real value there, so they //! are not offered as an API (#110). +//! //! **Fixed-length types.** `u128` and `i128` (22 blocks) are too wide for //! the packed prefix of `OreAes128Bit6` and for the legacy scheme, so their //! [`crate::OreEncrypt`] impls live here. The outputs, [`crate::scheme::chained::ChainedCipherText`] and @@ -96,7 +97,6 @@ //! ); //! ``` //! -//! //! **Stored rows compare offline.** A full ciphertext carries its //! deterministic left half (`xt`, `f`), so anyone holding two stored full //! ciphertexts can run `compare_raw_slices` with no @@ -1964,8 +1964,9 @@ mod tests { // Strings are unpadded here: a padded one carries another policy // tag, which every comparator refuses before looking at the count. let unpadded = ore_with(Padding::None); - let s = unpadded.encrypt_str("alice").unwrap(); // 7 blocks - // 16 bytes = 22 blocks; and the same string padded (to 16 bytes). + // 5 bytes = 7 blocks. + let s = unpadded.encrypt_str("alice").unwrap(); + // 16 bytes = 22 blocks; and the same string padded (to 16 bytes). let s16 = unpadded.encrypt_str("0123456789abcdef").unwrap(); let s16_padded = c.encrypt_str("0123456789abcdef").unwrap(); assert_eq!(s16_padded.left.xt.len(), U128_BLOCKS); @@ -2313,8 +2314,6 @@ mod tests { true } - /// Two encryptions of one plaintext (fresh nonces each) compare Equal, - /// and never produce identical bytes (the nonce streams differ). /// Typed `u128` ciphertexts order as the integers, on both the full /// and the left-query path. fn prop_u128_order(a: u128, b: u128) -> bool { @@ -2333,6 +2332,8 @@ mod tests { && ChainedCipherText::::compare_left_to_full(&la, &cb) == 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); From 7402a9343ef2e18e3c4d36eb2d762ebb688d1f68 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 19 Oct 2026 10:30:06 +1100 Subject: [PATCH 5/7] fix(bit6): return TooManyBlocks for a plaintext past MAX_BLOCKS instead of panicking Review of #113. `OreAes128Bit6` asserted `N <= MAX_BLOCKS` in `encrypt_blocks` and `encrypt_left_blocks`, so `OreCipher::encrypt(&[0u8; 15])` panicked. Both now return `OreError::TooManyBlocks`, as `OreAes128` does since the first commit of this PR. The `OreEncrypt` impls are unaffected: their block counts are checked at compile time. --- packages/ore-rs/CHANGELOG.md | 7 +++++-- packages/ore-rs/src/scheme/bit2_w6.rs | 27 ++++++++++++++++++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/ore-rs/CHANGELOG.md b/packages/ore-rs/CHANGELOG.md index 87b3ab72..effdb239 100644 --- a/packages/ore-rs/CHANGELOG.md +++ b/packages/ore-rs/CHANGELOG.md @@ -39,8 +39,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `OreAes128` panicked with an index out of bounds on any 16-byte plaintext, which is what the `u128`/`i128` impls on that scheme had always done (#105). Those impls are removed (see Removed): the legacy scheme cannot take 16 bytes, and the impl macro now rejects such a type - at compile time. Encrypting a `PlainText` with `N > 15` directly returns `OreError::TooManyBlocks` - instead of panicking. + at compile time. Encrypting a `PlainText` with `N > 15` directly returns + `OreError::TooManyBlocks` instead of panicking. +- `OreAes128Bit6` panicked on a `PlainText` with `N > 14` (`MAX_BLOCKS`) through + `OreCipher::encrypt` / `encrypt_left`; it now returns `OreError::TooManyBlocks`, as the legacy + scheme does. ## [0.9.0-rc.1] - 2026-10-12 diff --git a/packages/ore-rs/src/scheme/bit2_w6.rs b/packages/ore-rs/src/scheme/bit2_w6.rs index 0fe4d5a1..4f9c80cb 100644 --- a/packages/ore-rs/src/scheme/bit2_w6.rs +++ b/packages/ore-rs/src/scheme/bit2_w6.rs @@ -152,7 +152,9 @@ impl OreAes128Bit6 { &self, x: &Blocks6>, ) -> EncryptLeftResult { - assert!(N <= MAX_BLOCKS); + if N > MAX_BLOCKS { + return Err(OreError::TooManyBlocks); + } let mut output = Left::::init(); let seeds = self.derive_prp_seeds(x.inner()); @@ -177,7 +179,9 @@ impl OreAes128Bit6 { &self, x: &Blocks6>, ) -> EncryptResult { - assert!(N <= MAX_BLOCKS); + if N > MAX_BLOCKS { + return Err(OreError::TooManyBlocks); + } let mut left = Left::::init(); let mut right = Right::::init(); @@ -260,7 +264,7 @@ impl OreCipher for OreAes128Bit6 { /// Encrypt `x`, whose entries are **6-bit block values** (`< 64`, as /// the crate-private `scheme::decompose` produces); `N` is the block count - /// (≤ [`MAX_BLOCKS`]). The [`crate::OreEncrypt`] impls handle the + /// (≤ [`MAX_BLOCKS`]; more returns [`OreError::TooManyBlocks`]). The [`crate::OreEncrypt`] impls handle the /// byte→block decomposition for primitive types and skip the check /// below. A value `>= 64` returns [`OreError::PrpError`]. fn encrypt_left(&self, x: &PlainText) -> EncryptLeftResult { @@ -547,6 +551,23 @@ mod tests { OreCipher::init(&k1, &k2).unwrap() } + /// A block count past `MAX_BLOCKS` through the public `OreCipher` path + /// is an error, as on the legacy scheme, not a panic (review of #113). + #[test] + fn too_many_blocks_is_an_error_not_a_panic() { + let ore = init_ore(); + assert!(matches!( + ore.encrypt(&[0u8; MAX_BLOCKS + 1]), + Err(OreError::TooManyBlocks) + )); + assert!(matches!( + ore.encrypt_left(&[0u8; MAX_BLOCKS + 1]), + Err(OreError::TooManyBlocks) + )); + assert!(ore.encrypt(&[0u8; MAX_BLOCKS]).is_ok()); + assert!(ore.encrypt_left(&[0u8; MAX_BLOCKS]).is_ok()); + } + /// Cross-scheme regression (review of #82): under keys shared with the /// legacy scheme, a legacy `N = 15` left ciphertext must never publish /// one of Bit6's PRF₁ tags. Bit6's `f[0]` for an 11-block value has PRF₁ From 30ad26838471eecddcff5a4fc988423e20db804a Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 19 Oct 2026 10:42:34 +1100 Subject: [PATCH 6/7] refactor!: no runtime panics in library code; deny the panic lints Review of #113: library code still panicked through `assert!`, `debug_assert!` and `expect`. Each is now a type, an error or gone: - SIMD kernels, the Z2 hashes and the PRP builders take fixed-size arrays (`&mut [u8; 32]`, `&[AesBlock; 256]`, `&[u8; 16]`, a whole draw stream) instead of slices whose lengths they asserted. `Hash::hash_all_into` and the scalar packs are generic over the block and byte counts, checked equal at compile time (`Packed`); `Prp` and `RightBitVec` name their mask type and `BlockWidth` ties the two, so a right block's bitvector is the PRP's mask by type. - The draw stream is `STREAM_BYTES = 512` (the keystream every caller already has; the builders read the first 504), so `LemireFyPrp::from_stream` cannot fail. The two `expect`s in `ct_bench` and the chained scheme's `?` on `prp_at` go with it. - The wire header takes a `u16` count: a const `N` that does not fit is a compile error (`WireCount`). - `decompose_6bit` reads past the end of its input as zero, which is what `decompose_padded` did by hand. - `RightBlock32::set_bit` takes a `u8`. The `get_bit`s and the comparator's resolve step select obliviously and cannot index out of bounds; their debug asserts are gone, as are the decimal encoder's, whose invariants the comments already prove. Tests that only checked a length panic are removed: those lengths are now types. The wire format is unchanged; every pinned vector passes. Both crates deny `clippy::{panic, unwrap_used, expect_used, unreachable, todo, unimplemented, panic_in_result_fn}` outside `cfg(test)`. Clippy has no lint for `assert!`, so `scripts/no-runtime-asserts.py`, a new CI step, rejects runtime assertions outside tests and kani proofs; compile-time `const NAME: () = assert!(..)` checks stay allowed. --- .github/workflows/test.yml | 5 + packages/orderable-bytes/src/decimal.rs | 13 +- packages/orderable-bytes/src/lib.rs | 15 ++ packages/ore-rs/CHANGELOG.md | 5 + packages/ore-rs/src/ciphertext.rs | 29 +++- packages/ore-rs/src/lib.rs | 22 ++- packages/ore-rs/src/primitives.rs | 29 +++- packages/ore-rs/src/primitives/hash.rs | 144 ++++++------------ packages/ore-rs/src/primitives/prp.rs | 53 +++---- .../ore-rs/src/primitives/prp/oblivious.rs | 86 ++++++----- packages/ore-rs/src/primitives/simd.rs | 103 ++++++------- packages/ore-rs/src/scheme/bit2.rs | 7 +- .../ore-rs/src/scheme/bit2/block_types.rs | 17 +-- .../ore-rs/src/scheme/bit2_w6/block_types.rs | 8 +- packages/ore-rs/src/scheme/chained.rs | 19 ++- packages/ore-rs/src/scheme/decompose.rs | 21 ++- packages/ore-rs/src/scheme/width.rs | 34 ++++- scripts/no-runtime-asserts.py | 125 +++++++++++++++ 18 files changed, 447 insertions(+), 288 deletions(-) create mode 100755 scripts/no-runtime-asserts.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 43b0a84c..1ba9ebec 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -53,6 +53,11 @@ jobs: - name: format run: cargo fmt -- --check + # Clippy denies the panic lints in library code; it has none for + # `assert!` / `debug_assert!`, so this covers them. + - name: no runtime asserts + run: python3 scripts/no-runtime-asserts.py + # Broken or private intra-doc links fail here rather than on docs.rs. - name: docs env: diff --git a/packages/orderable-bytes/src/decimal.rs b/packages/orderable-bytes/src/decimal.rs index d523256a..36d1465e 100644 --- a/packages/orderable-bytes/src/decimal.rs +++ b/packages/orderable-bytes/src/decimal.rs @@ -138,13 +138,8 @@ impl_fixed_orderable_bytes! { // `[-29, 28]` covers both branches without leaking the zero/non-zero // distinction in debug builds either. let leading_exp = digits as i32 - 1 + trailing - scale; - debug_assert!( - (-29..=28).contains(&leading_exp), - "leading_exp {} out of bounds — mantissa or scale corrupted", - leading_exp, - ); + // `leading_exp + EXP_BIAS` is in `35..=92`, so it fits the 7-bit field. let biased_exp = (leading_exp + EXP_BIAS) as u8; - debug_assert!(biased_exp <= EXP_MASK, "biased_exp overflowed 7 bits"); // Pad the significand out to 29 decimal digits so same-exponent compares // across different significand lengths are byte-wise correct. @@ -174,11 +169,9 @@ impl_fixed_orderable_bytes! { let stepped = padded_mantissa.wrapping_mul(10); padded_mantissa = (padded_mantissa & !mask) | (stepped & mask); } + // `padded_mantissa ≤ 10^29 < 2^97` (above), so the bytes dropped here + // are zero. let mant_be = padded_mantissa.to_be_bytes(); - debug_assert!( - mant_be[..16 - MANTISSA_BYTES].iter().all(|&b| b == 0), - "padded mantissa overflowed 104 bits", - ); let mant_field = &mant_be[16 - MANTISSA_BYTES..]; // Sign-class handling is folded into a single branchless mask so the diff --git a/packages/orderable-bytes/src/lib.rs b/packages/orderable-bytes/src/lib.rs index d6bb3995..0d459dcd 100644 --- a/packages/orderable-bytes/src/lib.rs +++ b/packages/orderable-bytes/src/lib.rs @@ -1,4 +1,19 @@ #![deny(missing_docs)] +// Library code does not panic: it returns an error or carries the invariant +// in a type. Tests may. Clippy has no lint for `assert!`; +// `scripts/no-runtime-asserts.py` covers that in CI. +#![cfg_attr( + not(test), + deny( + clippy::panic, + clippy::unwrap_used, + clippy::expect_used, + clippy::unreachable, + clippy::todo, + clippy::unimplemented, + clippy::panic_in_result_fn + ) +)] //! Canonical, order-preserving byte encodings for plaintext types. //! //! Each supported type implements [`OrderableBytes`], which maps a diff --git a/packages/ore-rs/CHANGELOG.md b/packages/ore-rs/CHANGELOG.md index effdb239..dc637e7a 100644 --- a/packages/ore-rs/CHANGELOG.md +++ b/packages/ore-rs/CHANGELOG.md @@ -27,6 +27,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `OreCipher`) can implement it. Existing impls and callers are unaffected. - Every `OreEncrypt` impl macro now emits a round-trip and ordering test for the type it implements, so a type can't be declared supported without being exercised (#105). +- Library code no longer panics through `assert!`, `debug_assert!`, `unwrap` or `expect`. + Length contracts between internal buffers are carried by array types, so a mismatch is a + compile error, and clippy's panic lints are denied outside tests (CI also rejects runtime + assertions, which clippy cannot lint). **Breaking:** `RightBlock32::set_bit` takes the bit + as a `u8`, which names exactly its 256 bits. ### Removed diff --git a/packages/ore-rs/src/ciphertext.rs b/packages/ore-rs/src/ciphertext.rs index 19f53c66..bc5fc3a2 100644 --- a/packages/ore-rs/src/ciphertext.rs +++ b/packages/ore-rs/src/ciphertext.rs @@ -25,11 +25,10 @@ pub struct WireHeader { 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); + pub(crate) fn write(&self, num_blocks: u16, out: &mut Vec) { out.push(self.version); out.push(self.scheme_id); - out.extend_from_slice(&(num_blocks as u16).to_be_bytes()); + out.extend_from_slice(&num_blocks.to_be_bytes()); } /// Validate `data`'s header against `self` and an expected block count, @@ -47,6 +46,24 @@ impl WireHeader { } } +/// A const block count `N` as the header's `u16`. An `N` the header cannot +/// hold is a compile error, so the cast below never truncates. +struct WireCount; + +impl WireCount { + const FITS: () = assert!( + N <= u16::MAX as usize, + "WireCount: too many blocks for the wire header" + ); + + #[inline] + #[allow(clippy::let_unit_value)] + fn get() -> u16 { + let () = Self::FITS; + N as u16 + } +} + /// `(version, scheme_id, block_count)` as parsed from a wire header. pub(crate) type ParsedHeader = (u8, u8, usize); @@ -191,7 +208,7 @@ impl OreOutput for Left { 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); + header.write(WireCount::::get(), &mut vec); } self.write_body(&mut vec); vec @@ -250,7 +267,7 @@ impl OreOutput for Right { 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); + header.write(WireCount::::get(), &mut vec); } self.write_body(&mut vec); vec @@ -276,7 +293,7 @@ impl OreOutput for CipherText { 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); + header.write(WireCount::::get(), &mut vec); } self.left.write_body(&mut vec); self.right.write_body(&mut vec); diff --git a/packages/ore-rs/src/lib.rs b/packages/ore-rs/src/lib.rs index c8501eba..bb560c5c 100644 --- a/packages/ore-rs/src/lib.rs +++ b/packages/ore-rs/src/lib.rs @@ -136,6 +136,21 @@ //! ``` #![deny(missing_docs)] +// Library code does not panic: it returns an error or carries the invariant +// in a type. Tests may. Clippy has no lint for `assert!`; +// `scripts/no-runtime-asserts.py` covers that in CI. +#![cfg_attr( + not(test), + deny( + clippy::panic, + clippy::unwrap_used, + clippy::expect_used, + clippy::unreachable, + clippy::todo, + clippy::unimplemented, + clippy::panic_in_result_fn + ) +)] #[cfg(feature = "chrono")] mod chrono; @@ -160,14 +175,15 @@ pub mod ct_bench { /// the builder the schemes use (the target's oblivious builder). pub fn lemire_fy_prp_from_stream(stream: &[u8; 512]) -> u8 { LemireFyPrp::<64>::from_stream(stream) - .expect("512 bytes is a full stream") .permute(Symbol::from_low_bits(0)) .get() } - fn from_stream_with(stream: &[u8; 512], build: fn(&[u8], &mut [u8; 64], &mut [u8; 64])) -> u8 { + fn from_stream_with( + stream: &[u8; 512], + build: fn(&[u8; 512], &mut [u8; 64], &mut [u8; 64]), + ) -> u8 { LemireFyPrp::<64>::from_stream_with(stream, build) - .expect("512 bytes is a full stream") .permute(Symbol::from_low_bits(0)) .get() } diff --git a/packages/ore-rs/src/primitives.rs b/packages/ore-rs/src/primitives.rs index b45ffeb7..9ec6ba44 100644 --- a/packages/ore-rs/src/primitives.rs +++ b/packages/ore-rs/src/primitives.rs @@ -20,11 +20,26 @@ pub trait Prf { pub trait Hash { fn new(key: &HashKey) -> Self; - fn hash(&self, data: &[u8]) -> u8; + fn hash(&self, data: &[u8; 16]) -> u8; /// Hash every block in `input` (in place, trashing it) and pack the /// 1-bit outputs LSB-first into `out`: bit `j` of `out` is the hash of - /// `input[j]`. `out.len() * 8` must equal `input.len()`. - fn hash_all_into(&self, input: &mut [AesBlock], out: &mut [u8]); + /// `input[j]`. `D` must be `8 * B`, checked at compile time + /// ([`Packed`]). + fn hash_all_into( + &self, + input: &mut [AesBlock; D], + out: &mut [u8; B], + ); +} + +/// `D` one-bit values packed LSB-first into `B` bytes. [`Self::OK`] is a +/// compile-time check that they fit exactly; a function taking a +/// `[_; D]` and a `[u8; B]` evaluates it, so a mismatched pair does not +/// compile. +pub(crate) struct Packed; + +impl Packed { + pub(crate) const OK: () = assert!(D == 8 * B, "Packed: D values need exactly D / 8 bytes"); } #[derive(Debug, Error)] @@ -86,13 +101,15 @@ pub trait Prp: Sized { #[allow(dead_code)] fn invert(&self, data: S) -> S; + /// The indicator mask: one bit per domain value, `[u8; DOMAIN / 8]`. + type Mask; + /// XOR the indicator mask for `data` into `out`: bit `j` of the mask is /// `1` iff `invert(j) > data`. Bit order matches - /// `RightBlock32::set_bit` (LSB-first within each byte). `out.len() * 8` - /// must equal the permutation domain. + /// `RightBlock32::set_bit` (LSB-first within each byte). /// /// This is the bulk form of the per-`j` `invert`-and-compare loop the /// right-ciphertext encoder needs; implementations walk their inverse /// table linearly instead of doing `DOMAIN` indexed lookups. - fn indicator_mask_xor(&self, data: S, out: &mut [u8]); + fn indicator_mask_xor(&self, data: S, out: &mut Self::Mask); } diff --git a/packages/ore-rs/src/primitives/hash.rs b/packages/ore-rs/src/primitives/hash.rs index 898c6765..089dd8a3 100644 --- a/packages/ore-rs/src/primitives/hash.rs +++ b/packages/ore-rs/src/primitives/hash.rs @@ -15,41 +15,22 @@ impl Hash for Aes128Z2Hash { Self { cipher } } - fn hash(&self, data: &[u8]) -> u8 { - /* - * Slice size is not known at compile time so we assert here - * We could do this with compile checks but this would require an additional - * copy (and doesn't entirely avoid runtime checks anyway) - * See https://stackoverflow.com/questions/38168956/take-slice-of-certain-length-known-at-compile-time - */ - assert_eq!(data.len(), 16); - // Can we clone into GenericArray directly? Are we doing an extra copy? - let mut output = [0u8; 16]; - output.clone_from_slice(data); - let block = GenericArray::from_mut_slice(&mut output); - self.cipher.encrypt_block(block); - output[0] & 1u8 + fn hash(&self, data: &[u8; 16]) -> u8 { + let mut block = AesBlock::from(*data); + self.cipher.encrypt_block(&mut block); + block[0] & 1u8 } - fn hash_all_into(&self, data: &mut [AesBlock], out: &mut [u8]) { - // A real assert: the scalar pack below zips `out` with `data`, so a - // mismatch would silently truncate the mask in a release build. - assert_eq!( - out.len() * 8, - data.len(), - "hash_all_into: out.len() * 8 must equal data.len()" - ); + fn hash_all_into( + &self, + data: &mut [AesBlock; D], + out: &mut [u8; B], + ) { self.cipher.encrypt_blocks(data); // Pack the Z2 (1-bit) outputs LSB-first, eight blocks per byte — - // the same bit order as `RightBlock32::set_bit`. The 256-block case - // (Bit8's per-block RO output) has a vectorised gather; other sizes - // use the scalar pack. - if data.len() == 256 { - crate::primitives::simd::lsb_mask_256(data, out); - } else { - crate::primitives::simd::scalar::lsb_mask(data, out); - } + // the same bit order as `RightBlock32::set_bit`. + crate::primitives::simd::lsb_mask(data, out); } } @@ -106,17 +87,13 @@ pub(crate) fn gf128_double_u128(x: u128) -> u128 { (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 +/// In-place GF(2^128) doubling `b ← 2·b` on a 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 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()); +fn gf128_double(b: &mut [u8; 16]) { + *b = gf128_double_u128(u128::from_be_bytes(*b)).to_be_bytes(); } impl Hash for FixedPiZ2Hash { @@ -124,54 +101,41 @@ impl Hash for FixedPiZ2Hash { Self { nonce: *nonce } } - fn hash(&self, data: &[u8]) -> u8 { - assert_eq!(data.len(), 16); + fn hash(&self, data: &[u8; 16]) -> u8 { // BHKR σ-MMO: m = σ(x) ⊕ r; return lsb(π(m) ⊕ m). - let mut block = [0u8; 16]; - block.copy_from_slice(data); + let mut block = *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); + let mut block = AesBlock::from(block); + pi().encrypt_block(&mut block); (block[0] & 1u8) ^ m_lsb } - fn hash_all_into(&self, data: &mut [AesBlock], out: &mut [u8]) { - debug_assert_eq!(out.len() * 8, data.len()); - + fn hash_all_into( + &self, + data: &mut [AesBlock; D], + out: &mut [u8; B], + ) { // 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). 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); + let nonce = u128::from_be_bytes(self.nonce.into()); for block in data.iter_mut() { - 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()); + let m = gf128_double_u128(u128::from_be_bytes((*block).into())) ^ nonce; + *block = m.to_be_bytes().into(); } // feedforward lsb(m) - if data.len() == 256 { - crate::primitives::simd::lsb_mask_256(data, out); - } else { - crate::primitives::simd::scalar::lsb_mask(data, out); - } + crate::primitives::simd::lsb_mask(data, out); pi().encrypt_blocks(data); // π(m) - 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); - } + let mut pi_mask = [0u8; B]; + crate::primitives::simd::lsb_mask(data, &mut pi_mask); for (slot, &m) in out.iter_mut().zip(pi_mask.iter()) { *slot ^= m; } @@ -205,24 +169,6 @@ mod tests { assert_eq!(0u8, hash.hash(&input)); } - #[test] - #[should_panic(expected = "assertion `left == right` failed")] - fn hash_test_input_too_small() { - let hash = init_hash(); - let input: [u8; 8] = hex!("00010203 04050607"); - - assert_eq!(0u8, hash.hash(&input)); - } - - #[test] - #[should_panic(expected = "assertion `left == right` failed")] - fn hash_test_input_too_large() { - let hash = init_hash(); - let input: [u8; 24] = hex!("00010203 04050607 ffffffff bbbbbbbb cccccccc abababab"); - - 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` @@ -232,25 +178,25 @@ mod tests { 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]; + check::<64, 8>(&h); + check::<256, 32>(&h); + + fn check(h: &FixedPiZ2Hash) { + let mut blocks = [AesBlock::default(); D]; + for (i, block) in blocks.iter_mut().enumerate() { + for (j, slot) in block.iter_mut().enumerate() { + *slot = (i.wrapping_mul(31).wrapping_add(j)) as u8; + } + } + + let mut expected = [0u8; B]; for (i, b) in blocks.iter().enumerate() { - expected[i / 8] |= h.hash(b.as_slice()) << (i % 8); + expected[i / 8] |= h.hash(&(*b).into()) << (i % 8); } - let mut out = vec![0u8; n / 8]; + let mut out = [0u8; B]; h.hash_all_into(&mut blocks, &mut out); - assert_eq!(out, expected, "scalar vs bulk mismatch for n={}", n); + assert_eq!(out, expected, "scalar vs bulk mismatch for n={}", D); } } diff --git a/packages/ore-rs/src/primitives/prp.rs b/packages/ore-rs/src/primitives/prp.rs index ed269d6e..3117a747 100644 --- a/packages/ore-rs/src/primitives/prp.rs +++ b/packages/ore-rs/src/primitives/prp.rs @@ -100,9 +100,9 @@ macro_rules! impl_knuth_shuffle_prp { oblivious_lookup(&self.permutation, input) } - fn indicator_mask_xor(&self, data: Symbol<$domain>, out: &mut [u8]) { - debug_assert_eq!(out.len() * 8, $domain); + type Mask = [u8; $domain / 8]; + fn indicator_mask_xor(&self, data: Symbol<$domain>, out: &mut Self::Mask) { // `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 @@ -187,14 +187,14 @@ macro_rules! impl_lemire_fy_prp { 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 + /// (shape (ii)): `$domain - 1` little-endian u64 draws, each + /// Lemire-reduced (see [`oblivious::STREAM_BYTES`] for the + /// stream's length). The chained scheme feeds the CMAC accumulator's /// `PRP_STREAM` branch here, avoiding a per-block AES key schedule; /// [`Prp::new`] feeds it an AES-CTR keystream. Either way the /// tables come from the oblivious builder ([`oblivious::build`]), /// so no address depends on the stream. - pub(crate) fn from_stream(stream: &[u8]) -> PrpResult { + pub(crate) fn from_stream(stream: &[u8; oblivious::STREAM_BYTES]) -> Self { // Fisher–Yates with Lemire-reduced wide draws: draw `d` // (8 bytes) drives step `$domain - 1 - d`. Fixed trip count, // branch-free index reduction, and no secret-dependent @@ -210,19 +210,19 @@ macro_rules! impl_lemire_fy_prp { /// so they time this same function. `build` is a generic /// parameter, not a function pointer, so the scheme path is /// monomorphised and the builder still inlines. - pub(crate) fn from_stream_with(stream: &[u8], build: F) -> PrpResult + pub(crate) fn from_stream_with( + stream: &[u8; oblivious::STREAM_BYTES], + build: F, + ) -> Self where - F: FnOnce(&[u8], &mut [u8; $domain], &mut [u8; $domain]), + F: FnOnce(&[u8; oblivious::STREAM_BYTES], &mut [u8; $domain], &mut [u8; $domain]), { - if stream.len() < ($domain - 1) * 8 { - return Err(PrpError); - } let mut perm = Self { permutation: [0u8; $domain], inverse: [0u8; $domain], }; build(stream, &mut perm.permutation, &mut perm.inverse); - Ok(perm) + perm } } @@ -244,12 +244,14 @@ macro_rules! impl_lemire_fy_prp { } cipher.encrypt_blocks(&mut blocks); - let mut stream = [0u8; $stream_blocks * 16]; + // A whole stream by type: `$stream_blocks` blocks must be + // `oblivious::STREAM_BYTES`, or this does not compile. + let mut stream: [u8; oblivious::STREAM_BYTES] = [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)?; + let perm = Self::from_stream(&stream); // The keystream determined the permutation — wipe it. stream.zeroize(); @@ -268,8 +270,9 @@ macro_rules! impl_lemire_fy_prp { oblivious_lookup(&self.permutation, input) } - fn indicator_mask_xor(&self, data: Symbol<$domain>, out: &mut [u8]) { - debug_assert_eq!(out.len() * 8, $domain); + type Mask = [u8; $domain / 8]; + + fn indicator_mask_xor(&self, data: Symbol<$domain>, out: &mut Self::Mask) { $gt_mask(&self.permutation, data.get(), out); } } @@ -377,22 +380,6 @@ mod tests { assert!((0..64u8).any(|v| a.permute(sym64(v)) != c.permute(sym64(v)))); } - /// `from_stream` rejects every stream shorter than 63 * 8 bytes and - /// accepts the first full length. Exhaustive over the 504 short lengths; - /// this was a Kani harness, which took 335 s to prove the same thing. - #[test] - fn fy_from_stream_rejects_every_short_stream() { - let buf = [0u8; 63 * 8]; - for len in 0..buf.len() { - assert!( - LemireFyPrp::<64>::from_stream(&buf[..len]).is_err(), - "len {}", - len - ); - } - assert!(LemireFyPrp::<64>::from_stream(&buf).is_ok()); - } - #[test] fn fy_rejects_short_key() { assert!( as Prp>>::new(&[0u8; 8]).is_err()); @@ -472,7 +459,7 @@ mod kani_proofs { #[kani::proof] #[kani::unwind(64)] fn lemire_draw_in_range() { - let stream: [u8; 63 * 8] = kani::any(); + let stream: [u8; oblivious::STREAM_BYTES] = kani::any(); for i in 1..64 { let d = 63 - i; let mut draw = [0u8; 8]; diff --git a/packages/ore-rs/src/primitives/prp/oblivious.rs b/packages/ore-rs/src/primitives/prp/oblivious.rs index fdfc744f..25cbda1c 100644 --- a/packages/ore-rs/src/primitives/prp/oblivious.rs +++ b/packages/ore-rs/src/primitives/prp/oblivious.rs @@ -52,14 +52,18 @@ use zeroize::Zeroize; /// The domain the oblivious builders support. pub(crate) const DOMAIN: usize = 64; -/// Bytes of stream the builders consume: one 8-byte draw per step. -pub(crate) const STREAM_BYTES: usize = (DOMAIN - 1) * 8; +/// Bytes in a draw stream: one 8-byte draw per step for the `DOMAIN - 1` +/// steps, plus 8 bytes the builders never read, which make the stream a +/// whole number of AES blocks (32), as [`super::LemireFyPrp`] produces it. +/// Every builder takes exactly this array, so a short stream is a type +/// error, not a runtime check. +pub(crate) const STREAM_BYTES: usize = DOMAIN * 8; /// The Lemire-reduced draw for Fisher–Yates step `i` (`1..64`): draw /// `63 - i`, as a little-endian u64, mapped to `0..=i` by multiply-high. /// Identical to the indexed builder's reduction. #[inline(always)] -pub(crate) fn lemire_draw(stream: &[u8], i: usize) -> u8 { +pub(crate) fn lemire_draw(stream: &[u8; STREAM_BYTES], i: usize) -> u8 { let d = DOMAIN - 1 - i; let mut draw = [0u8; 8]; draw.copy_from_slice(&stream[d * 8..d * 8 + 8]); @@ -72,18 +76,19 @@ pub(crate) fn lemire_draw(stream: &[u8], i: usize) -> u8 { j } -/// Build `perm` and `inverse` from `stream` (at least [`STREAM_BYTES`] -/// bytes) with a data-independent memory access pattern, using the fastest +/// Build `perm` and `inverse` from `stream` with a data-independent memory access pattern, using the fastest /// oblivious builder for the target. #[inline] -pub(crate) fn build(stream: &[u8], perm: &mut [u8; DOMAIN], inverse: &mut [u8; DOMAIN]) { - assert!(stream.len() >= STREAM_BYTES); - +pub(crate) fn build( + stream: &[u8; STREAM_BYTES], + perm: &mut [u8; DOMAIN], + inverse: &mut [u8; DOMAIN], +) { // Kani cannot model the NEON and SSSE3 intrinsics, so under `cargo kani` // every target dispatches to the SWAR builder, which the harnesses prove // equal to the reference builder. #[cfg(all(target_arch = "aarch64", not(kani)))] - // SAFETY: NEON is baseline on aarch64; `stream` length asserted above. + // SAFETY: NEON is baseline on aarch64; `stream` is a full stream by type. unsafe { neon::build(stream, perm, inverse) } @@ -92,7 +97,7 @@ pub(crate) fn build(stream: &[u8], perm: &mut [u8; DOMAIN], inverse: &mut [u8; D { #[cfg(all(target_arch = "x86_64", not(kani)))] if is_x86_feature_detected!("ssse3") { - // SAFETY: SSSE3 presence just checked; `stream` length asserted. + // SAFETY: SSSE3 presence just checked; `stream` is a full stream by type. unsafe { ssse3::build(stream, perm, inverse) }; return; } @@ -111,8 +116,11 @@ pub(crate) fn build(stream: &[u8], perm: &mut [u8; DOMAIN], inverse: &mut [u8; D pub(crate) mod reference { use super::*; - pub(crate) fn build(stream: &[u8], perm: &mut [u8; DOMAIN], inverse: &mut [u8; DOMAIN]) { - assert!(stream.len() >= STREAM_BYTES); + pub(crate) fn build( + stream: &[u8; STREAM_BYTES], + perm: &mut [u8; DOMAIN], + inverse: &mut [u8; DOMAIN], + ) { for (k, p) in perm.iter_mut().enumerate() { *p = k as u8; } @@ -136,8 +144,11 @@ pub(crate) mod scalar { use super::*; use subtle_ng::{ConditionallySelectable, ConstantTimeEq}; - pub(crate) fn build(stream: &[u8], perm: &mut [u8; DOMAIN], inverse: &mut [u8; DOMAIN]) { - assert!(stream.len() >= STREAM_BYTES); + pub(crate) fn build( + stream: &[u8; STREAM_BYTES], + perm: &mut [u8; DOMAIN], + inverse: &mut [u8; DOMAIN], + ) { for (k, (p, v)) in perm.iter_mut().zip(inverse.iter_mut()).enumerate() { *p = k as u8; *v = k as u8; @@ -218,8 +229,11 @@ pub(crate) mod swar { } } - pub(crate) fn build(stream: &[u8], perm: &mut [u8; DOMAIN], inverse: &mut [u8; DOMAIN]) { - assert!(stream.len() >= STREAM_BYTES); + pub(crate) fn build( + stream: &[u8; STREAM_BYTES], + perm: &mut [u8; DOMAIN], + inverse: &mut [u8; DOMAIN], + ) { let mut index = [0u64; 8]; for (w, word) in index.iter_mut().enumerate() { let mut b = [0u8; 8]; @@ -259,7 +273,7 @@ pub(crate) mod swar { #[cfg(all(target_arch = "aarch64", not(kani)))] mod neon { - use super::{lemire_draw, DOMAIN}; + use super::{lemire_draw, DOMAIN, STREAM_BYTES}; use core::arch::aarch64::*; /// `0, 1, …, 63`: the lane index of each table entry. @@ -298,7 +312,7 @@ mod neon { /// dependency through `perm`. It covers all four chunks. #[inline(always)] unsafe fn steps( - stream: &[u8], + stream: &[u8; STREAM_BYTES], p: &mut [uint8x16_t; 4], v: &mut [uint8x16_t; 4], index: &[uint8x16_t; 4], @@ -330,15 +344,19 @@ mod neon { /// # Safety /// - /// Requires NEON (baseline on aarch64) and `stream.len() >= - /// STREAM_BYTES` (asserted by the caller). The only memory operations + /// Requires NEON (baseline on aarch64). `stream` is a full stream by + /// type. The only memory operations /// are the load of the constant `INDEX`, the bounds-checked stream reads /// in `lemire_draw`, and the two 64-byte stores into `perm` and /// `inverse`, which are exclusive references to 64-byte arrays. Both /// tables live in vector registers in between: every `p[k]` / `v[k]` /// index is a compile-time constant after inlining. #[target_feature(enable = "neon")] - pub(super) unsafe fn build(stream: &[u8], perm: &mut [u8; DOMAIN], inverse: &mut [u8; DOMAIN]) { + pub(super) unsafe fn build( + stream: &[u8; STREAM_BYTES], + perm: &mut [u8; DOMAIN], + inverse: &mut [u8; DOMAIN], + ) { let ix = vld1q_u8_x4(INDEX.as_ptr()); let index = [ix.0, ix.1, ix.2, ix.3]; let mut p = index; @@ -356,7 +374,7 @@ mod neon { #[cfg(all(target_arch = "x86_64", not(kani)))] mod ssse3 { - use super::{lemire_draw, DOMAIN}; + use super::{lemire_draw, DOMAIN, STREAM_BYTES}; use core::arch::x86_64::*; /// `select(m, x, y)`: `x` where the mask lane is all ones, else `y`. @@ -391,7 +409,7 @@ mod ssse3 { /// builder's `steps` (public chunk bounds, value-side inverse). #[inline(always)] unsafe fn steps( - stream: &[u8], + stream: &[u8; STREAM_BYTES], p: &mut [__m128i; 4], v: &mut [__m128i; 4], index: &[__m128i; 4], @@ -416,11 +434,15 @@ mod ssse3 { /// # Safety /// - /// Requires SSSE3 (checked by the caller) and `stream.len() >= - /// STREAM_BYTES` (asserted by the caller). Unaligned stores cover + /// Requires SSSE3 (checked by the caller). `stream` is a full stream by + /// type. Unaligned stores cover /// exactly the 64-byte `perm` and `inverse` arrays. #[target_feature(enable = "ssse3")] - pub(super) unsafe fn build(stream: &[u8], perm: &mut [u8; DOMAIN], inverse: &mut [u8; DOMAIN]) { + pub(super) unsafe fn build( + stream: &[u8; STREAM_BYTES], + perm: &mut [u8; DOMAIN], + inverse: &mut [u8; DOMAIN], + ) { let mut index = [_mm_setzero_si128(); 4]; for (c, chunk) in index.iter_mut().enumerate() { *chunk = _mm_add_epi8( @@ -449,7 +471,10 @@ mod tests { type Tables = ([u8; DOMAIN], [u8; DOMAIN]); - fn run(builder: fn(&[u8], &mut [u8; DOMAIN], &mut [u8; DOMAIN]), stream: &[u8]) -> Tables { + fn run( + builder: fn(&[u8; STREAM_BYTES], &mut [u8; DOMAIN], &mut [u8; DOMAIN]), + stream: &[u8; STREAM_BYTES], + ) -> Tables { let mut perm = [0u8; DOMAIN]; let mut inverse = [0u8; DOMAIN]; builder(stream, &mut perm, &mut inverse); @@ -544,11 +569,4 @@ mod tests { assert_eq!(usize::from(inverse[usize::from(*p)]), k); } } - - /// One byte short of a full stream is refused. - #[test] - #[should_panic] - fn build_rejects_a_short_stream() { - run(build, &[0u8; STREAM_BYTES - 1]); - } } diff --git a/packages/ore-rs/src/primitives/simd.rs b/packages/ore-rs/src/primitives/simd.rs index 8e9ee046..fd83d4f6 100644 --- a/packages/ore-rs/src/primitives/simd.rs +++ b/packages/ore-rs/src/primitives/simd.rs @@ -30,19 +30,15 @@ //! between scalar and vector paths is pinned by tests below; equivalence //! against the per-bit reference is pinned in `prp.rs`. -use super::AesBlock; +use super::{AesBlock, Packed}; +use core::convert::TryFrom; /// XOR the indicator mask for `x` over `table` into `out`: /// `out[j/8] ^= ((table[j] > x) as u8) << (j%8)`. #[inline] -pub(crate) fn gt_mask_xor_256(table: &[u8; 256], x: u8, out: &mut [u8]) { - // A real assert, so every backend sees the same contract in release: the - // scalar path would otherwise silently truncate, and the vector paths - // would panic only on a short slice. - assert_eq!(out.len(), 32); - +pub(crate) fn gt_mask_xor_256(table: &[u8; 256], x: u8, out: &mut [u8; 32]) { #[cfg(target_arch = "aarch64")] - // SAFETY: NEON is baseline on aarch64; `out` length asserted above. + // SAFETY: NEON is baseline on aarch64; the lengths are in the types. unsafe { neon::gt_mask_xor_256(table, x, out) } @@ -53,7 +49,7 @@ pub(crate) fn gt_mask_xor_256(table: &[u8; 256], x: u8, out: &mut [u8]) { { #[cfg(target_arch = "x86_64")] if is_x86_feature_detected!("avx2") { - // SAFETY: AVX2 presence just checked; `out` length asserted above. + // SAFETY: AVX2 presence just checked; the lengths are in the types. unsafe { avx2::gt_mask_xor_256(table, x, out) }; return; } @@ -66,13 +62,9 @@ pub(crate) fn gt_mask_xor_256(table: &[u8; 256], x: u8, out: &mut [u8]) { /// 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]) { - // A real assert, as for `gt_mask_xor_256`: the same contract on every - // backend in release. - assert_eq!(out.len(), 8); - +pub(crate) fn gt_mask_xor_64(table: &[u8; 64], x: u8, out: &mut [u8; 8]) { #[cfg(target_arch = "aarch64")] - // SAFETY: NEON is baseline on aarch64; `out` length asserted above. + // SAFETY: NEON is baseline on aarch64; the lengths are in the types. unsafe { neon::gt_mask_xor_64(table, x, out) } @@ -81,19 +73,29 @@ pub(crate) fn gt_mask_xor_64(table: &[u8; 64], x: u8, out: &mut [u8]) { scalar::gt_mask_xor(table, x, out); } -/// Pack the LSB of byte 0 of each of 256 AES blocks into `out`: -/// `out[j/8] |= (blocks[j][0] & 1) << (j%8)` over zeroed positions -/// (callers pass `out` slots they own; bits are assigned, not accumulated). +/// Pack the LSB of byte 0 of each block into `out`: bit `j` of `out` is +/// `blocks[j][0] & 1` (bits are assigned, not accumulated). The 256-block +/// case (Bit8's per-block RO output) has a vectorised gather; other sizes +/// use the scalar pack. `D` must be `8 * B`, checked at compile time. #[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); +pub(crate) fn lsb_mask(blocks: &[AesBlock; D], out: &mut [u8; B]) { + // Decided by `D` and `B` alone, so the untaken arm is compiled out. + if let (Ok(blocks), Ok(out)) = ( + <&[AesBlock; 256]>::try_from(blocks.as_slice()), + <&mut [u8; 32]>::try_from(out.as_mut_slice()), + ) { + lsb_mask_256(blocks, out); + return; + } + scalar::lsb_mask(blocks, out); +} +/// [`lsb_mask`] for 256 blocks. +#[inline] +pub(crate) fn lsb_mask_256(blocks: &[AesBlock; 256], out: &mut [u8; 32]) { #[cfg(target_arch = "aarch64")] - // SAFETY: NEON is baseline on aarch64; lengths asserted above. + // SAFETY: NEON is baseline on aarch64; the NEON path gathers `blocks` + // through raw pointers, which the array type keeps to 256 blocks. unsafe { neon::lsb_mask_256(blocks, out) } @@ -103,10 +105,9 @@ pub(crate) fn lsb_mask_256(blocks: &[AesBlock], out: &mut [u8]) { } pub(crate) mod scalar { - use super::AesBlock; + use super::{AesBlock, Packed}; - /// Length-generic scalar indicator pack: `table.len()` must be a - /// multiple of 8 and equal to `out.len() * 8`. + /// Scalar indicator pack: `D` must be `8 * B`, checked at compile time. /// /// Used by the non-aarch64 dispatchers and as the test oracle; on /// aarch64 the lib always reaches NEON, so outside `cfg(test)` it is @@ -114,7 +115,13 @@ pub(crate) mod scalar { /// 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(table: &[u8], x: u8, out: &mut [u8]) { + #[allow(clippy::let_unit_value)] + pub(crate) fn gt_mask_xor( + table: &[u8; D], + x: u8, + out: &mut [u8; B], + ) { + let () = Packed::::OK; for (slot, chunk) in out.iter_mut().zip(table.chunks_exact(8)) { let mut byte = 0u8; for (bit, &p) in chunk.iter().enumerate() { @@ -124,14 +131,13 @@ pub(crate) mod scalar { } } - /// Length-generic scalar LSB pack: `blocks.len()` must be a multiple - /// of 8 and equal to `out.len() * 8`. - pub(crate) fn lsb_mask(blocks: &[AesBlock], out: &mut [u8]) { - assert_eq!( - out.len() * 8, - blocks.len(), - "lsb_mask: out.len() * 8 must equal blocks.len()" - ); + /// Scalar LSB pack: `D` must be `8 * B`, checked at compile time. + #[allow(clippy::let_unit_value)] + pub(crate) fn lsb_mask( + blocks: &[AesBlock; D], + out: &mut [u8; B], + ) { + let () = Packed::::OK; for (slot, chunk) in out.iter_mut().zip(blocks.chunks_exact(8)) { let mut byte = 0u8; for (bit, block) in chunk.iter().enumerate() { @@ -160,7 +166,7 @@ mod neon { } #[target_feature(enable = "neon")] - pub(super) unsafe fn gt_mask_xor_256(table: &[u8; 256], x: u8, out: &mut [u8]) { + pub(super) unsafe fn gt_mask_xor_256(table: &[u8; 256], x: u8, out: &mut [u8; 32]) { let xv = vdupq_n_u8(x); for i in 0..16 { let t = vld1q_u8(table.as_ptr().add(i * 16)); @@ -171,7 +177,7 @@ mod neon { } #[target_feature(enable = "neon")] - pub(super) unsafe fn gt_mask_xor_64(table: &[u8; 64], x: u8, out: &mut [u8]) { + pub(super) unsafe fn gt_mask_xor_64(table: &[u8; 64], x: u8, out: &mut [u8; 8]) { let xv = vdupq_n_u8(x); for i in 0..4 { let t = vld1q_u8(table.as_ptr().add(i * 16)); @@ -210,7 +216,7 @@ mod neon { } #[target_feature(enable = "neon")] - pub(super) unsafe fn lsb_mask_256(blocks: &[AesBlock], out: &mut [u8]) { + pub(super) unsafe fn lsb_mask_256(blocks: &[AesBlock; 256], out: &mut [u8; 32]) { let p = blocks.as_ptr() as *const u8; let one = vdupq_n_u8(1); for i in 0..16 { @@ -229,7 +235,7 @@ mod avx2 { /// AVX2 has only *signed* byte compares; XOR-ing 0x80 into both sides /// maps unsigned `>` onto signed `>`. #[target_feature(enable = "avx2")] - pub(super) unsafe fn gt_mask_xor_256(table: &[u8; 256], x: u8, out: &mut [u8]) { + pub(super) unsafe fn gt_mask_xor_256(table: &[u8; 256], x: u8, out: &mut [u8; 32]) { let bias = _mm256_set1_epi8(i8::MIN); let xv = _mm256_xor_si256(_mm256_set1_epi8(x as i8), bias); for i in 0..8 { @@ -293,21 +299,6 @@ mod tests { } } - #[test] - #[should_panic(expected = "lsb_mask: out.len() * 8 must equal blocks.len()")] - fn scalar_lsb_mask_rejects_a_short_output() { - let blocks = [AesBlock::default(); 64]; - let mut out = [0u8; 7]; - scalar::lsb_mask(&blocks, &mut out); - } - - #[test] - #[should_panic] - fn gt_mask_xor_256_rejects_a_short_output() { - let mut out = [0u8; 31]; - gt_mask_xor_256(&[0u8; 256], 0, &mut out); - } - #[test] fn lsb_mask_dispatched_matches_scalar() { let mut rng = ChaCha20Rng::seed_from_u64(11); diff --git a/packages/ore-rs/src/scheme/bit2.rs b/packages/ore-rs/src/scheme/bit2.rs index d8f229ab..967ef344 100644 --- a/packages/ore-rs/src/scheme/bit2.rs +++ b/packages/ore-rs/src/scheme/bit2.rs @@ -99,11 +99,10 @@ pub(crate) fn encode_right_block( prp: &W::Prp, x: W::Symbol, hasher: &H, - ro_blocks: &mut [AesBlock], + ro_blocks: &mut W::RoKeyBuf, ) { - debug_assert_eq!(ro_blocks.len(), W::DOMAIN); - let out = block.as_mut_bytes(); - hasher.hash_all_into(ro_blocks, out); + let out = block.as_mut_bits(); + W::hash_into(hasher, ro_blocks, out); prp.indicator_mask_xor(x, out); } diff --git a/packages/ore-rs/src/scheme/bit2/block_types.rs b/packages/ore-rs/src/scheme/bit2/block_types.rs index 4de40211..cc00a16a 100644 --- a/packages/ore-rs/src/scheme/bit2/block_types.rs +++ b/packages/ore-rs/src/scheme/bit2/block_types.rs @@ -17,14 +17,12 @@ pub struct RightBlock32 { } impl RightBlock32 { - /// Set bit `bit` (in `0..256`) to `value` (which must be `0` or `1`), - /// overwriting whatever the bit held. Panics in debug if `bit >= 256`. + /// Set bit `bit` to the low bit of `value`, overwriting whatever the + /// bit held. A `u8` names every one of the 256 bits and no other. #[inline] - pub fn set_bit(&mut self, bit: usize, value: u8) { - debug_assert!(bit < 256); - let byte_index = bit / 8; + pub fn set_bit(&mut self, bit: u8, value: u8) { let shift = bit % 8; - let b = &mut self.data[byte_index]; + let b = &mut self.data[usize::from(bit / 8)]; *b = (*b & !(1 << shift)) | ((value & 1) << shift); } @@ -37,15 +35,14 @@ impl RightBlock32 { /// Mutable form of [`Self::bytes`]. #[inline] - pub(crate) fn bytes_mut(&mut self) -> &mut [u8] { + pub(crate) fn bytes_mut(&mut self) -> &mut [u8; 32] { &mut self.data } - /// Read bit `bit` (in `0..256`); returns `0` or `1`. Panics in debug if - /// `bit >= 256`. + /// Read bit `bit` (in `0..256`); returns `0` or `1`. A `bit` past the + /// block reads as `0`: the oblivious select finds no byte. #[inline] pub fn get_bit(&self, bit: usize) -> u8 { - debug_assert!(bit < 256); // `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); 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 7ae81dbb..1c8519f8 100644 --- a/packages/ore-rs/src/scheme/bit2_w6/block_types.rs +++ b/packages/ore-rs/src/scheme/bit2_w6/block_types.rs @@ -14,10 +14,10 @@ pub struct RightBlock8 { } impl RightBlock8 { - /// Read bit `bit` (in `0..64`); returns `0` or `1`. + /// Read bit `bit` (in `0..64`); returns `0` or `1`. A `bit` past the + /// block reads as `0`: the oblivious select finds no byte. #[inline] pub fn get_bit(&self, bit: usize) -> u8 { - debug_assert!(bit < 64); // `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); @@ -33,10 +33,12 @@ impl RightBlock8 { } impl RightBitVec for RightBlock8 { + type Bits = [u8; 8]; + fn get_bit(&self, bit: usize) -> u8 { RightBlock8::get_bit(self, bit) } - fn as_mut_bytes(&mut self) -> &mut [u8] { + fn as_mut_bits(&mut self) -> &mut [u8; 8] { &mut self.data } } diff --git a/packages/ore-rs/src/scheme/chained.rs b/packages/ore-rs/src/scheme/chained.rs index d518d317..8f3eaf17 100644 --- a/packages/ore-rs/src/scheme/chained.rs +++ b/packages/ore-rs/src/scheme/chained.rs @@ -514,8 +514,13 @@ impl VarCipherText { } /// Write the wire header: version, scheme id, block count, padding policy. +/// +/// Every ciphertext has at most `u16::MAX` blocks: encryption refuses more +/// ([`OreError::TooManyBlocks`]) and parsing reads the count from a `u16`. +/// Were a longer one made, the saturated count would not match its body, so +/// every parser would refuse the bytes. fn write_header(count: usize, padding: Padding, out: &mut Vec) { - WIRE.write(count, out); + WIRE.write(u16::try_from(count).unwrap_or(u16::MAX), out); out.extend_from_slice(&padding.to_wire()); } @@ -592,7 +597,7 @@ impl OreAes128Bit6Chained { } /// Derive the per-block PRP from the accumulator's `PRP_STREAM` branch. - fn prp_at(&self, acc: &CmacAccumulator, n: u16) -> Result, OreError> { + fn prp_at(&self, acc: &CmacAccumulator, n: u16) -> LemireFyPrp { let mut stream = [0u8; STREAM_BLOCKS * 16]; for (c, chunk) in stream.chunks_mut(16).enumerate() { chunk.copy_from_slice(&acc.finalize(&final_block( @@ -602,9 +607,9 @@ impl OreAes128Bit6Chained { WIDTH_BIT6, ))); } - let prp = LemireFyPrp::::from_stream(&stream)?; + let prp = LemireFyPrp::::from_stream(&stream); stream.zeroize(); - Ok(prp) + prp } /// Full Left+Right ciphertext for raw symbols `x` (one 6-bit symbol per @@ -650,7 +655,7 @@ impl OreAes128Bit6Chained { for n in 0..count { let sym = x.symbol(n); let n16 = n as u16; - let prp = self.prp_at(&acc, n16)?; + let prp = self.prp_at(&acc, n16); let permuted = prp.permute(sym).get(); xt.push(permuted); @@ -719,7 +724,7 @@ impl OreAes128Bit6Chained { for n in 0..count { let sym = x.symbol(n); let n16 = n as u16; - let prp = self.prp_at(&acc, n16)?; + let prp = self.prp_at(&acc, n16); let permuted = prp.permute(sym).get(); xt.push(permuted); f.push(acc.finalize(&final_block( @@ -2377,7 +2382,7 @@ mod tests { let mut acc = CmacAccumulator::new(&ore.k_acc); let mut needles = Vec::new(); for (n, &sym) in blocks.as_bytes().iter().enumerate() { - let prp = ore.prp_at(&acc, n as u16).unwrap(); + let prp = ore.prp_at(&acc, n as u16); let symbols = (0..64).map(crate::primitives::Symbol::<64>::from_low_bits); needles.push(symbols.clone().map(|i| prp.permute(i).get()).collect()); needles.push(symbols.map(|i| prp.invert(i).get()).collect()); diff --git a/packages/ore-rs/src/scheme/decompose.rs b/packages/ore-rs/src/scheme/decompose.rs index 38d2719a..a26b0c07 100644 --- a/packages/ore-rs/src/scheme/decompose.rs +++ b/packages/ore-rs/src/scheme/decompose.rs @@ -23,14 +23,14 @@ pub(crate) const fn num_blocks_6bit(n: usize) -> usize { } /// Decompose `bytes` MSB-first into 6-bit block values, writing them to -/// `out`. `out.len()` must be exactly `num_blocks_6bit(bytes.len())`. +/// `out`, which is normally `num_blocks_6bit(bytes.len())` long. /// /// 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. +/// the input bytes. Bits past the end of `bytes` read as zero, so a longer +/// `out` holds the decomposition of `bytes` followed by NULs, and a shorter +/// one a prefix of it. 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; @@ -38,7 +38,7 @@ pub(crate) fn decompose_6bit(bytes: &[u8], out: &mut [u8]) { // 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 hi = *bytes.get(byte).unwrap_or(&0) as u16; let lo = *bytes.get(byte + 1).unwrap_or(&0) as u16; let window = (hi << 8) | lo; @@ -103,8 +103,8 @@ impl> Blocks6 { } impl + AsMut<[u8]>> Blocks6 { - /// Decompose `bytes` into `out` (see [`decompose_6bit`]). - /// `out.as_ref().len()` must be `num_blocks_6bit(bytes.len())`. + /// Decompose `bytes` into `out` (see [`decompose_6bit`]), which is + /// normally `num_blocks_6bit(bytes.len())` long. pub(crate) fn decompose(bytes: &[u8], mut out: B) -> Self { decompose_6bit(bytes, out.as_mut()); Self(out) @@ -114,11 +114,10 @@ impl + AsMut<[u8]>> Blocks6 { /// `num_blocks_6bit(bytes.len())` blocks of `out`, and zeros after. NUL /// bytes add only zero bits, so this is the decomposition of `bytes` /// padded with NULs to any length with `out.as_ref().len()` blocks, - /// which must be at least `num_blocks_6bit(bytes.len())`. + /// which should be at least `num_blocks_6bit(bytes.len())` (a shorter + /// `out` holds a prefix; see [`decompose_6bit`]). pub(crate) fn decompose_padded(bytes: &[u8], mut out: B) -> Self { - let (head, tail) = out.as_mut().split_at_mut(num_blocks_6bit(bytes.len())); - decompose_6bit(bytes, head); - tail.fill(0); + decompose_6bit(bytes, out.as_mut()); Self(out) } } diff --git a/packages/ore-rs/src/scheme/width.rs b/packages/ore-rs/src/scheme/width.rs index 3cff3cbf..e6d5089b 100644 --- a/packages/ore-rs/src/scheme/width.rs +++ b/packages/ore-rs/src/scheme/width.rs @@ -100,7 +100,6 @@ pub(crate) fn ct_select_byte(block: &[u8], idx: usize) -> u8 { #[inline] pub(crate) fn ct_assign_bytes(dst: &mut [u8], src: &[u8], choice: subtle_ng::Choice) { use subtle_ng::ConditionallySelectable; - debug_assert_eq!(dst.len(), src.len()); for (d, s) in dst.iter_mut().zip(src) { d.conditional_assign(s, choice); } @@ -170,7 +169,8 @@ impl FirstDiff { /// `xt` either. #[inline] pub(crate) fn resolve(&self, hasher: &H) -> u8 { - debug_assert!(usize::from(self.xt) < R * 8); + // `xt` is a parsed, in-domain symbol. Were it not, the oblivious + // select below would read no byte and give 0, not go out of bounds. let byte = ct_select_byte(&self.right, usize::from(self.xt / 8)); ct_bit(byte, self.xt % 8) ^ hasher.hash(&self.f) } @@ -183,16 +183,20 @@ pub trait RightBitVec { /// scheme; the legacy comparator calls the inherent method.) #[allow(dead_code)] fn get_bit(&self, bit: usize) -> u8; + /// The raw bitvector, `[u8; DOMAIN / 8]`. + type Bits; /// The raw bitvector bytes, LSB-first within each byte (the same bit /// order as `RightBlock32::set_bit`), for bulk mask construction. - fn as_mut_bytes(&mut self) -> &mut [u8]; + fn as_mut_bits(&mut self) -> &mut Self::Bits; } impl RightBitVec for RightBlock32 { + type Bits = [u8; 32]; + fn get_bit(&self, bit: usize) -> u8 { RightBlock32::get_bit(self, bit) } - fn as_mut_bytes(&mut self) -> &mut [u8] { + fn as_mut_bits(&mut self) -> &mut [u8; 32] { self.bytes_mut() } } @@ -209,10 +213,20 @@ pub trait BlockWidth: sealed::Sealed + 'static { type RightBlock: CipherTextBlock + RightBitVec; /// A block value: a [`Symbol`] of the `DOMAIN`-element domain. type Symbol: Copy; - /// PRP over the block domain. - type Prp: Prp; + /// PRP over the block domain. Its indicator mask is a right block's + /// bitvector. + type Prp: Prp::Bits>; /// Buffer holding `DOMAIN` random-oracle keys. type RoKeyBuf: AesBlockBuf; + + /// Hash the `DOMAIN` random-oracle keys in `ro` (in place, trashing + /// them) into `out`, one bit per key: [`Hash::hash_all_into`] at this + /// width's sizes, which the types fix. + fn hash_into( + hasher: &H, + ro: &mut Self::RoKeyBuf, + out: &mut ::Bits, + ); } /// The 8-bit block width used by the legacy [`crate::scheme::bit2`] scheme: @@ -226,6 +240,10 @@ impl BlockWidth for Bit8 { type Symbol = Symbol<256>; type Prp = crate::primitives::prp::KnuthShufflePRP; type RoKeyBuf = [AesBlock; 256]; + + fn hash_into(hasher: &H, ro: &mut [AesBlock; 256], out: &mut [u8; 32]) { + hasher.hash_all_into(ro, out); + } } /// The 6-bit block width used by [`crate::scheme::bit2_w6`]: six plaintext @@ -243,6 +261,10 @@ impl BlockWidth for Bit6 { type Prp = crate::primitives::prp::LemireFyPrp<64>; type Symbol = Symbol<64>; type RoKeyBuf = [AesBlock; 64]; + + fn hash_into(hasher: &H, ro: &mut [AesBlock; 64], out: &mut [u8; 8]) { + hasher.hash_all_into(ro, out); + } } #[cfg(kani)] diff --git a/scripts/no-runtime-asserts.py b/scripts/no-runtime-asserts.py new file mode 100755 index 00000000..725440dd --- /dev/null +++ b/scripts/no-runtime-asserts.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Fail if library code can panic through an assertion macro. + +The crates deny clippy's panic lints (`panic`, `unwrap_used`, `expect_used`, +`unreachable`, `todo`, `unimplemented`, `panic_in_result_fn`) outside tests, +but clippy has no lint for `assert!` or `debug_assert!` in general. This +check covers those: library code returns an error or carries the invariant +in a type instead. + +Exempt: +- items under `#[cfg(test)]`, `#[cfg(kani)]`, `#[test]` or `#[kani::proof]`; +- compile-time assertions, `const NAME: () = assert!(...)`, which cannot + panic at run time. + +Usage: scripts/no-runtime-asserts.py [SRC_DIR ...] +(default: the library sources of every workspace crate). +""" + +import pathlib +import re +import sys + +DEFAULT_DIRS = ["packages/ore-rs/src", "packages/orderable-bytes/src"] + +EXEMPT_ATTR = re.compile(r"#\[\s*(cfg\(\s*(test|kani)\s*\)|test|kani::proof)\s*\]") +ASSERT = re.compile(r"(? str: + """Replace comments and string/char literal contents with spaces, + keeping newlines, so that line numbers and brace positions survive.""" + out = [] + i, n = 0, len(src) + while i < n: + c = src[i] + if src.startswith("//", i): + j = src.find("\n", i) + j = n if j == -1 else j + out.append(" " * (j - i)) + i = j + elif src.startswith("/*", i): + depth, j = 1, i + 2 + while j < n and depth: + if src.startswith("/*", j): + depth, j = depth + 1, j + 2 + elif src.startswith("*/", j): + depth, j = depth - 1, j + 2 + else: + j += 1 + out.append(re.sub(r"[^\n]", " ", src[i:j])) + i = j + elif c == '"' or (c == "b" and src.startswith('b"', i)): + j = i + (2 if c == "b" else 1) + while j < n and src[j] != '"': + j += 2 if src[j] == "\\" else 1 + j += 1 + out.append('"' + re.sub(r"[^\n]", " ", src[i + 1 : j - 1]) + '"') + i = j + else: + m = re.match(r"b?'(\\.|[^\\'\n])'", src[i:]) + if m: + out.append(" " * len(m.group(0))) + i += len(m.group(0)) + else: + out.append(c) + i += 1 + return "".join(out) + + +def exempt_spans(src: str): + """(start, end) offsets of every exempt item: from its attribute to the + end of its body (matching brace) or to its `;`.""" + spans = [] + for m in EXEMPT_ATTR.finditer(src): + j = m.end() + while j < len(src) and src[j] not in "{;": + j += 1 + if j < len(src) and src[j] == "{": + depth = 0 + while j < len(src): + if src[j] == "{": + depth += 1 + elif src[j] == "}": + depth -= 1 + if depth == 0: + break + j += 1 + spans.append((m.start(), j + 1)) + return spans + + +def check(path: pathlib.Path): + src = blank_comments_and_literals(path.read_text()) + spans = exempt_spans(src) + for m in ASSERT.finditer(src): + if any(a <= m.start() < b for a, b in spans): + continue + line_start = src.rfind("\n", 0, m.start()) + 1 + line_end = src.find("\n", m.start()) + line = src[line_start : line_end if line_end != -1 else len(src)] + if CONST_ASSERT.search(line): + continue + lineno = src.count("\n", 0, m.start()) + 1 + yield f"{path}:{lineno}: {line.strip()}" + + +def main(argv): + dirs = argv[1:] or DEFAULT_DIRS + found = [hit for d in dirs for f in sorted(pathlib.Path(d).rglob("*.rs")) for hit in check(f)] + for hit in found: + print(hit) + if found: + print( + f"\n{len(found)} runtime assertion(s) in library code. Return an error or " + "carry the invariant in a type; a compile-time check is " + "`const NAME: () = assert!(...)`.", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) From 3f792d24fabd4008937860ad09dc3334ef17df76 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 19 Oct 2026 11:03:38 +1100 Subject: [PATCH 7/7] docs: say what the key and the padding policy belong to without calling it a column ore-rs is a general-purpose library; "column" is a database word. The chained scheme's docs, the README and the CHANGELOG now say "the values compared together" and "one key per kind of value" instead. The design notes and benchmark write-ups under `docs/`, which describe database deployments, are unchanged. --- packages/ore-rs/CHANGELOG.md | 2 +- packages/ore-rs/README.md | 2 +- packages/ore-rs/src/scheme/chained.rs | 26 ++++++++++++++------------ 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/ore-rs/CHANGELOG.md b/packages/ore-rs/CHANGELOG.md index dc637e7a..f8ac46a9 100644 --- a/packages/ore-rs/CHANGELOG.md +++ b/packages/ore-rs/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 bytes, `DEFAULT_MAX_STR_BYTES`). A longer string returns `OreError::TooLong`. Ciphertext size and encryption time grow with the padded length and the string is caller-supplied, so a ceiling bounds what one input can cost; it also bounds the number of length buckets - a column can show. `init_with_padding` is replaced by `init_with`. + the ciphertexts can show. `init_with_padding` is replaced by `init_with`. - `u128` and `i128` can be encrypted, on the chained scheme (`scheme::chained`): `OreEncrypt` impls producing `ChainedCipherText<22>` / `ChainedLeft<22>`, whose typed parsers and comparators accept only that block count, plus `encrypt_fixed` / `encrypt_left_fixed` for diff --git a/packages/ore-rs/README.md b/packages/ore-rs/README.md index 0f27b252..6ef1aeda 100644 --- a/packages/ore-rs/README.md +++ b/packages/ore-rs/README.md @@ -21,7 +21,7 @@ Reference documentation is on [docs.rs/ore-rs](https://docs.rs/ore-rs). ## Supported plaintext types -`OreEncrypt` is implemented for `bool`, integers up to 64 bits (`u8`–`u64`, `i8`–`i64`), `char`, `f32`, and `f64` on both `OreAes128` and `OreAes128Bit6`. `u128` and `i128` are implemented on the chained scheme only (`OreAes128Bit6Chained`, `scheme::chained`): at 16 bytes they exceed both the 15-byte limit of `OreAes128` and the 14-block packed prefix of `OreAes128Bit6`, and the chained scheme's CMAC accumulator has no length cap. Their ciphertexts are `ChainedCipherText<22>` / `ChainedLeft<22>`, which pin the block count: the typed parsers and comparators refuse a ciphertext of another length. That is a length guard, not a type guard; a 16-byte string or an `i128` is 22 blocks too, and under one key equal encodings (`0u128`, `i128::MIN`) encrypt identically, so use one key per column. Two optional features extend this via the sibling [`orderable-bytes`](https://crates.io/crates/orderable-bytes) crate: +`OreEncrypt` is implemented for `bool`, integers up to 64 bits (`u8`–`u64`, `i8`–`i64`), `char`, `f32`, and `f64` on both `OreAes128` and `OreAes128Bit6`. `u128` and `i128` are implemented on the chained scheme only (`OreAes128Bit6Chained`, `scheme::chained`): at 16 bytes they exceed both the 15-byte limit of `OreAes128` and the 14-block packed prefix of `OreAes128Bit6`, and the chained scheme's CMAC accumulator has no length cap. Their ciphertexts are `ChainedCipherText<22>` / `ChainedLeft<22>`, which pin the block count: the typed parsers and comparators refuse a ciphertext of another length. That is a length guard, not a type guard; a 16-byte string or an `i128` is 22 blocks too, and under one key equal encodings (`0u128`, `i128::MIN`) encrypt identically, so use one key per kind of value. Two optional features extend this via the sibling [`orderable-bytes`](https://crates.io/crates/orderable-bytes) crate: - `chrono` — ORE support for `chrono::NaiveDate` and `chrono::DateTime` - `decimal` — ORE support for `rust_decimal::Decimal` diff --git a/packages/ore-rs/src/scheme/chained.rs b/packages/ore-rs/src/scheme/chained.rs index 8f3eaf17..4abb91d8 100644 --- a/packages/ore-rs/src/scheme/chained.rs +++ b/packages/ore-rs/src/scheme/chained.rs @@ -50,7 +50,8 @@ //! The header also carries the padding policy, and the comparators return //! `None` for two ciphertexts made under different policies: an unpadded //! `"abc"` and a padded `"abc"` have different blocks, and would compare -//! unequal. A column's stored values and its queries must use one policy. +//! unequal. Stored values and the queries that compare with them must use +//! one policy. //! //! A cipher also has a longest string it encrypts //! ([`Options::with_max_bytes`](crate::scheme::chained::Options::with_max_bytes), @@ -77,7 +78,7 @@ //! The count is a length guard, not a type guard: a 16-byte string and an //! `i128` are 22 blocks too, and under one key an equal encoding gives an //! equal ciphertext (`0u128`, `i128::MIN`, sixteen NUL bytes). Use one key -//! per column. A fixed-length value is not padded (every value of the type +//! per kind of value. A fixed-length value is not padded (every value of the type //! has the same length, so there is nothing to hide) and is not subject to //! the cipher's string ceiling; its ciphertext is tagged //! [`Padding::None`](crate::scheme::chained::Padding::None), and the typed @@ -186,16 +187,16 @@ fn right_at(s: &[u8], count: usize, i: usize) -> &[u8] { /// ciphertext reveals a length bucket rather than the exact length (see the /// module docs). /// -/// Lengths are in UTF-8 bytes. The policy belongs to a column: its stored -/// values and its queries must use the same one, and changing it means -/// re-encrypting the column. The comparators refuse to compare ciphertexts +/// Lengths are in UTF-8 bytes. The policy belongs with the key: stored +/// values and the queries that compare with them must use the same one, +/// and changing it means re-encrypting the stored values. The comparators refuse to compare ciphertexts /// made under different policies. /// /// Padding hides the exact length in the ciphertext only: the time the /// encryptor takes still depends on the exact length of the string. /// /// More policies may follow (a growth ratio between buckets, boundaries -/// fitted to a column), so the enum is non-exhaustive. +/// fitted to the data), so the enum is non-exhaustive. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[non_exhaustive] pub enum Padding { @@ -314,8 +315,9 @@ impl Padding { pub const DEFAULT_MAX_STR_BYTES: usize = 1024; /// Per-cipher settings for the string encryptors, passed to -/// [`OreAes128Bit6Chained::init_with`]. They belong to a column, like the -/// key: its stored values and its queries use the same ones. +/// [`OreAes128Bit6Chained::init_with`]. Like the key, they belong to the +/// values that are compared together: stored values and the queries that +/// compare with them use the same ones. /// /// ```rust /// use ore_rs::scheme::chained::{OreAes128Bit6ChainedChaCha20, Options, Padding}; @@ -356,8 +358,8 @@ impl Options { /// The longest string, in UTF-8 bytes, the cipher encrypts; a longer /// one returns [`OreError::TooLong`]. Bounds what one input can cost in - /// ciphertext size and encryption time, and how many length buckets a - /// column can show. Under [`Padding::Fixed`] the fixed length is also a + /// ciphertext size and encryption time, and how many length buckets the + /// ciphertexts can show. Under [`Padding::Fixed`] the fixed length is also a /// ceiling, and the lower of the two applies. `init_with` refuses a /// ceiling of 0 or one that pads past [`MAX_STR_BYTES`]. pub fn with_max_bytes(self, max_bytes: usize) -> Self { @@ -783,7 +785,7 @@ impl OreAes128Bit6Chained { /// or a padded one. That is a length guard, not a type guard: /// ciphertexts of two types with equal-length encodings (`u128` and /// `i128`, or an unpadded 16-byte string) compare, and equal encodings - /// under one key give equal ciphertexts. Use one key per column. + /// under one key give equal ciphertexts. Use one key per kind of value. /// /// A type whose encoding is longer than [`MAX_STR_BYTES`] returns /// [`OreError::TooManyBlocks`] before the encoding is made (`[u8; N]` @@ -1889,7 +1891,7 @@ mod tests { /// A ciphertext whose tag names a policy that cannot produce its block /// count was made by no cipher, and is refused: rewriting an unpadded - /// ciphertext's tag does not smuggle it into a padded column. + /// ciphertext's tag does not smuggle it in among padded ciphertexts. #[test] fn parsers_reject_counts_the_policy_cannot_produce() { type C = OreAes128Bit6ChainedChaCha20;