Skip to content
5 changes: 5 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion docs/plans/2026-06-12-ore-v2-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 3 additions & 10 deletions packages/orderable-bytes/src/decimal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions packages/orderable-bytes/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down
37 changes: 36 additions & 1 deletion packages/ore-rs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,42 @@ 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
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<T>` 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).
- 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

- **Breaking:** `OreEncrypt<OreAes128>` 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 (see
Removed): the legacy scheme cannot take 16 bytes, and the impl macro now rejects such a type
at compile time. Encrypting a `PlainText<N>` with `N > 15` directly returns
`OreError::TooManyBlocks` instead of panicking.
- `OreAes128Bit6` panicked on a `PlainText<N>` 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

Expand Down
4 changes: 2 additions & 2 deletions packages/ore-rs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: 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<Utc>`
- `decimal` — ORE support for `rust_decimal::Decimal`
Expand Down Expand Up @@ -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.

Expand Down
29 changes: 23 additions & 6 deletions packages/ore-rs/src/ciphertext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>) {
debug_assert!(num_blocks <= u16::MAX as usize);
pub(crate) fn write(&self, num_blocks: u16, out: &mut Vec<u8>) {
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,
Expand All @@ -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<const N: usize>;

impl<const N: usize> WireCount<N> {
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);

Expand Down Expand Up @@ -191,7 +208,7 @@ impl<S: OreCipher, const N: usize> OreOutput for Left<S, N> {
fn to_bytes(&self) -> Vec<u8> {
let mut vec = Vec::with_capacity(Self::size());
if let Some(header) = S::WIRE_HEADER {
header.write(N, &mut vec);
header.write(WireCount::<N>::get(), &mut vec);
}
self.write_body(&mut vec);
vec
Expand Down Expand Up @@ -250,7 +267,7 @@ impl<S: OreCipher, const N: usize> OreOutput for Right<S, N> {
fn to_bytes(&self) -> Vec<u8> {
let mut vec = Vec::with_capacity(Self::size());
if let Some(header) = S::WIRE_HEADER {
header.write(N, &mut vec);
header.write(WireCount::<N>::get(), &mut vec);
}
self.write_body(&mut vec);
vec
Expand All @@ -276,7 +293,7 @@ impl<S: OreCipher, const N: usize> OreOutput for CipherText<S, N> {
fn to_bytes(&self) -> Vec<u8> {
let mut vec = Vec::with_capacity(Self::size());
if let Some(header) = S::WIRE_HEADER {
header.write(N, &mut vec);
header.write(WireCount::<N>::get(), &mut vec);
}
self.left.write_body(&mut vec);
self.right.write_body(&mut vec);
Expand Down
90 changes: 70 additions & 20 deletions packages/ore-rs/src/encrypt.rs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<T: OreCipher> {
pub trait OreEncrypt<T> {
/// Output type produced by [`encrypt_left`](Self::encrypt_left).
type LeftOutput: OreOutput;
/// Output type produced by [`encrypt`](Self::encrypt).
Expand Down Expand Up @@ -45,8 +46,6 @@ const U32_LEN: usize = <u32 as FixedOrderableBytes>::ENCODED_LEN;
const I32_LEN: usize = <i32 as FixedOrderableBytes>::ENCODED_LEN;
const U64_LEN: usize = <u64 as FixedOrderableBytes>::ENCODED_LEN;
const I64_LEN: usize = <i64 as FixedOrderableBytes>::ENCODED_LEN;
const U128_LEN: usize = <u128 as FixedOrderableBytes>::ENCODED_LEN;
const I128_LEN: usize = <i128 as FixedOrderableBytes>::ENCODED_LEN;
const CHAR_LEN: usize = <char as FixedOrderableBytes>::ENCODED_LEN;
const F32_LEN: usize = <f32 as FixedOrderableBytes>::ENCODED_LEN;
const F64_LEN: usize = <f64 as FixedOrderableBytes>::ENCODED_LEN;
Expand All @@ -58,8 +57,17 @@ const F64_LEN: usize = <f64 as FixedOrderableBytes>::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<R: Rng + SeedableRng> OreEncrypt<OreAes128<R>> for $type {
type LeftOutput = Left<OreAes128<R>, $len_const>;
type FullOutput = CipherText<OreAes128<R>, $len_const>;
Expand All @@ -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::<OreAes128ChaCha20, $len_const>::from_slice(&lo_bytes)
.unwrap()
.to_bytes(),
lo_bytes
);
assert_eq!(
Left::<OreAes128ChaCha20, $len_const>::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<R: Rng + SeedableRng, const N: usize> OreEncrypt<OreAes128<R>> for PlainText<N> {
type LeftOutput = Left<OreAes128<R>, N>;
Expand Down
Loading
Loading