diff --git a/.changeset/eql-text-equality-transcoding.md b/.changeset/eql-text-equality-transcoding.md index cf5651050..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 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. 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/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/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/languages/golang/encrypt/guest/Cargo.lock b/languages/golang/encrypt/guest/Cargo.lock index 926813680..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", ] @@ -2087,6 +2086,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 +2463,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 +2764,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 +2785,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/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\"`"}, } { diff --git a/packages/eql/Cargo.lock b/packages/eql/Cargo.lock index 9ab983cb6..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", ] @@ -4242,6 +4241,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 +5198,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 +5219,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/crates/eql-bindings/CHANGELOG.md b/packages/eql/crates/eql-bindings/CHANGELOG.md index 61cbda704..1ddef0db9 100644 --- a/packages/eql/crates/eql-bindings/CHANGELOG.md +++ b/packages/eql/crates/eql-bindings/CHANGELOG.md @@ -35,6 +35,16 @@ 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, 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 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.** `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/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.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/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/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/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/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/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()); diff --git a/packages/eql/tests/encryption/tests/v4_types.rs b/packages/eql/tests/encryption/tests/v4_types.rs index 70ad17053..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}" ); } @@ -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/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 6fab532c5..078ddab80 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -146,6 +146,46 @@ 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`); 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. 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. + `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**, 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. + - **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/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..6388c2e15 100644 --- a/packages/stack-encrypt/src/codes.rs +++ b/packages/stack-encrypt/src/codes.rs @@ -81,6 +81,11 @@ mod tests { TermError::InvalidOptions(_) => TermError::InvalidOptions("k out of range"), TermError::EmptyTermText => TermError::EmptyTermText, TermError::InvertedRange => TermError::InvertedRange, + TermError::NoEqualityTerm => TermError::NoEqualityTerm, + TermError::NoOrderTerm => TermError::NoOrderTerm, + TermError::Normalize(_) => { + TermError::Normalize(vitaminc_normalize::NormalizeError::UnassignedCodePoint) + }, TermError::Bytes(_) => TermError::Bytes(TermBytesError::OddMatchTermsLength(3)), ])); errors.extend(boxed(variants![ @@ -387,6 +392,14 @@ 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, + )), + json!({}), + ), ( Box::new(TermBytesError::MalformedCllwCiphertext(5)), json!({ "len": 5 }), 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..8382e9dd4 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 +//! ([`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. +//! 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,158 @@ 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. +/// +/// 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>, - 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 +527,169 @@ 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. +/// 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 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 +704,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 +715,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 +725,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 +734,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; @@ -626,19 +761,44 @@ 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 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 +809,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 +820,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 +837,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 +852,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 +867,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 +891,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 +912,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 +920,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 +952,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 +988,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 +1117,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 +1126,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 +1214,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 +1477,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 +1491,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/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..83741743f 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; } @@ -879,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); diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 66599a1eb..bca0cb965 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,36 @@ 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, + /// 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 + /// 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 +267,10 @@ impl crate::ErrorPayload for TermError { | Self::Ore(_) | Self::InvalidOptions(_) | Self::EmptyTermText - | Self::InvertedRange => serde_json::Map::new(), + | Self::InvertedRange + | Self::NoEqualityTerm + | Self::NoOrderTerm + | Self::Normalize(_) => serde_json::Map::new(), } } } @@ -311,7 +352,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 +427,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 [`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. 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 +811,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 +919,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 +956,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 +1027,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())); } @@ -1211,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( @@ -1224,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> @@ -1231,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( @@ -1343,13 +1447,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 +1499,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 +1597,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 +1657,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..72f003a85 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 (`Encrypt`), 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/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/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/index.rs b/packages/stack-encrypt/tests/index.rs index 34a079153..38c527284 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 @@ -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" + ); +} 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/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") })) } 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..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.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..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 @@ -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()); }