From 2413bd0e4bf7eb8932a31101c635e2373c1096c6 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 16 Oct 2026 23:15:49 +1100 Subject: [PATCH 1/2] feat(ore)!: compare order terms through the scheme and column, not through Ord `OrderScheme::Term` no longer requires `Ord`. A term is plain bytes and records nothing of the column it came from, so an `Ord` on it promised an order between any two terms that no order scheme can keep: two terms of different keys or contexts compare as noise, and reversing them need not reverse the answer, which breaks every sort and tree that trusts `Ord`. The scheme crates' fallbacks for terms their comparator refused made it worse, with a deterministic cycle between padded text and unpadded bytes under the chained scheme. The scheme now carries its comparison as `OrderScheme::compare(a, b) -> Option`, a total order over one column's terms and `None` wherever the scheme can tell the two were not made to compare, and the column exposes it as `OrderColumn::compare`. The mocks refuse to compare across columns, and a test pins that a column sorts its own terms and has no order against another column's. --- packages/ore/README.md | 20 +++++++++- packages/ore/src/scheme.rs | 47 +++++++++++++++++++--- packages/ore/tests/common/mod.rs | 29 +++++++++++--- packages/ore/tests/conformance.rs | 66 ++++++++++++++++++++++++++----- 4 files changed, 140 insertions(+), 22 deletions(-) diff --git a/packages/ore/README.md b/packages/ore/README.md index b64d41a1..f3e75369 100644 --- a/packages/ore/README.md +++ b/packages/ore/README.md @@ -41,6 +41,7 @@ A caller opens a column on a scheme with its target and the context its terms compare within, and encrypts values there: ```rust +use std::cmp::Ordering; use vitaminc_ore::{Int, Logical, NormalizeError, OrderEncrypt, OrderError, OrderTarget, Physical, Raw}; # use vitaminc_ore::{Context, OrderPlaintext, OrderScheme}; # /// Keeps the bytes as they are, for the example. Any scheme will do. @@ -53,13 +54,17 @@ use vitaminc_ore::{Int, Logical, NormalizeError, OrderEncrypt, OrderError, Order # use vitaminc_protected::Controlled; # Ok(p.bytes().risky_ref().clone()) # } +# fn compare(a: &Vec, b: &Vec) -> Option { +# Some(a.cmp(b)) +# } # } # let scheme = Clear; let total = scheme.column(OrderTarget::I32, ("orders", "total")); let three = total.encrypt(3i8)?; let forty = total.encrypt(40i64)?; -assert!(three < forty); +// The column compares its terms; a term alone has no order. +assert_eq!(total.compare(&three, &forty), Some(Ordering::Less)); // Outside the column's type, or of another kind: refused. assert!(matches!( @@ -137,6 +142,7 @@ is implemented for every scheme and cannot be overridden, so no scheme can change what its terms are bound to. ```rust +use core::cmp::Ordering; use core::convert::Infallible; use vitaminc_ore::{Context, OrderError, OrderPlaintext, OrderScheme}; use vitaminc_protected::Controlled; @@ -162,6 +168,12 @@ impl OrderScheme for FourBytes { term.extend_from_slice(plaintext.bytes().risky_ref()); Ok(term) } + + /// The scheme's own comparison, without a key. `None` for a term that + /// is not this scheme's; here, one of the wrong length. + fn compare(a: &Vec, b: &Vec) -> Option { + (a.len() == b.len()).then(|| a.cmp(b)) + } } ``` @@ -174,7 +186,11 @@ the way a PRF is keyed, so an index layer constructs any scheme the same way. A term compares only with terms of the same scheme, key, target and -context, which is what a column is. A column's leakage is the combined +context, which is what a column is, through `column.compare(&a, &b)` +(the scheme's [`OrderScheme::compare`], which needs no key). A term is +plain bytes and records nothing of its column, so it is not `Ord`: a +promise to order any two terms is one no order scheme can keep across +columns, and a caller keeps a column's terms together. A column's leakage is the combined leakage of every term it holds: an order term reveals order, an equality term reveals equality, and a column that carries both reveals both. diff --git a/packages/ore/src/scheme.rs b/packages/ore/src/scheme.rs index dbc6f7c3..4d609ce3 100644 --- a/packages/ore/src/scheme.rs +++ b/packages/ore/src/scheme.rs @@ -10,6 +10,7 @@ //! is [`order`]) or a salt it could derive differently from another //! scheme. +use core::cmp::Ordering; use core::fmt; use vitaminc_aead::plaintext::{self, SourceError}; @@ -125,8 +126,12 @@ impl OrderError { /// /// Terms compare only between terms of the same scheme, key, target and /// context: a column. The scheme keeps its own comparison (CLLW ORE and -/// block ORE do not compare byte by byte) through its term's `Ord`, and -/// its own stored bytes through `AsRef<[u8]>`. +/// block ORE do not compare byte by byte) in [`compare`](Self::compare), +/// and its own stored bytes through `AsRef<[u8]>`. A term is not `Ord`: +/// that would promise an order between any two terms, and no term carries +/// which column it came from, so two terms of different columns would +/// compare as noise and break every sort and tree that trusted the +/// promise. /// /// Implemented by the scheme crates behind a `vitaminc` feature, and by a /// mock in this crate's tests. An implementation reads the plaintext's @@ -139,7 +144,7 @@ pub trait OrderScheme { const NAME: &'static str; /// What one term encrypts to. - type Term: Ord + AsRef<[u8]>; + type Term: AsRef<[u8]>; /// The scheme's own failure. [`Infallible`](core::convert::Infallible) /// for a scheme that cannot fail once keyed. @@ -159,6 +164,20 @@ pub trait OrderScheme { plaintext: &OrderPlaintext, salt: &Context<'_>, ) -> Result>; + + /// The order of two terms of one column, as the order of the values + /// they encrypt. Needs no key: that is what makes the scheme an order + /// scheme. + /// + /// Over the terms of one column this is a total order, so a caller may + /// sort with it. `None` says the two cannot be compared: one is not a + /// term of this scheme, or the two were not made to compare (block + /// ORE under different padding). A scheme returns `None` wherever it + /// can tell, and never falls back to comparing bytes it cannot order. + /// It cannot tell everything: two well-formed terms of different + /// columns may compare as noise, so a caller keeps a column's terms + /// together, as [`OrderColumn::compare`] says. + fn compare(a: &Self::Term, b: &Self::Term) -> Option; } /// What a caller does with any [`OrderScheme`]: open a column. Implemented @@ -172,6 +191,7 @@ pub trait OrderEncrypt: OrderScheme { /// # Examples /// /// ``` + /// use std::cmp::Ordering; /// use vitaminc_ore::{Int, OrderEncrypt, OrderTarget, Physical, Raw}; /// # use vitaminc_ore::{Context, OrderError, OrderPlaintext, OrderScheme}; /// # /// Keeps the bytes as they are, for the example. Any scheme will do. @@ -184,12 +204,17 @@ pub trait OrderEncrypt: OrderScheme { /// # use vitaminc_protected::Controlled; /// # Ok(p.bytes().risky_ref().clone()) /// # } + /// # fn compare(a: &Vec, b: &Vec) -> Option { + /// # Some(a.cmp(b)) + /// # } /// # } /// # let scheme = Clear; /// /// let total = scheme.column(OrderTarget::I32, ("orders", "total")); - /// // Whatever width a value arrives at, it is encoded as the column's. - /// assert!(total.encrypt(41i8)? < total.encrypt(42i64)?); + /// // Whatever width a value arrives at, it is encoded as the column's, + /// // and the column compares its terms. + /// let (a, b) = (total.encrypt(41i8)?, total.encrypt(42i64)?); + /// assert_eq!(total.compare(&a, &b), Some(Ordering::Less)); /// /// // A typed scalar goes in as `Raw`, into a column of its type. /// let placed = scheme.column(OrderTarget::Date, ("orders", "placed")); @@ -281,6 +306,18 @@ impl OrderColumn<'_, S> { } self.scheme.encrypt_plaintext(plaintext, &self.salt) } + + /// The order of two of this column's terms, as the order of the values + /// they encrypt: [`OrderScheme::compare`]. A total order over the + /// column's terms, so they sort by it. + /// + /// Both terms must be this column's. A term records nothing of its + /// column, so the scheme cannot always tell two columns' terms apart, + /// and may order them as noise. `None` where it can tell: a term not + /// of this scheme, or two terms not made to compare. + pub fn compare(&self, a: &S::Term, b: &S::Term) -> Option { + S::compare(a, b) + } } /// A scheme keyed from secret bytes, as diff --git a/packages/ore/tests/common/mod.rs b/packages/ore/tests/common/mod.rs index 3688cfe2..999ef7c0 100644 --- a/packages/ore/tests/common/mod.rs +++ b/packages/ore/tests/common/mod.rs @@ -5,21 +5,28 @@ #![allow(dead_code)] +use core::cmp::Ordering; use core::convert::Infallible; use vitaminc_ore::{Context, OrderError, OrderKeyInit, OrderPlaintext, OrderScheme}; use vitaminc_protected::{Controlled, Protected}; -/// A term from a mock: the salt it was bound under and the plaintext -/// bytes, which it "encrypts" by keeping. Orders by salt then bytes, so -/// terms under one salt order as the values do and terms under different -/// salts never compare equal. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +/// A term from a mock: the salt and key it was bound under and the +/// plaintext bytes, which it "encrypts" by keeping. Terms under one salt +/// and key compare as the values do; a mock refuses to compare terms of +/// different columns, which a real scheme cannot always tell apart. +#[derive(Debug, Clone, PartialEq, Eq)] pub struct MockTerm { pub salt: Vec, pub bytes: Vec, } +/// The mocks' comparison: the values' order within one column, `None` +/// across columns. +fn compare(a: &MockTerm, b: &MockTerm) -> Option { + (a.salt == b.salt).then(|| a.bytes.cmp(&b.bytes)) +} + impl AsRef<[u8]> for MockTerm { fn as_ref(&self) -> &[u8] { &self.bytes @@ -48,6 +55,10 @@ impl OrderScheme for Mock { bytes: plaintext.bytes().risky_ref().clone(), }) } + + fn compare(a: &MockTerm, b: &MockTerm) -> Option { + compare(a, b) + } } /// Why a mock refused key material. @@ -92,6 +103,10 @@ impl OrderScheme for Fixed { } self.0.encrypt_plaintext(plaintext, salt) } + + fn compare(a: &MockTerm, b: &MockTerm) -> Option { + compare(a, b) + } } /// A scheme whose backend fails. @@ -113,6 +128,10 @@ impl OrderScheme for Down { ) -> Result> { Err(OrderError::Backend(BackendDown)) } + + fn compare(a: &MockTerm, b: &MockTerm) -> Option { + compare(a, b) + } } pub fn key() -> Protected<[u8; 32]> { diff --git a/packages/ore/tests/conformance.rs b/packages/ore/tests/conformance.rs index 118fba87..46da3859 100644 --- a/packages/ore/tests/conformance.rs +++ b/packages/ore/tests/conformance.rs @@ -15,7 +15,7 @@ use vitaminc_aead::{Float, Int, Logical, Physical, TimeUnit}; use vitaminc_normalize::{domain as equality, normalize}; use vitaminc_ore::{ domain, order, Context, IntoContext, NormalizeError, OrderEncrypt, OrderError, OrderKeyInit, - OrderTarget, Raw, + OrderScheme, OrderTarget, Raw, }; use vitaminc_protected::{Controlled, Protected}; @@ -191,7 +191,12 @@ fn every_kind_has_an_order_target_or_is_a_named_exception() { column.encrypt_term(&below).expect("mock"), column.encrypt_term(&above).expect("mock"), ); - assert_eq!(x.cmp(&y), Ordering::Less, "{}", kind.domain); + assert_eq!( + column.compare(&x, &y), + Some(Ordering::Less), + "{}", + kind.domain + ); } // The exceptions: no order term under any target, and the walk skips // them. @@ -574,7 +579,13 @@ fn typed_values_encrypt_into_the_column_of_their_type() { date, typed(T::Date, Logical::Date, Physical::Int(Int::I32(19_000))) ); - assert!(dates.encrypt(day.pred_opt().expect("date")).expect("date") < date); + assert_eq!( + dates.compare( + &dates.encrypt(day.pred_opt().expect("date")).expect("date"), + &date + ), + Some(Ordering::Less) + ); let at = Utc .timestamp_opt(1_700_000_000, 123_456_789) .single() @@ -610,11 +621,10 @@ fn typed_values_encrypt_into_the_column_of_their_type() { fn order_through_a_column_is_value_order(a: i32, b: i16, text_a: String, text_b: String) -> bool { let mock = Mock::new(key()); let ints = mock.column(T::I64, "col"); - let int_order = ints - .encrypt(a) - .expect("mock") - .cmp(&ints.encrypt(b).expect("mock")) - == i64::from(a).cmp(&i64::from(b)); + let int_order = ints.compare( + &ints.encrypt(a).expect("mock"), + &ints.encrypt(b).expect("mock"), + ) == Some(i64::from(a).cmp(&i64::from(b))); // Bytes compare as bytes; text ties where it folds together, so only // bytes are checked for order here. let bytes = mock.column(T::Bytes, "col"); @@ -623,11 +633,47 @@ fn order_through_a_column_is_value_order(a: i32, b: i16, text_a: String, text_b: .encrypt(Raw(None, Physical::Bytes(s.as_bytes()))) .expect("mock") }; - let byte_order = - under(&text_a).cmp(&under(&text_b)) == text_a.as_bytes().cmp(text_b.as_bytes()); + let byte_order = bytes.compare(&under(&text_a), &under(&text_b)) + == Some(text_a.as_bytes().cmp(text_b.as_bytes())); int_order && byte_order } +#[test] +fn a_column_sorts_its_own_terms_and_refuses_another_columns() { + let mock = Mock::new(key()); + let column = mock.column(T::I32, "col"); + let mut terms: Vec<_> = [5i32, -3, 9, 0] + .into_iter() + .map(|v| column.encrypt(v).expect("mock")) + .collect(); + // The column's comparison is total over its terms, so they sort by it. + terms.sort_by(|a, b| column.compare(a, b).expect("one column")); + assert_eq!( + terms.iter().map(|t| t.bytes.clone()).collect::>(), + [-3i32, 0, 5, 9] + .into_iter() + .map(|v| (v as u32 ^ 0x8000_0000).to_be_bytes().to_vec()) + .collect::>() + ); + // Another column's term, under another context, another key or another + // scheme, has no order against them: the mock can tell, a real scheme + // often cannot, so a caller never mixes them. + let own = &terms[0]; + let other_context = mock.column(T::I32, "other").encrypt(1i32).expect("mock"); + let other_key = Mock::new(Protected::new([8; 32])) + .column(T::I32, "col") + .encrypt(1i32) + .expect("mock"); + let other_scheme = Fixed::<4>(Mock::new(key())) + .column(T::I32, "col") + .encrypt(1i32) + .expect("mock"); + for foreign in [other_context, other_key, other_scheme] { + assert_eq!(column.compare(own, &foreign), None); + assert_eq!(Mock::compare(own, &foreign), None); + } +} + #[test] fn a_mock_term_is_the_plaintext_bytes() { // So the tests above are about the plaintext layer, not the mock. From 7f3a4e431d85037aab3ed0d83863bc9e6c7e4987 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 17 Oct 2026 12:12:21 +1100 Subject: [PATCH 2/2] docs(ore): the README's toy scheme refuses short terms and other columns' terms Its compare ordered any two terms of equal length, so two columns whose salts happened to be the same length compared as if they were one, and two empty terms compared Equal. The toy term is the salt then four bytes, so it can tell both cases apart: a term shorter than four bytes is not its own, and two different salts are two columns. Return None for both, as the compare contract asks, and assert all three cases. --- packages/ore/README.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/ore/README.md b/packages/ore/README.md index f3e75369..512da994 100644 --- a/packages/ore/README.md +++ b/packages/ore/README.md @@ -170,11 +170,18 @@ impl OrderScheme for FourBytes { } /// The scheme's own comparison, without a key. `None` for a term that - /// is not this scheme's; here, one of the wrong length. + /// is not this scheme's, or for two terms of different columns: here, + /// a term shorter than its four bytes, or two salts that differ. fn compare(a: &Vec, b: &Vec) -> Option { - (a.len() == b.len()).then(|| a.cmp(b)) + let (a_salt, a_bytes) = a.split_at(a.len().checked_sub(4)?); + let (b_salt, b_bytes) = b.split_at(b.len().checked_sub(4)?); + (a_salt == b_salt).then(|| a_bytes.cmp(b_bytes)) } } + +assert_eq!(FourBytes::compare(&b"salt\0\0\0\x01".to_vec(), &b"salt\0\0\0\x02".to_vec()), Some(Ordering::Less)); +assert_eq!(FourBytes::compare(&b"salt\0\0\0\x01".to_vec(), &b"SALT\0\0\0\x02".to_vec()), None); +assert_eq!(FourBytes::compare(&vec![], &vec![]), None); ``` The salt is `PAE("vitaminc/ore/v1", scheme, domain, context)`, derived