From 9686e9d86aa6ef8bfb06e55a894ea4a4e95e47bc Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 16 Oct 2026 02:28:20 +1100 Subject: [PATCH 1/9] feat(stack-encrypt)!: derive equality and match terms through vitaminc-normalize so text compares in Unicode 16 NFC An equality term is now the PRF over the value's vitaminc-normalize encoding under vitaminc's domain for its kind, read through that crate's Terms sink: one term per number whatever its width, text in Unicode 16 NFC, and no term for a boolean, a null or a container. Match text is tokenized in the same normal form. The equality and match domains move to /v2 and the pins and the record fixture are regenerated; ORE and OPE do not move. vitaminc-normalize is not on crates.io, so it joins the vitaminc patch blocks of every workspace that builds stack-encrypt. Part of #1140 --- Cargo.lock | 22 ++ Cargo.toml | 6 +- languages/golang/encrypt/guest/Cargo.lock | 22 ++ packages/eql/Cargo.lock | 16 ++ packages/stack-encrypt/Cargo.toml | 5 + packages/stack-encrypt/fuzz/Cargo.lock | 22 ++ packages/stack-encrypt/src/codes.rs | 11 + packages/stack-encrypt/src/dynamic/value.rs | 231 ------------------ packages/stack-encrypt/src/op/mod.rs | 10 +- packages/stack-encrypt/src/sem/mod.rs | 198 +++++++++++++-- packages/stack-encrypt/src/sem/tokenize.rs | 43 ++++ packages/stack-encrypt/src/target/index.rs | 4 +- .../stack-encrypt/src/target/operations.rs | 7 +- .../tests/fixtures/record_lowering.json | 32 +-- packages/stack-encrypt/tests/frozen_bytes.rs | 2 +- packages/stack-encrypt/tests/op.rs | 43 ++++ packages/stack-encrypt/tests/term_bytes.rs | 46 +++- .../tests/ui/op_target_does_not_answer.stderr | 4 +- .../tests/ui/owned_fan_out_needs_clone.rs | 2 +- .../tests/ui/owned_fan_out_needs_clone.stderr | 4 +- .../tests/ui/pass/owned_without_clone.rs | 2 +- .../tests/ui/pass/query_only_and_borrowed.rs | 7 +- 22 files changed, 439 insertions(+), 300 deletions(-) delete mode 100644 packages/stack-encrypt/src/dynamic/value.rs diff --git a/Cargo.lock b/Cargo.lock index 3891a1ce9..dc2453168 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3301,6 +3301,7 @@ dependencies = [ "vitaminc-aead 0.6.0", "vitaminc-encrypt 0.6.0", "vitaminc-hmac", + "vitaminc-normalize", "vitaminc-prf", "vitaminc-protected 0.6.0", "zeroize", @@ -3948,6 +3949,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.23" @@ -4257,6 +4264,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "vitaminc-normalize" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dda6ec7f852ba8cd7e0ba6e8e7eaa3045e6fb866007c6d7ad6128833d8980f0c" +dependencies = [ + "thiserror 2.0.18", + "unicode-general-category", + "unicode-normalization", + "vitaminc-aead 0.6.0", + "vitaminc-protected 0.6.0", +] + [[package]] name = "vitaminc-prf" version = "0.6.0" @@ -4265,7 +4285,9 @@ checksum = "11de28e524ab1deecedbb023bd328916b6c023e2f52156521ac1df75a689d911" dependencies = [ "mutants", "thiserror 2.0.18", + "vitaminc-aead 0.6.0", "vitaminc-context 0.6.0", + "vitaminc-normalize", "vitaminc-protected 0.6.0", ] diff --git a/Cargo.toml b/Cargo.toml index 325540829..22e8a3d21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -127,5 +127,9 @@ vitaminc-aead = "0.6.0" vitaminc-aead-frame = "0.6.0" vitaminc-encrypt = "0.6.0" vitaminc-hmac = "0.6.0" -vitaminc-prf = "0.6.0" +# Not on crates.io yet: the `[patch.crates-io]` block below supplies it. +# Normalized equality terms (Unicode 16 NFC text, one integer encoding for +# every width), which `vitaminc-prf`'s `normalize` feature derives over. +vitaminc-normalize = "0.6.0" +vitaminc-prf = { version = "0.6.0", features = ["normalize"] } vitaminc-protected = "0.6.0" diff --git a/languages/golang/encrypt/guest/Cargo.lock b/languages/golang/encrypt/guest/Cargo.lock index 926813680..bd5d65953 100644 --- a/languages/golang/encrypt/guest/Cargo.lock +++ b/languages/golang/encrypt/guest/Cargo.lock @@ -2087,6 +2087,7 @@ dependencies = [ "vitaminc-aead 0.6.0", "vitaminc-encrypt 0.6.0", "vitaminc-hmac", + "vitaminc-normalize", "vitaminc-prf", "vitaminc-protected 0.6.0", "zeroize", @@ -2463,6 +2464,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2758,6 +2765,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "vitaminc-normalize" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dda6ec7f852ba8cd7e0ba6e8e7eaa3045e6fb866007c6d7ad6128833d8980f0c" +dependencies = [ + "thiserror 2.0.20", + "unicode-general-category", + "unicode-normalization", + "vitaminc-aead 0.6.0", + "vitaminc-protected 0.6.0", +] + [[package]] name = "vitaminc-prf" version = "0.6.0" @@ -2766,7 +2786,9 @@ checksum = "11de28e524ab1deecedbb023bd328916b6c023e2f52156521ac1df75a689d911" dependencies = [ "mutants", "thiserror 2.0.20", + "vitaminc-aead 0.6.0", "vitaminc-context 0.6.0", + "vitaminc-normalize", "vitaminc-protected 0.6.0", ] diff --git a/packages/eql/Cargo.lock b/packages/eql/Cargo.lock index 9ab983cb6..25ee1d1f9 100644 --- a/packages/eql/Cargo.lock +++ b/packages/eql/Cargo.lock @@ -4242,6 +4242,7 @@ dependencies = [ "vitaminc-aead 0.6.0", "vitaminc-encrypt 0.6.0", "vitaminc-hmac", + "vitaminc-normalize", "vitaminc-prf", "vitaminc-protected 0.6.0", "zeroize", @@ -5198,6 +5199,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "vitaminc-normalize" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dda6ec7f852ba8cd7e0ba6e8e7eaa3045e6fb866007c6d7ad6128833d8980f0c" +dependencies = [ + "thiserror 2.0.20", + "unicode-general-category", + "unicode-normalization", + "vitaminc-aead 0.6.0", + "vitaminc-protected 0.6.0", +] + [[package]] name = "vitaminc-prf" version = "0.6.0" @@ -5206,7 +5220,9 @@ checksum = "11de28e524ab1deecedbb023bd328916b6c023e2f52156521ac1df75a689d911" dependencies = [ "mutants", "thiserror 2.0.20", + "vitaminc-aead 0.6.0", "vitaminc-context 0.6.0", + "vitaminc-normalize", "vitaminc-protected 0.6.0", ] diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index 0351ca5d7..f7b45ef5d 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -32,6 +32,11 @@ stack-encrypt-derive = { path = "../stack-encrypt-derive", version = "0.3.0" } vitaminc-aead = { workspace = true } vitaminc-encrypt = { workspace = true } vitaminc-hmac = { workspace = true } +# Equality terms derive over `vitaminc-normalize`'s encoding of a value +# (the workspace turns on vitaminc-prf's `normalize` feature): one term per +# number whatever its width, text in Unicode 16 NFC. Match text is +# normalized by the same crate before it is tokenized. +vitaminc-normalize = { workspace = true } vitaminc-prf = { workspace = true } vitaminc-protected = { workspace = true } serde = { workspace = true } diff --git a/packages/stack-encrypt/fuzz/Cargo.lock b/packages/stack-encrypt/fuzz/Cargo.lock index bc151b4f7..99aa082bd 100644 --- a/packages/stack-encrypt/fuzz/Cargo.lock +++ b/packages/stack-encrypt/fuzz/Cargo.lock @@ -2044,6 +2044,7 @@ dependencies = [ "vitaminc-aead 0.6.0", "vitaminc-encrypt 0.6.0", "vitaminc-hmac", + "vitaminc-normalize", "vitaminc-prf", "vitaminc-protected 0.6.0", "zeroize", @@ -2365,6 +2366,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.23" @@ -2652,6 +2659,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "vitaminc-normalize" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dda6ec7f852ba8cd7e0ba6e8e7eaa3045e6fb866007c6d7ad6128833d8980f0c" +dependencies = [ + "thiserror 2.0.18", + "unicode-general-category", + "unicode-normalization", + "vitaminc-aead 0.6.0", + "vitaminc-protected 0.6.0", +] + [[package]] name = "vitaminc-prf" version = "0.6.0" @@ -2660,7 +2680,9 @@ checksum = "11de28e524ab1deecedbb023bd328916b6c023e2f52156521ac1df75a689d911" dependencies = [ "mutants", "thiserror 2.0.18", + "vitaminc-aead 0.6.0", "vitaminc-context 0.6.0", + "vitaminc-normalize", "vitaminc-protected 0.6.0", ] diff --git a/packages/stack-encrypt/src/codes.rs b/packages/stack-encrypt/src/codes.rs index 6ac2e381a..baa3bed3d 100644 --- a/packages/stack-encrypt/src/codes.rs +++ b/packages/stack-encrypt/src/codes.rs @@ -81,6 +81,10 @@ mod tests { TermError::InvalidOptions(_) => TermError::InvalidOptions("k out of range"), TermError::EmptyTermText => TermError::EmptyTermText, TermError::InvertedRange => TermError::InvertedRange, + TermError::NoEqualityTerm => TermError::NoEqualityTerm, + TermError::Normalize(_) => { + TermError::Normalize(vitaminc_normalize::NormalizeError::UnassignedCodePoint) + }, TermError::Bytes(_) => TermError::Bytes(TermBytesError::OddMatchTermsLength(3)), ])); errors.extend(boxed(variants![ @@ -387,6 +391,13 @@ mod tests { ), (Box::new(TermError::EmptyTermText), json!({})), (Box::new(TermError::InvertedRange), json!({})), + (Box::new(TermError::NoEqualityTerm), json!({})), + ( + Box::new(TermError::Normalize( + vitaminc_normalize::NormalizeError::UnassignedCodePoint, + )), + json!({}), + ), ( Box::new(TermBytesError::MalformedCllwCiphertext(5)), json!({ "len": 5 }), diff --git a/packages/stack-encrypt/src/dynamic/value.rs b/packages/stack-encrypt/src/dynamic/value.rs deleted file mode 100644 index 4d92c52dc..000000000 --- a/packages/stack-encrypt/src/dynamic/value.rs +++ /dev/null @@ -1,231 +0,0 @@ -//! An [`FfiValue`] as a plan field. See [`Value`]. - -use std::fmt; - -use vitaminc_aead::plaintext::Sink; -use vitaminc_aead::{Decrypt, Encrypt}; -use vitaminc_protected::{Controlled, Protected}; - -use super::{FfiValue, Put, ValueSink}; - -use crate::plan::IntoLabel; -use crate::{Label, LabelError}; - -/// A runtime value as the plaintext of a plan field: what every field -/// lowered from data holds, whatever its declared type, and what a Rust -/// chain's field holds when its rows must open from a binding. -/// -/// The engine hands a borrowed field to every operation that consumes it -/// and clones it on the way in, as it does a `String` field of a derived -/// record, so a field's plaintext type is `Clone`. [`FfiValue`] is not: -/// vitaminc keeps its leaves in [`Protected`] and gives no copy out -/// implicitly. This wrapper is the copy made explicit, once, here: its -/// `Clone` rebuilds the tree leaf by leaf into fresh `Protected` payloads, -/// which wipe on drop as the originals do, so a clone is under the same -/// custody as the value it was made from. A record of a few fields clones -/// each once per operation that consumes it, as the derive's does. -/// -/// It seals and opens as the [`FfiValue`] it wraps, in vitaminc's -/// self-describing tagged leaf encoding — the same leaf a bare `u32` or -/// `String` of the same value seals as. Its `Debug` names the type and -/// nothing else: the value is plaintext. -pub struct Value(FfiValue); - -impl Value { - /// Wrap a value. - pub fn new(value: FfiValue) -> Self { - Self(value) - } - - /// The value. - pub fn get(&self) -> &FfiValue { - &self.0 - } - - /// Unwrap the value. - pub fn into_inner(self) -> FfiValue { - self.0 - } -} - -impl From for Value { - fn from(value: FfiValue) -> Self { - Self(value) - } -} - -impl From for FfiValue { - fn from(value: Value) -> Self { - value.0 - } -} - -impl fmt::Debug for Value { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("Value") - } -} - -impl Clone for Value { - fn clone(&self) -> Self { - Self(duplicate(&self.0)) - } -} - -/// A deep copy of a value, each byte-bearing leaf into a fresh -/// [`Protected`]. Exhaustive, so a variant added to [`FfiValue`] has to say -/// how it is copied. -fn duplicate(value: &FfiValue) -> FfiValue { - match value { - FfiValue::Null => FfiValue::Null, - FfiValue::Undefined => FfiValue::Undefined, - FfiValue::Bool(v) => FfiValue::Bool(*v), - FfiValue::Int32(v) => FfiValue::Int32(*v), - FfiValue::Int64(v) => FfiValue::Int64(*v), - FfiValue::UInt32(v) => FfiValue::UInt32(*v), - FfiValue::UInt64(v) => FfiValue::UInt64(*v), - FfiValue::Float32(v) => FfiValue::Float32(*v), - FfiValue::Float64(v) => FfiValue::Float64(*v), - // Valid UTF-8 by `Utf8String`'s construction invariant. Were it not, - // the copy is a bytes leaf: a different type the plan then refuses, - // never a string that reads differently from its original. - FfiValue::String(s) => match std::str::from_utf8(s.risky_ref()) { - Ok(text) => FfiValue::String(text.into()), - Err(_) => FfiValue::Bytes(Protected::new(s.risky_ref().to_vec())), - }, - FfiValue::Bytes(b) => FfiValue::Bytes(Protected::new(b.risky_ref().to_vec())), - FfiValue::Array(items) => FfiValue::Array(items.iter().map(duplicate).collect()), - FfiValue::Object(entries) => FfiValue::Object( - entries - .iter() - .map(|(key, value)| (key.clone(), duplicate(value))) - .collect(), - ), - FfiValue::Passthrough(inner) => FfiValue::Passthrough(Box::new(duplicate(inner))), - } -} - -impl Encrypt for Value { - fn visit(self, sink: S) -> Result { - self.0.visit(sink) - } -} - -impl Encrypt for &Value { - fn visit(self, sink: S) -> Result { - (&self.0).visit(sink) - } -} - -impl Decrypt for Value { - type Sink = ValueSink; - fn sink() -> Self::Sink { - ValueSink::new(AsValue) - } -} - -/// Where [`Value`]'s [`Decrypt`] sink puts what it builds: the -/// [`FfiValue`] sink's value, wrapped. -pub struct AsValue; - -impl Put for AsValue { - type Ok = Value; - fn put(self, value: FfiValue) -> Value { - Value(value) - } -} - -/// A string value read as a plan's context: what a data plan's -/// `"context_field"` holds (`"tenants/acme"`), parsed as a Rust chain's -/// `String` context field is ([`Label::parse`]). Any other variant is -/// [`LabelError::NotText`], which the plan reports as -/// `PlanError::ContextLabel` before any key is requested. -impl IntoLabel for Value { - fn into_label(self) -> Result { - match self.0 { - FfiValue::String(s) => match std::str::from_utf8(s.risky_ref()) { - Ok(text) => Label::parse(text), - Err(_) => Err(LabelError::NotText), - }, - _ => Err(LabelError::NotText), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn s(value: &str) -> FfiValue { - FfiValue::String(value.into()) - } - - /// Structural equality, which `FfiValue` offers only inside vitaminc's - /// own tests. - fn same(a: &FfiValue, b: &FfiValue) -> bool { - match (a, b) { - (FfiValue::Null, FfiValue::Null) | (FfiValue::Undefined, FfiValue::Undefined) => true, - (FfiValue::Bool(x), FfiValue::Bool(y)) => x == y, - (FfiValue::Int32(x), FfiValue::Int32(y)) => x == y, - (FfiValue::Int64(x), FfiValue::Int64(y)) => x == y, - (FfiValue::UInt32(x), FfiValue::UInt32(y)) => x == y, - (FfiValue::UInt64(x), FfiValue::UInt64(y)) => x == y, - (FfiValue::Float32(x), FfiValue::Float32(y)) => x.to_bits() == y.to_bits(), - (FfiValue::Float64(x), FfiValue::Float64(y)) => x.to_bits() == y.to_bits(), - (FfiValue::String(x), FfiValue::String(y)) => x.risky_ref() == y.risky_ref(), - (FfiValue::Bytes(x), FfiValue::Bytes(y)) => x.risky_ref() == y.risky_ref(), - (FfiValue::Array(x), FfiValue::Array(y)) => { - x.len() == y.len() && x.iter().zip(y).all(|(a, b)| same(a, b)) - } - (FfiValue::Object(x), FfiValue::Object(y)) => { - x.len() == y.len() - && x.iter() - .zip(y) - .all(|((ka, va), (kb, vb))| ka == kb && same(va, vb)) - } - (FfiValue::Passthrough(x), FfiValue::Passthrough(y)) => same(x, y), - _ => false, - } - } - - /// A clone is the same value, leaf for leaf, at every depth and for - /// every variant, and shares no payload with its original. - #[test] - fn a_clone_is_the_same_value_in_fresh_payloads() { - let original = Value::new(FfiValue::Object(vec![ - ("n".to_string(), FfiValue::Null), - ("u".to_string(), FfiValue::Undefined), - ("b".to_string(), FfiValue::Bool(true)), - ("i32".to_string(), FfiValue::Int32(-3)), - ("i64".to_string(), FfiValue::Int64(-4)), - ("u32".to_string(), FfiValue::UInt32(34)), - ("u64".to_string(), FfiValue::UInt64(35)), - ("f32".to_string(), FfiValue::Float32(1.5)), - ("f64".to_string(), FfiValue::Float64(2.5)), - ("s".to_string(), s("alice")), - ( - "bytes".to_string(), - FfiValue::Bytes(Protected::new(vec![1, 2, 3])), - ), - ( - "list".to_string(), - FfiValue::Array(vec![s("a"), FfiValue::Passthrough(Box::new(s("p")))]), - ), - ])); - let copy = original.clone(); - assert!(same(copy.get(), original.get()), "the same value"); - let (FfiValue::Object(a), FfiValue::Object(b)) = (original.get(), copy.get()) else { - panic!("objects"); - }; - let (FfiValue::String(x), FfiValue::String(y)) = (&a[9].1, &b[9].1) else { - panic!("strings"); - }; - assert_ne!( - x.risky_ref().as_ptr(), - y.risky_ref().as_ptr(), - "the text was copied, not shared" - ); - assert_eq!(format!("{copy:?}"), "Value", "debug shows no plaintext"); - assert!(matches!(copy.into_inner(), FfiValue::Object(_))); - } -} diff --git a/packages/stack-encrypt/src/op/mod.rs b/packages/stack-encrypt/src/op/mod.rs index 7bc228e69..19fb1b8b1 100644 --- a/packages/stack-encrypt/src/op/mod.rs +++ b/packages/stack-encrypt/src/op/mod.rs @@ -685,7 +685,7 @@ impl QueryToken { } } -impl Supports for Equality { +impl Supports for Equality { type Query = EqualityTerm; type Verifier = (); fn check(&self, _: &S) -> Result<(), Error> { @@ -708,7 +708,9 @@ where self.admit(value).map(drop) } fn verifier(&self, value: &S) -> QueryTokens { - QueryTokens::of::(value.as_ref()) + // `admit` refuses text the index cannot normalize, so this is the + // query's tokens; a query that got here otherwise verifies nothing. + QueryTokens::of::(value.as_ref()).unwrap_or_default() } fn admit(&self, value: &S) -> Result { let folds = O::options().downcase; @@ -811,7 +813,7 @@ pub trait Answers> { type Via: Supports + Default; } -impl Answers for EqualityTerm { +impl Answers for EqualityTerm { type Via = Equality; } @@ -859,7 +861,7 @@ pub trait DefaultIndex>: Operator { type Via: Supports + Default; } -impl DefaultIndex for Eq { +impl DefaultIndex for Eq { type Via = Equality; } diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 66599a1eb..9f529e448 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -138,7 +138,9 @@ use std::marker::PhantomData; // wrapper over the term APIs has to be able to name them without depending // on `cllw-ore` directly. pub use cllw_ore::{CllwOpeEncrypt, CllwOreEncrypt}; +use vitaminc_aead::Encrypt; use vitaminc_hmac::HmacSha256Prf; +use vitaminc_normalize::{terms, NormalizeError, Normalized}; use vitaminc_prf::{ BlockVisitor, Context, IntoPrfContext, MapAccess, PrfError, PrfValue, PrfVisitor, PrfVisitorError, SeqAccess, @@ -152,16 +154,22 @@ use crate::target::core::Term; use crate::target::{DecryptField, Decryptable, Decryption}; use crate::{Error, KeysetCipher, Pending}; -// The `/v1` suffix versions the *derivation* (domain + input framing), not the -// crate. Any change to the bytes a term derives from must bump it: a changed +// The version suffix (`/v1`, `/v2`) versions the *derivation* (domain + +// input framing), not the crate. Any change to the bytes a term derives from must bump it: a changed // derivation under an unchanged domain makes every existing term silently // unfindable, with no error to notice. The byte-level pins in // `tests/term_bytes.rs` are what force that bump to be deliberate. /// PAE domain for equality (exact-match) terms. -const EQUALITY_DOMAIN: &[u8] = b"stack-encrypt/sem/equality/v1"; +/// +/// `v2`: the PRF input is the value's `vitaminc-normalize` encoding under +/// its domain, where `v1` was the value's own PRF encoding. +const EQUALITY_DOMAIN: &[u8] = b"stack-encrypt/sem/equality/v2"; /// PAE domain for match (full-text) token terms. -const MATCH_DOMAIN: &[u8] = b"stack-encrypt/sem/match/v1"; +/// +/// `v2`: the text is tokenized in Unicode 16 NFC, where `v1` tokenized it as +/// written. +const MATCH_DOMAIN: &[u8] = b"stack-encrypt/sem/match/v2"; /// PAE domain for ORE key derivation. const ORE_KEY_DOMAIN: &[u8] = b"stack-encrypt/sem/ore-key/v1"; /// PAE domain for OPE key derivation (distinct from ORE: OPE ciphertexts are @@ -214,6 +222,26 @@ pub enum TermError { help("Give `op::Between` its bounds as `(lo, hi)` with `lo <= hi`. The range is inclusive at both ends, so one value is `(v, v)`.") )] InvertedRange, + /// The value is not one scalar an equality term is defined for: a + /// null, a boolean, a container, or a value of a logical type vitaminc + /// gives no equality domain. Equality derives over one scalar, in the + /// domain `vitaminc-normalize` gives its kind (every integer width one + /// domain, both float widths one, text, bytes, and the dates, + /// timestamps, decimals and uuids that opt in); there is no term for + /// anything else. + #[error("no equality term is defined for this value")] + #[diagnostic( + code(stack_encrypt::no_equality_term), + help("An equality term is defined for one integer, float, text, bytes, date, timestamp, decimal or uuid value, not for a boolean, a null or a container.") + )] + NoEqualityTerm, + /// The value cannot be normalized for its term: text with a code point + /// unassigned in Unicode 16 (an equality or match term), or a value not + /// valid for its logical type. The reason is vitaminc's, and names no + /// part of the value. + #[error("the value cannot be normalized for its term: {0}")] + #[diagnostic(code(stack_encrypt::term_normalize))] + Normalize(#[from] NormalizeError), /// Term bytes do not decode under the term kind's frozen encoding — see /// [`TermBytesError`]. #[error(transparent)] @@ -229,7 +257,9 @@ impl crate::ErrorPayload for TermError { | Self::Ore(_) | Self::InvalidOptions(_) | Self::EmptyTermText - | Self::InvertedRange => serde_json::Map::new(), + | Self::InvertedRange + | Self::NoEqualityTerm + | Self::Normalize(_) => serde_json::Map::new(), } } } @@ -311,7 +341,8 @@ impl TermError { // Equality // ============================================================================= -/// An equality (exact-match) index term: one PRF block over the whole value. +/// An equality (exact-match) index term: one PRF block over the value's +/// normalized bytes (see [`KeysetCipher::equality_term`]). /// /// Terms are pseudorandom under the index key; they are stored server-side and /// are not secret key material. @@ -385,27 +416,68 @@ impl PrfVisitor<[u8; 32], P> for EqualityVisitor { /// deferred backend the same visitor runs after the round-trip instead). /// `context` is the encoding of a [`NonEmpty`] — every caller holds one — /// so there is nothing left to validate here. +/// +/// The value is normalized first, by `vitaminc-normalize`'s [`terms`] sink: +/// it must be one scalar with an equality domain, and the PRF derives over +/// that scalar's [`Normalized`] bytes under its domain. So every integer +/// width of one number is one term, both float widths of one number are +/// one term, and text is compared in Unicode 16 NFC: `café` written with a +/// precomposed `é` and with `e` and a combining accent is one term. fn equality( prf: &HmacSha256Prf, value: T, context: Context<'_>, ) -> Result where - T: PrfValue, + T: Encrypt, { + equality_of_normalized(prf, normalized(value)?, context) +} + +/// The equality term of a scalar already normalized: the PRF over its +/// bytes, under its `vitaminc-normalize` domain and the PAE-framed +/// `[EQUALITY_DOMAIN, context]`. [`equality`] and the `dynamic` path, which +/// normalizes the one scalar it walks to, both end here. +fn equality_of_normalized( + prf: &HmacSha256Prf, + normalized: Normalized, + context: Context<'_>, +) -> Result { let context = Context::pae(&[EQUALITY_DOMAIN, context.as_bytes()]); - value + normalized .prf_visit_with_context(prf, context, EqualityVisitor) .into_result() .map_err(TermError::from_prf) } -/// An equality term of any [`PrfValue`] source. Under the local HMAC -/// backend, derived during the synchronous build — the returned [`Pending`] -/// carries no requests. +/// The one [`Normalized`] scalar of a value, as `vitaminc-normalize`'s +/// [`terms`] sink derives it: [`TermError::NoEqualityTerm`] unless the value +/// is one scalar with an equality domain, and [`TermError::Normalize`] for a +/// scalar the sink refuses (text with a code point unassigned in Unicode 16, +/// a value not valid for its logical type). +fn normalized(value: T) -> Result { + let mut terms = terms(value).map_err(|error| match error.term() { + Some(refused) => TermError::Normalize(refused.error()), + // A shape refusal or a source failure: not one scalar the sink could + // read, so there is no term. + None => TermError::NoEqualityTerm, + })?; + match (terms.pop(), terms.is_empty()) { + // One term, and at the root: the value is that scalar. A container + // with one indexable scalar inside is not a scalar, and a null or a + // boolean produces no term at all. + (Some(term), true) if term.path.steps().is_empty() => Ok(term.normalized), + _ => Err(TermError::NoEqualityTerm), + } +} + +/// An equality term of any [`Plaintext`] source that is one scalar with an +/// equality domain (see [`equality`]). Under the local HMAC backend, +/// derived during the synchronous build — the returned [`Pending`] carries +/// no requests. impl<'c, S, K, T> Term> for EqualityTerm where - S: PrfValue, + S: Encrypt, T: IntoPrfContext<'c>, { // Derived locally: no data key, no descriptor. @@ -728,11 +800,11 @@ impl crate::op::Probabilistic for MatchQuery { fn verify(&self, value: &str) -> bool { let options = O::options(); - let tokens = Zeroizing::new(tokenize::tokenize( - value, - options.tokenizer, - options.downcase, - )); + // A value the index cannot normalize holds no token of any query. + let Ok(tokens) = tokenize::tokens(value, options.tokenizer, options.downcase) else { + return false; + }; + let tokens = Zeroizing::new(tokens); let have: HashSet<&str> = tokens.iter().map(String::as_str).collect(); !self.tokens.0.is_empty() && self.tokens.0.iter().all(|t| have.contains(t.as_str())) } @@ -836,22 +908,31 @@ impl RangeQuery { /// ([`Supports::query`](crate::op::Supports::query) takes a token no caller /// can make), so one query's tokens never verify rows for another value's /// filter. -#[derive(Clone)] +#[derive(Clone, Default)] pub struct QueryTokens(Zeroizing>); impl QueryTokens { /// The tokens of `text` under `O`'s tokenizer and case folding. - pub(crate) fn of(text: &str) -> Self { + /// + /// # Errors + /// + /// [`TermError::Normalize`] for text the index cannot normalize (a code + /// point unassigned in Unicode 16). + pub(crate) fn of(text: &str) -> Result { Self::under(text, &O::options()) } /// The tokens of `text` under `options`' tokenizer and case folding. - pub(crate) fn under(text: &str, options: &MatchOptions) -> Self { - Self(Zeroizing::new(tokenize::tokenize( + /// + /// # Errors + /// + /// As for [`of`](Self::of). + pub(crate) fn under(text: &str, options: &MatchOptions) -> Result { + Ok(Self(Zeroizing::new(tokenize::tokens( text, options.tokenizer, options.downcase, - ))) + )?))) } /// The tokens of a query's `text` under `options`, or why the query is @@ -864,7 +945,7 @@ impl QueryTokens { /// as the empty filter instead. pub(crate) fn of_query(text: &str, options: &MatchOptions) -> Result { let _mask = options.validate()?; - let tokens = Self::under(text, options); + let tokens = Self::under(text, options)?; if tokens.0.is_empty() { return Err(TermError::EmptyTermText); } @@ -935,7 +1016,7 @@ fn match_term( options: MatchOptions, ) -> Result, TermError> { let mask = options.validate()?; - let tokens = tokenize::tokenize(text, options.tokenizer, options.downcase); + let tokens = tokenize::tokens(text, options.tokenizer, options.downcase)?; if tokens.is_empty() { return Ok(MatchTerms::normalised(Vec::new())); } @@ -1343,13 +1424,22 @@ impl KeysetCipher<'_, K> { /// the same term, at write time and at query time. Byte-identical to /// `value.encrypt_into_with_context(&keyset, descriptor)` into an /// `EqualityTerm`. + /// + /// The value is one scalar, normalized by `vitaminc-normalize` before the + /// PRF sees it: every integer width of one number is one term + /// (`34u32` and `34i64` match), both float widths of one number are one + /// term, text is compared in Unicode 16 NFC, and a date, timestamp, + /// decimal or uuid derives in a domain of its own. A boolean, a null or + /// a container has no term ([`TermError::NoEqualityTerm`]), and text with + /// a code point unassigned in Unicode 16 is refused + /// ([`TermError::Normalize`]). pub fn equality_term<'c, T>( &self, value: T, descriptor: NonEmpty>, ) -> Pending<'_, EqualityTerm, K> where - T: PrfValue, + T: Encrypt, { let term = equality(self.prf(), value, descriptor.into_prf_context()); Pending::ready(self, term.map_err(Error::from)) @@ -1386,6 +1476,20 @@ impl KeysetCipher<'_, K> { self.match_terms_under(text, descriptor, O::options()) } + /// [`equality_term`](Self::equality_term) of one scalar the caller has + /// normalized already, for the `dynamic` path, which walks a value to + /// its one scalar and normalizes it there, with the same + /// `vitaminc_normalize::normalize` the typed path's sink applies. + #[cfg_attr(not(feature = "dynamic"), allow(dead_code))] + pub(crate) fn equality_term_normalized<'c>( + &self, + normalized: Normalized, + descriptor: NonEmpty>, + ) -> Pending<'_, EqualityTerm, K> { + let term = equality_of_normalized(self.prf(), normalized, descriptor.into_prf_context()); + Pending::ready(self, term.map_err(Error::from)) + } + /// [`match_terms`](Self::match_terms) under runtime options, for the /// `dynamic` path, whose plan carries a match index's options as data /// (an `IndexSpec::Match`). Not public: the typed API keeps the options @@ -1470,6 +1574,45 @@ mod tests { assert!(!crate::op::Probabilistic::verify(&query, "")); } + /// An equality term is one scalar's: a container, even one holding one + /// scalar, and a scalar with no equality domain have none, and text with + /// no normal form is refused for that reason. + #[test] + fn equality_is_defined_for_one_scalar_with_a_domain() { + let prf = HmacSha256Prf::new(Protected::new([7; 32])); + let derive = |value| equality(&prf, value, Context::empty()); + assert!(equality(&prf, 34u32, Context::empty()).is_ok()); + for (what, refused) in [ + ("a boolean", equality(&prf, true, Context::empty())), + ("a null", equality(&prf, None::, Context::empty())), + ( + "a sequence of one", + equality(&prf, vec![34u32], Context::empty()), + ), + ( + "a map of one", + equality(&prf, BTreeMap::from([("a", 34u32)]), Context::empty()), + ), + ( + "a map of two", + equality( + &prf, + BTreeMap::from([("a", 34u32), ("b", 35u32)]), + Context::empty(), + ), + ), + ] { + assert!( + matches!(refused, Err(TermError::NoEqualityTerm)), + "{what}: {refused:?}" + ); + } + assert!(matches!( + derive("a\u{378}b"), + Err(TermError::Normalize(NormalizeError::UnassignedCodePoint)) + )); + } + /// A match query's one rule: options the index cannot use are refused /// before the text is tokenised, then text that cuts no token; text /// that cuts one comes back as its tokens. @@ -1491,7 +1634,10 @@ mod tests { let tokens = QueryTokens::of_query("ALICE", &options).expect("cuts tokens"); assert_eq!( tokens.0.as_slice(), - QueryTokens::under("alice", &options).0.as_slice() + QueryTokens::under("alice", &options) + .expect("normalizes") + .0 + .as_slice() ); } diff --git a/packages/stack-encrypt/src/sem/tokenize.rs b/packages/stack-encrypt/src/sem/tokenize.rs index d583fe18d..419ae6c89 100644 --- a/packages/stack-encrypt/src/sem/tokenize.rs +++ b/packages/stack-encrypt/src/sem/tokenize.rs @@ -10,6 +10,8 @@ //! is that empty tokens are dropped (v1 keeps the empty strings its separator //! split produces, which only add noise bits to every filter). +use vitaminc_normalize::NormalizeError; +use vitaminc_protected::Controlled; use zeroize::Zeroizing; /// How text is split into tokens before each token is run through the PRF. @@ -40,6 +42,28 @@ impl Default for Tokenizer { } } +/// Split `text` into tokens as a match index does: in Unicode 16 NFC, the +/// form `vitaminc-normalize` gives text, then [`tokenize`]d. So text written +/// with a precomposed `é` and with `e` and a combining accent cuts the same +/// tokens, as it derives one equality term. Text holding a code point +/// unassigned in Unicode 16 is refused, as equality refuses it: its NFC +/// form could change when the code point is assigned. +/// +/// The normalized copy is held in [`Protected`](vitaminc_protected::Protected) +/// and wiped on drop. +pub(crate) fn tokens( + text: &str, + tokenizer: Tokenizer, + downcase: bool, +) -> Result, NormalizeError> { + let normalized = vitaminc_normalize::text(text)?; + // `text` returns the UTF-8 of a `str`, so this does not fail; a failure + // would be a bug there, refused rather than tokenized. + let normalized = + std::str::from_utf8(normalized.risky_ref()).map_err(|_| NormalizeError::Invalid)?; + Ok(tokenize(normalized, tokenizer, downcase)) +} + /// Split `text` into tokens. `downcase` lower-cases the text first so matches /// are case-insensitive. /// @@ -108,6 +132,25 @@ fn gram(chars: &[char]) -> String { mod tests { use super::*; + #[test] + fn tokens_are_cut_from_the_nfc_form() { + let standard = |text| tokens(text, Tokenizer::Standard, false).expect("tokens"); + assert_eq!( + standard("cafe\u{301} au lait"), + standard("caf\u{e9} au lait"), + "a combining accent and the precomposed letter cut the same tokens" + ); + assert_eq!(standard("caf\u{e9}"), vec!["caf\u{e9}"], "the token is NFC"); + } + + #[test] + fn tokens_refuse_a_code_point_unassigned_in_unicode_16() { + assert_eq!( + tokens("a\u{378}b", Tokenizer::Standard, true), + Err(NormalizeError::UnassignedCodePoint) + ); + } + #[test] fn ngram_tokenizes_sliding_windows() { assert_eq!( diff --git a/packages/stack-encrypt/src/target/index.rs b/packages/stack-encrypt/src/target/index.rs index d44d950c0..f44fdbb54 100644 --- a/packages/stack-encrypt/src/target/index.rs +++ b/packages/stack-encrypt/src/target/index.rs @@ -203,7 +203,7 @@ impl fmt::Debug for Match { } } -impl Index for Equality { +impl Index for Equality { type Term = EqualityTerm; fn spec(&self) -> IndexSpec { IndexSpec::Equality @@ -553,7 +553,7 @@ pub trait TermSet: Sized + 'static { /// Those indexes, as a value. const INDEXES: Self::Indexes; } -impl TermSet for EqualityTerm { +impl TermSet for EqualityTerm { type Indexes = Equality; const INDEXES: Equality = Equality; } diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index 688a67dbc..d3c1c59f7 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -535,8 +535,9 @@ macro_rules! term_operation { } term_operation!( /// The equality term of `S` under the context the tree hands it. Requires - /// only `S`'s PRF contract, not recoverable encryption. - equality, crate::sem::EqualityTerm, consume, [], [S: vitaminc_prf::PrfValue] + /// only that `S` describes itself (`Plaintext`), as one scalar with an + /// equality domain; not recoverable encryption. + equality, crate::sem::EqualityTerm, consume, [], [S: crate::Encrypt] ); term_operation!( /// The match term of any text `S` under the context the tree hands it, @@ -893,7 +894,7 @@ impl DecryptInto

for StackCipherText { open(self, context) } } -impl EncryptFrom for crate::sem::EqualityTerm { +impl EncryptFrom for crate::sem::EqualityTerm { type Context = CallerContext; fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> where diff --git a/packages/stack-encrypt/tests/fixtures/record_lowering.json b/packages/stack-encrypt/tests/fixtures/record_lowering.json index b0744958c..868b695f7 100644 --- a/packages/stack-encrypt/tests/fixtures/record_lowering.json +++ b/packages/stack-encrypt/tests/fixtures/record_lowering.json @@ -47,8 +47,8 @@ "passthrough": "tenants/acme" }, "text": { - "c": "0100000000000000000000000000000000cf1a45d1517a08c45d6ae1c989ae99de2000da620d1dbdbba9372d1439b6d44f7cc4169e9a255387aa180d6a95fb17926d70027eff9bae3dcbbf74666b87b43afcdeeb5d90fe4c4e316142e647677cb9597d6cf9c0", - "eq": "0d84fbd44d82b5150351556d6d25a038c70cf0c696e2499a71b659a7732a9d08" + "c": "0100000000000000000000000000000000cf1a45d1517a08c45d6ae1c989ae99de2000da620d1dbdbba9372d1439b6d44f7cc4169e9a255387aa180d6a95fb17926d7002bc0ceebce867d0c4942a2cf544f1195a02327f01117b05892d35d19348b1018e2dc2", + "eq": "d07ccccb55205dbb5c69c80bd88c907496a18bbed9317f39d96a83f6fd308a85" } }, "typed_chain": { @@ -59,8 +59,8 @@ "passthrough": "tenants/acme" }, "text": { - "c": "010000000000000000000000000000000048a99626f697c02001f07da2edb19f4220009868b833b6220fa0d13b612af03af1f38fee082c7555b6e7fbf8e13ccb70f80702c1591a387d3f6fd6c83d4b547b857ec46a3ef86919ace434e5ca69581ce699cf0765", - "eq": "0d84fbd44d82b5150351556d6d25a038c70cf0c696e2499a71b659a7732a9d08" + "c": "010000000000000000000000000000000048a99626f697c02001f07da2edb19f4220009868b833b6220fa0d13b612af03af1f38fee082c7555b6e7fbf8e13ccb70f807027e9874c89aa2b6dc1f7a073c6c41e7679b8380880c5daa0aff707d91b85142f688c2", + "eq": "d07ccccb55205dbb5c69c80bd88c907496a18bbed9317f39d96a83f6fd308a85" } } } @@ -125,38 +125,38 @@ "records": { "lowering": { "age": { - "c": "01000000000000000000000000000000004d5626148f77c4c6d9da0ddbdf7663a2200021162641133485216f389886cbd7c6e9b25afa0f5f166f985b23384e81ffcfac02b8821c190a544161b6ac13825768cd6b494515bdd96c26715ffecd7321277d13d6", - "eq": "5433335e609685a33b29c98369e68cd5d92e838aa566a3ff1df5c2d251b63885", + "c": "01000000000000000000000000000000004d5626148f77c4c6d9da0ddbdf7663a2200021162641133485216f389886cbd7c6e9b25afa0f5f166f985b23384e81ffcfac0228dd18e6432418924ee25f51a3859d9a22895f62ee52705e57ea1491ef1d88a5bf", + "eq": "cc854fd5c7e3f4bb28cb15cae47d866b65b7012c06df43b4b9a6c51adff4710a", "ore": "7ca54e16d7944c0a9d8577d87f3864fdf79e99fea5956ba956a126a605cbf3f1" }, "email": { - "c": "0100000000000000000000000000000000b2afa77357a61488146c0edaa0246f65200043956c4f4bba857028c28165dfce9c63d904b738bbb039b6e11c805112d4a1ee02d3ce6ecc6cc31f82d07a829142cf39b2fcd9a742f89627f316622416f49df065d381337c5da03a8f3ab5b84d", - "eq": "ec534cfbf2336785378c3761ff88623c46361b2ba7c5fa83930ebacae49cf646", - "match": "06000d0013001d002200260029002a003000320043005a006000660068006c0077008f00960098009e00c200c500d600d900de00e300e700e900eb00ec00f200fa00" + "c": "0100000000000000000000000000000000b2afa77357a61488146c0edaa0246f65200043956c4f4bba857028c28165dfce9c63d904b738bbb039b6e11c805112d4a1ee028903df249ec7db270d3e5f25a72060fd12bc5174ed5776c9477df43aa7dc6421105f6090f8fd54e0287081c1", + "eq": "0cf708c07acaaa169e84e4adf7081f0433d87760bd7e9c63bd7a58c000b8d33b", + "match": "030005000900100012001a001c0027002a002e003000340042004b0068006d007100750083009200a800b200b400b700ba00c200c500dc00e500ea00f200f300f900fe00" }, "id": { "passthrough": 42 }, "notes": { - "c": "0100000000000000000000000000000000723f7a7f14b07197168aa13bc52010b3200072a73194b45af604cc6201b5a57554b05cc7ed6ad22b9102cbcb71bd90678496020f72b5054a1a4467aa441215b4af28502c55a2590aae2a844ea40392f5fd64e679614a072b8736" + "c": "0100000000000000000000000000000000723f7a7f14b07197168aa13bc52010b3200072a73194b45af604cc6201b5a57554b05cc7ed6ad22b9102cbcb71bd90678496025763fabba65839aab92f515ccb053a4bfbea672c516be6a6c51f97e00cd0071ab8e7b337fd8055" } }, "typed_chain": { "age": { - "c": "010000000000000000000000000000000092e427c5e176b60261add6ce80fd02df20008d231453c97fdec97a0b6559ac8f509129d20163f262d40ce5d77cf1ebe0cad502c51f3fa24db5e34fadc3553b401d674928b00126a41cb7f30da889259f472223e8", - "eq": "5433335e609685a33b29c98369e68cd5d92e838aa566a3ff1df5c2d251b63885", + "c": "010000000000000000000000000000000092e427c5e176b60261add6ce80fd02df20008d231453c97fdec97a0b6559ac8f509129d20163f262d40ce5d77cf1ebe0cad502f71ddc78cf13e463d4af42f9f837778bf5a0793bb6794a2f3fa406a8d3aa20e772", + "eq": "cc854fd5c7e3f4bb28cb15cae47d866b65b7012c06df43b4b9a6c51adff4710a", "ore": "7ca54e16d7944c0a9d8577d87f3864fdf79e99fea5956ba956a126a605cbf3f1" }, "email": { - "c": "0100000000000000000000000000000000cf92191950409f9fe82a2b11888ffc1d2000ca4d5bbafbff919af4d3a95c7cc046d632fd4c28785b238f4b6f7ea3aa08482902a44da283fdefa835c68377a191ccf172943720b07e7779cc903619869844eb9cfed80a8a7f9034e69385ddb1", - "eq": "ec534cfbf2336785378c3761ff88623c46361b2ba7c5fa83930ebacae49cf646", - "match": "06000d0013001d002200260029002a003000320043005a006000660068006c0077008f00960098009e00c200c500d600d900de00e300e700e900eb00ec00f200fa00" + "c": "0100000000000000000000000000000000cf92191950409f9fe82a2b11888ffc1d2000ca4d5bbafbff919af4d3a95c7cc046d632fd4c28785b238f4b6f7ea3aa08482902053217eb2c4844e6700106487ec76d10b0052c05089848a1e18a003a8fc94a4b0b9b90db118ddf73eba753d2", + "eq": "0cf708c07acaaa169e84e4adf7081f0433d87760bd7e9c63bd7a58c000b8d33b", + "match": "030005000900100012001a001c0027002a002e003000340042004b0068006d007100750083009200a800b200b400b700ba00c200c500dc00e500ea00f200f300f900fe00" }, "id": { "passthrough": 42 }, "notes": { - "c": "0100000000000000000000000000000000562a41e032316ff2d1264a9e9f5c3b842000de33c8d1f2caaab8b90f691161a57eeb1328f7f1c9b5be76dec83e8a53765f1102c76bf0a61684b320c316ad94b01eb76bb52b3c8493f21a73012de6289580bf355b7192c61500e0" + "c": "0100000000000000000000000000000000562a41e032316ff2d1264a9e9f5c3b842000de33c8d1f2caaab8b90f691161a57eeb1328f7f1c9b5be76dec83e8a53765f1102bfdd18635fde4d7d775297d5e47c5b9a3ade28f51212c0bc5a2b64925f0225c006685f8db07cee" } } } diff --git a/packages/stack-encrypt/tests/frozen_bytes.rs b/packages/stack-encrypt/tests/frozen_bytes.rs index f930043a0..12efc2303 100644 --- a/packages/stack-encrypt/tests/frozen_bytes.rs +++ b/packages/stack-encrypt/tests/frozen_bytes.rs @@ -378,7 +378,7 @@ async fn match_term_bytes_are_pinned() { let bytes = term.to_bytes(); assert_eq!( hex(&bytes), - "040005000a000d000e001e002700350038003d0055005e005f0064006f007d007f009c00ad00bc00bd00ca00d000e000e500ef00" + "06000f001c0028003300360037003d0040005c0073009400970099009b00a100a300ac00b100b300ba00bb00c500d800f600fb00fe00" ); assert_eq!( MatchTerms::::from_bytes(&bytes).expect("decode match term"), diff --git a/packages/stack-encrypt/tests/op.rs b/packages/stack-encrypt/tests/op.rs index 1a19e95d6..f6c6fd8b7 100644 --- a/packages/stack-encrypt/tests/op.rs +++ b/packages/stack-encrypt/tests/op.rs @@ -588,6 +588,49 @@ async fn verify_keeps_every_real_match_and_folds_case_as_the_index_does() { ); } +/// A match query is cut in Unicode 16 NFC, as the stored value is, so a +/// query and a row that spell one string in its two normal forms match and +/// verify. Text holding a code point unassigned in Unicode 16 has no normal +/// form: the query is refused, and such a row verifies against nothing. +#[tokio::test] +async fn a_match_query_is_cut_in_nfc_and_refuses_text_with_no_normal_form() { + let cipher = stack_cipher().await; + let plan = people_plan(); + let email_plan = plan.field("email").unwrap(); + + let query = cipher + .query(op::Matches, "Rene\u{301}e") + .on(&email_plan) + .await + .unwrap(); + let precomposed = cipher + .query(op::Matches, "Ren\u{e9}e") + .on(&email_plan) + .await + .unwrap(); + assert_eq!(query, precomposed, "one string, one filter"); + assert!(query.verify("Ren\u{e9}e Martin")); + assert!(precomposed.verify("Rene\u{301}e Martin")); + assert!( + !query.verify("Ren\u{e9}e \u{378}"), + "a value with no normal form verifies against nothing" + ); + + let refused = cipher + .query(op::Matches, "Ren\u{378}e") + .on(&email_plan) + .await; + assert!( + matches!( + refused, + Err(Error::Term(TermError::Normalize( + vitaminc_normalize::NormalizeError::UnassignedCodePoint + ))) + ), + "{refused:?}" + ); +} + #[tokio::test] async fn verify_drops_a_row_the_filter_matched_by_chance() { let cipher = stack_cipher().await; diff --git a/packages/stack-encrypt/tests/term_bytes.rs b/packages/stack-encrypt/tests/term_bytes.rs index ebe5f04cb..164eb66af 100644 --- a/packages/stack-encrypt/tests/term_bytes.rs +++ b/packages/stack-encrypt/tests/term_bytes.rs @@ -5,7 +5,7 @@ //! them changes every stored term — and because terms are compared for //! equality server-side, the failure mode is not an error but a query that //! silently stops matching. Breaking one of these tests means the derivation -//! moved, and the `/v1` suffix in the domain labels has to move with it. +//! moved, and the version suffix in the domain labels has to move with it. //! //! Keyed by `FakeDataKeySource`'s deterministic index key, so the expected //! bytes are stable without ZeroKMS. @@ -16,6 +16,12 @@ //! labels and framing here did not. That was a prerelease wire break, taken //! deliberately (CIP-4036); the `/v1` suffixes stayed because nothing of //! this crate's own moved. +//! +//! The equality and match pins moved again with the labels, to `/v2`, when +//! both began deriving through `vitaminc-normalize`: an equality term is the +//! PRF over the value's normalized bytes under vitaminc's domain for its +//! kind, and match text is tokenized in Unicode 16 NFC. No stored term +//! existed to keep. The ORE and OPE derivations did not move. use stack_encrypt::nonempty; use stack_encrypt::sem::{DefaultMatch, MatchConfig, MatchOptions}; @@ -45,7 +51,33 @@ async fn equality_term_bytes_are_pinned() { assert_eq!( hex(term.as_bytes()), - "c101ef066547cb33003c352dcd02a42bfe4a24e2d0609caca0189d9f1af8262a" + "0aa0df4adabbad408580770b6e156aecca21a1d45f84e554ad4a1a0f76006fef" + ); +} + +/// Equality compares text in Unicode 16 NFC and numbers whatever their +/// width: two spellings of one string, and one number at two widths, are +/// one term each. +#[tokio::test] +async fn equality_terms_are_one_per_normal_form() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let term = |value: &'static str| keyset.equality_term(value, nonempty!("users/email")); + assert_eq!( + term("cafe\u{301}").await.unwrap(), + term("caf\u{e9}").await.unwrap(), + "a combining accent and the precomposed letter are one string" + ); + assert_eq!( + keyset + .equality_term(34u32, nonempty!("users/age")) + .await + .unwrap(), + keyset + .equality_term(34i64, nonempty!("users/age")) + .await + .unwrap(), + "34 is one number at every width" ); } @@ -63,8 +95,8 @@ async fn match_term_positions_are_pinned() { assert_eq!( term.positions(), [ - 4, 5, 10, 13, 14, 30, 39, 53, 56, 61, 85, 94, 95, 100, 111, 125, 127, 156, 173, 188, - 189, 202, 208, 224, 229, 239 + 6, 15, 28, 40, 51, 54, 55, 61, 64, 92, 115, 148, 151, 153, 155, 161, 163, 172, 177, + 179, 186, 187, 197, 216, 246, 251, 254 ] ); } @@ -130,9 +162,9 @@ async fn match_term_positions_are_pinned_for_a_wide_filter() { assert_eq!( term.positions(), [ - 2287, 2398, 2404, 2829, 5150, 5181, 5293, 9255, 11964, 14175, 16080, 24350, 25354, - 28362, 31748, 33647, 35845, 39141, 39480, 41998, 42621, 45365, 50303, 60896, 64668, - 64957, 65365 + 6398, 8087, 11318, 13096, 14587, 16924, 19131, 21007, 22427, 22518, 24792, 25004, + 34675, 35505, 35770, 36919, 38067, 41990, 42949, 45716, 48448, 55869, 56883, 57753, + 58019, 60065, 65116 ], "positions for a 65536-bit filter are frozen: both bytes of each slice are in play" ); diff --git a/packages/stack-encrypt/tests/ui/op_target_does_not_answer.stderr b/packages/stack-encrypt/tests/ui/op_target_does_not_answer.stderr index 24ed43ab0..3a9ac194a 100644 --- a/packages/stack-encrypt/tests/ui/op_target_does_not_answer.stderr +++ b/packages/stack-encrypt/tests/ui/op_target_does_not_answer.stderr @@ -9,7 +9,7 @@ help: the trait `Answers` is no but trait `Answers` is implemented for it --> src/op/mod.rs | - | impl Answers for EqualityTerm { - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | impl Answers for EqualityTerm { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ = help: for that trait implementation, expected `stack_encrypt::op::Eq`, found `stack_encrypt::op::Matches` = note: required for `ValuePlan>` to implement `Chooses` diff --git a/packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.rs b/packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.rs index e567cfd8d..4e607736d 100644 --- a/packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.rs +++ b/packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.rs @@ -6,7 +6,7 @@ use stack_encrypt::target::{ciphertext, equality, CallerContext, Owned}; use stack_encrypt::{nonempty, KeysetCipher, Encrypt}; use stack_kms::FakeDataKeySource; -fn seal_and_index( +fn seal_and_index( keyset: &KeysetCipher<'_, FakeDataKeySource>, value: S, ) { diff --git a/packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.stderr b/packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.stderr index aca006b38..ac961ab71 100644 --- a/packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.stderr +++ b/packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.stderr @@ -15,5 +15,5 @@ note: required by a bound in `Encryption::<'s, S, T, K, Ctx, M>::zip` | ^^^^^^^^^^^^^^^^^^ required by this bound in `Encryption::<'s, S, T, K, Ctx, M>::zip` help: consider further restricting type parameter `S` with trait `Clone` | - 9 | fn seal_and_index( - | +++++++++++++++++++ + 9 | fn seal_and_index( + | +++++++++++++++++++ diff --git a/packages/stack-encrypt/tests/ui/pass/owned_without_clone.rs b/packages/stack-encrypt/tests/ui/pass/owned_without_clone.rs index b871b992f..8c0bdf58a 100644 --- a/packages/stack-encrypt/tests/ui/pass/owned_without_clone.rs +++ b/packages/stack-encrypt/tests/ui/pass/owned_without_clone.rs @@ -14,7 +14,7 @@ fn seal<'a, S: Encrypt>( ) -> Pending<'a, StackCipherText, FakeDataKeySource> { keyset.run(ciphertext::<_, _, Owned>(), value, nonempty!("column").into()) } -fn index<'a, S: vitaminc_prf::PrfValue>( +fn index<'a, S: Encrypt>( keyset: &'a KeysetCipher<'_, FakeDataKeySource>, value: S, ) -> Pending<'a, EqualityTerm, FakeDataKeySource> { diff --git a/packages/stack-encrypt/tests/ui/pass/query_only_and_borrowed.rs b/packages/stack-encrypt/tests/ui/pass/query_only_and_borrowed.rs index 78f5c82f6..3fb03fd8f 100644 --- a/packages/stack-encrypt/tests/ui/pass/query_only_and_borrowed.rs +++ b/packages/stack-encrypt/tests/ui/pass/query_only_and_borrowed.rs @@ -1,12 +1,13 @@ -use stack_encrypt::{EncryptFrom, StackCipher, StackCipherText, nonempty}; +use stack_encrypt::{EncryptFrom, Encrypt, StackCipher, StackCipherText, nonempty}; use stack_encrypt::sem::EqualityTerm; use stack_kms::FakeDataKeySource; #[derive(EncryptFrom)] struct Probe { hm: EqualityTerm } -// No Plaintext bound: producing a term requires only the PRF capability. -fn query(cipher:&StackCipher, value:&S) { +// No `Decrypt` bound: producing a term requires only that the value +// describes itself, not that it can be read back. +fn query(cipher:&StackCipher, value:&S) { let keyset=cipher.default_keyset(); let _ = keyset.encrypt_as::<_,Probe>(value,nonempty!("column").into()); } From 407090950c71ce00a1f08b7fca6e81e31751fdf9 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 16 Oct 2026 02:28:31 +1100 Subject: [PATCH 2/9] feat(stack-encrypt)!: walk a dynamic value to its scalar as vitaminc Plaintext and give a boolean no order term dynamic::Value and dynamic::Scalar restated vitaminc's vocabulary: one to make an FfiValue Clone, the other to pick a Rust type per variant. FfiValue is now Clone itself and is a plan field's plaintext, and dynamic::term takes any Plaintext, so a guest walks the frame it was handed in place and the term derives from the scalar vitaminc hands the sink. IndexSpec::defines replaces supports, and admits reads the same table over kinds: equality is vitaminc's (floats now have a domain, booleans none), match takes text, and ORE and OPE refuse a boolean, whose order term would tell anyone holding the column which rows are true. Part of #1140 --- packages/stack-encrypt/CHANGELOG.md | 29 + .../stack-encrypt/src/dynamic/ffi_value.rs | 99 ++ packages/stack-encrypt/src/dynamic/kind.rs | 96 +- packages/stack-encrypt/src/dynamic/mod.rs | 41 +- packages/stack-encrypt/src/dynamic/record.rs | 142 ++- packages/stack-encrypt/src/dynamic/term.rs | 943 +++++++++++------- .../stack-encrypt/tests/fixtures/README.md | 2 +- packages/stack-encrypt/tests/index.rs | 10 +- .../stack-encrypt/tests/record_lowering.rs | 41 +- 9 files changed, 882 insertions(+), 521 deletions(-) diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 6fab532c5..7d18bd60d 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -146,6 +146,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 release of vitaminc ADR 0001, which the entries below build on. A crate that names a vitaminc type stack-encrypt takes or returns (`Encrypt`, `Decrypt`, `Protected`) moves to 0.6 with it. +- **Equality terms derive through `vitaminc-normalize`, so `TextEq` compares + text in Unicode 16 NFC.** An equality term is the PRF over the value's + normalized bytes under vitaminc's domain for its kind: `café` written with + a precomposed `é` and with `e` and a combining accent is one term, every + integer width of one number is one term (`34u32` and `34i64` match), both + float widths of one number are one term, and a date, timestamp, decimal + or uuid derives in a domain of its own. `KeysetCipher::equality_term`, + `target::equality`, the `Equality` index and `op::Eq` take an `Encrypt` + that is one scalar where they took a `PrfValue`. A boolean, a null or a + container has no equality term (`TermError::NoEqualityTerm`), and text + holding a code point unassigned in Unicode 16 is refused + (`TermError::Normalize`). Match text is tokenized in the same normal + form, and refused the same way. Every equality and match term changes + bytes (the domains move to `/v2`); ORE and OPE terms do not. No stored + term existed to keep; the record fixture is regenerated. +- **`dynamic::Value` and `dynamic::Scalar` are gone.** A field lowered from + data holds an `FfiValue`, which is now `Clone` (a deep copy into fresh + `Protected` payloads, as `Value`'s was) and reads as a context label. + `dynamic::term` takes any `Encrypt` (an `&FfiValue`, a transport frame + walked in place, a Rust value) and derives at the one scalar it holds; + `IndexSpec::defines(value)` replaces `IndexSpec::supports(&scalar)`, and + `IndexSpec` is an `Index`. +- **A boolean has no order term**, through a plan or `dynamic::term`: with + two values, an ORE or OPE term would tell anyone holding the column which + rows are `true`. `dynamic::admits` now refuses ORE and OPE on `bool`, and + admits equality on `float32` and `float64`, which vitaminc gives an + equality domain. A 128-bit integer has an equality term and no order + term. + - **Values describe themselves with vitaminc's new `Encrypt` and read back with `Decrypt` (vitaminc ADR 0001).** `KeysetCipher::encrypt`, `StackCipher::decrypt` and every target take the new `Encrypt`, a source diff --git a/packages/stack-encrypt/src/dynamic/ffi_value.rs b/packages/stack-encrypt/src/dynamic/ffi_value.rs index 34c7ebc95..a6141af08 100644 --- a/packages/stack-encrypt/src/dynamic/ffi_value.rs +++ b/packages/stack-encrypt/src/dynamic/ffi_value.rs @@ -24,6 +24,9 @@ use vitaminc_aead::{to_frame, DecodeError, Decrypt, Encrypt, Frame}; use vitaminc_protected::{Controlled, Protected}; use zeroize::Zeroize; +use crate::plan::IntoLabel; +use crate::{Label, LabelError}; + /// An owned, `Send`, language-neutral representation of a dynamically typed /// host value: what a binding converts a host value into on the host's /// thread, so encryption and decryption can run on any thread. @@ -128,6 +131,62 @@ impl TryFrom>> for Utf8String { } } +/// A deep copy, each text and bytes leaf into a fresh [`Protected`], which +/// wipes on drop as the original does, so a copy is under the same custody +/// as the value it was made from. +/// +/// The engine hands a borrowed field to every operation that consumes it and +/// clones it on the way in, as it does a `String` field of a derived record, +/// so a field's plaintext type is `Clone`; this is what lets an `FfiValue` +/// be that type for every field lowered from data. A record of a few fields +/// clones each once per operation that consumes it, as the derive's does. +impl Clone for FfiValue { + fn clone(&self) -> Self { + // Exhaustive, so a variant added later has to say how it is copied. + match self { + FfiValue::Null => FfiValue::Null, + FfiValue::Undefined => FfiValue::Undefined, + FfiValue::Bool(v) => FfiValue::Bool(*v), + FfiValue::Int32(v) => FfiValue::Int32(*v), + FfiValue::Int64(v) => FfiValue::Int64(*v), + FfiValue::UInt32(v) => FfiValue::UInt32(*v), + FfiValue::UInt64(v) => FfiValue::UInt64(*v), + FfiValue::Float32(v) => FfiValue::Float32(*v), + FfiValue::Float64(v) => FfiValue::Float64(*v), + FfiValue::String(s) => FfiValue::String(s.clone()), + FfiValue::Bytes(b) => FfiValue::Bytes(Protected::new(b.risky_ref().to_vec())), + FfiValue::Array(items) => FfiValue::Array(items.clone()), + FfiValue::Object(entries) => FfiValue::Object(entries.clone()), + FfiValue::Passthrough(inner) => FfiValue::Passthrough(inner.clone()), + } + } +} + +/// A copy of the text in a fresh [`Protected`]: valid UTF-8 because the +/// original is. +impl Clone for Utf8String { + fn clone(&self) -> Self { + Self(Protected::new(self.0.risky_ref().to_vec())) + } +} + +/// A string value read as a plan's context: what a data plan's +/// `"context_field"` holds (`"tenants/acme"`), parsed as a Rust chain's +/// `String` context field is ([`Label::parse`]). Any other variant is +/// [`LabelError::NotText`], which the plan reports as +/// `PlanError::ContextLabel` before any key is requested. +impl IntoLabel for FfiValue { + fn into_label(self) -> Result { + match &self { + FfiValue::String(s) => match s.as_str() { + Some(text) => Label::parse(text), + None => Err(LabelError::NotText), + }, + _ => Err(LabelError::NotText), + } + } +} + impl FfiValue { /// This value as a transport frame: what a binding hands its host. /// @@ -578,6 +637,46 @@ mod tests { ]) } + /// A clone is the same value, leaf for leaf, at every depth and for + /// every variant, and shares no payload with its original. + #[test] + fn a_clone_is_the_same_value_in_fresh_payloads() { + let original = every_variant(); + let copy = original.clone(); + assert_eq!(copy, original, "the same value"); + let (FfiValue::Object(a), FfiValue::Object(b)) = (&original, ©) else { + panic!("objects"); + }; + let (FfiValue::String(x), FfiValue::String(y)) = (&a[9].1, &b[9].1) else { + panic!("strings"); + }; + assert_ne!( + x.risky_ref().as_ptr(), + y.risky_ref().as_ptr(), + "the text was copied, not shared" + ); + let (FfiValue::Bytes(x), FfiValue::Bytes(y)) = (&a[10].1, &b[10].1) else { + panic!("bytes"); + }; + assert_ne!( + x.risky_ref().as_ptr(), + y.risky_ref().as_ptr(), + "the bytes were copied, not shared" + ); + } + + #[test] + fn a_string_reads_as_a_label_and_nothing_else_does() { + assert_eq!( + s("tenants/acme").into_label().expect("a label"), + Label::parse("tenants/acme").expect("a label") + ); + assert!(matches!( + FfiValue::UInt32(7).into_label(), + Err(LabelError::NotText) + )); + } + /// Reading an object refuses a key it has already read, wherever the /// repeat falls. #[test] diff --git a/packages/stack-encrypt/src/dynamic/kind.rs b/packages/stack-encrypt/src/dynamic/kind.rs index 4d8ba4ade..875821ebc 100644 --- a/packages/stack-encrypt/src/dynamic/kind.rs +++ b/packages/stack-encrypt/src/dynamic/kind.rs @@ -13,7 +13,8 @@ //! of a kind. Beside it, this module adds two rules: //! //! - [`admits`]: which indexes a kind is defined for, so a plan asking for -//! match on an integer or equality on a float is refused when it is built; +//! match on an integer or equality on a boolean is refused when it is +//! built; //! - [`read`]: how a query value is read as a field's kind, converting a //! number only when the conversion is exact. //! @@ -35,6 +36,8 @@ //! field has none is refused when it is built //! ([`Error::UntypedIndex`], naming the field). A field that only seals, or //! only carries its value through, may leave it out: no term derives from it. +use vitaminc_aead::plaintext::{Float, Int, Physical}; + use super::{FfiValue, ValueKind}; use super::{Error, Reason}; @@ -42,28 +45,45 @@ use crate::target::IndexSpec; /// Whether the scheme defines an `index` term for values of `kind`. /// -/// The same table as [`IndexSpec::supports`], stated over kinds instead of -/// values, so a plan can be refused when it is built rather than when its -/// first value arrives: equality over every integer, text and bytes (no -/// floats, no booleans); match over text alone; ORE and OPE over every -/// scalar. A composite (`array`, `object`) has no term. Only the index's -/// kind matters: a match index's options do not change what it applies to. +/// The table [`IndexSpec::defines`] reads for a value, stated over kinds +/// instead, so a plan can be refused when it is built rather than when its +/// first value arrives. Equality is vitaminc's: every kind +/// `vitaminc-normalize` gives an equality domain, which is every integer, +/// both floats, text and bytes, and not a boolean. The rest are this +/// crate's: match over text alone, and ORE and OPE over every scalar but a +/// boolean, whose order term would reveal which rows are `true`. A +/// composite (`array`, `object`) has no term. Only the index's kind +/// matters: a match index's options do not change what it applies to. /// /// ``` /// use stack_encrypt::dynamic::{admits, ValueKind}; /// use stack_encrypt::target::IndexSpec; /// /// assert!(admits(ValueKind::UInt64, &IndexSpec::Equality)); -/// assert!(!admits(ValueKind::Float64, &IndexSpec::Equality)); +/// assert!(!admits(ValueKind::Bool, &IndexSpec::Equality)); +/// assert!(!admits(ValueKind::Bool, &IndexSpec::Ore)); /// assert!(!admits(ValueKind::Object, &IndexSpec::Ore)); /// ``` pub fn admits(kind: ValueKind, index: &IndexSpec) -> bool { - use ValueKind::*; - match index { - IndexSpec::Equality => matches!(kind, Int32 | Int64 | UInt32 | UInt64 | String | Bytes), - IndexSpec::Match(_) => kind == String, - IndexSpec::Ore | IndexSpec::Ope => !matches!(kind, Array | Object), - } + scalar_of(kind).is_some_and(|scalar| index.takes(None, &scalar)) +} + +/// A scalar of `kind`, for asking the table about the kind: any value of a +/// kind is taken by an index exactly when every other is. `None` for a +/// composite, which is no scalar. +fn scalar_of(kind: ValueKind) -> Option> { + Some(match kind { + ValueKind::Bool => Physical::Bool(false), + ValueKind::Int32 => Physical::Int(Int::I32(0)), + ValueKind::Int64 => Physical::Int(Int::I64(0)), + ValueKind::UInt32 => Physical::Int(Int::U32(0)), + ValueKind::UInt64 => Physical::Int(Int::U64(0)), + ValueKind::Float32 => Physical::Float(Float::F32(0.0)), + ValueKind::Float64 => Physical::Float(Float::F64(0.0)), + ValueKind::String => Physical::Text(""), + ValueKind::Bytes => Physical::Bytes(&[]), + ValueKind::Array | ValueKind::Object => return None, + }) } /// Read a query value as `kind`. @@ -82,8 +102,7 @@ pub fn admits(kind: ValueKind, index: &IndexSpec) -> bool { /// and is refused otherwise. /// /// **The engine does not call this.** [`term`](super::term()) takes a -/// [`Scalar`](super::Scalar) and an [`IndexSpec`] and never sees the field's -/// declared kind, so a binding deriving a query term for a typed field must +/// value and an [`IndexSpec`] and never sees the field's declared kind, so a binding deriving a query term for a typed field must /// call `read` first, with [`FieldPlan::field_type`](super::FieldPlan::field_type). /// One that skips it hands `Float64(34.0)` to a `uint64` field, derives a /// float term where the stored one is the `u64` term, and matches nothing, @@ -190,7 +209,6 @@ impl Number { #[cfg(test)] mod tests { use super::*; - use crate::dynamic::Scalar; use crate::sem::{MatchOptions, Tokenizer}; use vitaminc_protected::Protected; @@ -224,27 +242,23 @@ mod tests { ] } - /// `admits` is `IndexSpec::supports` stated over kinds: the two tables - /// cannot disagree about any scalar, and a composite admits nothing. + /// `admits` is `IndexSpec::defines` stated over kinds: the two cannot + /// disagree about a value of any kind, and a composite admits nothing. #[test] - fn admits_agrees_with_supports_for_every_scalar_kind() { + fn admits_agrees_with_defines_for_every_kind() { for kind in ValueKind::ALL { for index in indexes() { - match Scalar::of(&sample(kind), &index) { - Ok(scalar) => { - assert_eq!( - admits(kind, &index), - index.supports(&scalar), - "{kind} and {index}" - ) - } - Err(_) => assert!(!admits(kind, &index), "{kind} is not a scalar"), - } + assert_eq!( + admits(kind, &index), + index.defines(sample(kind)), + "{kind} and {index}" + ); } } // Spelled out, so the table reads without the cross-check. assert!(admits(ValueKind::UInt64, &IndexSpec::Equality)); - assert!(!admits(ValueKind::Float64, &IndexSpec::Equality)); + assert!(admits(ValueKind::Float64, &IndexSpec::Equality)); + assert!(admits(ValueKind::Bytes, &IndexSpec::Equality)); assert!(!admits(ValueKind::Bool, &IndexSpec::Equality)); assert!(admits( ValueKind::String, @@ -255,10 +269,30 @@ mod tests { &IndexSpec::Match(MatchOptions::default()) )); assert!(admits(ValueKind::Float64, &IndexSpec::Ore)); + assert!(admits(ValueKind::String, &IndexSpec::Ope)); + assert!(!admits(ValueKind::Bool, &IndexSpec::Ore)); + assert!(!admits(ValueKind::Bool, &IndexSpec::Ope)); assert!(!admits(ValueKind::Object, &IndexSpec::Ope)); assert!(!admits(ValueKind::Array, &IndexSpec::Ore)); } + /// Equality is vitaminc's table: a kind admits it exactly when + /// `vitaminc-normalize` gives the kind's values an equality domain. + #[test] + fn admits_equality_exactly_where_vitaminc_gives_a_domain() { + for kind in ValueKind::ALL { + let has_domain = matches!( + vitaminc_normalize::terms(sample(kind)).as_deref(), + Ok([term]) if term.path.steps().is_empty() + ); + assert_eq!( + admits(kind, &IndexSpec::Equality), + has_domain, + "{kind} admits equality exactly when it has a domain" + ); + } + } + /// A match index's options are not part of what it applies to: text /// admits it under any options, and nothing else does. #[test] diff --git a/packages/stack-encrypt/src/dynamic/mod.rs b/packages/stack-encrypt/src/dynamic/mod.rs index 1b4f8dd56..e68125f0e 100644 --- a/packages/stack-encrypt/src/dynamic/mod.rs +++ b/packages/stack-encrypt/src/dynamic/mod.rs @@ -2,8 +2,7 @@ //! //! Everything else in this crate is typed: a target names its source type, //! and the operations it composes are reached through bounds — `Encrypt` for -//! a ciphertext, [`PrfValue`](vitaminc_prf::PrfValue) for an equality term, -//! `AsRef` for a match term. That is what makes a term a cross-language +//! a ciphertext or an equality term, `AsRef` for a match term. That is what makes a term a cross-language //! contract: `equality_term(34u32)` derives the same bytes wherever it is //! called from, because `34u32` is the same value everywhere. //! @@ -13,13 +12,19 @@ //! the typed operation. This module is that something, written once here //! rather than once per language binding. //! -//! The runtime value is [`FfiValue`], a language-neutral value tree and -//! the type every binding already funnels through. +//! A term is derived from anything that describes itself to vitaminc +//! ([`Encrypt`](crate::Encrypt)), walked to its one scalar: a guest +//! walks the transport frame it was handed in place, with no tree built. +//! What the engine holds is [`FfiValue`], a language-neutral value tree: a +//! plan, a record source and a field's plaintext are values a binding +//! decoded, which the engine reads, splits into fields and clones, and +//! which outlive the buffer they arrived in. //! //! # What is here //! //! * [`context`](context()) — an [`FfiValue`] read as an encryption context. -//! * [`term`](term()) — one index term for a value, dispatched on its variant. +//! * [`term`](term()) — one index term for a value, derived at the one +//! scalar it is walked to. //! * [`record`] — a fields plan spelled as data, *lowered* into the plan //! builder ([`Plan`](crate::Plan)) and run by the engine: the same code a //! Rust chain and the derive run, so a record written from any language @@ -30,9 +35,8 @@ //! that links them, so a plan field may name one as its target and the //! lowering runs that type's own plan in the same request; [`NoTargets`] //! is the build without them. -//! * [`Value`] — an [`FfiValue`] as a plan field's plaintext, the type of -//! every field lowered from data; [`TermBytes`] — the term such a field -//! derives, as its frozen bytes. +//! * [`TermBytes`] — the term a field lowered from data derives, as its +//! frozen bytes. Such a field's plaintext is an [`FfiValue`]. //! * [`Scope`] — which cipher an opening operation decrypts through. //! //! # What is not here @@ -55,11 +59,10 @@ //! //! A plan field's `"type"` names (`"int64"`, `"string"`, …) are wire format //! in the same way: a binding spells them, and a stored row opens only under -//! the type it was sealed as. They are not this crate's: a declared type is -//! vitaminc's [`ValueKind`], re-exported here, whose names vitaminc freezes -//! beside its tag table. This crate adds only what a kind means to an index -//! ([`admits`]) and to a query value ([`read`]); it decides nothing about -//! the bytes ([`record`]). Every field with a term output declares one; +//! the type it was sealed as. A declared type is a [`ValueKind`], whose +//! names are frozen. What a kind means to an index ([`admits`]) and to a +//! query value ([`read`]) is stated over it; the bytes are vitaminc's, from +//! the tagged leaf a value of the kind seals as ([`record`]). Every field with a term output declares one; //! a plan whose indexed field has none is refused when it is built //! ([`Error::UntypedIndex`], naming the field). //! @@ -75,7 +78,6 @@ mod kind; pub mod record; mod target; mod term; -mod value; use std::fmt; @@ -94,8 +96,7 @@ pub use ffi_value::{ pub use kind::{admits, read}; pub use record::{FieldPlan, Output, Plan}; pub use target::{NoTargets, TargetDescriptor, TargetError, TargetResolver}; -pub use term::{term, Scalar, TermBytes}; -pub use value::{AsValue, Value}; +pub use term::{term, TermBytes}; use crate::target::IndexSpec; use crate::{KeysetCipher, StackCipher}; @@ -190,9 +191,9 @@ pub enum Error { /// A term was asked for a value the scheme defines no such term for: a /// container, null or passthrough (which have no term semantics at all), - /// or a scalar outside the kind's domain — equality over a float or a - /// boolean, match over anything but text. See - /// [`IndexSpec::supports`]. + /// or a scalar outside the kind's domain — equality over a boolean, + /// match over anything but text, an order term over a boolean. See + /// [`IndexSpec::defines`]. #[error("no {kind} term is defined for this value{}", in_field_text(field))] #[diagnostic(code(stack_encrypt::dynamic_term))] Term { @@ -504,7 +505,7 @@ reasons! { /// `"type"` is not a string naming a value type. UnknownType => "unknown_type", r#""type" does not name a value type"#; /// The field's declared type has no such index: match on an integer, - /// equality on a float, any index on a composite. + /// equality or an order index on a boolean, any index on a composite. IndexNotAdmitted => "index_not_admitted", "the declared type has no such index"; /// Two fields are keyed under one identity. SharedIdentity => "shared_identity", "two fields are keyed under one identity"; diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 84a64e75e..10a6a2ac1 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -86,7 +86,7 @@ //! //! # What a field's `"type"` decides, and what it does not //! -//! Every field lowered from data is a [`Value`]: its plaintext type is the +//! Every field lowered from data is an [`FfiValue`]: its plaintext type is the //! runtime value itself, and it seals in vitaminc's self-describing tagged //! leaf encoding (`[tag] ++ payload`) whatever its declared `"type"`. The //! type decides which indexes the field admits ([`admits`]), which values it @@ -99,13 +99,13 @@ //! whatever tag each value arrived with (`34` sent once as a float and once //! as an integer would otherwise store two terms under one field). A field //! that only seals, or only carries its value through, may leave the type -//! out. A field's terms dispatch on the value's variant — the declared kind, -//! once checked — to the typed term operation, so they are the bytes a Rust -//! `u32` or `String` field derives under the same label. +//! out. A field's terms derive at the value's one scalar — of the declared +//! kind, once checked — by the typed path's derivation, so they are the +//! bytes a Rust `u32` or `String` field derives under the same label. //! //! The ciphertext is where a data plan and a Rust chain meet too. Since //! vitaminc ADR 0001 every leaf is tagged, a typed Rust value's as much as -//! a [`Value`]'s, so a Rust `u32` field and a `uint32` data field seal the +//! an [`FfiValue`]'s, so a Rust `u32` field and a `uint32` data field seal the //! same leaf under the same context, and each opens the other's. A reader //! of another type refuses a leaf rather than reading it as changed //! plaintext: the tag is authenticated. The lowering still never chooses an @@ -142,8 +142,7 @@ use vitaminc_aead::frame::FrameWriteError; use vitaminc_protected::Controlled; use super::{ - admits, utf8, Error, NoTargets, Reason, Scalar, Scope, TargetError, TargetResolver, TermBytes, - Value, + admits, utf8, Error, NoTargets, Reason, Scope, TargetError, TargetResolver, TermBytes, }; use crate::plan::{FieldValues, FieldsBuilder, Opens, Runs}; use crate::target::{CallerContext, DeclaredContext, Decryption, Encrypted, IndexSpec}; @@ -342,8 +341,8 @@ impl FieldPlan { /// # Errors /// /// [`Error::Plan`] if the field asks for an index the kind is not - /// defined for ([`admits`]): match on an integer, equality on a float, - /// any index on a composite. + /// defined for ([`admits`]): match on an integer, equality or + /// an order index on a boolean, any index on a composite. pub fn with_type(mut self, field_type: ValueKind) -> Result { for output in &self.outputs { if let Output::Term(index) = output { @@ -845,7 +844,7 @@ impl Plan { /// /// Built afresh per call: a built plan is bound to its key source type, /// and a declaration is not. Every field is declared by name, as a - /// [`Value`], read out of the [`FieldValues`] the source is converted + /// [`FfiValue`], read out of the [`FieldValues`] the source is converted /// into. A context field is declared through the builder's own /// `context_field`, which carries it as a passthrough and reads it as /// the context; the loop then skips it. @@ -853,7 +852,7 @@ impl Plan { let mut builder = match &self.context { Context::Label(label) => crate::Plan::context(label.clone()).fields::(), Context::Field(name) => { - crate::Plan::fields::().context_field::(name.as_str()) + crate::Plan::fields::().context_field::(name.as_str()) } }; for field in &self.fields { @@ -891,18 +890,18 @@ impl Plan { } } -/// One field's verb, over a [`Value`]; its indexes are the [`IndexSpec`]s -/// the plan named, each an [`Index`] of a `Value`. +/// One field's verb, over an [`FfiValue`]; its indexes are the [`IndexSpec`]s +/// the plan named, each an [`Index`] of a `FfiValue`. fn declare( builder: FieldsBuilder, field: &FieldPlan, ) -> FieldsBuilder { let name = field.name.as_str(); match field.verb() { - Verb::Encrypt => builder.encrypt::(name), - Verb::EncryptIndex => builder.encrypt_index::(name, field.indexes()), - Verb::Index => builder.index::(name, field.indexes()), - Verb::Passthrough => builder.passthrough::(name), + Verb::Encrypt => builder.encrypt::(name), + Verb::EncryptIndex => builder.encrypt_index::(name, field.indexes()), + Verb::Index => builder.index::(name, field.indexes()), + Verb::Passthrough => builder.passthrough::(name), // `lower` never hands a target field here: the resolver runs it. Verb::Target => builder, } @@ -1793,7 +1792,7 @@ fn source_row(mut row: Vec<(String, FfiValue)>, plan: &Plan) -> Result, plan: &Plan) -> Result Result<(), Error> { @@ -1824,9 +1823,7 @@ fn check_field(value: &FfiValue, field: &FieldPlan) -> Result<(), Error> { match output { Output::Ciphertext => check_tree(value, &field.name)?, Output::Term(kind) => { - let scalar = - Scalar::of(value, kind).map_err(|error| error.in_field(&field.name))?; - if !kind.supports(&scalar) { + if !kind.defines(value) { return Err(Error::Term { field: Some(field.name.clone()), kind: kind.clone(), @@ -1960,7 +1957,7 @@ fn keyed_terms( /// The field `name` out of the record, as the value it holds. fn take_leaf(values: &mut FieldValues, name: &str) -> Result { - Ok(slot::(values, name)?.into_inner()) + slot::(values, name) } /// The record the plan opened, as a value: the fields that come back, in @@ -2068,7 +2065,7 @@ fn record_row(mut row: Vec<(String, StackCipherText)>, plan: &Plan) -> Result { let ciphertext = node("c", Reason::NoCiphertextNode)?; @@ -3125,33 +3122,41 @@ mod tests { ); } - /// A float cannot reach an equality index through a plan: a field - /// declared `float64` is refused when the plan is built, and a field - /// declared an integer refuses the float value as the wrong kind, - /// before any key request. There is no untyped indexed field for a - /// float to slip through. + /// A boolean cannot reach an equality or order index through a + /// plan: a field declared `bool` is refused when the plan is built, + /// and a field declared an integer refuses the boolean value as the + /// wrong kind, before any key request. There is no untyped indexed + /// field for a boolean to slip through. A float can: vitaminc gives + /// floats an equality domain. #[tokio::test] - async fn a_float_asked_for_equality_is_refused_at_build_or_as_the_wrong_kind() { + async fn a_boolean_asked_for_a_term_is_refused_at_build_or_as_the_wrong_kind() { let cipher = cipher().await; let keyset = cipher.default_keyset(); - let as_float = plan(obj(vec![( - "score", - typed(label("score"), &["c", "eq"], "float64"), - )])); - assert!( - matches!(as_float, Err(Error::Plan { .. })), - "no PRF encoding exists for a float: {as_float:?}" - ); + for outputs in [&["c", "eq"], &["c", "ore"], &["c", "ope"]] { + let as_bool = plan(obj(vec![("flag", typed(label("flag"), outputs, "bool"))])); + assert!( + matches!(as_bool, Err(Error::Plan { .. })), + "a boolean has no {outputs:?} term: {as_bool:?}" + ); + } let as_u32 = plan(obj(vec![( - "score", - typed(label("score"), &["c", "eq"], "uint32"), + "flag", + typed(label("flag"), &["c", "eq"], "uint32"), )])) .expect("plan"); - let source = obj(vec![("score", FfiValue::Float64(1.5))]); + let source = obj(vec![("flag", FfiValue::Bool(true))]); let err = encrypt(&keyset, source, &as_u32).err(); assert!( matches!(err, Some(Error::Source { .. })), - "a float is not the declared kind: {err:?}" + "a boolean is not the declared kind: {err:?}" + ); + assert!( + plan(obj(vec![( + "score", + typed(label("score"), &["c", "eq"], "float64"), + )])) + .is_ok(), + "a float has an equality term" ); assert_eq!(generates(&cipher), 0, "refused before any key request"); } @@ -3282,7 +3287,7 @@ mod tests { let eq = term( &keyset, - Scalar::U32(34), + &FfiValue::UInt32(34), &IndexSpec::Equality, age_ctx.clone(), ) @@ -3293,7 +3298,7 @@ mod tests { eq, "the equality term is the standalone derivation under the field label" ); - let ore = term(&keyset, Scalar::U32(34), &IndexSpec::Ore, age_ctx) + let ore = term(&keyset, &FfiValue::UInt32(34), &IndexSpec::Ore, age_ctx) .await .expect("standalone ore term"); assert_eq!( @@ -3301,14 +3306,9 @@ mod tests { ore, "the ore term is the standalone derivation under the field label" ); - let scalar = Scalar::of( - &s("al smith"), - &IndexSpec::Match(crate::sem::MatchOptions::default()), - ) - .expect("text"); let matched = term( &keyset, - scalar, + &s("al smith"), &IndexSpec::Match(crate::sem::MatchOptions::default()), nick_ctx, ) @@ -3351,13 +3351,15 @@ mod tests { let stored = term_bytes(&node(&mut nick, "match")); let kind = IndexSpec::Match(options); - let scalar = Scalar::of(&s("al smith"), &kind).expect("text"); - let expected = term(&keyset, scalar.clone(), &kind, ctx.clone()) + let scalar = s("al smith"); + let expected = term(&keyset, &scalar, &kind, ctx.clone()) .await .expect("standalone"); assert_eq!(stored, expected, "the term is the plan options' derivation"); let default = IndexSpec::Match(crate::sem::MatchOptions::default()); - let under_default = term(&keyset, scalar, &default, ctx).await.expect("default"); + let under_default = term(&keyset, &scalar, &default, ctx) + .await + .expect("default"); assert_ne!(stored, under_default, "and not the defaults'"); } @@ -3433,22 +3435,22 @@ mod tests { /// The lowering is the plan builder: the record a data plan seals is /// the record the chain seals under the same declaration over the - /// same plaintext type, a [`Value`] per field — the same terms, and + /// same plaintext type, an [`FfiValue`] per field — the same terms, and /// ciphertexts each side opens. #[tokio::test] async fn seals_what_the_chain_seals_under_the_same_declaration() { struct User { - age: Value, - email: Value, - nick: Value, + age: FfiValue, + email: FfiValue, + nick: FfiValue, id: u64, } let cipher = cipher().await; let keyset = cipher.default_keyset(); let user = User { - age: Value::new(FfiValue::UInt32(34)), - email: Value::new(s("a@x")), - nick: Value::new(s("al smith")), + age: FfiValue::UInt32(34), + email: s("a@x"), + nick: s("al smith"), id: 7, }; let default_match = IndexSpec::Match(crate::sem::MatchOptions::default()); @@ -4662,16 +4664,16 @@ mod tests { parsed.fields()[0].outputs(), [Output::Ciphertext, Output::Term(IndexSpec::Equality)] ); - let refused = plan(obj(vec![("age", early("float64"))])); + let refused = plan(obj(vec![("age", early("bool"))])); assert!( matches!(refused, Err(Error::Plan { .. })), - "equality on a float is refused whatever the key order: {refused:?}" + "equality on a boolean is refused whatever the key order: {refused:?}" ); } #[test] fn the_plan_refuses_an_unresolvable_type_or_one_that_does_not_admit_an_index() { - let refused: [(&str, FfiValue); 8] = [ + let refused: [(&str, FfiValue); 9] = [ ("an unknown type", typed(label("x"), &["c"], "u64")), ( "a type in the wrong case", @@ -4698,11 +4700,9 @@ mod tests { "match on an integer", typed(label("x"), &["c", "match"], "int64"), ), - ( - "equality on a float", - typed(label("x"), &["c", "eq"], "float64"), - ), ("equality on a bool", typed(label("x"), &["eq"], "bool")), + ("order on a bool", typed(label("x"), &["c", "ore"], "bool")), + ("ope on a bool", typed(label("x"), &["ope"], "bool")), ( "order on a composite", typed(label("x"), &["c", "ore"], "object"), @@ -4732,7 +4732,7 @@ mod tests { .expect("equality admits a u32"); assert_eq!(typed.field_type(), Some(ValueKind::UInt32)); assert!(matches!( - field.with_type(ValueKind::Float32), + field.with_type(ValueKind::Bool), Err(Error::Plan { .. }) )); let sealed_only = FieldPlan::new( @@ -4954,10 +4954,9 @@ mod tests { let declared = field.field_type().expect("typed"); let value = crate::dynamic::read(declared, FfiValue::Float64(34.0)).expect("an exact 34"); - let scalar = Scalar::of(&value, &IndexSpec::Equality).expect("scalar"); let query = term( &keyset, - scalar, + &value, &IndexSpec::Equality, field.context().clone(), ) @@ -5087,8 +5086,7 @@ mod tests { let mut fields = map(seal(&keyset, obj(vec![("f", value())]), &plan).await); let mut f = map(node(&mut fields, "f")); let stored = term_bytes(&node(&mut f, kind.key())); - let scalar = Scalar::of(&value(), &kind).expect("scalar"); - let probe = term(&keyset, scalar, &kind, plan.fields()[0].context().clone()) + let probe = term(&keyset, &value(), &kind, plan.fields()[0].context().clone()) .await .expect("probe"); assert_eq!(stored, probe, "{ty} {kind}"); diff --git a/packages/stack-encrypt/src/dynamic/term.rs b/packages/stack-encrypt/src/dynamic/term.rs index de0b79852..65dc6e20f 100644 --- a/packages/stack-encrypt/src/dynamic/term.rs +++ b/packages/stack-encrypt/src/dynamic/term.rs @@ -1,11 +1,27 @@ //! One index term for a runtime value. //! -//! The dispatch here is the whole point of the module: which arm a value -//! takes decides the term's PRF/CLLW *input encoding*, and that encoding is -//! part of the cross-language contract. An equality term for -//! `FfiValue::UInt32(34)` must equal the term the typed path derives for -//! `34u32`, so each variant is handed to the same typed operation a Rust -//! caller would have named. +//! A value arrives here as anything that describes itself to vitaminc +//! ([`Plaintext`]): a transport frame a guest walks in place, an +//! [`FfiValue`] a binding decoded, a Rust value. It is walked to the one +//! scalar it must be, and the term derives there, from the scalar vitaminc +//! hands the sink, so this module restates none of vitaminc's vocabulary. +//! The derivation is the typed path's, which is what makes a term a +//! cross-language contract: an equality term for a frame holding `34` is the +//! term `equality_term(34u32)` derives. +//! +//! - **Equality** normalizes the scalar with `vitaminc-normalize` and runs +//! the PRF over the result, as the typed path does through that crate's +//! `Terms` sink. Every integer width of one number is one term, text is +//! compared in Unicode 16 NFC, and a date, timestamp, decimal or uuid has +//! a domain of its own. A boolean has none. +//! - **Match** takes text only, cut in Unicode 16 NFC. +//! - **ORE and OPE** take an integer of at most 64 bits, a float, text or +//! bytes, each as the Rust type the typed path would have named. An 8- or +//! 16-bit integer is read as the 32-bit integer of its sign, as an +//! [`FfiValue`] reads it, so a frame from a host with narrower integers +//! derives the term of the width a plan field declares. A boolean has no +//! order term: with two values, its order term would tell anyone holding +//! the column which rows are `true`. //! //! # These are the query-probe path //! @@ -14,17 +30,23 @@ //! going to be **stored** should come from the record path, where it shares //! one context with the ciphertext beside it (ADR-0004). +use std::convert::Infallible; +use std::marker::PhantomData; + use super::FfiValue; use stack_kms::DataKeySource; -use vitaminc_protected::{Controlled, OpaqueDebug, Protected}; +use vitaminc_aead::plaintext::{ + Error as SinkError, Float, Int, Logical, MapSink, Physical, SeqSink, Sink, SourceError, +}; +use vitaminc_normalize::{domain_of, normalize, NormalizeError}; use zeroize::Zeroizing; -use super::{utf8, Error, Reason, Value}; +use super::{utf8, Error, Reason}; use crate::sem::{ - CllwOpeEncrypt, CllwOreEncrypt, DefaultMatch, MatchOptions, QueryTokens, Tokenizer, + CllwOpeEncrypt, CllwOreEncrypt, DefaultMatch, MatchOptions, QueryTokens, TermError, Tokenizer, }; use crate::target::{chosen, CallerContext, ConsumeSource, Encryption, Index, IndexSpec, Pending}; -use crate::{IntoPrfContext, KeysetCipher, NonEmpty}; +use crate::{Encrypt, IntoPrfContext, KeysetCipher, NonEmpty}; /// The runtime half of [`IndexSpec`]: the domain table, and the index's wire /// form in a record plan. @@ -116,26 +138,65 @@ impl IndexSpec { } } - /// Whether the scheme defines this index's term for `scalar`. + /// Whether the scheme defines this index's term for `value`: whether it + /// is one scalar the index takes. Equality takes every scalar vitaminc + /// gives an equality domain (an integer, a float, text, bytes, and a + /// date, timestamp, decimal or uuid), not a boolean or a null. Match + /// takes text. ORE and OPE take an integer of at most 64 bits, a float, + /// text or bytes, not a boolean. A match index's options do not change + /// what it takes. + /// + /// This is the one table: [`term`] refuses exactly what it refuses, and + /// checking it first is how a binding refuses a bad request at its + /// boundary, before a keyset is resolved or anything is derived. It says + /// nothing about whether the value's text normalizes; that is found when + /// the term is derived. /// - /// No PRF encoding exists for floats (equality on IEEE-754 values is a - /// modelling error) or booleans; match is text-only; the ordering - /// schemes take every scalar. This is the one table — [`term`]'s arms - /// mirror it and are unreachable for a pair it refuses — and it is - /// consulted before any cipher work, so a binding can reject a bad - /// request at its boundary without minting anything. A match index's - /// options do not change its domain. - pub fn supports(&self, scalar: &Scalar) -> bool { + /// ``` + /// use stack_encrypt::target::IndexSpec; + /// + /// assert!(IndexSpec::Equality.defines(34u32)); + /// assert!(IndexSpec::Equality.defines(1.5f64)); + /// assert!(!IndexSpec::Equality.defines(true)); + /// assert!(!IndexSpec::Ore.defines(true)); + /// assert!(!IndexSpec::Ope.defines(vec![1u32, 2])); + /// ``` + pub fn defines(&self, value: P) -> bool { + value + .visit(OneScalar::new( + |ty: Option, value: Physical<'_>| { + if self.takes(ty, &value) { + Ok(()) + } else { + Err(None) + } + }, + )) + .is_ok() + } + + /// The table [`defines`](Self::defines) reads, over one scalar. + pub(super) fn takes(&self, ty: Option, value: &Physical<'_>) -> bool { match self { - IndexSpec::Equality => { - !matches!(scalar, Scalar::Bool(_) | Scalar::F32(_) | Scalar::F64(_)) - } - IndexSpec::Match(_) => matches!(scalar, Scalar::Text(_)), - IndexSpec::Ore | IndexSpec::Ope => true, + // vitaminc's table: a kind with an equality domain. + IndexSpec::Equality => domain_of(ty, value).is_some(), + IndexSpec::Match(_) => ty.is_none() && matches!(value, Physical::Text(_)), + IndexSpec::Ore | IndexSpec::Ope => ty.is_none() && ordered(value), } } } +/// Whether CLLW ORE and OPE order this scalar: an integer of at most 64 +/// bits, a float, text or bytes. Not a boolean, which would reveal which +/// rows hold `true`, and not a null. +fn ordered(value: &Physical<'_>) -> bool { + match value { + Physical::Int(int) => !matches!(int, Int::I128(_) | Int::U128(_)), + Physical::Float(_) | Physical::Text(_) | Physical::Bytes(_) => true, + Physical::Null | Physical::Bool(_) => false, + } +} + /// The options object of a match index's wire form: each key at most once, /// each defaulting, and the whole checked against the scheme's bounds. fn match_options(entries: &[(String, FfiValue)]) -> Result { @@ -185,75 +246,6 @@ fn integer>(value: &FfiValue) -> Result { .ok_or(Error::bad_plan(Reason::UnknownOutput)) } -/// A term-able scalar lifted out of an [`FfiValue`] leaf. -/// -/// Lifting is a copy, so the value it came from stays movable into the -/// ciphertext path beside it. The owned text and bytes copies wipe on drop; -/// the PRF and CLLW layers move them into [`Protected`] internally. -/// -/// It is plaintext, so its `Debug` is opaque: the variant is named, the -/// value is masked. -#[derive(Clone, OpaqueDebug)] -#[non_exhaustive] -pub enum Scalar { - /// From [`FfiValue::Bool`]. - Bool(bool), - /// From [`FfiValue::Int32`]. - I32(i32), - /// From [`FfiValue::Int64`]. - I64(i64), - /// From [`FfiValue::UInt32`]. - U32(u32), - /// From [`FfiValue::UInt64`]. - U64(u64), - /// From [`FfiValue::Float32`]. - F32(f32), - /// From [`FfiValue::Float64`]. - F64(f64), - /// From [`FfiValue::String`]. - Text(Zeroizing), - /// From [`FfiValue::Bytes`]. - Bytes(Zeroizing>), -} - -impl Scalar { - /// Lift the scalar out of a value leaf. - /// - /// # Errors - /// - /// [`Error::Term`] for a container, null, undefined or passthrough: - /// those have no term semantics at all, whatever the kind. `kind` names - /// the term the caller was after, for the error only — whether that kind - /// is defined for the scalar is [`IndexSpec::supports`]. - pub fn of(value: &FfiValue, kind: &IndexSpec) -> Result { - Ok(match value { - FfiValue::Bool(v) => Scalar::Bool(*v), - FfiValue::Int32(v) => Scalar::I32(*v), - FfiValue::Int64(v) => Scalar::I64(*v), - FfiValue::UInt32(v) => Scalar::U32(*v), - FfiValue::UInt64(v) => Scalar::U64(*v), - FfiValue::Float32(v) => Scalar::F32(*v), - FfiValue::Float64(v) => Scalar::F64(*v), - FfiValue::String(s) => Scalar::Text(Zeroizing::new( - utf8(s) - .ok_or_else(|| Error::Term { - field: None, - kind: kind.clone(), - })? - .to_string(), - )), - FfiValue::Bytes(b) => Scalar::Bytes(Zeroizing::new(b.risky_ref().to_vec())), - // Containers, nulls and passthroughs have no term semantics. - _ => { - return Err(Error::Term { - field: None, - kind: kind.clone(), - }) - } - }) - } -} - /// Derive one index term's frozen byte encoding. /// /// # Examples @@ -262,7 +254,7 @@ impl Scalar { /// derives for the same value under the same context: /// /// ``` -/// use stack_encrypt::dynamic::{context, term, FfiValue, Scalar}; +/// use stack_encrypt::dynamic::{context, term, FfiValue}; /// use stack_encrypt::target::IndexSpec; /// use stack_encrypt::StackCipher; /// use stack_encrypt::kms::FakeDataKeySource; @@ -278,45 +270,40 @@ impl Scalar { /// FfiValue::String("users".into()), /// FfiValue::String("age".into()), /// ]))?; -/// let probe = term(&keyset, Scalar::U32(34), &IndexSpec::Equality, ctx.clone()).await?; +/// let probe = term(&keyset, &FfiValue::UInt32(34), &IndexSpec::Equality, ctx.clone()).await?; /// let typed = keyset.equality_term(34u32, ctx).await?; /// assert_eq!(probe, typed.into_bytes().to_vec()); /// # Ok::<(), stack_encrypt::dynamic::Error>(()) /// # }).unwrap(); /// ``` /// -/// The derivation is `scalar_term`'s, the one dispatch from a runtime -/// scalar to the typed term operation; the record path runs the same -/// dispatch as an [`Index`] of a [`Value`] field. It is local to the keyset -/// cipher, so the await settles a pending that has nothing to request. +/// The record path runs the same derivation as an [`Index`] of an +/// [`FfiValue`] field. It is local to the keyset cipher, so the await +/// settles a pending that has nothing to request. /// /// # Errors /// -/// [`Error::Term`] if the scheme defines no such term for the scalar -/// ([`IndexSpec::supports`] is the table, and checking it first is how a +/// [`Error::Term`] if the value is not one scalar the index takes +/// ([`IndexSpec::defines`] is the table, and checking it first is how a /// binding turns this into a boundary rejection). [`Error::Cipher`] if the -/// derivation itself fails, or for the one query value the engine refuses: +/// derivation itself fails: text with a code point unassigned in Unicode 16 +/// ([`TermError::Normalize`]), or the one query value the engine refuses, /// match text that cuts no token under the index's options /// ([`TermError::EmptyTermText`](crate::sem::TermError::EmptyTermText)), whose filter would match every row. That /// refusal is the query path's alone; the record path stores such a value /// as the empty filter, which no query matches. -pub async fn term<'c, K, D>( +pub async fn term<'c, K, P, D>( cipher: &KeysetCipher<'_, K>, - scalar: Scalar, + value: P, kind: &IndexSpec, context: NonEmpty, ) -> Result, Error> where K: DataKeySource + Sync + 'static, + P: Encrypt, D: IntoPrfContext<'c>, { - if let (IndexSpec::Match(options), Scalar::Text(text)) = (kind, &scalar) { - // The query chain's rule, from the same helper: options the index - // cannot use, then text that cuts no token. - let _tokens = - QueryTokens::of_query(text, options).map_err(|error| Error::Cipher(error.into()))?; - } - scalar_term(cipher, scalar, kind, context)? + derive(cipher, value, kind, context, Use::Query)? .await .map(TermBytes::into_bytes) .map_err(Error::Cipher) @@ -353,139 +340,153 @@ impl AsRef<[u8]> for TermBytes { } } -/// The one dispatch from a runtime scalar to a typed term operation: which -/// arm a scalar takes decides the term's input encoding, and each arm is -/// the operation a Rust caller would have named for that type, so the -/// bytes are the typed path's. Nothing is requested: a term is derived by -/// the keyset cipher's own PRF, so the pending is ready when it is built. +/// What a derived term is for: a query refuses match text that cuts no +/// token, which a stored value keeps as the empty filter. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Use { + Query, + Store, +} + +/// Walk `value` to its one scalar and derive `kind`'s term there. Nothing +/// is requested: a term is derived by the keyset cipher's own PRF, so the +/// pending is ready when it is built. /// /// # Errors /// -/// [`Error::Term`] if the scheme defines no such term for the scalar. -pub(super) fn scalar_term<'a, 'c, K: 'static, D>( +/// [`Error::Term`] if the value is not one scalar `kind` takes, and +/// [`Error::Cipher`] for a query's match text with no token or text the +/// index cannot normalize, as for [`term`]. +fn derive<'a, 'c, K: 'static, P, D>( cipher: &'a KeysetCipher<'_, K>, - scalar: Scalar, + value: P, kind: &IndexSpec, context: NonEmpty, + purpose: Use, ) -> Result, Error> where + P: Encrypt, D: IntoPrfContext<'c>, { - Ok(match kind { - IndexSpec::Equality => equality_of(cipher, scalar, context)?, - IndexSpec::Match(options) => match_of(cipher, scalar, options, context)?, - IndexSpec::Ore => ore_of(cipher, scalar, context), - IndexSpec::Ope => ope_of(cipher, scalar, context), - }) -} - -fn equality_bytes(term: crate::sem::EqualityTerm) -> TermBytes { - TermBytes(term.into_bytes().to_vec()) -} - -/// [`IndexSpec::Equality`] per scalar: one PRF block over the value, for -/// every integer width, text and bytes. -fn equality_of<'a, 'c, K: 'static, D>( - cipher: &'a KeysetCipher<'_, K>, - scalar: Scalar, - context: NonEmpty, -) -> Result, Error> -where - D: IntoPrfContext<'c>, -{ - let term = match scalar { - Scalar::I32(v) => cipher.equality_term(v, context), - Scalar::I64(v) => cipher.equality_term(v, context), - Scalar::U32(v) => cipher.equality_term(v, context), - Scalar::U64(v) => cipher.equality_term(v, context), - Scalar::Text(t) => cipher.equality_term(String::clone(&t), context), - Scalar::Bytes(b) => cipher.equality_term(Protected::new(Vec::clone(&b)), context), - // No PRF encoding is defined for floats (equality on IEEE-754 - // values is a modelling error) or booleans. - Scalar::Bool(_) | Scalar::F32(_) | Scalar::F64(_) => { - return Err(Error::Term { + value + .visit(OneScalar::new( + |ty: Option, value: Physical<'_>| { + scalar_term(cipher, kind, ty, value, context, purpose) + }, + )) + .map_err(|refused| { + refused.0.unwrap_or(Error::Term { field: None, - kind: IndexSpec::Equality, + kind: kind.clone(), }) - } - }; - Ok(term.map(equality_bytes)) + }) } -/// [`IndexSpec::Match`] per scalar: text only, under the index's options. -/// Under the default options the bytes are the typed -/// `match_terms::`'s; under others, those of a -/// [`MatchConfig`](crate::sem::MatchConfig) returning the same options. -fn match_of<'a, 'c, K: 'static, D>( +/// The term of one scalar: [`IndexSpec::takes`] decides whether there is +/// one, and each arm is the operation a Rust caller would have named for +/// the scalar's type, so the bytes are the typed path's. +/// +/// `None` is a scalar the index does not take; the caller names the index. +fn scalar_term<'a, 'c, K: 'static, D>( cipher: &'a KeysetCipher<'_, K>, - scalar: Scalar, - options: &MatchOptions, + kind: &IndexSpec, + ty: Option, + value: Physical<'_>, context: NonEmpty, -) -> Result, Error> + purpose: Use, +) -> Result, Option> where D: IntoPrfContext<'c>, { - match scalar { - Scalar::Text(t) => Ok(cipher - .match_terms_under::(&t, context, options.clone()) - .map(|terms| TermBytes(terms.to_bytes()))), - _ => Err(Error::Term { - field: None, - kind: IndexSpec::Match(options.clone()), - }), + if !kind.takes(ty, &value) { + return Err(None); + } + let unnormalized = |error: NormalizeError| Some(Error::Cipher(TermError::from(error).into())); + match kind { + IndexSpec::Equality => { + let normalized = normalize(ty, value).map_err(unnormalized)?; + Ok(cipher + .equality_term_normalized(normalized, context) + .map(|term| TermBytes(term.into_bytes().to_vec()))) + } + IndexSpec::Match(options) => { + let Physical::Text(text) = value else { + return Err(None); + }; + if purpose == Use::Query { + // The query chain's rule, from the same helper: options the + // index cannot use, then text the index cannot normalize or + // that cuts no token. + let _tokens = QueryTokens::of_query(text, options) + .map_err(|error| Some(Error::Cipher(error.into())))?; + } + Ok(cipher + .match_terms_under::(text, context, options.clone()) + .map(|terms| TermBytes(terms.to_bytes()))) + } + IndexSpec::Ore => ore_of(cipher, value, context).ok_or(None), + IndexSpec::Ope => ope_of(cipher, value, context).ok_or(None), } } -/// [`IndexSpec::Ore`] per scalar: every scalar has an ORE encoding. +/// [`IndexSpec::Ore`] per scalar, each handed to the CLLW encryptor as the +/// Rust type the typed path would have named; `None` for a scalar it does +/// not order ([`ordered`]). /// -/// The text and bytes arms hand the encryptor the `Zeroizing` operand -/// itself, not a bare clone of its contents: the CLLW encryptors take -/// their value by `'static` ownership (the visitor carries it), so a -/// cloned-out `String`/`Vec` would be freed with the plaintext -/// still in it — in a guest's linear memory, where the host can read -/// it. Keeping the wrapper costs nothing and saves the copy as well. +/// Text and bytes are copied out of the walk into a `Zeroizing` wrapper, +/// and the encryptor is handed the wrapper itself: the CLLW encryptors take +/// their value by `'static` ownership (the visitor carries it), so a bare +/// `String` or `Vec` would be freed with the plaintext still in it, in +/// a guest's linear memory, where the host can read it. fn ore_of<'a, 'c, K: 'static, D>( cipher: &'a KeysetCipher<'_, K>, - scalar: Scalar, + value: Physical<'_>, context: NonEmpty, -) -> Pending<'a, TermBytes, K> +) -> Option> where D: IntoPrfContext<'c>, { - match scalar { - Scalar::Bool(v) => ore(cipher, v, context), - Scalar::I32(v) => ore(cipher, v, context), - Scalar::I64(v) => ore(cipher, v, context), - Scalar::U32(v) => ore(cipher, v, context), - Scalar::U64(v) => ore(cipher, v, context), - Scalar::F32(v) => ore(cipher, v, context), - Scalar::F64(v) => ore(cipher, v, context), - Scalar::Text(t) => ore(cipher, t, context), - Scalar::Bytes(b) => ore(cipher, b, context), - } + Some(match value { + Physical::Int(Int::I8(v)) => ore(cipher, i32::from(v), context), + Physical::Int(Int::I16(v)) => ore(cipher, i32::from(v), context), + Physical::Int(Int::I32(v)) => ore(cipher, v, context), + Physical::Int(Int::I64(v)) => ore(cipher, v, context), + Physical::Int(Int::U8(v)) => ore(cipher, u32::from(v), context), + Physical::Int(Int::U16(v)) => ore(cipher, u32::from(v), context), + Physical::Int(Int::U32(v)) => ore(cipher, v, context), + Physical::Int(Int::U64(v)) => ore(cipher, v, context), + Physical::Float(Float::F32(v)) => ore(cipher, v, context), + Physical::Float(Float::F64(v)) => ore(cipher, v, context), + Physical::Text(t) => ore(cipher, Zeroizing::new(t.to_owned()), context), + Physical::Bytes(b) => ore(cipher, Zeroizing::new(b.to_vec()), context), + _ => return None, + }) } -/// [`IndexSpec::Ope`] per scalar; see [`ore_of`] for why the text and bytes -/// arms pass the wrapper. +/// [`IndexSpec::Ope`] per scalar; see [`ore_of`]. fn ope_of<'a, 'c, K: 'static, D>( cipher: &'a KeysetCipher<'_, K>, - scalar: Scalar, + value: Physical<'_>, context: NonEmpty, -) -> Pending<'a, TermBytes, K> +) -> Option> where D: IntoPrfContext<'c>, { - match scalar { - Scalar::Bool(v) => ope(cipher, v, context), - Scalar::I32(v) => ope(cipher, v, context), - Scalar::I64(v) => ope(cipher, v, context), - Scalar::U32(v) => ope(cipher, v, context), - Scalar::U64(v) => ope(cipher, v, context), - Scalar::F32(v) => ope(cipher, v, context), - Scalar::F64(v) => ope(cipher, v, context), - Scalar::Text(t) => ope(cipher, t, context), - Scalar::Bytes(b) => ope(cipher, b, context), - } + Some(match value { + Physical::Int(Int::I8(v)) => ope(cipher, i32::from(v), context), + Physical::Int(Int::I16(v)) => ope(cipher, i32::from(v), context), + Physical::Int(Int::I32(v)) => ope(cipher, v, context), + Physical::Int(Int::I64(v)) => ope(cipher, v, context), + Physical::Int(Int::U8(v)) => ope(cipher, u32::from(v), context), + Physical::Int(Int::U16(v)) => ope(cipher, u32::from(v), context), + Physical::Int(Int::U32(v)) => ope(cipher, v, context), + Physical::Int(Int::U64(v)) => ope(cipher, v, context), + Physical::Float(Float::F32(v)) => ope(cipher, v, context), + Physical::Float(Float::F64(v)) => ope(cipher, v, context), + Physical::Text(t) => ope(cipher, Zeroizing::new(t.to_owned()), context), + Physical::Bytes(b) => ope(cipher, Zeroizing::new(b.to_vec()), context), + _ => return None, + }) } /// The `AsRef<[u8]>` on the output is what turns the typed CLLW ciphertext @@ -521,42 +522,163 @@ where .map(|term| TermBytes(term.as_ref().to_vec())) } +/// A sink that takes one scalar and hands it to `F`, and refuses anything +/// else: a sequence, a map or a passthrough has no term. +/// +/// It refuses with [`Refused`], whose `None` is "not a scalar this index +/// takes" (the caller names the index) and whose `Some` is the error `F` +/// returned or the walk failed with. +struct OneScalar(F); + +impl OneScalar { + fn new(f: F) -> Self { + Self(f) + } +} + +/// Why a [`OneScalar`] walk stopped. A source that fails (an [`FfiValue`] +/// string that is not UTF-8, which its construction rules out) is `None`, +/// as a value with no term is. +struct Refused(Option); + +impl SinkError for Refused { + fn source(_: SourceError) -> Self { + Self(None) + } +} + +impl Sink for OneScalar +where + F: FnOnce(Option, Physical<'_>) -> Result>, +{ + type Ok = T; + type Error = Refused; + type Seq = NoContainer; + type Map = NoContainer; + + fn scalar(self, ty: Option, value: Physical<'_>) -> Result { + (self.0)(ty, value).map_err(Refused) + } + + fn seq(self, _: Option) -> Result, Refused> { + Err(Refused(None)) + } + + fn map(self, _: Option) -> Result, Refused> { + Err(Refused(None)) + } + + fn passthrough(self, _: P) -> Result { + Err(Refused(None)) + } +} + +/// The container sinks a [`OneScalar`] never makes: it refuses a sequence +/// or a map before one exists, so this type has no values and its methods +/// cannot be called. +enum NoContainer { + #[allow(dead_code)] + Never(Infallible, PhantomData), +} + +impl NoContainer { + fn unreachable(&self) -> ! { + match *self { + NoContainer::Never(never, _) => match never {}, + } + } +} + +impl Sink for NoContainer { + type Ok = T; + type Error = Refused; + type Seq = Self; + type Map = Self; + + fn scalar(self, _: Option, _: Physical<'_>) -> Result { + self.unreachable() + } + + fn seq(self, _: Option) -> Result { + self.unreachable() + } + + fn map(self, _: Option) -> Result { + self.unreachable() + } + + fn passthrough(self, _: P) -> Result { + self.unreachable() + } +} + +impl SeqSink for NoContainer { + type Ok = T; + type Error = Refused; + type Element<'s> + = Self + where + Self: 's; + + fn element(&mut self) -> Result { + self.unreachable() + } + + fn end(self) -> Result { + self.unreachable() + } +} + +impl MapSink for NoContainer { + type Ok = T; + type Error = Refused; + type Entry<'s> + = Self + where + Self: 's; + + fn entry(&mut self, _: &str) -> Result { + self.unreachable() + } + + fn end(self) -> Result { + self.unreachable() + } +} + /// A dynamic error, where an operation's pending can only carry the crate's. fn lifted(error: Error) -> crate::Error { crate::Error::Other(Box::new(error)) } -/// An [`IndexSpec`] is an [`Index`] of a [`Value`]: the index named as data, -/// over a plaintext whose type is known only when it arrives. Its -/// `operation` is `scalar_term`'s dispatch — the one step of the dynamic -/// path that stays dynamic — wrapped as a description, so a field lowered -/// from data runs through the same `indexed()` and `zip` every other field -/// does, and its term is a [`TermBytes`]. A Rust chain over `Value` fields -/// names its indexes this way too (`(IndexSpec::Equality, IndexSpec::Ore)`), -/// and is then the same declaration as a data plan's. +/// An [`IndexSpec`] is an [`Index`] of an [`FfiValue`]: the index named as +/// data, over a plaintext whose type is known only when it arrives. Its +/// `operation` is [`term`]'s derivation wrapped as a description, so a +/// field lowered from data runs through the same `indexed()` and `zip` +/// every other field does, and its term is a [`TermBytes`]. A Rust chain +/// over `FfiValue` fields names its indexes this way too +/// (`(IndexSpec::Equality, IndexSpec::Ore)`), and is then the same +/// declaration as a data plan's. /// -/// A value the scheme defines no such term for (a container, a float under -/// equality) fails the description when it runs; a plan lowered from data -/// refuses it before that, at its boundary ([`IndexSpec::supports`]). -impl Index for IndexSpec { +/// A value the index does not take (a container, a boolean under ORE) +/// fails the description when it runs, with the dynamic [`Error::Term`] +/// inside [`crate::Error::Other`]; a plan lowered from data refuses it +/// before that, at its boundary ([`admits`](super::admits)). +impl Index for IndexSpec { type Term = TermBytes; fn spec(&self) -> IndexSpec { self.clone() } - fn operation<'s, K: 'static, M: ConsumeSource<'s, Value>>( + fn operation<'s, K: 'static, M: ConsumeSource<'s, FfiValue>>( &self, - ) -> Encryption<'s, Value, TermBytes, K, CallerContext, M> { + ) -> Encryption<'s, FfiValue, TermBytes, K, CallerContext, M> { let spec = self.clone(); chosen(move |source: M::Source, cipher, cx: CallerContext| { - let scalar = match Scalar::of(M::view(&source).get(), &spec) { - Ok(scalar) => scalar, - Err(error) => return Pending::failed(cipher, lifted(error)), - }; let context = match cx.validated() { Ok(context) => context, Err(error) => return Pending::failed(cipher, error), }; - match scalar_term(cipher, scalar, &spec, context) { + match derive(cipher, M::view(&source), &spec, context, Use::Store) { Ok(pending) => pending, Err(error) => Pending::failed(cipher, lifted(error)), } @@ -571,6 +693,7 @@ mod tests { use crate::sem::TermError; use crate::{nonempty, StackCipher}; use stack_kms::FakeDataKeySource; + use vitaminc_protected::Controlled; async fn cipher() -> StackCipher { StackCipher::builder() @@ -581,9 +704,9 @@ mod tests { } /// A pair the scheme refuses fails the run of `IndexSpec`'s public - /// `Index` with the dynamic `Error::Term` inside the crate error, - /// and requests nothing: the record path refuses such a pair at its - /// boundary, a Rust caller running the index directly meets it here. + /// `Index` with the dynamic `Error::Term` inside the crate + /// error, and requests nothing: the record path refuses such a pair at + /// its boundary, a Rust caller running the index directly meets it here. #[tokio::test] async fn a_refused_pair_fails_the_index_when_it_runs() { let cipher = cipher().await; @@ -591,8 +714,8 @@ mod tests { let ctx = || CallerContext::from(nonempty!("users/age")); for (what, value, kind) in [ ( - "a float under equality", - FfiValue::Float64(1.5), + "a boolean under equality", + FfiValue::Bool(true), IndexSpec::Equality, ), ( @@ -600,16 +723,17 @@ mod tests { FfiValue::UInt32(34), default_match(), ), + ("a boolean under ore", FfiValue::Bool(true), IndexSpec::Ore), ( - "a container under ore", + "a container under ope", FfiValue::Array(vec![FfiValue::UInt32(1)]), - IndexSpec::Ore, + IndexSpec::Ope, ), ] { let refused = keyset .run( - Index::::operation::(&kind), - Value::new(value), + Index::::operation::(&kind), + value, ctx(), ) .await; @@ -627,18 +751,17 @@ mod tests { } /// The query path refuses match text with no tokens; the record path, - /// which runs the same dispatch as an index of a `Value`, stores it as - /// the empty filter. Same scalar, same options, two answers, because a - /// query with no tokens would match every row and a stored value with - /// none is matched by no query. + /// which runs the same derivation as an index of an `FfiValue`, stores + /// it as the empty filter. Same value, same options, two answers, + /// because a query with no tokens would match every row and a stored + /// value with none is matched by no query. #[tokio::test] async fn match_text_with_no_tokens_is_refused_as_a_query_and_stored_empty() { let cipher = cipher().await; let keyset = cipher.default_keyset(); let ctx = context(s("users/bio")).expect("context"); for text in ["", " ", "hi"] { - let scalar = Scalar::of(&s(text), &default_match()).expect("a scalar"); - let refused = term(&keyset, scalar, &default_match(), ctx.clone()).await; + let refused = term(&keyset, &s(text), &default_match(), ctx.clone()).await; assert!( matches!( refused, @@ -649,10 +772,10 @@ mod tests { let stored: TermBytes = keyset .run( - Index::::operation::( + Index::::operation::( &default_match(), ), - Value::new(s(text)), + s(text), CallerContext::from(nonempty!("users/bio")), ) .await @@ -660,8 +783,9 @@ mod tests { assert!(stored.as_bytes().is_empty(), "{text:?} is the empty filter"); } // Text with a token is a query like any other. - let scalar = Scalar::of(&s("hello"), &default_match()).expect("a scalar"); - assert!(term(&keyset, scalar, &default_match(), ctx).await.is_ok()); + assert!(term(&keyset, &s("hello"), &default_match(), ctx) + .await + .is_ok()); } /// Options the index cannot use are refused before the query's text is @@ -676,8 +800,7 @@ mod tests { tokenizer: Tokenizer::Ngram { length: 0 }, ..MatchOptions::default() }); - let scalar = Scalar::of(&s("hello"), &zero).expect("a scalar"); - let refused = term(&keyset, scalar, &zero, ctx).await; + let refused = term(&keyset, &s("hello"), &zero, ctx).await; assert!( matches!( refused, @@ -692,7 +815,7 @@ mod tests { /// A `TermBytes` reads as the typed term's bytes, through every /// accessor: `as_bytes`, `as_ref` and `into_bytes` give the same /// 32 PRF bytes an `EqualityTerm` holds, for a term derived through - /// `IndexSpec`'s `Index`. + /// `IndexSpec`'s `Index`. #[tokio::test] async fn term_bytes_read_as_the_typed_terms_bytes() { let cipher = cipher().await; @@ -707,10 +830,10 @@ mod tests { let through_value: TermBytes = keyset .run( - Index::::operation::( + Index::::operation::( &IndexSpec::Equality, ), - Value::new(FfiValue::UInt32(34)), + FfiValue::UInt32(34), CallerContext::from(nonempty!("users/age")), ) .await @@ -731,10 +854,10 @@ mod tests { } fn bytes(value: &[u8]) -> FfiValue { - FfiValue::Bytes(Protected::new(value.to_vec())) + FfiValue::Bytes(vitaminc_protected::Protected::new(value.to_vec())) } - /// Every scalar variant, from the leaf it lifts out of. + /// One value of every scalar variant. fn every_scalar() -> Vec<(&'static str, FfiValue)> { vec![ ("a bool", FfiValue::Bool(true)), @@ -752,7 +875,7 @@ mod tests { mod given_a_scalar_the_scheme_defines_the_term_for { use super::*; - /// The contract the dispatch exists for: the bytes are the typed + /// The contract the derivation exists for: the bytes are the typed /// path's, so a probe from any language finds a Rust-written term. #[tokio::test] async fn derives_the_bytes_the_typed_path_derives() { @@ -760,64 +883,31 @@ mod tests { let keyset = cipher.default_keyset(); let ctx = context(s("users/x")).expect("context"); let ks = &keyset; - let dynamic = |value: &FfiValue, kind: IndexSpec| { - let scalar = Scalar::of(value, &kind).expect("a scalar"); + let dynamic = |value: FfiValue, kind: IndexSpec| { let ctx = ctx.clone(); - async move { term(ks, scalar, &kind, ctx).await } + async move { term(ks, &value, &kind, ctx).await } }; let eq = |t: crate::sem::EqualityTerm| t.into_bytes().to_vec(); - // Equality, per PRF-encodable variant. - let typed = keyset.equality_term(-3i32, nonempty!("users/x")).await; - assert_eq!( - dynamic(&FfiValue::Int32(-3), IndexSpec::Equality) - .await - .expect("eq"), - eq(typed.expect("typed")), - "i32 equality" - ); - let typed = keyset.equality_term(-4i64, nonempty!("users/x")).await; - assert_eq!( - dynamic(&FfiValue::Int64(-4), IndexSpec::Equality) - .await - .expect("eq"), - eq(typed.expect("typed")), - "i64 equality" - ); - let typed = keyset.equality_term(34u32, nonempty!("users/x")).await; - assert_eq!( - dynamic(&FfiValue::UInt32(34), IndexSpec::Equality) - .await - .expect("eq"), - eq(typed.expect("typed")), - "u32 equality" - ); - let typed = keyset.equality_term(35u64, nonempty!("users/x")).await; - assert_eq!( - dynamic(&FfiValue::UInt64(35), IndexSpec::Equality) - .await - .expect("eq"), - eq(typed.expect("typed")), - "u64 equality" - ); - let typed = keyset - .equality_term("alice".to_string(), nonempty!("users/x")) - .await; - assert_eq!( - dynamic(&s("alice"), IndexSpec::Equality).await.expect("eq"), - eq(typed.expect("typed")), - "text equality" - ); - let typed = keyset - .equality_term(Protected::new(b"ab".to_vec()), nonempty!("users/x")) - .await; - assert_eq!( - dynamic(&bytes(b"ab"), IndexSpec::Equality) - .await - .expect("eq"), - eq(typed.expect("typed")), - "bytes equality" - ); + // Equality, per variant with an equality domain. + macro_rules! equal { + ($value:expr, $leaf:expr, $label:literal) => { + let typed = keyset.equality_term($value, nonempty!("users/x")).await; + assert_eq!( + dynamic($leaf, IndexSpec::Equality).await.expect("eq"), + eq(typed.expect("typed")), + concat!($label, " equality") + ); + }; + } + equal!(-3i32, FfiValue::Int32(-3), "i32"); + equal!(-4i64, FfiValue::Int64(-4), "i64"); + equal!(34u32, FfiValue::UInt32(34), "u32"); + equal!(35u64, FfiValue::UInt64(35), "u64"); + equal!(1.5f32, FfiValue::Float32(1.5), "f32"); + equal!(2.5f64, FfiValue::Float64(2.5), "f64"); + equal!("alice".to_string(), s("alice"), "text"); + equal!(b"ab".to_vec(), bytes(b"ab"), "bytes"); // Match, text only. let typed = keyset @@ -825,32 +915,32 @@ mod tests { .await .expect("typed"); assert_eq!( - dynamic(&s("alice smith"), default_match()) + dynamic(s("alice smith"), default_match()) .await .expect("match"), typed.to_bytes(), "text match" ); - // The ordering schemes take every scalar; the typed side is - // spelled once per variant because each is its own type. + // The ordering schemes take every scalar but a boolean; the + // typed side is spelled once per variant because each is its + // own type. macro_rules! ordered { ($value:expr, $leaf:expr, $label:literal) => { let typed = keyset.ore_term($value, nonempty!("users/x")).await; assert_eq!( - dynamic(&$leaf, IndexSpec::Ore).await.expect("ore"), + dynamic($leaf, IndexSpec::Ore).await.expect("ore"), typed.expect("typed").as_ref().to_vec(), concat!($label, " ore") ); let typed = keyset.ope_term($value, nonempty!("users/x")).await; assert_eq!( - dynamic(&$leaf, IndexSpec::Ope).await.expect("ope"), + dynamic($leaf, IndexSpec::Ope).await.expect("ope"), typed.expect("typed").as_ref().to_vec(), concat!($label, " ope") ); }; } - ordered!(true, FfiValue::Bool(true), "bool"); ordered!(-3i32, FfiValue::Int32(-3), "i32"); ordered!(-4i64, FfiValue::Int64(-4), "i64"); ordered!(34u32, FfiValue::UInt32(34), "u32"); @@ -861,32 +951,125 @@ mod tests { ordered!(b"ab".to_vec(), bytes(b"ab"), "bytes"); } - #[test] - fn supports_is_true() { - for (label, leaf) in every_scalar() { - let scalar = Scalar::of(&leaf, &IndexSpec::Ore).expect("a scalar"); - assert!( - IndexSpec::Ore.supports(&scalar), - "{label} takes an ore term" - ); - assert!( - IndexSpec::Ope.supports(&scalar), - "{label} takes an ope term" + /// Equality normalizes: every integer width of one number is one + /// term, and text in two Unicode forms of one string is one term, + /// whichever path derives it. + #[tokio::test] + async fn equality_is_one_term_per_number_and_per_nfc_string() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ctx = context(s("users/x")).expect("context"); + let eq = |value: FfiValue| { + let ctx = ctx.clone(); + let keyset = &keyset; + async move { + term(keyset, &value, &IndexSpec::Equality, ctx) + .await + .expect("eq") + } + }; + let thirty_four = eq(FfiValue::UInt32(34)).await; + for width in [ + FfiValue::Int32(34), + FfiValue::Int64(34), + FfiValue::UInt64(34), + ] { + assert_eq!(eq(width).await, thirty_four, "34 at every width"); + } + assert_ne!( + eq(FfiValue::Int64(-34)).await, + thirty_four, + "the sign counts" + ); + assert_ne!( + eq(FfiValue::Float64(34.0)).await, + thirty_four, + "a float is not the integer it equals" + ); + let composed = eq(s("cafe\u{301}")).await; + assert_eq!(composed, eq(s("caf\u{e9}")).await, "one string in NFC"); + assert_eq!( + composed, + keyset + .equality_term("caf\u{e9}", nonempty!("users/x")) + .await + .expect("typed") + .into_bytes() + .to_vec(), + "the typed path normalizes too" + ); + } + + /// A transport frame is walked in place to the same term an + /// `FfiValue` derives, at every width a host sends: an 8- or 16-bit + /// integer is read as the 32-bit integer of its sign, as an + /// `FfiValue` reads it. + #[tokio::test] + async fn a_frame_derives_the_terms_its_value_does() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ctx = context(s("users/x")).expect("context"); + let derive = |frame: Vec, kind: IndexSpec| { + let ctx = ctx.clone(); + let keyset = &keyset; + async move { + let frame = vitaminc_aead::Frame::parse(&frame).expect("a frame"); + term(keyset, frame, &kind, ctx).await.expect("derives") + } + }; + let of_value = |value: FfiValue, kind: IndexSpec| { + let ctx = ctx.clone(); + let keyset = &keyset; + async move { term(keyset, &value, &kind, ctx).await.expect("derives") } + }; + for kind in [IndexSpec::Equality, IndexSpec::Ore, IndexSpec::Ope] { + for (frame, value) in [ + (vitaminc_aead::to_frame(-8i8), FfiValue::Int32(-8)), + (vitaminc_aead::to_frame(-300i16), FfiValue::Int32(-300)), + (vitaminc_aead::to_frame(300u16), FfiValue::UInt32(300)), + (vitaminc_aead::to_frame(34u64), FfiValue::UInt64(34)), + (vitaminc_aead::to_frame("alice"), s("alice")), + ] { + let frame = frame.expect("frames"); + assert_eq!( + derive(frame.risky_ref().to_vec(), kind.clone()).await, + of_value(value, kind.clone()).await, + "{kind}" + ); + } + } + // The `UINT8` tag and its byte: no Rust source walks a `u8` as an + // int, but a host's frame can carry one. + for kind in [IndexSpec::Equality, IndexSpec::Ore, IndexSpec::Ope] { + assert_eq!( + derive(vec![vitaminc_aead::tags::UINT8, 200], kind.clone()).await, + of_value(FfiValue::UInt32(200), kind.clone()).await, + "{kind}" ); } + } + + #[test] + fn defines_is_true() { for (label, leaf) in every_scalar() { - let scalar = Scalar::of(&leaf, &IndexSpec::Equality).expect("a scalar"); - let prf_encodable = !matches!( - leaf, - FfiValue::Bool(_) | FfiValue::Float32(_) | FfiValue::Float64(_) + let not_bool = !matches!(leaf, FfiValue::Bool(_)); + assert_eq!( + IndexSpec::Ore.defines(&leaf), + not_bool, + "{label} takes an ore term exactly when it is not a boolean" ); assert_eq!( - IndexSpec::Equality.supports(&scalar), - prf_encodable, - "{label} takes an equality term exactly when it has a PRF encoding" + IndexSpec::Ope.defines(&leaf), + not_bool, + "{label} takes an ope term exactly when it is not a boolean" ); assert_eq!( - default_match().supports(&scalar), + IndexSpec::Equality.defines(&leaf), + not_bool, + "{label} takes an equality term exactly when it has a domain" + ); + assert_eq!( + default_match().defines(&leaf), matches!(leaf, FfiValue::String(_)), "{label} takes a match term exactly when it is text" ); @@ -897,9 +1080,8 @@ mod tests { mod given_a_pair_the_scheme_refuses { use super::*; - /// The arms of `term` are unreachable for a pair `supports` refuses, - /// and they say so with the same error the table would have let a - /// binding raise at its boundary. + /// `term` refuses exactly what `defines` refuses, with the error the + /// table would have let a binding raise at its boundary. #[tokio::test] async fn term_is_error_term_naming_the_kind() { let cipher = cipher().await; @@ -907,32 +1089,74 @@ mod tests { let ctx = context(s("users/x")).expect("context"); let refused = [ ("a bool", FfiValue::Bool(true), IndexSpec::Equality), - ("an f32", FfiValue::Float32(1.5), IndexSpec::Equality), - ("an f64", FfiValue::Float64(2.5), IndexSpec::Equality), ("a bool", FfiValue::Bool(true), default_match()), ("a u32", FfiValue::UInt32(34), default_match()), ("bytes", bytes(b"ab"), default_match()), + ("a bool", FfiValue::Bool(true), IndexSpec::Ore), + ("a bool", FfiValue::Bool(false), IndexSpec::Ope), ]; for (label, leaf, kind) in refused { - let scalar = Scalar::of(&leaf, &kind).expect("a scalar"); - assert!( - !kind.supports(&scalar), - "{label} must not take a {kind} term" - ); - let result = term(&keyset, scalar, &kind, ctx.clone()).await; + assert!(!kind.defines(&leaf), "{label} must not take a {kind} term"); + let result = term(&keyset, &leaf, &kind, ctx.clone()).await; assert!( matches!(&result, Err(Error::Term { kind: k, .. }) if *k == kind), "{label} asked for a {kind} term must be refused as that kind: {result:?}" ); } } + + /// A 128-bit integer has an equality term, from vitaminc, and no + /// order term: the order schemes take what an `FfiValue` holds. + #[tokio::test] + async fn a_128_bit_integer_has_no_order_term() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ctx = context(s("users/x")).expect("context"); + for kind in [IndexSpec::Ore, IndexSpec::Ope] { + assert!(!kind.defines(7u128)); + assert!(matches!( + term(&keyset, 7u128, &kind, ctx.clone()).await, + Err(Error::Term { .. }) + )); + } + assert!(IndexSpec::Equality.defines(7u128)); + assert!(term(&keyset, 7u128, &IndexSpec::Equality, ctx) + .await + .is_ok()); + } + + /// Text with a code point unassigned in Unicode 16 has a domain but + /// no normal form, so equality and match refuse it when they derive. + #[tokio::test] + async fn text_that_does_not_normalize_is_refused_when_derived() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ctx = context(s("users/x")).expect("context"); + for kind in [IndexSpec::Equality, default_match()] { + let unassigned = s("a\u{378}b"); + assert!(kind.defines(&unassigned), "{kind} takes text"); + let result = term(&keyset, &unassigned, &kind, ctx.clone()).await; + assert!( + matches!( + result, + Err(Error::Cipher(crate::Error::Term(TermError::Normalize( + NormalizeError::UnassignedCodePoint + )))) + ), + "{kind}: {result:?}" + ); + } + } } mod given_a_value_that_is_not_a_scalar { use super::*; - #[test] - fn lifting_is_error_term_naming_the_kind() { + #[tokio::test] + async fn term_is_error_term_naming_the_kind() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ctx = context(s("users/x")).expect("context"); let not_scalars = [ ("null", FfiValue::Null), ("undefined", FfiValue::Undefined), @@ -953,7 +1177,8 @@ mod tests { IndexSpec::Ore, IndexSpec::Ope, ] { - let result = Scalar::of(&value, &kind); + assert!(!kind.defines(&value), "{label} has no {kind} term"); + let result = term(&keyset, &value, &kind, ctx.clone()).await; assert!( matches!(&result, Err(Error::Term { kind: k, .. }) if *k == kind), "{label} has no {kind} term: {result:?}" @@ -1215,10 +1440,10 @@ mod tests { let cipher = cipher().await; let keyset = cipher.default_keyset(); let ctx = context(s("users/x")).expect("context"); - let text = || Scalar::of(&s("Alice Smith"), &default_match()).expect("text"); + let text = || s("Alice Smith"); let wide = term( &keyset, - text(), + &text(), &IndexSpec::Match(non_default_match()), ctx.clone(), ) @@ -1229,32 +1454,12 @@ mod tests { .await .expect("typed"); assert_eq!(wide, typed.to_bytes(), "the options are the typed config's"); - let default = term(&keyset, text(), &default_match(), ctx) + let default = term(&keyset, &text(), &default_match(), ctx) .await .expect("default"); assert_ne!(wide, default, "other options derive other terms"); } } - - mod given_a_scalar_holding_plaintext { - use super::*; - - #[test] - fn debug_prints_none_of_it() { - let rendered = format!( - "{:?}", - Scalar::of(&s("hunter2"), &IndexSpec::Equality).expect("a scalar") - ); - assert!( - !rendered.contains("hunter2"), - "a scalar's Debug must not print its plaintext: {rendered}" - ); - assert!( - rendered.contains("Text"), - "a scalar's Debug names the variant, which is not secret: {rendered}" - ); - } - } } /// The typed side's lowering meets the wire form: an `Index`'s `spec()` is diff --git a/packages/stack-encrypt/tests/fixtures/README.md b/packages/stack-encrypt/tests/fixtures/README.md index c7a90d045..c48fd835f 100644 --- a/packages/stack-encrypt/tests/fixtures/README.md +++ b/packages/stack-encrypt/tests/fixtures/README.md @@ -2,7 +2,7 @@ One declaration, two authors, one engine. A Rust chain (`cipher.encrypt(&user).context("users").fields()…`, over fields of type -`stack_encrypt::dynamic::Value`, the plaintext type a binding's values have) +`stack_encrypt::dynamic::FfiValue`, the plaintext type a binding's values have) and the data-plan lowering (`stack_encrypt::dynamic::record`, what a language binding calls) each sealed the same plaintext under the same declaration. The fixture holds both records, with their term bytes, sealed diff --git a/packages/stack-encrypt/tests/index.rs b/packages/stack-encrypt/tests/index.rs index 34a079153..af13019f2 100644 --- a/packages/stack-encrypt/tests/index.rs +++ b/packages/stack-encrypt/tests/index.rs @@ -38,19 +38,19 @@ fn caller() -> CallerContext { #[cfg(feature = "dynamic")] #[tokio::test] async fn a_vec_of_indexes_is_an_index_set_in_order_and_never_empty() { - use stack_encrypt::dynamic::{FfiValue, Value}; + use stack_encrypt::dynamic::FfiValue; let (cipher, generates, _) = counting_cipher().await; let keyset = cipher.default_keyset(); let list: Vec = vec![IndexSpec::Ore, IndexSpec::Equality]; assert_eq!( - Indexes::::specs(&list), + Indexes::::specs(&list), [IndexSpec::Ore, IndexSpec::Equality], "the specs are the indexes', in the order given" ); assert_eq!( - Indexes::::specs(&vec![IndexSpec::Equality]), + Indexes::::specs(&vec![IndexSpec::Equality]), [IndexSpec::Equality] ); assert_eq!( @@ -61,8 +61,8 @@ async fn a_vec_of_indexes_is_an_index_set_in_order_and_never_empty() { let terms: Vec = keyset .run( - Indexes::::operations::<_, Borrowed>(&list), - &Value::new(FfiValue::UInt32(34)), + Indexes::::operations::<_, Borrowed>(&list), + &FfiValue::UInt32(34), caller(), ) .await diff --git a/packages/stack-encrypt/tests/record_lowering.rs b/packages/stack-encrypt/tests/record_lowering.rs index 7f84e5999..fef703d28 100644 --- a/packages/stack-encrypt/tests/record_lowering.rs +++ b/packages/stack-encrypt/tests/record_lowering.rs @@ -1,7 +1,7 @@ //! The record fixture is the proof of the lowering (ADR-0007, amended //! 2026-10-06): a Rust chain and the data-plan lowering each open the //! records the other sealed, and both derive the same bytes for each term. -//! The chain's fields are `dynamic::Value`s, the plaintext type a binding's +//! The chain's fields are `dynamic::FfiValue`s, the plaintext type a binding's //! values have, so the two authors write one declaration over one type; a //! chain over bare `u32`/`String` fields seals the same tagged leaves (see //! `dynamic::record`). `tests/fixtures/record_lowering.json` holds one @@ -29,7 +29,7 @@ use std::path::PathBuf; use common::{deterministic_cipher, DeterministicSource}; use serde_json::{json, Map, Value as Json}; use stack_encrypt::dynamic::record::{self, Plan as DataPlan}; -use stack_encrypt::dynamic::{FfiValue, Scope, TermBytes, Value}; +use stack_encrypt::dynamic::{FfiValue, Scope, TermBytes}; use stack_encrypt::plan::{pick, FieldValues}; use stack_encrypt::sem::MatchOptions; use stack_encrypt::target::{Encrypted, IndexSpec}; @@ -39,22 +39,22 @@ use vitaminc_protected::Controlled; const SEED: [u8; 32] = *b"stack-encrypt record fixture v1 "; struct User { - age: Value, - email: Value, - notes: Value, + age: FfiValue, + email: FfiValue, + notes: FfiValue, id: u32, } fn user() -> User { User { - age: Value::new(FfiValue::UInt32(34)), - email: Value::new(FfiValue::String("bob@example.com".into())), - notes: Value::new(FfiValue::String("likes cats".into())), + age: FfiValue::UInt32(34), + email: FfiValue::String("bob@example.com".into()), + notes: FfiValue::String("likes cats".into()), id: 42, } } -/// The one declaration, as the Rust chain writes it over `Value` fields. +/// The one declaration, as the Rust chain writes it over `FfiValue` fields. fn typed_plan() -> Plan { Plan::context("users") .fields() @@ -365,8 +365,7 @@ async fn open_typed(cipher: &StackCipher, tree: StackCipher .using(&typed_plan()) .await .expect("the typed chain opens it"); - let mut value = - |name: &str| json_of_opened(opened.take::(name).expect(name).into_inner()); + let mut value = |name: &str| json_of_opened(opened.take::(name).expect(name)); let (age, email, notes) = (value("age"), value("email"), value("notes")); json!({ "age": age, @@ -523,15 +522,15 @@ fn write_fixture(path: &PathBuf, fixture: &Json) { /// context, `text` is sealed and indexed under `/text`, `id` is /// carried through. struct Note { - tenant: Value, - text: Value, + tenant: FfiValue, + text: FfiValue, id: u32, } fn note() -> Note { Note { - tenant: Value::new(FfiValue::String("tenants/acme".into())), - text: Value::new(FfiValue::String("hello".into())), + tenant: FfiValue::String("tenants/acme".into()), + text: FfiValue::String("hello".into()), id: 42, } } @@ -572,16 +571,13 @@ fn globex() -> Label { /// The typed chain's note record, shaped as the stored tree. fn record_of_note(mut values: FieldValues) -> StackCipherText { - let tenant: Value = values.take("tenant").expect("tenant"); + let tenant: FfiValue = values.take("tenant").expect("tenant"); let text: Encrypted = values.take("text").expect("text"); let id: u32 = values.take("id").expect("id"); CipherText::Map(vec![ ( "tenant".into(), - CipherText::Map(vec![( - "passthrough".into(), - passthrough(tenant.into_inner()), - )]), + CipherText::Map(vec![("passthrough".into(), passthrough(tenant))]), ), ( "text".into(), @@ -629,7 +625,7 @@ fn note_of_record(tree: StackCipherText) -> FieldValues { let _ = values.insert(&name, id); } text => { - let _ = values.insert(&name, Value::new(text)); + let _ = values.insert(&name, text); } } } @@ -675,8 +671,7 @@ async fn open_note_typed( Some(expected) => open.context(expected).using(¬e_plan()).await?, None => open.using(¬e_plan()).await?, }; - let mut value = - |name: &str| json_of_opened(opened.take::(name).expect(name).into_inner()); + let mut value = |name: &str| json_of_opened(opened.take::(name).expect(name)); let (tenant, text) = (value("tenant"), value("text")); Ok(json!({ "tenant": tenant, "text": text, "id": opened.take::("id").expect("id") })) } From bb06ead0a3cb891b2f599a6ae3de1549958deb3e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 16 Oct 2026 02:28:31 +1100 Subject: [PATCH 3/9] feat(golang)!: walk a term's frame in place in the encrypt guest and refuse order terms on a boolean se_term parses the value's frame and hands it to dynamic::term without decoding it into a value tree. The stashgen fake engine and the Go tests follow the engine's new table: a float takes an equality term, a boolean takes no equality or order term. Part of #1140 --- languages/golang/encrypt/guest/src/ops.rs | 54 ++++++++++++------- .../golang/encrypt/guest/tests/native_ops.rs | 15 +++--- languages/golang/encrypt/guest_test.go | 7 +-- .../golang/stashgen/enginetest/enginetest.go | 11 ++-- languages/golang/stashgen/refusal_test.go | 5 +- 5 files changed, 55 insertions(+), 37 deletions(-) diff --git a/languages/golang/encrypt/guest/src/ops.rs b/languages/golang/encrypt/guest/src/ops.rs index a20f36286..747296da2 100644 --- a/languages/golang/encrypt/guest/src/ops.rs +++ b/languages/golang/encrypt/guest/src/ops.rs @@ -35,14 +35,21 @@ //! directions, buffers sized before a byte of plaintext is written, the //! ABI's numeric term kinds, and the mapping from a library error to a //! status code. - -use stack_encrypt::dynamic::{self, FfiValue, Scalar, Scope}; +//! +//! A term's value is not decoded at all: the frame is parsed and walked in +//! place to the scalar it holds, the way vitaminc's own guest walks a frame +//! into encryption, so no copy of the plaintext is built to derive it. A +//! record's source, a plan and an options object are decoded into an +//! [`FfiValue`]: the engine reads them, splits a source into fields and +//! keeps each field's value past this call. + +use stack_encrypt::dynamic::{self, FfiValue, Scope}; use stack_encrypt::sem::MatchOptions; use stack_encrypt::target::IndexSpec; use stack_encrypt::ErrorPayload; use stack_encrypt::{CipherText, Encrypt, KeysetCipher, Label, Node, SealedValue, StackCipherText}; use stack_kms::DataKeySource; -use vitaminc_aead_frame::{decode_ciphertext, encode_ciphertext, FrameWriter}; +use vitaminc_aead_frame::{decode_ciphertext, encode_ciphertext, Frame, FrameWriter}; use vitaminc_protected::Controlled; use zeroize::Zeroizing; @@ -71,7 +78,7 @@ type BytesTree = Node>; /// Derive one index term: a codec-encoded scalar and a codec-encoded /// context in, the term's frozen byte encoding out (see `stack-encrypt`'s -/// `sem` module docs). Under the local HMAC backend the derivation is one +/// `sem` module docs). The scalar's frame is walked in place. Under the local HMAC backend the derivation is one /// PRF/CLLW computation with no ZeroKMS I/O; that is the backend's /// property, not this operation's contract — the term API is a `Pending` /// so a backend that derives terms at ZeroKMS settles the same way. @@ -98,22 +105,22 @@ where // The same proof every stack-encrypt leaf demands: an empty context is // `STATUS_ENCODING` here, before any derivation. let context = dynamic::context(decode_value(context)?).map_err(|e| fail_dynamic(&e))?; - let (scalar, kind) = parse_term(decode_value(value)?, kind)?; - dynamic::term(cipher, scalar, &kind, context) + let value = parse_frame(value)?; + let kind = parse_term(value, kind)?; + dynamic::term(cipher, value, &kind, context) .await .map_err(|e| fail_dynamic(&e)) } -/// The static half of a term: the kind is one of the ABI's table, the value -/// is a scalar, and the scheme defines the pair -/// ([`IndexSpec::supports`]). Shared by [`term`] and [`validate::term`] so -/// the ABI refuses exactly what the operation would, before any keyset is -/// resolved. +/// The static half of a term: the kind is one of the ABI's table, and the +/// value is one scalar the index takes ([`IndexSpec::defines`]). Shared by +/// [`term`] and [`validate::term`] so the ABI refuses exactly what the +/// operation would, before any keyset is resolved. /// /// The ABI's kind codes carry no options, so [`TERM_MATCH`] is the match /// index under the default options, the same index a plan's bare `"match"` /// names. -fn parse_term(value: FfiValue, kind: u32) -> Result<(Scalar, IndexSpec), u32> { +fn parse_term(value: Frame<'_>, kind: u32) -> Result { let kind = match kind { TERM_EQUALITY => IndexSpec::Equality, TERM_MATCH => IndexSpec::Match(MatchOptions::default()), @@ -125,11 +132,10 @@ fn parse_term(value: FfiValue, kind: u32) -> Result<(Scalar, IndexSpec), u32> { )) } }; - let scalar = Scalar::of(&value, &kind).map_err(|e| fail_dynamic(&e))?; - if !kind.supports(&scalar) { + if !kind.defines(value) { return Err(fail_dynamic(&dynamic::Error::Term { field: None, kind })); } - Ok((scalar, kind)) + Ok(kind) } // ============================================================================= @@ -308,13 +314,12 @@ pub mod validate { /// A term's inputs, as [`term`] takes them: the context decodes and is /// non-empty, the kind is one of [`TERM_EQUALITY`] .. [`TERM_OPE`], and - /// the value is a scalar the scheme defines that term for - /// ([`IndexSpec::supports`]). + /// the value is one scalar the index takes ([`IndexSpec::defines`]). pub fn term(value: &[u8], context: &[u8], kind: u32) -> Result<(), u32> { dynamic::context(decode_value(context)?) .map(drop) .map_err(|e| fail_dynamic(&e))?; - parse_term(decode_value(value)?, kind).map(drop) + parse_term(parse_frame(value)?, kind).map(drop) } /// A record source against its plan, as [`encrypt_record`] takes them: @@ -374,8 +379,17 @@ pub mod validate { // ============================================================================= fn decode_value(bytes: &[u8]) -> Result { - FfiValue::from_frame(bytes) - .map_err(|_| last_error::malformed("an input is not a value in the transport frame")) + FfiValue::from_frame(bytes).map_err(|_| not_a_frame()) +} + +/// One frame, parsed and checked but not decoded: what a term's value is +/// walked from, in place. +fn parse_frame(bytes: &[u8]) -> Result, u32> { + Frame::parse(bytes).map_err(|_| not_a_frame()) +} + +fn not_a_frame() -> u32 { + last_error::malformed("an input is not a value in the transport frame") } /// Encode a value tree into a buffer whose capacity is exactly its length. diff --git a/languages/golang/encrypt/guest/tests/native_ops.rs b/languages/golang/encrypt/guest/tests/native_ops.rs index bcd86402d..56acfb085 100644 --- a/languages/golang/encrypt/guest/tests/native_ops.rs +++ b/languages/golang/encrypt/guest/tests/native_ops.rs @@ -347,12 +347,13 @@ fn unsupported_term_inputs_are_encoding_errors() { let cipher = cipher(); let ctx = encode(s("f")); - // Floats and bools have no equality encoding; match is text-only; + // Bools have no equality domain and no order term; match is text-only; // containers have no term semantics; kinds outside the table and empty // contexts are rejected. for (value, kind) in [ - (FfiValue::Float64(1.5), TERM_EQUALITY), (FfiValue::Bool(true), TERM_EQUALITY), + (FfiValue::Bool(true), TERM_ORE), + (FfiValue::Bool(false), TERM_OPE), (FfiValue::UInt32(1), TERM_MATCH), (FfiValue::Array(vec![]), TERM_ORE), (FfiValue::Null, TERM_EQUALITY), @@ -1055,12 +1056,9 @@ fn term_validation_refuses_what_the_term_op_refuses() { let ctx = encode(s("f")); for (label, value, kind) in [ - ( - "a float under equality", - FfiValue::Float64(1.5), - TERM_EQUALITY, - ), ("a bool under equality", FfiValue::Bool(true), TERM_EQUALITY), + ("a bool under ore", FfiValue::Bool(true), TERM_ORE), + ("a bool under ope", FfiValue::Bool(true), TERM_OPE), ("an integer under match", FfiValue::UInt32(1), TERM_MATCH), ("a container", FfiValue::Array(vec![]), TERM_ORE), ("an object", obj(vec![("k", s("v"))]), TERM_EQUALITY), @@ -1101,8 +1099,9 @@ fn term_validation_refuses_what_the_term_op_refuses() { (s("x"), TERM_EQUALITY), (FfiValue::Bytes(Protected::new(vec![1])), TERM_EQUALITY), (s("x y"), TERM_MATCH), + (FfiValue::Float64(1.5), TERM_EQUALITY), (FfiValue::Float64(1.5), TERM_ORE), - (FfiValue::Bool(true), TERM_OPE), + (FfiValue::Int32(-1), TERM_OPE), (s("x"), TERM_ORE), ] { assert_eq!(ops::validate::term(&encode(value), &ctx, kind), Ok(())); diff --git a/languages/golang/encrypt/guest_test.go b/languages/golang/encrypt/guest_test.go index f6863f62a..11a21dfdb 100644 --- a/languages/golang/encrypt/guest_test.go +++ b/languages/golang/encrypt/guest_test.go @@ -734,10 +734,10 @@ func TestGuestRefusesMalformedInputsBeforeState(t *testing.T) { c := rawInstance(t) def := c.DefaultKeyset() plan := usersPlan() - floatAge := &record.Plan{Context: []string{"users"}, Fields: []record.Field{{Name: "age", Kind: record.Float64, Outputs: []record.Output{record.Ciphertext, record.Equality}}}} + boolFlag := &record.Plan{Context: []string{"users"}, Fields: []record.Field{{Name: "flag", Kind: record.Bool, Outputs: []record.Output{record.Ciphertext, record.Equality}}}} matchInt := &record.Plan{Context: []string{"users"}, Fields: []record.Field{{Name: "age", Kind: record.Uint32, Outputs: []record.Output{record.Ciphertext, record.Match}}}} calls := map[string]func() error{ - "float under equality": func() error { _, err := def.Derive(ctx, floatAge, "age", record.Equality, 1.5); return err }, + "bool under equality": func() error { _, err := def.Derive(ctx, boolFlag, "flag", record.Equality, true); return err }, "integer under match": func() error { _, err := def.Derive(ctx, matchInt, "age", record.Match, uint32(1)); return err }, "container as term value": func() error { _, err := def.Derive(ctx, plan, "age", record.Equality, []any{1}); return err }, "a kind the type refuses": func() error { _, err := def.Seal(ctx, matchInt, []record.Source{usersRow(1, "x")}); return err }, @@ -837,7 +837,8 @@ func TestPlanCheckAnswersWithoutACipher(t *testing.T) { } refused := []*record.Plan{ {Context: []string{"users"}, Fields: []record.Field{{Name: "age", Kind: record.Uint32, Outputs: []record.Output{record.Ciphertext, record.Match}}}}, - {Context: []string{"users"}, Fields: []record.Field{{Name: "age", Kind: record.Float64, Outputs: []record.Output{record.Equality}}}}, + {Context: []string{"users"}, Fields: []record.Field{{Name: "flag", Kind: record.Bool, Outputs: []record.Output{record.Equality}}}}, + {Context: []string{"users"}, Fields: []record.Field{{Name: "flag", Kind: record.Bool, Outputs: []record.Output{record.Ore}}}}, } for i, p := range refused { if err := k.Check(ctx, p); !errors.Is(err, ErrEncoding) { diff --git a/languages/golang/stashgen/enginetest/enginetest.go b/languages/golang/stashgen/enginetest/enginetest.go index bae3a6b38..3979748c0 100644 --- a/languages/golang/stashgen/enginetest/enginetest.go +++ b/languages/golang/stashgen/enginetest/enginetest.go @@ -105,14 +105,15 @@ func (e Static) Check(ctx context.Context, d stashgen.Declaration) error { } // indexApplies mirrors dynamic::admits in stack-encrypt: equality over -// integers, text and bytes (no floats, no booleans: they have no PRF -// encoding), match over text alone, ORE and OPE over every scalar. The JSON -// index is not in the engine yet. +// integers, floats, text and bytes (the kinds vitaminc gives an equality +// domain; a boolean has none), match over text alone, ORE and OPE over +// every scalar but a boolean, whose order term would reveal which rows are +// true. The JSON index is not in the engine yet. func indexApplies(name stashgen.IndexName, t stashgen.GoType) error { switch name { case stashgen.IndexEquality: switch t.Kind { - case stashgen.KindString, stashgen.KindBytes, stashgen.KindInt, stashgen.KindUint: + case stashgen.KindString, stashgen.KindBytes, stashgen.KindInt, stashgen.KindUint, stashgen.KindFloat: return nil } case stashgen.IndexMatch: @@ -121,7 +122,7 @@ func indexApplies(name stashgen.IndexName, t stashgen.GoType) error { } return fmt.Errorf("match applies to a string, not to %s", t) case stashgen.IndexOre, stashgen.IndexOpe: - if t.Kind.Scalar() { + if t.Kind.Scalar() && t.Kind != stashgen.KindBool { return nil } case stashgen.IndexJSON: diff --git a/languages/golang/stashgen/refusal_test.go b/languages/golang/stashgen/refusal_test.go index 4e44f7ebb..77eaecf71 100644 --- a/languages/golang/stashgen/refusal_test.go +++ b/languages/golang/stashgen/refusal_test.go @@ -267,8 +267,9 @@ func TestRefusalsHoldAgainstTheEmbeddedEngine(t *testing.T) { {"equality on a map", "Attrs", "\tAttrs map[string]string `stash:\"attrs,encrypt,index=equality\"`", "seals only as part of an opaque struct"}, {"a sealed []string", "Tags", "\tTags []string `stash:\"tags,encrypt\"`", "seals only as part of an opaque struct"}, {"a sealed map[string]int64", "Counts", "\tCounts map[string]int64 `stash:\"counts,encrypt\"`", "seals only as part of an opaque struct"}, - {"equality on a float", "Score", "\tScore float64 `stash:\"score,encrypt,index=equality\"`", "the engine refuses the declaration"}, {"equality on a bool", "Done", "\tDone bool `stash:\"done,encrypt,index=equality\"`", "the engine refuses the declaration"}, + {"order on a bool", "Done", "\tDone bool `stash:\"done,encrypt,index=ore\"`", "the engine refuses the declaration"}, + {"ope on a bool", "Done", "\tDone bool `stash:\"done,encrypt,index=ope\"`", "the engine refuses the declaration"}, {"match on an integer", "Age", "\tAge int32 `stash:\"age,encrypt,index=match\"`", "the engine refuses the declaration"}, {"encrypt_into, no EQL type in this build", "Email", "\tEmail string `stash:\"email,encrypt_into=TextEq\"`", "EQL types are not available yet"}, } { @@ -286,6 +287,8 @@ func TestRefusalsHoldAgainstTheEmbeddedEngine(t *testing.T) { } // What the fake refuses, the engine refuses: the same cases against both. for _, c := range []struct{ name, line string }{ + {"equality on a bool", "\tDone bool `stash:\"done,encrypt,index=equality\"`"}, + {"order on a bool", "\tDone bool `stash:\"done,encrypt,index=ore\"`"}, {"equality on a float", "\tScore float64 `stash:\"score,encrypt,index=equality\"`"}, {"a declaration the engine runs", "\tAge uint32 `stash:\"age,encrypt,index=equality;ore\"`\n\tName string `stash:\"name,encrypt,index=match\"`"}, } { From c3a3ed9dec6e9b9c088b20c251beafd458324fb2 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 16 Oct 2026 02:28:32 +1100 Subject: [PATCH 4/9] feat(eql)!: compare TextEq text in Unicode 16 NFC through Stack Encrypt's normalized equality term Hmac256 encrypts from any Plaintext, eql-bindings no longer depends on vitaminc-prf, the EQL workspace patches vitaminc-normalize, and the TextEqQuery fixture the Go SDK reads is regenerated. Part of #1140 --- .changeset/eql-text-equality-transcoding.md | 2 +- languages/golang/encrypt/guest/Cargo.lock | 1 - packages/eql/Cargo.lock | 1 - packages/eql/crates/eql-bindings/CHANGELOG.md | 9 +++++++ packages/eql/crates/eql-bindings/Cargo.toml | 6 ----- .../eql/crates/eql-bindings/src/encryption.rs | 9 ++++--- .../encryption/fixtures/text_eq_query.json | 2 +- .../eql/tests/encryption/tests/text_eq.rs | 27 ++++++++++++++----- 8 files changed, 36 insertions(+), 21 deletions(-) diff --git a/.changeset/eql-text-equality-transcoding.md b/.changeset/eql-text-equality-transcoding.md index cf5651050..8c6d84db6 100644 --- a/.changeset/eql-text-equality-transcoding.md +++ b/.changeset/eql-text-equality-transcoding.md @@ -2,4 +2,4 @@ '@cipherstash/eql': minor --- -**Rust `eql_bindings::v4::text::{TextEq, TextEqQuery}` support Stack Encrypt's target-directed API behind the `stack-encrypt` feature.** Stack Encrypt writes EQL v4 only, so the `eql_v3` types carry no Stack Encrypt traits. The stored table and column identifier supplies the encryption context; native ciphertext and equality terms are transcoded into EQL payloads while Vitamin C owns plaintext encoding. This is a new producer profile with exact string equality, independent of existing cipherstash-client ciphertext and terms. Query operands carry no recoverable ciphertext. +**Rust `eql_bindings::v4::text::{TextEq, TextEqQuery}` support Stack Encrypt's target-directed API behind the `stack-encrypt` feature.** Stack Encrypt writes EQL v4 only, so the `eql_v3` types carry no Stack Encrypt traits. The stored table and column identifier supplies the encryption context; native ciphertext and equality terms are transcoded into EQL payloads while Vitamin C owns plaintext encoding. This is a new producer profile whose string equality compares text in Unicode 16 NFC without folding case, independent of existing cipherstash-client ciphertext and terms. Query operands carry no recoverable ciphertext. diff --git a/languages/golang/encrypt/guest/Cargo.lock b/languages/golang/encrypt/guest/Cargo.lock index bd5d65953..78fa33500 100644 --- a/languages/golang/encrypt/guest/Cargo.lock +++ b/languages/golang/encrypt/guest/Cargo.lock @@ -710,7 +710,6 @@ dependencies = [ "thiserror 2.0.20", "ts-rs", "vitaminc-aead 0.6.0", - "vitaminc-prf", "zeroize", ] diff --git a/packages/eql/Cargo.lock b/packages/eql/Cargo.lock index 25ee1d1f9..34ebc4188 100644 --- a/packages/eql/Cargo.lock +++ b/packages/eql/Cargo.lock @@ -1205,7 +1205,6 @@ dependencies = [ "thiserror 2.0.20", "ts-rs", "vitaminc-aead 0.6.0", - "vitaminc-prf", "zeroize", ] diff --git a/packages/eql/crates/eql-bindings/CHANGELOG.md b/packages/eql/crates/eql-bindings/CHANGELOG.md index 61cbda704..23561c0e6 100644 --- a/packages/eql/crates/eql-bindings/CHANGELOG.md +++ b/packages/eql/crates/eql-bindings/CHANGELOG.md @@ -35,6 +35,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **`TextEq` and `TextEqQuery` compare text in Unicode 16 NFC** + (`stack-encrypt` feature). Their `hm` is Stack Encrypt's equality term, + which now derives through `vitaminc-normalize`: `café` written with a + precomposed `é` and with `e` and a combining accent is one value, and + text holding a code point unassigned in Unicode 16 is refused. Case and + whitespace are kept. Every `hm` changes bytes; no stored value existed to + keep. `Hmac256` encrypts from any `Plaintext` where it took a + `PrfValue`, and the feature no longer depends on `vitaminc-prf`. + - **The `stack-encrypt` feature builds on vitaminc ADR 0001.** `encryption::targets` takes `FfiValue` and `ValueKind` from `stack_encrypt::dynamic` (the feature turns on stack-encrypt's `dynamic` diff --git a/packages/eql/crates/eql-bindings/Cargo.toml b/packages/eql/crates/eql-bindings/Cargo.toml index 87731ac19..90abbd35f 100644 --- a/packages/eql/crates/eql-bindings/Cargo.toml +++ b/packages/eql/crates/eql-bindings/Cargo.toml @@ -47,11 +47,6 @@ schemars = "1" # target takes and the kind names a plan's `"type"` key spells # (`encryption::targets`). stack-encrypt = { path = "../../../stack-encrypt", version = "0.3.0", optional = true, default-features = false, features = ["dynamic"] } -# The vitaminc line stack-encrypt is built on. `PrfValue` is named from this -# crate directly (stack-encrypt re-exports only `IntoPrfContext` and -# `PrfContext`), and two vitaminc lines in one graph do not interoperate, so -# the requirement is stack-encrypt's. -vitaminc-prf = { version = "0.6.0", optional = true } # `Physical`, `Int`, `Float` and `FromScalar`: the scalar plaintexts in # `encryption::plaintext` describe themselves through vitaminc's `Encrypt` # and read back only their own width through `FromScalar`. @@ -67,7 +62,6 @@ hex = { version = "0.4", optional = true } default = [] stack-encrypt = [ "dep:stack-encrypt", - "dep:vitaminc-prf", "dep:vitaminc-aead", "dep:zeroize", "dep:thiserror", diff --git a/packages/eql/crates/eql-bindings/src/encryption.rs b/packages/eql/crates/eql-bindings/src/encryption.rs index 2f4c64673..c8f6a3e07 100644 --- a/packages/eql/crates/eql-bindings/src/encryption.rs +++ b/packages/eql/crates/eql-bindings/src/encryption.rs @@ -2,8 +2,10 @@ //! //! The target declares its operations; Stack Encrypt invokes Vitamin C's //! plaintext implementations. These adapters only encode encrypted outputs. -//! Equality uses Vitamin C's exact string semantics: no normalization or case -//! folding. The stored identifier supplies context for ciphertext and terms. +//! Equality compares text in Unicode 16 NFC, the normal form +//! `vitaminc-normalize` gives it, and folds no case: `café` written with a +//! precomposed `é` and with `e` and a combining accent is one value, `Café` +//! another. The stored identifier supplies context for ciphertext and terms. //! //! This is a new producer profile, independent of existing cipherstash-client //! ciphertext and terms, so it writes the `eql_v4` domains ([`crate::v4`]), @@ -92,7 +94,6 @@ use stack_encrypt::{ Describe, Description, Encrypt, EncryptFrom, Encryption, Error, IntoContext, MaybeEmpty, NonEmpty, SealedValue, StackCipherText, }; -use vitaminc_prf::PrfValue; use crate::{ v3::terms::{BloomFilter, Ciphertext, Hmac256}, @@ -236,7 +237,7 @@ impl Transcode for Hmac256 { EqualityVisitor } } -impl EncryptFrom for Hmac256 { +impl EncryptFrom for Hmac256 { type Context = CallerContext; fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> where diff --git a/packages/eql/tests/encryption/fixtures/text_eq_query.json b/packages/eql/tests/encryption/fixtures/text_eq_query.json index f8ac16514..150a9d1e6 100644 --- a/packages/eql/tests/encryption/fixtures/text_eq_query.json +++ b/packages/eql/tests/encryption/fixtures/text_eq_query.json @@ -2,6 +2,6 @@ "_comment": "The TextEqQuery eql-bindings derives for the plaintext under FakeDataKeySource's index key (keyset nil), read by the Go SDK's hermetic test; regenerate with EQL_UPDATE_FIXTURES=1, do not edit.", "column": "email", "plaintext": "bob@example.com", - "query": "{\"v\":4,\"i\":{\"t\":\"users\",\"c\":\"email\"},\"hm\":\"ec534cfbf2336785378c3761ff88623c46361b2ba7c5fa83930ebacae49cf646\"}", + "query": "{\"v\":4,\"i\":{\"t\":\"users\",\"c\":\"email\"},\"hm\":\"0cf708c07acaaa169e84e4adf7081f0433d87760bd7e9c63bd7a58c000b8d33b\"}", "table": "users" } diff --git a/packages/eql/tests/encryption/tests/text_eq.rs b/packages/eql/tests/encryption/tests/text_eq.rs index 2c275c93f..9e77000b6 100644 --- a/packages/eql/tests/encryption/tests/text_eq.rs +++ b/packages/eql/tests/encryption/tests/text_eq.rs @@ -121,13 +121,26 @@ async fn independent_queries_match_native_terms_without_requesting_data_keys() { let json = serde_json::to_value(&query).unwrap(); assert!(json.get("c").is_none()); assert_eq!(serde_json::from_value::(json).unwrap(), query); - for different in ["CAFÉ", "cafe\u{301}", "café "] { - let probe: TextEqQuery = reader - .default_keyset() - .encrypt_as(&different.to_owned(), column()) - .await - .unwrap(); - assert_ne!(query.hm, probe.hm, "no added normalization or case folding"); + let probe = |text: &str| { + let text = text.to_owned(); + let keyset = reader.default_keyset(); + async move { + let probe: TextEqQuery = keyset.encrypt_as(&text, column()).await.unwrap(); + probe.hm + } + }; + // Unicode 16 NFC: the same text in its other normal form is one value. + assert_eq!( + probe("cafe\u{301}").await, + probe("caf\u{e9}").await, + "text is compared in NFC" + ); + for different in ["CAFÉ", "café "] { + assert_ne!( + query.hm, + probe(different).await, + "no case folding or trimming" + ); } let calls = calls.lock().unwrap(); assert!(calls.generate.is_empty()); From 25a0baf849dac053e727e9d69dbe6bf4b9abfcea Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 16 Oct 2026 23:55:30 +1100 Subject: [PATCH 5/9] test(eql): re-pin the v4 match fixtures under the normalized match terms, and follow the Encrypt rename Rebased onto next, which carries the EQL v4 types and their fixtures. This PR moves match tokens into vitaminc-normalize's form and the match terms to their /v2 domain, so every match filter changes bytes: the pinned `alice` positions in v4_types.rs, the query operand in v4_queries.json and the stored filters in v4_rows.json are regenerated here (the Rust fixture with EQL_UPDATE_FIXTURES=1, the rows by the Go test over the deterministic EQL guest). No eql_v4 filter has been released, so re-pinning costs nothing. The doc links this PR adds name vitaminc's source trait by its new name, `Encrypt`, and the trybuild expectation follows it. --- .../tests/encryption/fixtures/v4_queries.json | 2 +- .../tests/encryption/fixtures/v4_rows.json | 144 +++++++++--------- .../eql/tests/encryption/tests/v4_types.rs | 3 +- packages/stack-encrypt/src/dynamic/term.rs | 2 +- packages/stack-encrypt/src/sem/mod.rs | 2 +- .../tests/ui/op_target_does_not_answer.stderr | 4 +- .../tests/ui/owned_fan_out_needs_clone.stderr | 2 +- 7 files changed, 78 insertions(+), 81 deletions(-) diff --git a/packages/eql/tests/encryption/fixtures/v4_queries.json b/packages/eql/tests/encryption/fixtures/v4_queries.json index 4e1ce409f..5982c4622 100644 --- a/packages/eql/tests/encryption/fixtures/v4_queries.json +++ b/packages/eql/tests/encryption/fixtures/v4_queries.json @@ -2,7 +2,7 @@ "_comment": "The v4 query operands eql-bindings derives for each plaintext under FakeDataKeySource's index key (keyset nil), read by the Go SDK's hermetic test; regenerate with EQL_UPDATE_FIXTURES=1, do not edit.", "name": { "plaintext": "alice", - "query": "{\"v\":4,\"i\":{\"t\":\"profiles\",\"c\":\"name\"},\"bf\":[59,96,167,214,554,603,889,1061,1127,1148,1467,1479,1574,1675,1778,1781,1925,2008]}" + "query": "{\"v\":4,\"i\":{\"t\":\"profiles\",\"c\":\"name\"},\"bf\":[80,125,212,221,348,638,664,776,1141,1322,1400,1418,1532,1704,1762,1843,1865,1970]}" }, "table": "profiles" } diff --git a/packages/eql/tests/encryption/fixtures/v4_rows.json b/packages/eql/tests/encryption/fixtures/v4_rows.json index 932bc7da6..2da04aaf9 100644 --- a/packages/eql/tests/encryption/fixtures/v4_rows.json +++ b/packages/eql/tests/encryption/fixtures/v4_rows.json @@ -21,7 +21,7 @@ "t": "profiles", "c": "active" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAA7z71FqWkCCWSkyPiBK7IQIAD0ug/nDu5R6UBEM9vJjtvDMBTuommRH359HgY7qUe5cAKqQ9Rw3pcUd7BfnKYrOhzckWm8HdRMHxyVzd9RFw==" + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAA7z71FqWkCCWSkyPiBK7IQIAD0ug/nDu5R6UBEM9vJjtvDMBTuommRH359HgY7qUe5cAKm2WZaiFnjLqEGLDlJhvOJ4N4uQBzfyMNiLROxWw==" }, "balance": { "v": 4, @@ -29,7 +29,7 @@ "t": "profiles", "c": "balance" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAAZJDEBcfVqv/o1B2CASJrAIAA9a/qD4wfv0t6fGVxDhKQOlQDIuYj5si13wnlmGENABAL2IJzA54Z5ngSRGOJOvqJHMZMxYFECpqo2ctbI2/lFvH4dB2UG" + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAAZJDEBcfVqv/o1B2CASJrAIAA9a/qD4wfv0t6fGVxDhKQOlQDIuYj5si13wnlmGENABAJ9hNn4JVUPAf0Oqgp+srYrqieBVnldyN2orjNNIl/8coQzlPV7" }, "name": { "v": 4, @@ -37,26 +37,26 @@ "t": "profiles", "c": "name" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAASxNA10KeHfMqSIglat8/GIADzC2+uswJkz9HL6DqUBBNDtGODBX5yNHLKryZ0Lgc24AI7p9YY7R27PBmPccknBNdQVQ5bN3ZoYucccbZKDKHtZ5Oi", + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAASxNA10KeHfMqSIglat8/GIADzC2+uswJkz9HL6DqUBBNDtGODBX5yNHLKryZ0Lgc24AJL13hLIs56eETD0CVxajCIWD1YDvt/qQ96PAk6BQX1+wqM", "bf": [ - 59, - 96, - 167, - 214, - 554, - 603, - 889, - 1061, - 1127, - 1148, - 1467, - 1479, - 1574, - 1675, - 1778, - 1781, - 1925, - 2008 + 80, + 125, + 212, + 221, + 348, + 638, + 664, + 776, + 1141, + 1322, + 1400, + 1418, + 1532, + 1704, + 1762, + 1843, + 1865, + 1970 ] }, "note": { @@ -65,7 +65,7 @@ "t": "profiles", "c": "note" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAACaWtPMkbUZL3WUSHDD8aD7IADB6CfVElii+gglc1gys6JmpNlLoIFn47AvStPnpJb63wKnKP6D8GlSml9xks+a/2m67Xf/hmkdJoIjSiHatSvocY0rmNYwOK7y8D/1" + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAACaWtPMkbUZL3WUSHDD8aD7IADB6CfVElii+gglc1gys6JmpNlLoIFn47AvStPnpJb63wK/3W5csrSgiLUwUES8hwXQnhc0UhgyZv43mkk3tmtP0bJps5Y8bFvyxaLs" }, "ratio": { "v": 4, @@ -73,7 +73,7 @@ "t": "profiles", "c": "ratio" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAAuHszuxtkGGxdJ+yeeEgrtIACJ8iRTZnq+2wD3LmxdAqmDDHzDzzH5th5lG8EQq4qujQLHE5wCg6qAj8/uMYEVG5qF6L5gXLr5QaZstV9YZc45ZOQ=" + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAAuHszuxtkGGxdJ+yeeEgrtIACJ8iRTZnq+2wD3LmxdAqmDDHzDzzH5th5lG8EQq4qujQI0F4mjsLKrWLTCn4cnL+j9RJGWZUye1KEUEgFLy2w7jbg=" }, "score": { "v": 4, @@ -81,7 +81,7 @@ "t": "profiles", "c": "score" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAAbQulFdQ7itXP4biDOHDhxIAA4jkCscZEzyZymEuOdqFk34IZ2lMPnDiYFZgGjl+Qh0gLxDHYdX6NYqmC8h8famVOyZaf25m+nnYQN9DUOpfNIY1VytlWH" + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAAbQulFdQ7itXP4biDOHDhxIAA4jkCscZEzyZymEuOdqFk34IZ2lMPnDiYFZgGjl+Qh0gKQ8PLCnxbnz0u/MiTy1keIajXDNTMgKHZwKVDhzp2lyFdkxNyG" }, "visits": { "v": 4, @@ -89,7 +89,7 @@ "t": "profiles", "c": "visits" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAAFDIc6s4iXDM+dzEA4ntTLIACXLoQBRqkNqM7yC8syykzCq7A4M32xA4/pWvh7C8atkgLCJ46LW8E8GmjsBQyX5huv9Uu+kXR6u0IAiilhVTd0WUE=" + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAAFDIc6s4iXDM+dzEA4ntTLIACXLoQBRqkNqM7yC8syykzCq7A4M32xA4/pWvh7C8atkgJnOr7F0ixiqCPrPH8Fr+u40q0M0yY0SAGJAzRZL9RtKmk=" } } }, @@ -111,7 +111,7 @@ "t": "profiles", "c": "active" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAABGXg/rWzFqWgcCw0KVmlV7IADK5ojyhDsQsCNphHB+3xjIrvP/87lbuOrEITfr0CumXgLUiEl5E+cspiz1C60uZ+fJwoQTdc+Jp+a5wakFmw==" + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAABGXg/rWzFqWgcCw0KVmlV7IADK5ojyhDsQsCNphHB+3xjIrvP/87lbuOrEITfr0CumXgKas9j1WjeleoZNXxXCpQl5zTEbpuhNm3jz432uwQ==" }, "balance": { "v": 4, @@ -119,7 +119,7 @@ "t": "profiles", "c": "balance" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAACHOuVBG9X5O4D9ApUC1DpIADAVYvyCPRFFVrNaWA5vGxA5PF75NkyEagzyfXSd1UIKwLmqZbAOf4jZccVcQQuUwb7UNTUV2ss2ui6ssAwQzw+z8g0PEF4" + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAACHOuVBG9X5O4D9ApUC1DpIADAVYvyCPRFFVrNaWA5vGxA5PF75NkyEagzyfXSd1UIKwKPUkm2pn+4U7GMsAIcIoIwhG1aIpGmcAIvUtLfYiVA74iL35fU" }, "name": { "v": 4, @@ -127,50 +127,48 @@ "t": "profiles", "c": "name" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAAlsDmtaCz7PVYXLYbtkdYiIADMhjWHFWFQffW6FoF5Kd0/vSO5UX+T+vZyisdueEEtugJ8AJTL+FsTQv84umI+UgkWWr5miQkExs7tdXZQZb8WjeymUZN0aw==", + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAAAlsDmtaCz7PVYXLYbtkdYiIADMhjWHFWFQffW6FoF5Kd0/vSO5UX+T+vZyisdueEEtugLKO7i9XY98Meyx+sRY3ODPp1DEZ2mua3DQV27QBeWr2rj1P3qmzA==", "bf": [ - 3, - 15, - 23, - 50, - 91, - 140, - 162, - 308, - 382, - 441, - 455, - 505, - 532, - 575, - 760, - 770, - 863, - 957, - 979, - 1083, - 1109, - 1126, - 1166, - 1187, - 1196, - 1252, - 1266, - 1293, - 1328, - 1331, - 1337, - 1344, - 1364, + 59, + 178, + 215, + 219, + 333, + 410, + 429, + 431, + 584, + 596, + 623, + 633, + 653, + 801, + 812, + 866, + 897, + 1140, + 1143, + 1217, + 1221, + 1222, + 1240, + 1284, + 1314, + 1329, 1371, - 1426, - 1702, - 1730, - 1738, - 1881, - 1890, - 1925, - 1981 + 1497, + 1563, + 1586, + 1595, + 1680, + 1712, + 1735, + 1808, + 1837, + 1884, + 1933, + 1955, + 2036 ] }, "note": { @@ -179,7 +177,7 @@ "t": "profiles", "c": "note" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAABtkZKQt8AupdgAkSYhBWuLIABushFe05tB4gQmcP9j+7VPX95cAW65PgvtgFajKobc6gK+nIJANyuGOdzfonrK9/ZuZwh0i7+r+umozYODDw==" + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAABtkZKQt8AupdgAkSYhBWuLIABushFe05tB4gQmcP9j+7VPX95cAW65PgvtgFajKobc6gJRmGKJ3XDJKTAOVvT7vrNPR4suqu/qGHnVB23ZOg==" }, "ratio": { "v": 4, @@ -187,7 +185,7 @@ "t": "profiles", "c": "ratio" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAADJpOTT8kFqPHwznhWnYNgQIAC3GPCNDOMGBnh6gPSa5KTDr+VBQDNZAZr7kqKF16sx0QLztGogKoty9XImWxjaNUYSF8LDSdG148wg16xStACiBvw=" + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAADJpOTT8kFqPHwznhWnYNgQIAC3GPCNDOMGBnh6gPSa5KTDr+VBQDNZAZr7kqKF16sx0QKivm/zonrrAnhWeznFBi6gEq+aGsOWkmzf0JjTPjdfykA=" }, "score": { "v": 4, @@ -195,7 +193,7 @@ "t": "profiles", "c": "score" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAABAfBAgu3xTGBg6oVcxVdCnIADQvBhh5sMdxmdosyrWjFQngkmQ+312sbVAFpgs2UBpcQLccQgtBqswM85HQZDzsPtZO6xgsqyDMtKBRKqHCzmWAvYCoF9W" + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAABAfBAgu3xTGBg6oVcxVdCnIADQvBhh5sMdxmdosyrWjFQngkmQ+312sbVAFpgs2UBpcQJExgoNvIsa/YdKXID8Dglp2fw7W87SIT5c/Lomaou5MY0ldsFn" }, "visits": { "v": 4, @@ -203,7 +201,7 @@ "t": "profiles", "c": "visits" }, - "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAABi/myUaRsBCFbuVTRBJLPiIADSnhrChMGg0gQaL8/2ghirKHXXC41P2aq5DghRI2FtWgI+v9AtiXTz9LFxGu3KrmHaGwRcO9zEFfxpsWitt/WZob8=" + "c": "stack-encrypt:1:AQAAAAAAAAAAAAAAAAAAAABi/myUaRsBCFbuVTRBJLPiIADSnhrChMGg0gQaL8/2ghirKHXXC41P2aq5DghRI2FtWgLiTbX8qj/N69eB9aNPN6A0ilO2zy62YAxUlx97NnS+OMw=" } } } diff --git a/packages/eql/tests/encryption/tests/v4_types.rs b/packages/eql/tests/encryption/tests/v4_types.rs index 70ad17053..c4acd9368 100644 --- a/packages/eql/tests/encryption/tests/v4_types.rs +++ b/packages/eql/tests/encryption/tests/v4_types.rs @@ -449,8 +449,7 @@ async fn text_match_filter_positions_are_pinned() { } const PINNED_ALICE: [i16; 18] = [ - 52, 99, 224, 311, 899, 1190, 1228, 1232, 1290, 1378, 1486, 1505, 1529, 1613, 1763, 1848, 1880, - 2027, + 9, 203, 351, 542, 626, 697, 748, 872, 917, 1023, 1062, 1190, 1251, 1355, 1613, 1710, 1792, 2025, ]; #[tokio::test] diff --git a/packages/stack-encrypt/src/dynamic/term.rs b/packages/stack-encrypt/src/dynamic/term.rs index 65dc6e20f..cca3e461e 100644 --- a/packages/stack-encrypt/src/dynamic/term.rs +++ b/packages/stack-encrypt/src/dynamic/term.rs @@ -1,7 +1,7 @@ //! One index term for a runtime value. //! //! A value arrives here as anything that describes itself to vitaminc -//! ([`Plaintext`]): a transport frame a guest walks in place, an +//! ([`Encrypt`]): a transport frame a guest walks in place, an //! [`FfiValue`] a binding decoded, a Rust value. It is walked to the one //! scalar it must be, and the term derives there, from the scalar vitaminc //! hands the sink, so this module restates none of vitaminc's vocabulary. diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 9f529e448..9d9800846 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -471,7 +471,7 @@ fn normalized(value: T) -> Result { } } -/// An equality term of any [`Plaintext`] source that is one scalar with an +/// An equality term of any [`Encrypt`] source that is one scalar with an /// equality domain (see [`equality`]). Under the local HMAC backend, /// derived during the synchronous build — the returned [`Pending`] carries /// no requests. diff --git a/packages/stack-encrypt/tests/ui/op_target_does_not_answer.stderr b/packages/stack-encrypt/tests/ui/op_target_does_not_answer.stderr index 3a9ac194a..81b87a000 100644 --- a/packages/stack-encrypt/tests/ui/op_target_does_not_answer.stderr +++ b/packages/stack-encrypt/tests/ui/op_target_does_not_answer.stderr @@ -9,7 +9,7 @@ help: the trait `Answers` is no but trait `Answers` is implemented for it --> src/op/mod.rs | - | impl Answers for EqualityTerm { - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | impl Answers for EqualityTerm { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ = help: for that trait implementation, expected `stack_encrypt::op::Eq`, found `stack_encrypt::op::Matches` = note: required for `ValuePlan>` to implement `Chooses` diff --git a/packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.stderr b/packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.stderr index ac961ab71..0d16f0b94 100644 --- a/packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.stderr +++ b/packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.stderr @@ -16,4 +16,4 @@ note: required by a bound in `Encryption::<'s, S, T, K, Ctx, M>::zip` help: consider further restricting type parameter `S` with trait `Clone` | 9 | fn seal_and_index( - | +++++++++++++++++++ + | +++++++++++++++++++ From 6fcf900a0bccc6cca2fccd901653923d0c1dc5d9 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 17 Oct 2026 00:30:34 +1100 Subject: [PATCH 6/9] fix(stack-encrypt): refuse a boolean's order term on the typed path too, and say what moved to runtime Review follow-ups. A boolean had no order term only through `dynamic`: cllw-ore orders a `bool`, so `KeysetCipher::ore_term(true, ..)`, the `Ore` and `Ope` indexes and a plan `with::(Ore)` still stored a term that splits a column into its true and false rows. ORE and OPE derivation now refuses a `bool` with the new `TermError::NoOrderTerm` (`stack_encrypt::no_order_term`), before any key is minted, on every typed path. Equality on a `bool`, which used to fail to compile, now satisfies the `Encrypt` bound and fails with `NoEqualityTerm` when the term is derived; a test pins both, and the CHANGELOG says so. The CHANGELOGs and the `@cipherstash/eql` changeset now say that text with a code point unassigned in Unicode 16 is refused when it is written to an equality or match column, not only when it is queried. The EQL catalog's reasons for `IntegerEq`, `BigintEq`, `RealEq` and `DoubleEq` said vitaminc derives no float equality term and per-width integer terms, which the engine no longer does; they now name the gate, vitaminc 0.6's release, and the tables, the Go `eql_gen.go` and the eql-bindings README and module docs are regenerated or reworded to match. Two doc lines still called vitaminc's source trait `Plaintext`. --- .changeset/eql-text-equality-transcoding.md | 2 +- languages/golang/encrypt/eql/eql_gen.go | 16 +++--- packages/eql/crates/eql-bindings/CHANGELOG.md | 5 +- packages/eql/crates/eql-bindings/README.md | 24 ++++---- .../eql-bindings/src/encryption/plaintext.rs | 8 +-- .../eql/crates/eql-bindings/src/v4/targets.rs | 8 +-- .../eql/crates/eql-codegen/src/bindings.rs | 14 ++--- .../eql/crates/eql-codegen/src/targets.rs | 6 +- .../eql/tests/encryption/tests/targets.rs | 4 +- .../eql/tests/encryption/tests/v4_types.rs | 2 +- packages/stack-encrypt/CHANGELOG.md | 21 ++++--- packages/stack-encrypt/src/codes.rs | 2 + packages/stack-encrypt/src/sem/mod.rs | 23 ++++++++ .../stack-encrypt/src/target/operations.rs | 2 +- packages/stack-encrypt/tests/index.rs | 57 +++++++++++++++++++ 15 files changed, 143 insertions(+), 51 deletions(-) diff --git a/.changeset/eql-text-equality-transcoding.md b/.changeset/eql-text-equality-transcoding.md index 8c6d84db6..e3aae722e 100644 --- a/.changeset/eql-text-equality-transcoding.md +++ b/.changeset/eql-text-equality-transcoding.md @@ -2,4 +2,4 @@ '@cipherstash/eql': minor --- -**Rust `eql_bindings::v4::text::{TextEq, TextEqQuery}` support Stack Encrypt's target-directed API behind the `stack-encrypt` feature.** Stack Encrypt writes EQL v4 only, so the `eql_v3` types carry no Stack Encrypt traits. The stored table and column identifier supplies the encryption context; native ciphertext and equality terms are transcoded into EQL payloads while Vitamin C owns plaintext encoding. This is a new producer profile whose string equality compares text in Unicode 16 NFC without folding case, independent of existing cipherstash-client ciphertext and terms. Query operands carry no recoverable ciphertext. +**Rust `eql_bindings::v4::text::{TextEq, TextEqQuery}` support Stack Encrypt's target-directed API behind the `stack-encrypt` feature.** Stack Encrypt writes EQL v4 only, so the `eql_v3` types carry no Stack Encrypt traits. The stored table and column identifier supplies the encryption context; native ciphertext and equality terms are transcoded into EQL payloads while Vitamin C owns plaintext encoding. This is a new producer profile whose string equality compares text in Unicode 16 NFC without folding case, independent of existing cipherstash-client ciphertext and terms. Text holding a code point unassigned in Unicode 16 is refused, when it is stored as well as when it is queried. Query operands carry no recoverable ciphertext. diff --git a/languages/golang/encrypt/eql/eql_gen.go b/languages/golang/encrypt/eql/eql_gen.go index 77eee6fe3..3b329782b 100644 --- a/languages/golang/encrypt/eql/eql_gen.go +++ b/languages/golang/encrypt/eql/eql_gen.go @@ -10,7 +10,7 @@ import "database/sql/driver" // it does not, with the Reason. var Types = []Type{ {Name: "Integer", GoName: "Integer", Family: "integer", Suffix: "", Plaintext: "int32", SQLDomain: "public.eql_v4_integer", Indexes: nil, Query: "", QuerySQLDomain: "", Producible: true, Reason: ""}, - {Name: "IntegerEq", GoName: "IntegerEq", Family: "integer", Suffix: "Eq", Plaintext: "int32", SQLDomain: "public.eql_v4_integer_eq", Indexes: []string{"eq"}, Query: "IntegerEqQuery", QuerySQLDomain: "eql_v4.query_integer_eq", Producible: false, Reason: "the integer equality term moves to vitaminc 0.6's shared integer-orderable/v1 PRF domain; producing it before that release would leave stored terms unmatchable after the upgrade"}, + {Name: "IntegerEq", GoName: "IntegerEq", Family: "integer", Suffix: "Eq", Plaintext: "int32", SQLDomain: "public.eql_v4_integer_eq", Indexes: []string{"eq"}, Query: "IntegerEqQuery", QuerySQLDomain: "eql_v4.query_integer_eq", Producible: false, Reason: "integer equality is produced once vitaminc 0.6 is released: its one integer domain is the one the engine derives in, and a term derived under vitaminc 0.5's per-width domains would stop matching after the upgrade"}, {Name: "IntegerOrdOre", GoName: "IntegerOrdOre", Family: "integer", Suffix: "OrdOre", Plaintext: "int32", SQLDomain: "public.eql_v4_integer_ord_ore", Indexes: []string{"ore"}, Query: "IntegerOrdOreQuery", QuerySQLDomain: "eql_v4.query_integer_ord_ore", Producible: false, Reason: "ordering terms wait for vitaminc-ore, the order encodings in vitaminc 0.6 (ADR-0002)"}, {Name: "IntegerOrd", GoName: "IntegerOrd", Family: "integer", Suffix: "Ord", Plaintext: "int32", SQLDomain: "public.eql_v4_integer_ord", Indexes: []string{"ope"}, Query: "IntegerOrdQuery", QuerySQLDomain: "eql_v4.query_integer_ord", Producible: false, Reason: "ordering terms wait for vitaminc-ore, the order encodings in vitaminc 0.6 (ADR-0002)"}, {Name: "IntegerOrdOpe", GoName: "IntegerOrdOpe", Family: "integer", Suffix: "OrdOpe", Plaintext: "int32", SQLDomain: "public.eql_v4_integer_ord_ope", Indexes: []string{"ope"}, Query: "IntegerOrdOpeQuery", QuerySQLDomain: "eql_v4.query_integer_ord_ope", Producible: false, Reason: "ordering terms wait for vitaminc-ore, the order encodings in vitaminc 0.6 (ADR-0002)"}, @@ -20,7 +20,7 @@ var Types = []Type{ {Name: "SmallintOrd", GoName: "SmallintOrd", Family: "smallint", Suffix: "Ord", Plaintext: "", SQLDomain: "public.eql_v4_smallint_ord", Indexes: []string{"ope"}, Query: "SmallintOrdQuery", QuerySQLDomain: "eql_v4.query_smallint_ord", Producible: false, Reason: "vitaminc 0.5's value enum has no kind for this family; the kind and its encodings arrive in vitaminc 0.6 (ADR-0002)"}, {Name: "SmallintOrdOpe", GoName: "SmallintOrdOpe", Family: "smallint", Suffix: "OrdOpe", Plaintext: "", SQLDomain: "public.eql_v4_smallint_ord_ope", Indexes: []string{"ope"}, Query: "SmallintOrdOpeQuery", QuerySQLDomain: "eql_v4.query_smallint_ord_ope", Producible: false, Reason: "vitaminc 0.5's value enum has no kind for this family; the kind and its encodings arrive in vitaminc 0.6 (ADR-0002)"}, {Name: "Bigint", GoName: "Bigint", Family: "bigint", Suffix: "", Plaintext: "int64", SQLDomain: "public.eql_v4_bigint", Indexes: nil, Query: "", QuerySQLDomain: "", Producible: true, Reason: ""}, - {Name: "BigintEq", GoName: "BigintEq", Family: "bigint", Suffix: "Eq", Plaintext: "int64", SQLDomain: "public.eql_v4_bigint_eq", Indexes: []string{"eq"}, Query: "BigintEqQuery", QuerySQLDomain: "eql_v4.query_bigint_eq", Producible: false, Reason: "the integer equality term moves to vitaminc 0.6's shared integer-orderable/v1 PRF domain; producing it before that release would leave stored terms unmatchable after the upgrade"}, + {Name: "BigintEq", GoName: "BigintEq", Family: "bigint", Suffix: "Eq", Plaintext: "int64", SQLDomain: "public.eql_v4_bigint_eq", Indexes: []string{"eq"}, Query: "BigintEqQuery", QuerySQLDomain: "eql_v4.query_bigint_eq", Producible: false, Reason: "integer equality is produced once vitaminc 0.6 is released: its one integer domain is the one the engine derives in, and a term derived under vitaminc 0.5's per-width domains would stop matching after the upgrade"}, {Name: "BigintOrdOre", GoName: "BigintOrdOre", Family: "bigint", Suffix: "OrdOre", Plaintext: "int64", SQLDomain: "public.eql_v4_bigint_ord_ore", Indexes: []string{"ore"}, Query: "BigintOrdOreQuery", QuerySQLDomain: "eql_v4.query_bigint_ord_ore", Producible: false, Reason: "ordering terms wait for vitaminc-ore, the order encodings in vitaminc 0.6 (ADR-0002)"}, {Name: "BigintOrd", GoName: "BigintOrd", Family: "bigint", Suffix: "Ord", Plaintext: "int64", SQLDomain: "public.eql_v4_bigint_ord", Indexes: []string{"ope"}, Query: "BigintOrdQuery", QuerySQLDomain: "eql_v4.query_bigint_ord", Producible: false, Reason: "ordering terms wait for vitaminc-ore, the order encodings in vitaminc 0.6 (ADR-0002)"}, {Name: "BigintOrdOpe", GoName: "BigintOrdOpe", Family: "bigint", Suffix: "OrdOpe", Plaintext: "int64", SQLDomain: "public.eql_v4_bigint_ord_ope", Indexes: []string{"ope"}, Query: "BigintOrdOpeQuery", QuerySQLDomain: "eql_v4.query_bigint_ord_ope", Producible: false, Reason: "ordering terms wait for vitaminc-ore, the order encodings in vitaminc 0.6 (ADR-0002)"}, @@ -49,12 +49,12 @@ var Types = []Type{ {Name: "TextSearch", GoName: "TextSearch", Family: "text", Suffix: "Search", Plaintext: "string", SQLDomain: "public.eql_v4_text_search", Indexes: []string{"eq", "ope", "match"}, Query: "TextSearchQuery", QuerySQLDomain: "eql_v4.query_text_search", Producible: false, Reason: "ordering terms wait for vitaminc-ore, the order encodings in vitaminc 0.6 (ADR-0002)"}, {Name: "Boolean", GoName: "Boolean", Family: "boolean", Suffix: "", Plaintext: "bool", SQLDomain: "public.eql_v4_boolean", Indexes: nil, Query: "", QuerySQLDomain: "", Producible: true, Reason: ""}, {Name: "Real", GoName: "Real", Family: "real", Suffix: "", Plaintext: "float32", SQLDomain: "public.eql_v4_real", Indexes: nil, Query: "", QuerySQLDomain: "", Producible: true, Reason: ""}, - {Name: "RealEq", GoName: "RealEq", Family: "real", Suffix: "Eq", Plaintext: "float32", SQLDomain: "public.eql_v4_real_eq", Indexes: []string{"eq"}, Query: "RealEqQuery", QuerySQLDomain: "eql_v4.query_real_eq", Producible: false, Reason: "vitaminc 0.5 derives no equality term for a float; the canonical float form arrives in vitaminc 0.6 (ADR-0002)"}, + {Name: "RealEq", GoName: "RealEq", Family: "real", Suffix: "Eq", Plaintext: "float32", SQLDomain: "public.eql_v4_real_eq", Indexes: []string{"eq"}, Query: "RealEqQuery", QuerySQLDomain: "eql_v4.query_real_eq", Producible: false, Reason: "float equality is produced once vitaminc 0.6, which gives a float its canonical equality form, is released (ADR-0002)"}, {Name: "RealOrdOre", GoName: "RealOrdOre", Family: "real", Suffix: "OrdOre", Plaintext: "float32", SQLDomain: "public.eql_v4_real_ord_ore", Indexes: []string{"ore"}, Query: "RealOrdOreQuery", QuerySQLDomain: "eql_v4.query_real_ord_ore", Producible: false, Reason: "ordering terms wait for vitaminc-ore, the order encodings in vitaminc 0.6 (ADR-0002)"}, {Name: "RealOrd", GoName: "RealOrd", Family: "real", Suffix: "Ord", Plaintext: "float32", SQLDomain: "public.eql_v4_real_ord", Indexes: []string{"ope"}, Query: "RealOrdQuery", QuerySQLDomain: "eql_v4.query_real_ord", Producible: false, Reason: "ordering terms wait for vitaminc-ore, the order encodings in vitaminc 0.6 (ADR-0002)"}, {Name: "RealOrdOpe", GoName: "RealOrdOpe", Family: "real", Suffix: "OrdOpe", Plaintext: "float32", SQLDomain: "public.eql_v4_real_ord_ope", Indexes: []string{"ope"}, Query: "RealOrdOpeQuery", QuerySQLDomain: "eql_v4.query_real_ord_ope", Producible: false, Reason: "ordering terms wait for vitaminc-ore, the order encodings in vitaminc 0.6 (ADR-0002)"}, {Name: "Double", GoName: "Double", Family: "double", Suffix: "", Plaintext: "float64", SQLDomain: "public.eql_v4_double", Indexes: nil, Query: "", QuerySQLDomain: "", Producible: true, Reason: ""}, - {Name: "DoubleEq", GoName: "DoubleEq", Family: "double", Suffix: "Eq", Plaintext: "float64", SQLDomain: "public.eql_v4_double_eq", Indexes: []string{"eq"}, Query: "DoubleEqQuery", QuerySQLDomain: "eql_v4.query_double_eq", Producible: false, Reason: "vitaminc 0.5 derives no equality term for a float; the canonical float form arrives in vitaminc 0.6 (ADR-0002)"}, + {Name: "DoubleEq", GoName: "DoubleEq", Family: "double", Suffix: "Eq", Plaintext: "float64", SQLDomain: "public.eql_v4_double_eq", Indexes: []string{"eq"}, Query: "DoubleEqQuery", QuerySQLDomain: "eql_v4.query_double_eq", Producible: false, Reason: "float equality is produced once vitaminc 0.6, which gives a float its canonical equality form, is released (ADR-0002)"}, {Name: "DoubleOrdOre", GoName: "DoubleOrdOre", Family: "double", Suffix: "OrdOre", Plaintext: "float64", SQLDomain: "public.eql_v4_double_ord_ore", Indexes: []string{"ore"}, Query: "DoubleOrdOreQuery", QuerySQLDomain: "eql_v4.query_double_ord_ore", Producible: false, Reason: "ordering terms wait for vitaminc-ore, the order encodings in vitaminc 0.6 (ADR-0002)"}, {Name: "DoubleOrd", GoName: "DoubleOrd", Family: "double", Suffix: "Ord", Plaintext: "float64", SQLDomain: "public.eql_v4_double_ord", Indexes: []string{"ope"}, Query: "DoubleOrdQuery", QuerySQLDomain: "eql_v4.query_double_ord", Producible: false, Reason: "ordering terms wait for vitaminc-ore, the order encodings in vitaminc 0.6 (ADR-0002)"}, {Name: "DoubleOrdOpe", GoName: "DoubleOrdOpe", Family: "double", Suffix: "OrdOpe", Plaintext: "float64", SQLDomain: "public.eql_v4_double_ord_ope", Indexes: []string{"ope"}, Query: "DoubleOrdOpeQuery", QuerySQLDomain: "eql_v4.query_double_ord_ope", Producible: false, Reason: "ordering terms wait for vitaminc-ore, the order encodings in vitaminc 0.6 (ADR-0002)"}, @@ -87,7 +87,7 @@ func (v *Integer) UnmarshalJSON(b []byte) error { } // IntegerEq is the EQL type stored in public.eql_v4_integer_eq: the integer family with the eq index. -// The engine cannot produce it yet: the integer equality term moves to vitaminc 0.6's shared integer-orderable/v1 PRF domain; producing it before that release would leave stored terms unmatchable after the upgrade. +// The engine cannot produce it yet: integer equality is produced once vitaminc 0.6 is released: its one integer domain is the one the engine derives in, and a term derived under vitaminc 0.5's per-width domains would stop matching after the upgrade. type IntegerEq []byte // Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. @@ -511,7 +511,7 @@ func (v *Bigint) UnmarshalJSON(b []byte) error { } // BigintEq is the EQL type stored in public.eql_v4_bigint_eq: the bigint family with the eq index. -// The engine cannot produce it yet: the integer equality term moves to vitaminc 0.6's shared integer-orderable/v1 PRF domain; producing it before that release would leave stored terms unmatchable after the upgrade. +// The engine cannot produce it yet: integer equality is produced once vitaminc 0.6 is released: its one integer domain is the one the engine derives in, and a term derived under vitaminc 0.5's per-width domains would stop matching after the upgrade. type BigintEq []byte // Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. @@ -1736,7 +1736,7 @@ func (v *Real) UnmarshalJSON(b []byte) error { } // RealEq is the EQL type stored in public.eql_v4_real_eq: the real family with the eq index. -// The engine cannot produce it yet: vitaminc 0.5 derives no equality term for a float; the canonical float form arrives in vitaminc 0.6 (ADR-0002). +// The engine cannot produce it yet: float equality is produced once vitaminc 0.6, which gives a float its canonical equality form, is released (ADR-0002). type RealEq []byte // Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. @@ -1948,7 +1948,7 @@ func (v *Double) UnmarshalJSON(b []byte) error { } // DoubleEq is the EQL type stored in public.eql_v4_double_eq: the double family with the eq index. -// The engine cannot produce it yet: vitaminc 0.5 derives no equality term for a float; the canonical float form arrives in vitaminc 0.6 (ADR-0002). +// The engine cannot produce it yet: float equality is produced once vitaminc 0.6, which gives a float its canonical equality form, is released (ADR-0002). type DoubleEq []byte // Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. diff --git a/packages/eql/crates/eql-bindings/CHANGELOG.md b/packages/eql/crates/eql-bindings/CHANGELOG.md index 23561c0e6..1ddef0db9 100644 --- a/packages/eql/crates/eql-bindings/CHANGELOG.md +++ b/packages/eql/crates/eql-bindings/CHANGELOG.md @@ -39,9 +39,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (`stack-encrypt` feature). Their `hm` is Stack Encrypt's equality term, which now derives through `vitaminc-normalize`: `café` written with a precomposed `é` and with `e` and a combining accent is one value, and - text holding a code point unassigned in Unicode 16 is refused. Case and + text holding a code point unassigned in Unicode 16 is refused, when it is + stored as well as when it is queried. Case and whitespace are kept. Every `hm` changes bytes; no stored value existed to - keep. `Hmac256` encrypts from any `Plaintext` where it took a + keep. `Hmac256` encrypts from any vitaminc `Encrypt` where it took a `PrfValue`, and the feature no longer depends on `vitaminc-prf`. - **The `stack-encrypt` feature builds on vitaminc ADR 0001.** diff --git a/packages/eql/crates/eql-bindings/README.md b/packages/eql/crates/eql-bindings/README.md index f8cef452a..7f0a7af09 100644 --- a/packages/eql/crates/eql-bindings/README.md +++ b/packages/eql/crates/eql-bindings/README.md @@ -107,8 +107,8 @@ The optional `stack-encrypt` feature enables `EncryptFrom` for implements these traits. The derives are emitted by `eql-codegen`. The other v4 types Stack Encrypt produces take a plaintext from -`eql_bindings::encryption::plaintext`, because vitaminc 0.5 has no native -encryption for the scalar types: +`eql_bindings::encryption::plaintext`, which seal as the primitive they wrap +and read back only a value of their own width: | v4 type | Plaintext | | --- | --- | @@ -119,11 +119,12 @@ encryption for the scalar types: | `Real` | `Float32` | | `Double` | `Float64` | -Each seals as vitaminc's tagged leaf, the bytes the value enum (`FfiValue`) -seals for the same value, so values written here and by the Go SDK open each -other. `IntegerEq` and `BigintEq` wait for vitaminc 0.6, whose integer -equality term uses one PRF domain for every width; terms derived under -vitaminc 0.5's per-width domains would stop matching after that upgrade. +Each seals the leaf the value enum (`FfiValue`) seals for the same value, so +values written here and by the Go SDK open each other. `IntegerEq`, +`BigintEq`, `RealEq` and `DoubleEq` wait for vitaminc 0.6's release: the +engine derives integer and float equality in its shared domains, and terms +derived under vitaminc 0.5's per-width domains would stop matching after the +upgrade. `TextMatch` builds its filter under a configuration pinned in this crate (`EqlMatch`: 3-grams of the lower-cased text, `k = 6`, a 2048-bit filter) for stored values and queries alike. The configuration is part of the stored @@ -132,11 +133,10 @@ match a row that does not contain the query, so re-check after decrypting where that matters. Write the stored column on the left, `col @@ query`: the other order finds nothing until #1171 is fixed. Text too short to make a 3-gram (empty strings, two-letter codes) cannot be stored in a `TextMatch` -column yet, and a `TextMatchQuery` for it is refused too: Stack Encrypt 0.2 -refuses text with no tokens, on both sides. Ordering types -wait for vitaminc 0.6's order encodings, float -equality for its canonical float form, and `Json` for a decision on sealing a -document as one leaf. +column yet, and a `TextMatchQuery` for it is refused too: one filter type +builds both, and an empty filter as a query would match every row. Ordering +types wait for vitaminc 0.6's order encodings, and `Json` for a decision on +sealing a document as one leaf. The generated API docs include a complete, executable example in the `eql_bindings::encryption` module, from cipher setup through decryption. Its diff --git a/packages/eql/crates/eql-bindings/src/encryption/plaintext.rs b/packages/eql/crates/eql-bindings/src/encryption/plaintext.rs index 4c990f5fc..173681f07 100644 --- a/packages/eql/crates/eql-bindings/src/encryption/plaintext.rs +++ b/packages/eql/crates/eql-bindings/src/encryption/plaintext.rs @@ -16,10 +16,10 @@ //! refuse a leaf of another width, so a column's type is the one it was //! written with. //! -//! None of them derives an equality term. `IntegerEq` and `BigintEq` are held -//! back until vitaminc 0.6, whose integer equality term uses one PRF domain -//! for every width; a term derived here under vitaminc 0.5's per-width -//! domains would stop matching after that upgrade. +//! None of them derives an equality term. `IntegerEq`, `BigintEq`, `RealEq` +//! and `DoubleEq` are held back until vitaminc 0.6 is released: the engine +//! already derives integer and float equality in 0.6's shared domains, and a +//! published build must not derive them under vitaminc 0.5's. //! //! ```rust,ignore //! use eql_bindings::encryption::plaintext::Int32; diff --git a/packages/eql/crates/eql-bindings/src/v4/targets.rs b/packages/eql/crates/eql-bindings/src/v4/targets.rs index 9b7170749..02c37c36e 100644 --- a/packages/eql/crates/eql-bindings/src/v4/targets.rs +++ b/packages/eql/crates/eql-bindings/src/v4/targets.rs @@ -39,7 +39,7 @@ pub const TARGETS: &[Target] = &[ query_sql_domain: Some("eql_v4.query_integer_eq"), producible: false, reason: Some( - "the integer equality term moves to vitaminc 0.6's shared integer-orderable/v1 PRF domain; producing it before that release would leave stored terms unmatchable after the upgrade", + "integer equality is produced once vitaminc 0.6 is released: its one integer domain is the one the engine derives in, and a term derived under vitaminc 0.5's per-width domains would stop matching after the upgrade", ), }, Target { @@ -177,7 +177,7 @@ pub const TARGETS: &[Target] = &[ query_sql_domain: Some("eql_v4.query_bigint_eq"), producible: false, reason: Some( - "the integer equality term moves to vitaminc 0.6's shared integer-orderable/v1 PRF domain; producing it before that release would leave stored terms unmatchable after the upgrade", + "integer equality is produced once vitaminc 0.6 is released: its one integer domain is the one the engine derives in, and a term derived under vitaminc 0.5's per-width domains would stop matching after the upgrade", ), }, Target { @@ -573,7 +573,7 @@ pub const TARGETS: &[Target] = &[ query_sql_domain: Some("eql_v4.query_real_eq"), producible: false, reason: Some( - "vitaminc 0.5 derives no equality term for a float; the canonical float form arrives in vitaminc 0.6 (ADR-0002)", + "float equality is produced once vitaminc 0.6, which gives a float its canonical equality form, is released (ADR-0002)", ), }, Target { @@ -641,7 +641,7 @@ pub const TARGETS: &[Target] = &[ query_sql_domain: Some("eql_v4.query_double_eq"), producible: false, reason: Some( - "vitaminc 0.5 derives no equality term for a float; the canonical float form arrives in vitaminc 0.6 (ADR-0002)", + "float equality is produced once vitaminc 0.6, which gives a float its canonical equality form, is released (ADR-0002)", ), }, Target { diff --git a/packages/eql/crates/eql-codegen/src/bindings.rs b/packages/eql/crates/eql-codegen/src/bindings.rs index d6396a642..2c2be55a9 100644 --- a/packages/eql/crates/eql-codegen/src/bindings.rs +++ b/packages/eql/crates/eql-codegen/src/bindings.rs @@ -401,12 +401,12 @@ pub fn encryption_gap( { "ordering terms wait for vitaminc-ore, the order encodings in vitaminc 0.6 (ADR-0002)" } else if matches!(family.name, "integer" | "bigint") && domain.terms == [Term::Hm] { - "the integer equality term moves to vitaminc 0.6's shared integer-orderable/v1 PRF \ - domain; producing it before that release would leave stored terms unmatchable after \ - the upgrade" + "integer equality is produced once vitaminc 0.6 is released: its one integer domain is \ + the one the engine derives in, and a term derived under vitaminc 0.5's per-width \ + domains would stop matching after the upgrade" } else if matches!(family.name, "real" | "double") { - "vitaminc 0.5 derives no equality term for a float; the canonical float form \ - arrives in vitaminc 0.6 (ADR-0002)" + "float equality is produced once vitaminc 0.6, which gives a float its canonical \ + equality form, is released (ADR-0002)" } else { // Reached by an equality-only domain of a family whose plaintext is // specified but which is not listed: nothing in the catalog today. @@ -1737,13 +1737,13 @@ mod tests { { "ordering terms" } else if matches!(family.name, "integer" | "bigint") { - "integer-orderable/v1" + "integer equality is produced once vitaminc 0.6" } else { assert!( matches!(family.name, "real" | "double"), "{full}: every other specified, unordered domain is derived" ); - "equality term for a float" + "float equality is produced once vitaminc 0.6" }; assert!(reason.contains(expected), "{full}: {reason}"); } diff --git a/packages/eql/crates/eql-codegen/src/targets.rs b/packages/eql/crates/eql-codegen/src/targets.rs index 4b8f3d7b2..be25592c2 100644 --- a/packages/eql/crates/eql-codegen/src/targets.rs +++ b/packages/eql/crates/eql-codegen/src/targets.rs @@ -410,7 +410,7 @@ mod tests { assert!( gap(family(family_name), "eq") .unwrap() - .contains("integer-orderable/v1"), + .contains("integer equality is produced once vitaminc 0.6"), "{family_name}" ); assert!(gap(family(family_name), "").is_none(), "{family_name}"); @@ -418,7 +418,9 @@ mod tests { assert!(gap(family("integer"), "ord_ore") .unwrap() .contains("ordering terms")); - assert!(gap(family("real"), "eq").unwrap().contains("float")); + assert!(gap(family("real"), "eq") + .unwrap() + .contains("float equality is produced once vitaminc 0.6")); assert!(gap(family("date"), "").unwrap().contains("has no kind")); assert!(gap(family("smallint"), "eq") .unwrap() diff --git a/packages/eql/tests/encryption/tests/targets.rs b/packages/eql/tests/encryption/tests/targets.rs index fbf147bd9..a22b78835 100644 --- a/packages/eql/tests/encryption/tests/targets.rs +++ b/packages/eql/tests/encryption/tests/targets.rs @@ -583,8 +583,8 @@ mod the_table { ("json", "") => "tree of leaves", ("smallint" | "date" | "timestamp" | "numeric", _) => "has no kind", (_, "Ord" | "OrdOre" | "OrdOpe" | "Search" | "SearchOre") => "ordering terms", - ("real" | "double", "Eq") => "float", - ("integer" | "bigint", "Eq") => "integer-orderable/v1", + ("real" | "double", "Eq") => "float equality is produced once vitaminc 0.6", + ("integer" | "bigint", "Eq") => "integer equality is produced once vitaminc 0.6", (family, suffix) => panic!("{}: unexpected {family} {suffix}", row.name), }; assert!(reason.contains(expected), "{}: {reason}", row.name); diff --git a/packages/eql/tests/encryption/tests/v4_types.rs b/packages/eql/tests/encryption/tests/v4_types.rs index c4acd9368..1daa7c5cb 100644 --- a/packages/eql/tests/encryption/tests/v4_types.rs +++ b/packages/eql/tests/encryption/tests/v4_types.rs @@ -191,7 +191,7 @@ async fn integer_equality_types_are_held_back_with_their_reason() { assert!(!target.producible, "{name}"); let error = refused(targets::encrypt(name, &keyset, &label(), value)); assert!( - matches!(&error, TargetError::Unproducible { reason, .. } if reason.contains("integer-orderable/v1")), + matches!(&error, TargetError::Unproducible { reason, .. } if reason.contains("integer equality is produced once vitaminc 0.6")), "{name}: {error}" ); } diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 7d18bd60d..fc44b4282 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -155,10 +155,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 or uuid derives in a domain of its own. `KeysetCipher::equality_term`, `target::equality`, the `Equality` index and `op::Eq` take an `Encrypt` that is one scalar where they took a `PrfValue`. A boolean, a null or a - container has no equality term (`TermError::NoEqualityTerm`), and text - holding a code point unassigned in Unicode 16 is refused - (`TermError::Normalize`). Match text is tokenized in the same normal - form, and refused the same way. Every equality and match term changes + container has no equality term (`TermError::NoEqualityTerm`); a `bool` + satisfies the new bound, so equality on one now fails when the term is + derived where it used to fail to compile. Text holding a code point + unassigned in Unicode 16 (an emoji newer than it, say) is refused + (`TermError::Normalize`), when it is written to an equality or match + column as well as when it is queried: the whole record's encrypt fails. + Match text is tokenized in the same normal form, and refused the same + way. Every equality and match term changes bytes (the domains move to `/v2`); ORE and OPE terms do not. No stored term existed to keep; the record fixture is regenerated. - **`dynamic::Value` and `dynamic::Scalar` are gone.** A field lowered from @@ -168,9 +172,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 walked in place, a Rust value) and derives at the one scalar it holds; `IndexSpec::defines(value)` replaces `IndexSpec::supports(&scalar)`, and `IndexSpec` is an `Index`. -- **A boolean has no order term**, through a plan or `dynamic::term`: with - two values, an ORE or OPE term would tell anyone holding the column which - rows are `true`. `dynamic::admits` now refuses ORE and OPE on `bool`, and +- **A boolean has no order term**, on the typed path and through a plan or + `dynamic::term`: with two values, an ORE or OPE term would tell anyone + holding the column which rows are `true`. An ORE or OPE term of a `bool` + (`KeysetCipher::ore_term`, the `Ore` and `Ope` indexes) is refused with + the new `TermError::NoOrderTerm`, before any key is minted, where cllw-ore + used to order one. `dynamic::admits` now refuses ORE and OPE on `bool`, and admits equality on `float32` and `float64`, which vitaminc gives an equality domain. A 128-bit integer has an equality term and no order term. diff --git a/packages/stack-encrypt/src/codes.rs b/packages/stack-encrypt/src/codes.rs index baa3bed3d..6388c2e15 100644 --- a/packages/stack-encrypt/src/codes.rs +++ b/packages/stack-encrypt/src/codes.rs @@ -82,6 +82,7 @@ mod tests { TermError::EmptyTermText => TermError::EmptyTermText, TermError::InvertedRange => TermError::InvertedRange, TermError::NoEqualityTerm => TermError::NoEqualityTerm, + TermError::NoOrderTerm => TermError::NoOrderTerm, TermError::Normalize(_) => { TermError::Normalize(vitaminc_normalize::NormalizeError::UnassignedCodePoint) }, @@ -392,6 +393,7 @@ mod tests { (Box::new(TermError::EmptyTermText), json!({})), (Box::new(TermError::InvertedRange), json!({})), (Box::new(TermError::NoEqualityTerm), json!({})), + (Box::new(TermError::NoOrderTerm), json!({})), ( Box::new(TermError::Normalize( vitaminc_normalize::NormalizeError::UnassignedCodePoint, diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 9d9800846..bca0cb965 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -235,6 +235,16 @@ pub enum TermError { help("An equality term is defined for one integer, float, text, bytes, date, timestamp, decimal or uuid value, not for a boolean, a null or a container.") )] NoEqualityTerm, + /// No order term (ORE or OPE) is defined for a boolean: with two values, + /// an order term would tell anyone holding the column which rows are + /// `true`. Refused on the typed path as the dynamic path refuses it + /// (`dynamic::admits`). + #[error("no order term is defined for a boolean")] + #[diagnostic( + code(stack_encrypt::no_order_term), + help("A boolean column cannot be ordered without revealing which rows are true. Store it with no order index, and with no equality index either: a boolean has no equality term.") + )] + NoOrderTerm, /// The value cannot be normalized for its term: text with a code point /// unassigned in Unicode 16 (an equality or match term), or a value not /// valid for its logical type. The reason is vitaminc's, and names no @@ -259,6 +269,7 @@ impl crate::ErrorPayload for TermError { | Self::EmptyTermText | Self::InvertedRange | Self::NoEqualityTerm + | Self::NoOrderTerm | Self::Normalize(_) => serde_json::Map::new(), } } @@ -1292,6 +1303,7 @@ where T: CllwOreEncrypt + Send + 'static, T::Output: Send + 'static, { + refuse_bool::()?; let context_bytes = context.as_bytes(); context_bytes .prf_visit_with_context( @@ -1305,6 +1317,16 @@ where .map_err(TermError::Ore) } +/// cllw-ore orders a `bool`, and its two values would split a column into +/// its `true` and `false` rows, so no order term is derived for one, on the +/// typed path as on the dynamic one. +fn refuse_bool() -> Result<(), TermError> { + if std::any::TypeId::of::() == std::any::TypeId::of::() { + return Err(TermError::NoOrderTerm); + } + Ok(()) +} + /// Derive an OPE term — as [`ore`], under the OPE domain so the two schemes /// never share a key. fn ope(prf: &HmacSha256Prf, value: T, context: Context<'_>) -> Result, TermError> @@ -1312,6 +1334,7 @@ where T: CllwOpeEncrypt + Send + 'static, T::Output: Send + 'static, { + refuse_bool::()?; let context_bytes = context.as_bytes(); context_bytes .prf_visit_with_context( diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index d3c1c59f7..72f003a85 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -535,7 +535,7 @@ macro_rules! term_operation { } term_operation!( /// The equality term of `S` under the context the tree hands it. Requires - /// only that `S` describes itself (`Plaintext`), as one scalar with an + /// only that `S` describes itself (`Encrypt`), as one scalar with an /// equality domain; not recoverable encryption. equality, crate::sem::EqualityTerm, consume, [], [S: crate::Encrypt] ); diff --git a/packages/stack-encrypt/tests/index.rs b/packages/stack-encrypt/tests/index.rs index af13019f2..38c527284 100644 --- a/packages/stack-encrypt/tests/index.rs +++ b/packages/stack-encrypt/tests/index.rs @@ -752,3 +752,60 @@ mod given_encrypted_terms_as_a_target { ); } } + +/// A boolean has no order term on the typed path, as on the dynamic one +/// (`dynamic::admits`): cllw-ore would order a `bool`, and its two values +/// would split a column into its `true` and `false` rows. Nor has it an +/// equality term. Both are refused when the term is derived, before any +/// key is minted, through the index, the typed term and a plan alike. +#[tokio::test] +async fn a_boolean_has_no_order_or_equality_term() { + use stack_encrypt::sem::TermError; + use stack_encrypt::{EncryptInto, Plan}; + + let (cipher, generates, _) = counting_cipher().await; + let keyset = cipher.default_keyset(); + let ore_term: Result, _> = true + .encrypt_into_with_context(&keyset, nonempty!("users/flag")) + .await; + assert!(matches!(ore_term, Err(Error::Term(TermError::NoOrderTerm)))); + let ope_term: Result, _> = false + .encrypt_into_with_context(&keyset, nonempty!("users/flag")) + .await; + assert!(matches!(ope_term, Err(Error::Term(TermError::NoOrderTerm)))); + let equality_term: Result = true + .encrypt_into_with_context(&keyset, nonempty!("users/flag")) + .await; + assert!(matches!( + equality_term, + Err(Error::Term(TermError::NoEqualityTerm)) + )); + + for index in [IndexSpec::Ore, IndexSpec::Ope] { + let refused = match index { + IndexSpec::Ore => { + let plan = Plan::context("users/flag") + .with::(Ore) + .build() + .unwrap(); + cipher.encrypt(&true).using(&plan).await.map(|_| ()) + } + _ => { + let plan = Plan::context("users/flag") + .with::(Ope) + .build() + .unwrap(); + cipher.encrypt(&true).using(&plan).await.map(|_| ()) + } + }; + assert!( + matches!(refused, Err(Error::Term(TermError::NoOrderTerm))), + "{index:?}: {refused:?}" + ); + } + assert_eq!( + generates.load(Ordering::SeqCst), + 0, + "refused before any key" + ); +} From 2e6e584af24d28f841a32b20b98878bfdd160436 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 17 Oct 2026 00:48:14 +1100 Subject: [PATCH 7/9] fix(stack-encrypt): keep a refused term's kind when a record is written Copilot review. An equality- or match-indexed string with a code point unassigned in Unicode 16 passes a plan's boundary checks, and derivation refuses it with `TermError::Normalize`. On the record path that error went through `lifted`, which boxed every dynamic error into `crate::Error::Other`, so a guest reported a term failure as STATUS_INTERNAL where a standalone term reports STATUS_TERM. A cipher error is already the crate's and now passes through unchanged; a test writes such text under both indexes. --- packages/stack-encrypt/src/dynamic/term.rs | 34 +++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/stack-encrypt/src/dynamic/term.rs b/packages/stack-encrypt/src/dynamic/term.rs index cca3e461e..d0940ccfe 100644 --- a/packages/stack-encrypt/src/dynamic/term.rs +++ b/packages/stack-encrypt/src/dynamic/term.rs @@ -647,8 +647,14 @@ impl MapSink for NoContainer { } /// A dynamic error, where an operation's pending can only carry the crate's. +/// A cipher error is already the crate's, and passes through unchanged, so a +/// term the scheme refused (text that does not normalize) keeps its kind; +/// boxing it would report it as an internal failure. fn lifted(error: Error) -> crate::Error { - crate::Error::Other(Box::new(error)) + match error { + Error::Cipher(error) => error, + other => crate::Error::Other(Box::new(other)), + } } /// An [`IndexSpec`] is an [`Index`] of an [`FfiValue`]: the index named as @@ -750,6 +756,32 @@ mod tests { } } + /// A term the scheme refuses while a record is written keeps its kind: + /// text that does not normalize (a code point unassigned in Unicode 16) + /// under equality or match is the cipher's `TermError::Normalize`, which + /// a guest reports as a term failure, not boxed as an internal error. + #[tokio::test] + async fn a_refused_term_in_a_record_keeps_its_kind() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + for kind in [IndexSpec::Equality, default_match()] { + let refused = keyset + .run( + Index::::operation::(&kind), + s("a\u{0378}bc"), + CallerContext::from(nonempty!("users/bio")), + ) + .await; + assert!( + matches!( + refused, + Err(crate::Error::Term(crate::sem::TermError::Normalize(_))) + ), + "{kind:?}: {refused:?}" + ); + } + } + /// The query path refuses match text with no tokens; the record path, /// which runs the same derivation as an index of an `FfiValue`, stores /// it as the empty filter. Same value, same options, two answers, From acbfc45d472c26646f045a1f2523ed882b4f1fab Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 17 Oct 2026 12:16:53 +1100 Subject: [PATCH 8/9] fix(stack-encrypt): address the second review of normalized terms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ORE and OPE still order text as its raw UTF-8, while equality and match terms derive from its Unicode 16 NFC form. On a column with both an equality and an order index, two forms of `café` are one value to `=` and two to an ordering. `ore_of`'s rustdoc and the CHANGELOG now record this as a known gap, to be closed with block ORE (#1140). --- packages/stack-encrypt/CHANGELOG.md | 6 +++++- packages/stack-encrypt/src/dynamic/term.rs | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index fc44b4282..078ddab80 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -164,7 +164,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Match text is tokenized in the same normal form, and refused the same way. Every equality and match term changes bytes (the domains move to `/v2`); ORE and OPE terms do not. No stored - term existed to keep; the record fixture is regenerated. + term existed to keep; the record fixture is regenerated. ORE and OPE + text is still ordered as its raw UTF-8, not normalized, so on a column + with both an equality and an order index two forms of `café` are one + value to `=` and two to an ordering. This is a known gap, to be closed + with block ORE (#1140). - **`dynamic::Value` and `dynamic::Scalar` are gone.** A field lowered from data holds an `FfiValue`, which is now `Clone` (a deep copy into fresh `Protected` payloads, as `Value`'s was) and reads as a context label. diff --git a/packages/stack-encrypt/src/dynamic/term.rs b/packages/stack-encrypt/src/dynamic/term.rs index d0940ccfe..8382e9dd4 100644 --- a/packages/stack-encrypt/src/dynamic/term.rs +++ b/packages/stack-encrypt/src/dynamic/term.rs @@ -438,6 +438,11 @@ where /// their value by `'static` ownership (the visitor carries it), so a bare /// `String` or `Vec` would be freed with the plaintext still in it, in /// a guest's linear memory, where the host can read it. +/// +/// Text is ordered as its raw UTF-8, not in the Unicode 16 NFC that +/// equality and match terms use: `café` written precomposed and with a +/// combining accent is one value to `=` and two to an ordering. A known +/// gap, to close with block ORE (#1140). fn ore_of<'a, 'c, K: 'static, D>( cipher: &'a KeysetCipher<'_, K>, value: Physical<'_>, From 63739fec5ccd0c5bea6fb4566db645a04da89359 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 17 Oct 2026 15:23:45 +1100 Subject: [PATCH 9/9] test(stack-encrypt): pin a match index's verifier, which a query chain reaches only through admit The mutants gate found Match's verifier and QueryTokens::of untested: the chain takes the query's tokens through admit, which the match index overrides, so replacing either with an empty default changed no test. The new unit test builds a query from the verifier and checks it verifies a row holding the tokens and refuses one that does not. --- packages/stack-encrypt/src/op/mod.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/stack-encrypt/src/op/mod.rs b/packages/stack-encrypt/src/op/mod.rs index 19fb1b8b1..83741743f 100644 --- a/packages/stack-encrypt/src/op/mod.rs +++ b/packages/stack-encrypt/src/op/mod.rs @@ -881,6 +881,20 @@ mod tests { ); } + /// A match index's verifier is the query's tokens: a query built from + /// it verifies a row that holds them and no other. A query chain takes + /// them through `admit`, so this is the one test that calls `verifier`. + #[test] + fn a_match_verifier_is_the_query_tokens() { + let index = Match::::default(); + let verifier = + Supports::, String>::verifier(&index, &"alice".to_owned()); + let filter = MatchTerms::::from_positions(vec![1]).expect("in range"); + let query = MatchQuery::new(filter, verifier); + assert!(Probabilistic::verify(&query, "say hi to Alice")); + assert!(!Probabilistic::verify(&query, "bob")); + } + #[test] fn an_operator_is_equal_to_itself() { assert_eq!(Matches::::new(), Matches);