diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index ed1023898..db149485f 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -36,6 +36,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`Match` is a value.** `.with((Equality, Match))` declares the match index under the default configuration, as `Equality` declares equality; `Match::::new()` is another configuration. +- **`verify` drops a match query's false positives after decrypt.** A + `MatchQuery` is `op::Probabilistic`: `query.verify(&value)` is `true` when + every token of the query's value, cut by the index's own tokenizer and + case folding, is a token of the decrypted value. Filter decrypted rows + with it. The query keeps its value's tokens to do this; they are zeroized + on drop and never shown by `Debug`, and the tokenizer now zeroizes its own + copies of the text. An exact term has no `verify`, and none of this + crate's indexes can answer `op::Matches` but with a probabilistic term + (`op::TermFor`). - **Errors now expose stable codes, help text, and structured details.** All error types implement `miette::Diagnostic` with codes such as diff --git a/packages/stack-encrypt/docs/adr/0009-operators-select-the-index-and-a-query-term-is-not-the-stored-term.md b/packages/stack-encrypt/docs/adr/0009-operators-select-the-index-and-a-query-term-is-not-the-stored-term.md index 0cd8e8898..04cfc2e32 100644 --- a/packages/stack-encrypt/docs/adr/0009-operators-select-the-index-and-a-query-term-is-not-the-stored-term.md +++ b/packages/stack-encrypt/docs/adr/0009-operators-select-the-index-and-a-query-term-is-not-the-stored-term.md @@ -144,9 +144,11 @@ query against a case-folding index is a type error, not an empty result. pub trait Operator: Copy + 'static {} pub trait Supports: Index { - type Query: QueryTerm; // what this index sends for this operator + type Query: TermFor; // what this index sends for `Op` + type Verifier; // what the query keeps to check a row fn check(&self) -> Result<(), PlanError>; - fn query(term: Self::Term, token: QueryToken) -> Self::Query; + fn verifier(&self, value: &S) -> Self::Verifier; + fn query(verifier: Self::Verifier, term: Self::Term, token: QueryToken) -> Self::Query; } ``` @@ -164,10 +166,34 @@ ciphertext; its `Term` is left and right. A Bloom index's `Supports::Query` is a filter over the needle (`MatchQuery`); its `Term` is a filter over the value (`MatchTerms`). -The bound tightens per operator. `Supports` requires -`Query: Probabilistic`; `Supports` requires `Query: Ranked`. An -index cannot claim an inexact operator without saying how its answer is -checked. +The bound tightens per operator, through `TermFor`: any `QueryTerm` +answers `Eq`, but only a `Probabilistic` one answers `Matches`, and only a +`Ranked` one will answer `Nearest`. None of this crate's indexes can claim +an inexact operator without saying how its answer is checked. The rule binds +this crate's terms through blanket impls; a term in another crate can opt +into `TermFor` by hand, so there it is a guide rather than a proof. + +`Verifier` is what the query term keeps of the query's plaintext so it can +check a decrypted row later, and `verifier` takes it before the plaintext is +consumed. An exact term checks nothing, so an equality index's verifier is +`()`. A match index's is the query's tokens (`QueryTokens`), which `verify` +needs and the filter cannot give back: + +```rust +impl, O: MatchCase> Supports, S> for Match { + type Query = MatchQuery; + type Verifier = QueryTokens; // "alice" -> ali, lic, ice + fn verifier(&self, value: &S) -> QueryTokens { QueryTokens::of::(value.as_ref()) } + fn query(tokens: QueryTokens, term: MatchTerms, _: QueryToken) -> MatchQuery { + MatchQuery::new(term, tokens) // filter for the server, tokens for verify + } + // check elided +} +``` + +A row the server returns for `alice` passes `verify` only if its decrypted +text holds `ali`, `lic` and `ice`, so a Bloom false positive never reaches +the caller. ### 3. The domain type chooses the best index for an operator @@ -209,7 +235,8 @@ pub trait QueryTerm {} pub trait Probabilistic: QueryTerm { type Plaintext; fn verify(&self, value: &Self::Plaintext) -> bool; - fn target_limit(&self, want: u64, stats: &Stats) -> Limit; + // `target_limit(&self, want, &Stats) -> Limit` joins it with decision 5 + // (#1184). } pub trait Ranked: QueryTerm { diff --git a/packages/stack-encrypt/src/op.rs b/packages/stack-encrypt/src/op.rs index 5f1d26d2e..faccd5fc0 100644 --- a/packages/stack-encrypt/src/op.rs +++ b/packages/stack-encrypt/src/op.rs @@ -55,7 +55,9 @@ use std::fmt; use std::marker::PhantomData; -use crate::sem::{DefaultMatch, EqualityTerm, MatchConfig, MatchQuery, MatchTerms}; +use stack_kms::MaybeSend; + +use crate::sem::{DefaultMatch, EqualityTerm, MatchConfig, MatchQuery, MatchTerms, QueryTokens}; use crate::target::{Equality, Index, Match}; use crate::PlanError; @@ -199,13 +201,63 @@ pub trait QueryTerm {} impl QueryTerm for EqualityTerm {} impl QueryTerm for MatchQuery {} +/// A query term whose answer can hold **false positives and no false +/// negatives**: a row the server returns may not match, and a row that +/// matches is always returned. +/// +/// [`verify`](Self::verify) is the operator's own meaning, run on a +/// decrypted value: it decides whether a row the server returned is a real +/// match. The library never runs a query, so it cannot drop the false +/// positives itself; a caller filters the decrypted rows with `verify`. +/// +/// An exact term does not implement this, so `verify` on one is a compile +/// error rather than a method that is always `true`. +pub trait Probabilistic: QueryTerm { + /// The decrypted value `verify` reads: the plaintext the index was + /// declared over, which is not always the value the user wrote. + type Plaintext: ?Sized; + + /// Whether `value`, a row's decrypted value, really matches the query. + /// + /// Infallible: it runs on a value already decrypted and already the type + /// the index was declared over, so every outcome other than a match is + /// `false`, never an error. + fn verify(&self, value: &Self::Plaintext) -> bool; +} + +/// The query terms that may answer the operator `Op`: what +/// [`Supports::Query`] must be. +/// +/// Most operators take any [`QueryTerm`]. One whose answer is inherently +/// approximate requires more: a term that answers [`Matches`](struct@Matches) must be +/// [`Probabilistic`], so none of this crate's indexes can claim a text +/// match without saying how its answer is checked. +/// +/// The rule binds this crate's terms, through the blanket impls below. A +/// term defined in another crate can implement this for an operator by +/// hand, and coherence lets it; the bound is a guide there, not a proof. An +/// operator defined in another crate states its requirement the same way, +/// one term type at a time. +#[diagnostic::on_unimplemented( + message = "`{Self}` cannot answer `{Op}`", + label = "this query term does not meet the operator's requirement", + note = "a term that answers `op::Matches` must implement `op::Probabilistic`" +)] +pub trait TermFor: QueryTerm {} + +impl TermFor for T {} +impl TermFor> for T {} + /// An index that answers the operator `Op` over plaintexts `S`, and the /// query term it sends for it. /// /// [`Index::Term`] is what the index derives; [`query`](Self::query) makes -/// the query term from it. For an equality index the two are the same -/// HMAC. For a match index they are not: the query is a filter over the -/// query's value, and its type says so. +/// the query term from it and from the query's [`Verifier`](Self::Verifier). +/// For an equality index the two are the same HMAC and there is nothing to +/// verify. For a match index they are not: the query is a filter over the +/// query's value, which matches some rows that do not hold the value, so +/// the query keeps that value's tokens to +/// [`verify`](Probabilistic::verify) each decrypted row. #[diagnostic::on_unimplemented( message = "`{Self}` does not answer `{Op}` over `{S}`", label = "this index cannot answer the operator", @@ -213,15 +265,27 @@ impl QueryTerm for MatchQuery {} )] pub trait Supports: Index { /// What this index sends for `Op`. - type Query: QueryTerm; + type Query: TermFor; + /// What the query term keeps of the query's plaintext so that it can + /// later check a decrypted row, taken before the plaintext is consumed. + /// + /// An exact term checks nothing, so its verifier is `()`. A match + /// query's is [`crate::sem::QueryTokens`]: `Supports for Match` cuts `"alice"` into `ali`, `lic`, `ice`, and + /// [`MatchQuery::verify`](Probabilistic::verify) passes a row only if + /// its own text holds all three. The filter cannot give the tokens back, + /// so without the verifier a Bloom false positive would reach the caller. + type Verifier: MaybeSend + 'static; /// Why this index cannot answer `Op` as configured, if it cannot: a /// match index whose [`MatchCase`] disagrees with its options. fn check(&self) -> Result<(), PlanError>; - /// The query term, from the term the index derived for the query's - /// value. Only a query chain calls this: `QueryToken` cannot be made - /// outside this crate, so a stored term cannot be passed off as a - /// query. - fn query(term: Self::Term, token: QueryToken) -> Self::Query; + /// The verifier of the query's plaintext `value`. + fn verifier(&self, value: &S) -> Self::Verifier; + /// The query term, from the verifier and the term the index derived for + /// the query's value. Only a query chain calls this: `QueryToken` + /// cannot be made outside this crate, so no caller can pair one query's tokens + /// with another value's filter, or pass a stored term off as a query. + fn query(verifier: Self::Verifier, term: Self::Term, token: QueryToken) -> Self::Query; } /// Proof that [`Supports::query`] is called by a query chain. Only this @@ -238,10 +302,12 @@ impl QueryToken { impl Supports for Equality { type Query = EqualityTerm; + type Verifier = (); fn check(&self) -> Result<(), PlanError> { Ok(()) } - fn query(term: EqualityTerm, _: QueryToken) -> EqualityTerm { + fn verifier(&self, _: &S) {} + fn query((): (), term: EqualityTerm, _: QueryToken) -> EqualityTerm { term } } @@ -252,6 +318,7 @@ where O: MatchCase, { type Query = MatchQuery; + type Verifier = QueryTokens; fn check(&self) -> Result<(), PlanError> { let folds = O::options().downcase; if folds == O::Case::FOLDS { @@ -263,8 +330,11 @@ where }) } } - fn query(term: MatchTerms, _: QueryToken) -> MatchQuery { - MatchQuery::from_terms(term) + fn verifier(&self, value: &S) -> QueryTokens { + QueryTokens::of::(value.as_ref()) + } + fn query(tokens: QueryTokens, term: MatchTerms, _: QueryToken) -> MatchQuery { + MatchQuery::new(term, tokens) } } diff --git a/packages/stack-encrypt/src/plan/chain.rs b/packages/stack-encrypt/src/plan/chain.rs index 1142a5e21..0526a0504 100644 --- a/packages/stack-encrypt/src/plan/chain.rs +++ b/packages/stack-encrypt/src/plan/chain.rs @@ -1015,9 +1015,10 @@ where where 'a: 'p, { + let verifier = self.inner.index.verifier(&self.inner.value); self.inner .prepare(keyset, scoped) - .map(|term| I::query(term, QueryToken::new())) + .map(move |term| I::query(verifier, term, QueryToken::new())) } } diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 53d65cb85..635f38629 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -128,6 +128,7 @@ mod tokenize; pub use tokenize::Tokenizer; +use std::collections::HashSet; use std::fmt; use std::marker::PhantomData; @@ -142,7 +143,7 @@ use vitaminc_prf::{ PrfVisitorError, SeqAccess, }; use vitaminc_protected::NonEmpty; -use zeroize::Zeroize; +use zeroize::{Zeroize, Zeroizing}; use stack_kms::MaybeSend; @@ -653,14 +654,24 @@ impl Eq for MatchTerms {} /// stored filter is never sent as a query. ADR-0009 records why. A /// `MatchQuery` comes from a query chain ([`op::Matches`](struct@crate::op::Matches)), /// never from a target. +/// +/// A Bloom filter can hold every bit of the query without the value holding +/// the query's text, so a match query is +/// [`Probabilistic`](crate::op::Probabilistic): it keeps the query's tokens, +/// as the index cut them, and [`verify`](crate::op::Probabilistic::verify) +/// checks a decrypted value against them. The tokens are plaintext: they +/// never leave the process, never appear in `Debug`, and are zeroized when +/// the query is dropped. pub struct MatchQuery { terms: MatchTerms, + tokens: QueryTokens, } impl MatchQuery { - /// The query term of the filter `terms`, derived over the query's value. - pub(crate) fn from_terms(terms: MatchTerms) -> Self { - Self { terms } + /// The query term of the filter `terms`, derived over the value whose + /// tokens are `tokens`. + pub(crate) fn new(terms: MatchTerms, tokens: QueryTokens) -> Self { + Self { terms, tokens } } /// The set positions of the query's filter, sorted ascending, no @@ -680,11 +691,32 @@ impl MatchQuery { } } +/// A row is a real match when every token of the query is a token of the +/// value, cut by the index's own tokenizer and case folding. That is the +/// meaning a Bloom match approximates, so `verify` keeps every row the +/// filter would have kept had it no false positives: order and repetition +/// do not count, and a match is not a substring test. +impl crate::op::Probabilistic for MatchQuery { + type Plaintext = str; + + fn verify(&self, value: &str) -> bool { + let options = O::options(); + let tokens = Zeroizing::new(tokenize::tokenize( + value, + options.tokenizer, + options.downcase, + )); + 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())) + } +} + +/// Positions only: the tokens are plaintext. impl fmt::Debug for MatchQuery { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("MatchQuery") .field("positions", &self.terms.positions) - .finish() + .finish_non_exhaustive() } } @@ -692,10 +724,16 @@ impl Clone for MatchQuery { fn clone(&self) -> Self { Self { terms: self.terms.clone(), + tokens: self.tokens.clone(), } } } +/// Two queries are equal when they send the same filter. This compares no +/// plaintext. Two query values can share a filter by chance, as a short +/// value under a small filter does, and those queries are equal here yet +/// verify differently: equal queries select the same rows, not the same +/// matches. impl PartialEq for MatchQuery { fn eq(&self, other: &Self) -> bool { self.terms == other.terms @@ -704,6 +742,29 @@ impl PartialEq for MatchQuery { impl Eq for MatchQuery {} +/// The query's tokens, as a match index cuts them from the query's value: +/// a match query's verifier, which a [`MatchQuery`] keeps to verify a row. Plaintext, so it is +/// zeroized on drop and has no `Debug`. +/// [`Supports::verifier`](crate::op::Supports::verifier) makes one; only a query +/// chain can join it to a filter +/// ([`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)] +pub struct QueryTokens(Zeroizing>); + +impl QueryTokens { + /// The tokens of `text` under `O`'s tokenizer and case folding. + pub(crate) fn of(text: &str) -> Self { + let options = O::options(); + Self(Zeroizing::new(tokenize::tokenize( + text, + options.tokenizer, + options.downcase, + ))) + } +} + /// A [`PrfVisitor`] that folds a sequence of per-token PRF blocks into /// Bloom-filter bit positions: `k` little-endian 2-byte slices of each block, /// masked to the filter size. @@ -766,6 +827,10 @@ fn match_term( } let context = Context::pae(&[MATCH_DOMAIN, context.as_bytes()]); + // The PRF takes the tokens by value and moves each `String`'s buffer + // into a `Protected`, which zeroizes it on drop: the tokens are wiped + // there, not here. The `Vec` itself holds only their pointers. + let positions = tokens .prf_visit_with_context(prf, context, BloomVisitor { k: options.k, mask }) .into_result() @@ -1278,6 +1343,19 @@ mod tests { use super::*; + #[test] + fn a_match_query_with_no_tokens_verifies_nothing() { + // A query chain refuses token-less text before a term exists, so + // this cannot reach a `MatchQuery` through the API; the guard is for + // one built any other way, which must never match every row. + let query = MatchQuery::::new( + MatchTerms::normalised(vec![1, 2, 3]), + QueryTokens(Zeroizing::new(Vec::new())), + ); + assert!(!crate::op::Probabilistic::verify(&query, "anything at all")); + assert!(!crate::op::Probabilistic::verify(&query, "")); + } + /// The Bloom fold reads a token *stream*: handed a map-shaped PRF input /// it refuses, rather than folding the entries' blocks as if they were /// tokens. diff --git a/packages/stack-encrypt/src/sem/tokenize.rs b/packages/stack-encrypt/src/sem/tokenize.rs index 1d5986fe5..9e5457907 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 zeroize::Zeroizing; + /// How text is split into tokens before each token is run through the PRF. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Tokenizer { @@ -45,12 +47,26 @@ impl Default for Tokenizer { /// term can never reach a query. /// /// [`match_terms`]: crate::StackCipher::match_terms +/// +/// An n-gram length of zero yields no tokens rather than panicking, so a +/// caller that tokenizes before validating the options (a query's verifier) +/// still reaches [`MatchOptions::validate`]'s refusal. +/// +/// The text is plaintext, so the copies made on the way are zeroized when +/// they are dropped: the folded text, its characters, which are collected +/// into a buffer sized up front, and each n-gram, which is built at its +/// final size. Neither reallocates, so no freed buffer holds plaintext. The +/// one exception is inside `str::to_lowercase`, which grows its output when +/// folding lengthens the text (as `İ` does). The tokens returned are the +/// caller's to zeroize. +/// +/// [`MatchOptions::validate`]: super::MatchOptions pub(crate) fn tokenize(text: &str, tokenizer: Tokenizer, downcase: bool) -> Vec { - let text = if downcase { + let text = Zeroizing::new(if downcase { text.to_lowercase() } else { text.to_string() - }; + }); match tokenizer { Tokenizer::Standard => text @@ -59,19 +75,32 @@ pub(crate) fn tokenize(text: &str, tokenizer: Tokenizer, downcase: bool) -> Vec< .map(str::to_string) .collect(), Tokenizer::Ngram { length } => { - let chars: Vec = text.chars().collect(); + // `collect` would size the buffer from `Chars::size_hint`, about + // a quarter of the text, and grow it, leaving plaintext in each + // buffer it frees. + let mut chars: Zeroizing> = + Zeroizing::new(Vec::with_capacity(text.chars().count())); + chars.extend(text.chars()); // As in the v1 indexer's `process_ngram`: shorter than one gram // means no tokens (never a partial or whole-text token, which // could not match any stored gram). - if chars.len() < length { + if length == 0 || chars.len() < length { Vec::new() } else { - chars.windows(length).map(|w| w.iter().collect()).collect() + chars.windows(length).map(gram).collect() } } } } +/// One n-gram as a `String` allocated at its final size, so building it +/// leaves no reallocated copy behind. +fn gram(chars: &[char]) -> String { + let mut gram = String::with_capacity(chars.iter().map(|c| c.len_utf8()).sum()); + gram.extend(chars); + gram +} + #[cfg(test)] mod tests { use super::*; @@ -84,6 +113,11 @@ mod tests { ); } + #[test] + fn a_zero_ngram_length_yields_no_tokens_rather_than_panicking() { + assert!(tokenize("hello", Tokenizer::Ngram { length: 0 }, true).is_empty()); + } + #[test] fn ngram_gram_length_text_yields_one_token() { assert_eq!( diff --git a/packages/stack-encrypt/tests/op.rs b/packages/stack-encrypt/tests/op.rs index f3931aa41..55eb8f8a6 100644 --- a/packages/stack-encrypt/tests/op.rs +++ b/packages/stack-encrypt/tests/op.rs @@ -6,7 +6,7 @@ mod common; use common::{loads_counting_cipher, stack_cipher}; use stack_encrypt::kms::FakeDataKeySource; -use stack_encrypt::op::{self, MatchCase}; +use stack_encrypt::op::{self, MatchCase, Probabilistic}; use stack_encrypt::plan::{Field, FieldSchema, Fields, PlanError}; use stack_encrypt::sem::{EqualityTerm, MatchConfig, MatchOptions, MatchQuery, MatchTerms}; use stack_encrypt::target::{Encrypted, IndexSpec}; @@ -135,7 +135,7 @@ async fn a_matches_query_is_a_filter_over_the_query_value_that_the_stored_filter } #[tokio::test] -async fn a_match_index_with_its_own_configuration_is_named_with_op_via() { +async fn a_match_index_with_its_own_configuration_is_named_with_via() { let cipher = stack_cipher().await; let plan = people_plan(); let mut row = cipher.encrypt(&person()).using(&plan).await.unwrap(); @@ -269,7 +269,7 @@ async fn a_target_answers_an_operator_through_the_index_it_names() { } #[tokio::test] -async fn a_target_with_several_terms_names_the_index_with_op_via() { +async fn a_target_with_several_terms_names_the_index_with_via() { let cipher = stack_cipher().await; let plan = Plan::value::() .context("users/email") @@ -467,6 +467,44 @@ async fn a_query_names_its_context_before_or_after_the_index() { assert_eq!(before, after); } +/// A configuration with an n-gram length of zero, which `MatchOptions` +/// refuses when a term is derived. +struct ZeroGram; +impl MatchConfig for ZeroGram { + fn options() -> MatchOptions { + MatchOptions { + tokenizer: stack_encrypt::sem::Tokenizer::Ngram { length: 0 }, + ..Default::default() + } + } +} +impl MatchCase for ZeroGram { + type Case = op::CaseInsensitive; +} + +#[tokio::test] +async fn a_match_query_under_invalid_options_is_refused_not_a_panic() { + let cipher = stack_cipher().await; + let plan = Plan::context("users/name") + .with::(Match::::new()) + .build() + .unwrap(); + let refused = cipher + .query(op::Matches, "Bob") + .via(Match::::new()) + .on(&plan) + .await; + assert!( + matches!( + refused, + Err(Error::Term(stack_encrypt::sem::TermError::InvalidOptions( + _ + ))) + ), + "{refused:?}" + ); +} + #[tokio::test] async fn a_refused_operator_query_loads_no_keyset() { let (cipher, loads) = loads_counting_cipher().await; @@ -495,21 +533,6 @@ async fn a_refused_operator_query_loads_no_keyset() { ); } -#[tokio::test] -async fn a_match_query_debugs_as_its_positions() { - let cipher = stack_cipher().await; - let plan = people_plan(); - let query = cipher - .query(op::Matches, "bob") - .on(&plan.field("email").unwrap()) - .await - .unwrap(); - assert_eq!( - format!("{query:?}"), - format!("MatchQuery {{ positions: {:?} }}", query.positions()) - ); -} - #[tokio::test] async fn an_operator_query_runs_under_the_keyset_it_names() { let cipher = stack_cipher().await; @@ -537,3 +560,164 @@ async fn an_operator_query_runs_under_the_keyset_it_names() { .unwrap(); assert_ne!(under_default, stored.terms, "another keyset, another key"); } + +/// A match configuration with the smallest filter allowed, so a long value +/// sets nearly every bit and false positives are easy to find. +struct Tiny; +impl MatchConfig for Tiny { + fn options() -> MatchOptions { + MatchOptions { + m: 32, + ..Default::default() + } + } +} +impl MatchCase for Tiny { + type Case = op::CaseInsensitive; +} + +#[tokio::test] +async fn verify_keeps_every_real_match_and_folds_case_as_the_index_does() { + let cipher = stack_cipher().await; + let plan = people_plan(); + let email_plan = plan.field("email").unwrap(); + let name_plan = plan.field("name").unwrap(); + + let query = cipher + .query(op::Matches, "BOB@Example") + .on(&email_plan) + .await + .unwrap(); + assert!(query.verify("bob@example.com")); + assert!(query.verify(&String::from("say hi to Bob@example.org"))); + assert!(!query.verify("alice@example.com")); + assert!(query.clone().verify("bob@example.com")); + + let exact = cipher + .query(op::Matches.case_sensitive(), "Nguyen") + .via(Match::::new()) + .on(&name_plan) + .await + .unwrap(); + assert!(exact.verify("Alice Nguyen")); + assert!( + !exact.verify("alice nguyen"), + "a case-sensitive index keeps case" + ); +} + +#[tokio::test] +async fn verify_drops_a_row_the_filter_matched_by_chance() { + let cipher = stack_cipher().await; + let plan = Plan::context("notes/body") + .with::(Match::::new()) + .build() + .unwrap(); + let value = "the quick brown fox jumps over the lazy dog and keeps running"; + let stored: Encrypted> = cipher + .encrypt(&value.to_owned()) + .using(&plan) + .await + .unwrap(); + + let real = cipher + .query(op::Matches, "lazy dog") + .via(Match::::new()) + .on(&plan) + .await + .unwrap(); + assert!(stored.terms.matches(&real) && real.verify(value)); + + // The value holds no digit, so no query with one is in it; a 32-bit + // filter this full holds some of their filters anyway. + let mut false_positive = None; + for i in 0..1000 { + let digits = format!("{i:04}"); + let query = cipher + .query(op::Matches, &digits) + .via(Match::::new()) + .on(&plan) + .await + .unwrap(); + if stored.terms.matches(&query) { + false_positive = Some(query); + break; + } + } + let query = false_positive.expect("a 32-bit filter this full matches some query by chance"); + assert!( + !query.verify(value), + "the server would return this row; verify does not keep it" + ); +} + +#[tokio::test] +async fn a_match_query_debugs_without_its_tokens() { + let cipher = stack_cipher().await; + let plan = people_plan(); + let query = cipher + .query(op::Matches, "needle") + .on(&plan.field("email").unwrap()) + .await + .unwrap(); + let shown = format!("{query:?}"); + assert_eq!( + shown, + format!("MatchQuery {{ positions: {:?}, .. }}", query.positions()) + ); + for token in ["nee", "eed", "edl", "dle", "needle"] { + assert!(!shown.contains(token), "{shown}"); + } +} + +/// Whole words, as the v1 match indexer's standard tokenizer splits them. +struct Words; +impl MatchConfig for Words { + fn options() -> MatchOptions { + MatchOptions { + tokenizer: stack_encrypt::sem::Tokenizer::Standard, + ..Default::default() + } + } +} +impl MatchCase for Words { + type Case = op::CaseInsensitive; +} + +/// A Bloom match asks for the query's tokens, in any order and anywhere; +/// it is not a substring test, and `verify` must not become one, or it +/// would drop rows the index rightly returned. +#[tokio::test] +async fn verify_is_a_token_test_not_a_substring_test() { + let cipher = stack_cipher().await; + let plan = people_plan(); + let query = cipher + .query(op::Matches, "abcd") + .on(&plan.field("email").unwrap()) + .await + .unwrap(); + assert!(query.verify("abc bcd"), "both 3-grams, never adjacent"); + assert!(!query.verify("abc"), "one 3-gram of two"); + + let words = Plan::context("notes/body") + .with::(Match::::new()) + .build() + .unwrap(); + let stored: Encrypted> = cipher + .encrypt(&"Hello, big World!".to_owned()) + .using(&words) + .await + .unwrap(); + let query = cipher + .query(op::Matches, "world hello") + .via(Match::::new()) + .on(&words) + .await + .unwrap(); + assert!(stored.terms.matches(&query)); + assert!(query.verify("Hello, big World!")); + assert!( + !query.verify("worldly hello"), + "a word is a token, not a prefix" + ); +} diff --git a/packages/stack-encrypt/tests/ui/op_inexact_operator_needs_probabilistic.rs b/packages/stack-encrypt/tests/ui/op_inexact_operator_needs_probabilistic.rs new file mode 100644 index 000000000..2f58d682b --- /dev/null +++ b/packages/stack-encrypt/tests/ui/op_inexact_operator_needs_probabilistic.rs @@ -0,0 +1,35 @@ +//! A text match is approximate, so an index that answers `Matches` must send +//! a term that says how its answer is checked: an exact term cannot answer +//! it. +use stack_encrypt::op; +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{CallerContext, ConsumeSource, Encryption, Index, IndexSpec}; +use stack_encrypt::Equality; + +struct Shortcut; + +impl Index for Shortcut { + type Term = EqualityTerm; + fn spec(&self) -> IndexSpec { + IndexSpec::Equality + } + fn operation<'s, K: 'static, M: ConsumeSource<'s, String>>( + &self, + ) -> Encryption<'s, String, EqualityTerm, K, CallerContext, M> { + Index::::operation(&Equality) + } +} + +impl op::Supports for Shortcut { + type Query = EqualityTerm; + type Verifier = (); + fn check(&self) -> Result<(), stack_encrypt::PlanError> { + Ok(()) + } + fn verifier(&self, _: &String) {} + fn query((): (), term: EqualityTerm, _: op::QueryToken) -> EqualityTerm { + term + } +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/op_inexact_operator_needs_probabilistic.stderr b/packages/stack-encrypt/tests/ui/op_inexact_operator_needs_probabilistic.stderr new file mode 100644 index 000000000..ec190c313 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/op_inexact_operator_needs_probabilistic.stderr @@ -0,0 +1,19 @@ +error[E0277]: `EqualityTerm` cannot answer `stack_encrypt::op::Matches` + --> tests/ui/op_inexact_operator_needs_probabilistic.rs:24:18 + | +24 | type Query = EqualityTerm; + | ^^^^^^^^^^^^ this query term does not meet the operator's requirement + | + = help: the trait `Probabilistic` is not implemented for `EqualityTerm` + = note: a term that answers `op::Matches` must implement `op::Probabilistic` +help: the trait `Probabilistic` is implemented for `MatchQuery` + --> src/sem/mod.rs + | + | impl crate::op::Probabilistic for MatchQuery { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = note: required for `EqualityTerm` to implement `TermFor` +note: required by a bound in `stack_encrypt::op::Supports::Query` + --> src/op.rs + | + | type Query: TermFor; + | ^^^^^^^^^^^ required by this bound in `Supports::Query` diff --git a/packages/stack-encrypt/tests/ui/op_verify_exact_term.rs b/packages/stack-encrypt/tests/ui/op_verify_exact_term.rs new file mode 100644 index 000000000..d83af5055 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/op_verify_exact_term.rs @@ -0,0 +1,16 @@ +//! An exact query term has nothing to verify: the server's answer is the +//! answer, so `verify` on an equality term does not compile. +use stack_encrypt::kms::FakeDataKeySource; +use stack_encrypt::op::{self, Probabilistic}; +use stack_encrypt::{Equality, Plan, StackCipher}; + +async fn query(cipher: &StackCipher) { + let plan = Plan::context("users/email") + .with::(Equality) + .build() + .unwrap(); + let term = cipher.query(op::Eq, "bob").on(&plan).await.unwrap(); + let _ = term.verify("bob"); +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/op_verify_exact_term.stderr b/packages/stack-encrypt/tests/ui/op_verify_exact_term.stderr new file mode 100644 index 000000000..2ffdf93b3 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/op_verify_exact_term.stderr @@ -0,0 +1,5 @@ +error[E0599]: no method named `verify` found for struct `EqualityTerm` in the current scope + --> tests/ui/op_verify_exact_term.rs:13:18 + | +13 | let _ = term.verify("bob"); + | ^^^^^^ method not found in `EqualityTerm`