From 13978180996c6b13ab840f61fe5501b45adfe5e7 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 14 Nov 2026 23:16:23 +1100 Subject: [PATCH 1/6] feat(stack-encrypt): a field type that stores its context can be a record field A derived struct record gives each field its scope and the field's name, as a CallerContext. A field type that stores its context in the row, such as an EQL type that stores a table and a column, could not take it: the derive converts with From, and the conversion can fail. A record that lists a caller scope or a binding gives the field more than a table and a column, and an EQL payload has nowhere to store it. - target::FromFieldContext: a type that stores its context rebuilds it from the field's CallerContext, or returns None. - Encryption::under converts the field's context through the hidden trait target::FromCaller. Every From type implements it, so callers compile unchanged. RequiredContext> implements it for T: FromFieldContext, and refuses a context T cannot store. - ExpectedContext converts from a CallerContext the same way. On decrypt the stored context must equal the field's. - PlanError::ContextNotStorable, refused before any key is requested. - FromCaller carries an on_unimplemented message, so a field whose type cannot take the record's context says so, not that a hidden trait is missing. Part of #1658. --- packages/stack-encrypt/CHANGELOG.md | 20 ++ packages/stack-encrypt/src/codes.rs | 1 + packages/stack-encrypt/src/dynamic/record.rs | 8 +- packages/stack-encrypt/src/plan/error.rs | 14 +- packages/stack-encrypt/src/target/context.rs | 61 +++++- packages/stack-encrypt/src/target/input.rs | 44 +++- packages/stack-encrypt/src/target/mod.rs | 6 +- .../stack-encrypt/src/target/operations.rs | 8 +- packages/stack-encrypt/tests/field_context.rs | 198 ++++++++++++++++++ .../ui/owned_record_field_context.stderr | 21 +- 10 files changed, 355 insertions(+), 26 deletions(-) create mode 100644 packages/stack-encrypt/tests/field_context.rs diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 044fbe68df..f3bd363c00 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -9,6 +9,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **A field type that stores its context can be a record field** (#1658). + A derived `struct = ..` record gives each field its scope and the + field's name. A type that stores its context in the row, such as an EQL + type that stores a table and a column, implements the new trait + `target::FromFieldContext` to take that context as the one it stores. + - `FromFieldContext::from_field_context` rebuilds the stored context + from the field's `CallerContext`, or returns `None` when the type + cannot store it. + - A context the type cannot store is refused before any key is + requested, on encrypt and on decrypt, with the new + `PlanError::ContextNotStorable` + (`stack_encrypt::plan_context_not_storable`). A record that lists a + caller scope or a binding gives its fields such a context. + - On decrypt, the stored context must equal the field's, as a + standalone decrypt with an expected context checks it. + - `Encryption::under` now converts the field's context through the + hidden trait `target::FromCaller`, which every + `From` type implements. A caller of `under` compiles + unchanged. + - **`json::JsonPlaintext`, a JSON document as one ciphertext** (#1636). `JsonPlaintext` holds a `serde_json::Value` and wipes it when it is dropped. Build one with `JsonPlaintext::from(value)` or diff --git a/packages/stack-encrypt/src/codes.rs b/packages/stack-encrypt/src/codes.rs index 4fa0b408d6..30bb912597 100644 --- a/packages/stack-encrypt/src/codes.rs +++ b/packages/stack-encrypt/src/codes.rs @@ -175,6 +175,7 @@ mod tests { }, PlanError::EmptyIndexes => PlanError::EmptyIndexes, PlanError::EmptyPart => PlanError::EmptyPart, + PlanError::ContextNotStorable => PlanError::ContextNotStorable, PlanError::NotInPlan { .. } => PlanError::NotInPlan { field: field() }, PlanError::NotInValue { .. } => PlanError::NotInValue { field: field() }, PlanError::FieldType { .. } => PlanError::FieldType { diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 85927b0909..9984ca6507 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -672,9 +672,11 @@ fn refusal(error: crate::Error) -> (Option, Reason) { } P::BindInQuery => (None, Reason::BindInQuery), P::EmptyPart => (None, Reason::EmptyBinding), - P::IdentityWithoutField | P::MixedCiphers | P::TwoContextSources { .. } | P::NoContext => { - (None, Reason::Refused) - } + P::IdentityWithoutField + | P::MixedCiphers + | P::TwoContextSources { .. } + | P::NoContext + | P::ContextNotStorable => (None, Reason::Refused), } } diff --git a/packages/stack-encrypt/src/plan/error.rs b/packages/stack-encrypt/src/plan/error.rs index d660dfc39b..5f95ea0c80 100644 --- a/packages/stack-encrypt/src/plan/error.rs +++ b/packages/stack-encrypt/src/plan/error.rs @@ -251,6 +251,17 @@ pub enum PlanError { help("Give the scope or binding a value. An empty part would put every row under one context.") )] EmptyPart, + /// A record field's type stores its context in the row, and the record + /// gives the field a context it cannot store. An EQL type stores a + /// table and a column, so it takes the record's scope and the field's + /// name, and nothing more: no caller scope and no binding. Refused + /// before any key is requested. + #[error("the field's type cannot store the context the record gives it")] + #[diagnostic( + code(stack_encrypt::plan_context_not_storable), + help("A field whose type stores its context, such as an EQL type, takes the record's scope and the field's name alone. Remove the record's caller scope and binding, or give the field a type that does not store its context.") + )] + ContextNotStorable, /// A field was declared both as a typed target (`encrypt_into`) and with /// a data verb. A field is one or the other: the target's type decides /// its layout and its queries. @@ -326,7 +337,8 @@ impl crate::ErrorPayload for PlanError { | Self::MixedCiphers | Self::NoContext | Self::BindInQuery - | Self::EmptyPart => serde_json::Map::new(), + | Self::EmptyPart + | Self::ContextNotStorable => serde_json::Map::new(), } } } diff --git a/packages/stack-encrypt/src/target/context.rs b/packages/stack-encrypt/src/target/context.rs index feec30f47b..57e4c35edd 100644 --- a/packages/stack-encrypt/src/target/context.rs +++ b/packages/stack-encrypt/src/target/context.rs @@ -921,10 +921,22 @@ impl DeclaredContext { // both still showed the type. #[doc(hidden)] #[derive(Clone, Debug)] -pub struct ExpectedContext(Option>); +pub struct ExpectedContext(Expected); + +/// What [`ExpectedContext`] checks the stored context against. +#[derive(Clone, Debug)] +enum Expected { + /// Any nonempty stored context. + Any, + /// A stored context equal to this one. + Exactly(NonEmpty), + /// Nothing: a record gave the field a context its type cannot store, so + /// no stored context can be the one expected. + NotStorable, +} impl Default for ExpectedContext { fn default() -> Self { - Self(None) + Self(Expected::Any) } } impl From<()> for ExpectedContext { @@ -934,9 +946,40 @@ impl From<()> for ExpectedContext { } impl From> for ExpectedContext { fn from(value: NonEmpty) -> Self { - Self(Some(value)) + Self(Expected::Exactly(value)) + } +} +/// A record field whose type stores its context expects the context the +/// record gives the field. A context the type cannot store is refused when +/// the field is opened, before any key is requested. +impl From for ExpectedContext { + fn from(context: CallerContext) -> Self { + Self(match T::from_field_context(&context) { + Some(expected) => Expected::Exactly(expected), + None => Expected::NotStorable, + }) } } + +/// A context type that a record field's type stores in the row, and can +/// rebuild from the context the record gives the field. +/// +/// A derived `struct = ..` record gives each field the record's scope and +/// the field's name, extended by the caller's scopes and bindings, as a +/// [`CallerContext`]. A field type that stores its context takes that +/// context as the type it stores, so it can be a field of a record. +/// `eql_bindings::Identifier` implements this: an EQL type stores a table +/// and a column, so it takes `("members", "email")` and refuses a context +/// with a caller scope or a binding. +/// +/// The rebuilt context must give the same context as the one the record +/// gave. Then the field writes the bytes it writes on its own under that +/// context. +pub trait FromFieldContext: Sized { + /// The context the field's type stores, rebuilt from the context the + /// record gives the field, or `None` when the type cannot store it. + fn from_field_context(context: &CallerContext) -> Option>; +} impl<'c, T: MaybeEmpty + PartialEq + IntoContext<'c>> ExpectedContext { /// Check the stored context against this expectation and prove it /// nonempty, yielding the context the record is opened under. @@ -949,11 +992,15 @@ impl<'c, T: MaybeEmpty + PartialEq + IntoContext<'c>> ExpectedContext { /// context is data the record was handed, not something the cipher has /// authenticated yet: both checks happen before any key is requested. pub fn validate(self, stored: T) -> Result, Error> { - if let Some(expected) = self.0 { - if expected.into_inner() != stored { - let stored = Descriptor::from_piece(&stored.into_context()); - return Err(Error::ContextMismatch { stored }); + match self.0 { + Expected::Any => {} + Expected::Exactly(expected) => { + if expected.into_inner() != stored { + let stored = Descriptor::from_piece(&stored.into_context()); + return Err(Error::ContextMismatch { stored }); + } } + Expected::NotStorable => return Err(crate::plan::PlanError::ContextNotStorable.into()), } nonempty(stored) } diff --git a/packages/stack-encrypt/src/target/input.rs b/packages/stack-encrypt/src/target/input.rs index 9b42236f69..28560fd9e5 100644 --- a/packages/stack-encrypt/src/target/input.rs +++ b/packages/stack-encrypt/src/target/input.rs @@ -13,8 +13,8 @@ //! example, `RequiredContext` stays required, although `u64` has a //! default of zero. -use super::context::{CallerContext, DeclaredContext, ExpectedContext}; -use crate::{ContextPiece, IntoContext, NonEmpty}; +use super::context::{CallerContext, DeclaredContext, ExpectedContext, FromFieldContext}; +use crate::{ContextPiece, Error, IntoContext, NonEmpty}; mod sealed { pub trait Sealed {} @@ -144,6 +144,46 @@ impl> From for RequiredContext { Self::supplied(C::from(context)) } } + +/// The context a record field's declaration takes, built from the context +/// the record gives the field. [`Encryption::under`](super::Encryption::under) +/// converts through it. +/// +/// A context that converts from a [`CallerContext`] cannot fail. A field +/// type that stores its context ([`FromFieldContext`]) can refuse the one +/// the record gives it, before any key is requested. +/// +/// **This trait is for generated code.** A field type implements +/// [`FromFieldContext`], not this. +#[doc(hidden)] +#[diagnostic::on_unimplemented( + message = "a field whose type takes `{Self}` cannot be a field of a `struct = ..` record", + label = "this field's type cannot take the context the record gives it", + note = "the record gives each field its scope and the field's name as a `CallerContext`; a field's type takes it if its context input converts from `CallerContext`, or is `RequiredContext>` where `T` implements `FromFieldContext`" +)] +pub trait FromCaller: Sized { + /// Convert the field's context, or refuse it. + /// + /// # Errors + /// + /// [`PlanError::ContextNotStorable`](crate::PlanError::ContextNotStorable) + /// when the field's type cannot store the context. + fn from_caller(context: CallerContext) -> Result; +} +impl> FromCaller for C { + fn from_caller(context: CallerContext) -> Result { + Ok(C::from(context)) + } +} +// A field whose type stores its context takes the record's context for the +// field as that type, or refuses it. +impl FromCaller for RequiredContext> { + fn from_caller(context: CallerContext) -> Result { + T::from_field_context(&context) + .map(Self::supplied) + .ok_or_else(|| crate::plan::PlanError::ContextNotStorable.into()) + } +} // A record that derives terms gives its ciphertext fields the AEAD part of the // context it requires. impl From> for RequiredContext { diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 6ebfd35709..0e526f9f14 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -192,8 +192,8 @@ pub(crate) use self::core::{bind_pending, seal_pending}; #[doc(hidden)] pub use context::ExpectedContext; pub use context::{ - AeadContext, Bind, BindPart, CallerContext, CheckedInput, DeclaredContext, Extends, NoBind, - NoScope, Parts, Scope, ScopePart, Scopes, StoredParts, + AeadContext, Bind, BindPart, CallerContext, CheckedInput, DeclaredContext, Extends, + FromFieldContext, NoBind, NoScope, Parts, Scope, ScopePart, Scopes, StoredParts, }; pub use conversion::{DecryptFrom, DecryptSource, EncryptInto, EncryptTarget}; pub use index::{ @@ -201,7 +201,7 @@ pub use index::{ Select, TermSet, Whole, }; pub use input::{ - AcceptsContext, AllowsNoContext, ContextInput, DeclaredScopes, NoCallerContext, + AcceptsContext, AllowsNoContext, ContextInput, DeclaredScopes, FromCaller, NoCallerContext, OptionalContext, RequiredContext, ScalarContext, }; #[cfg(feature = "dynamic")] diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index c4a972cfdd..abe7cd17f7 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -18,7 +18,7 @@ use super::context::{AeadContext, CallerContext, DeclaredContext, Extends, Store use super::conversion::{DecryptSource, EncryptTarget}; use super::core::{encrypt_native, open_native, Term}; use super::index::{Index, IndexSpec, Json}; -use super::input::{AcceptsContext, NoCallerContext, RequiredContext}; +use super::input::{AcceptsContext, FromCaller, NoCallerContext, RequiredContext}; use super::source::{Borrowed, ConsumeSource, Owned, ShareSource, SourceMode}; use super::{CipherRef, Pending}; use crate::contexts::{self, Contexts, Placeholder, Shape, Shapes, SuppliedContext}; @@ -363,15 +363,15 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's, M: SourceMode<'s, S>> own: NonEmpty + MaybeSend + 's>, ) -> Encryption<'s, S, T, K, DeclaredContext, M> where - Ctx: From, + Ctx: FromCaller, { let own = contexts::piece_of(own); self.shapes = contexts::substitute(self.shapes, &Shape::under(own.get().clone())); let Self { build, shapes } = self; Encryption { build: Box::new(move |source, cipher, cx: DeclaredContext| { - match cx.checked_under(own) { - Ok(context) => build(source, cipher, context.into()), + match cx.checked_under(own).and_then(Ctx::from_caller) { + Ok(context) => build(source, cipher, context), Err(error) => Pending::failed(cipher, error), } }), diff --git a/packages/stack-encrypt/tests/field_context.rs b/packages/stack-encrypt/tests/field_context.rs new file mode 100644 index 0000000000..d73a61d579 --- /dev/null +++ b/packages/stack-encrypt/tests/field_context.rs @@ -0,0 +1,198 @@ +//! A field type that stores its context, as a field of a derived `struct = +//! ..` record (`FromFieldContext`). +//! +//! The record gives each field its scope and the field's name. A field type +//! that stores a table and a column takes that pair as the context it +//! stores, as an EQL type in `eql-bindings` does. Here `Column` stands in +//! for EQL's `Identifier`: +//! +//! - the field stores the pair, and writes the term it writes on its own +//! under that column; +//! - on decrypt, the stored column must be the field's; +//! - a record that lists a caller scope or a binding gives the field a +//! context it cannot store, and every call is refused before any key is +//! requested. +use std::sync::atomic::Ordering; + +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{ + Bind, CallerContext, DecryptSource, EncryptTarget, FromFieldContext, Scope, +}; +use stack_encrypt::{ + Ciphertext, ContextPiece, DecryptFrom, EncryptInto, Error, IntoContext, MaybeEmpty, NonEmpty, + PlanError, +}; +use zeroize::Zeroize; +mod common; + +/// A table and a column, as EQL's `Identifier` stores them. +#[derive(Clone, Debug, PartialEq)] +struct Column { + table: String, + column: String, +} + +impl Column { + fn of(table: &str, column: &str) -> NonEmpty { + NonEmpty::new(Self { + table: table.into(), + column: column.into(), + }) + .unwrap() + } +} + +impl MaybeEmpty for Column { + fn is_empty(&self) -> bool { + self.table.is_empty() || self.column.is_empty() + } +} + +/// The same two parts a record gives a field: the scope, then the name. +impl<'a> IntoContext<'a> for Column { + fn into_context(self) -> ContextPiece<'a> { + ContextPiece::List(vec![ + ContextPiece::Text(self.table.into()), + ContextPiece::Text(self.column.into()), + ]) + } +} + +impl FromFieldContext for Column { + fn from_field_context(context: &CallerContext) -> Option> { + if context.has_bindings() { + return None; + } + let ContextPiece::List(parts) = context.query() else { + return None; + }; + let [ContextPiece::Text(table), ContextPiece::Text(column)] = parts.as_slice() else { + return None; + }; + NonEmpty::new(Self { + table: table.to_string(), + column: column.to_string(), + }) + .ok() + } +} + +/// A value that stores its column, as an EQL `TextEq` does. +#[derive(Clone, EncryptTarget, DecryptSource)] +#[stash(plaintext = String)] +struct Stored { + #[stash(context_field)] + column: Column, + c: Ciphertext, + hm: EqualityTerm, +} + +#[derive(Clone, Debug, PartialEq, Zeroize)] +struct User { + email: String, +} + +fn ada() -> User { + User { + email: "ada@example.com".into(), + } +} + +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = User, scope = "users")] +struct EncryptedUser { + email: Stored, +} + +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = User, scope = "users", scope(tenant: u64))] +struct TenantUser { + email: Stored, +} + +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = User, scope = "users", bind(row: u128))] +struct BoundUser { + email: Stored, +} + +fn assert_not_storable(result: Result) { + match result { + Err(Error::Plan(PlanError::ContextNotStorable)) => {} + Err(other) => panic!("expected ContextNotStorable, got {other:?}"), + Ok(_) => panic!("expected ContextNotStorable, got a value"), + } +} + +#[tokio::test] +async fn a_field_stores_the_record_scope_and_its_name() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let stored: EncryptedUser = ada().encrypt_into(&keyset).await.unwrap(); + assert_eq!(stored.email.column, *Column::of("users", "email").get()); + + // The term the field writes on its own under the column. + let alone: Stored = ada() + .email + .encrypt_into_with_context(&keyset, Column::of("users", "email")) + .await + .unwrap(); + assert_eq!(stored.email.hm, alone.hm); + + let opened = User::decrypt_from(stored, &keyset).await.unwrap(); + assert_eq!(opened, ada()); + + // The value written on its own opens as the record's field. + let record = EncryptedUser { email: alone }; + assert_eq!(User::decrypt_from(record, &keyset).await.unwrap(), ada()); +} + +#[tokio::test] +async fn a_field_that_stores_another_column_is_refused() { + let (cipher, _, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let elsewhere: Stored = ada() + .email + .encrypt_into_with_context(&keyset, Column::of("users", "name")) + .await + .unwrap(); + let record = EncryptedUser { email: elsewhere }; + let before = retrieves.load(Ordering::SeqCst); + assert!(matches!( + User::decrypt_from(record, &keyset).await, + Err(Error::ContextMismatch { .. }) + )); + assert_eq!(retrieves.load(Ordering::SeqCst), before, "no key requested"); +} + +#[tokio::test] +async fn a_caller_scope_or_binding_is_refused_before_any_key() { + let (cipher, generates, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + + let scoped: Result = ada() + .encrypt_into_with_context(&keyset, Scope::new(42u64)) + .await; + assert_not_storable(scoped); + let bound: Result = ada() + .encrypt_into_with_context(&keyset, Bind::new(9u128)) + .await; + assert_not_storable(bound); + assert_eq!(generates.load(Ordering::SeqCst), 0, "no key requested"); + + // A value stored under the field's own column does not open in such a + // record either: the record's context is not the one it stores. + let alone: Stored = ada() + .email + .encrypt_into_with_context(&keyset, Column::of("users", "email")) + .await + .unwrap(); + let before = retrieves.load(Ordering::SeqCst); + let scoped = TenantUser { + email: alone.clone(), + }; + assert_not_storable(User::decrypt_from_with_context(scoped, &keyset, Scope::new(42u64)).await); + let bound = BoundUser { email: alone }; + assert_not_storable(User::decrypt_from_with_context(bound, &keyset, Bind::new(9u128)).await); + assert_eq!(retrieves.load(Ordering::SeqCst), before, "no key requested"); +} diff --git a/packages/stack-encrypt/tests/ui/owned_record_field_context.stderr b/packages/stack-encrypt/tests/ui/owned_record_field_context.stderr index 82fca205a9..9c21f46407 100644 --- a/packages/stack-encrypt/tests/ui/owned_record_field_context.stderr +++ b/packages/stack-encrypt/tests/ui/owned_record_field_context.stderr @@ -1,17 +1,25 @@ -error[E0277]: the trait bound `NoCallerContext: From` is not satisfied +error[E0277]: a field whose type takes `NoCallerContext` cannot be a field of a `struct = ..` record --> tests/ui/owned_record_field_context.rs:54:12 | 54 | fixed: Fixed, - | ^^^^^ the trait `From` is not implemented for `NoCallerContext` + | ^^^^^ this field's type cannot take the context the record gives it | + = help: the trait `From` is not implemented for `NoCallerContext` + = note: the record gives each field its scope and the field's name as a `CallerContext`; a field's type takes it if its context input converts from `CallerContext`, or is `RequiredContext>` where `T` implements `FromFieldContext` +help: the trait `FromCaller` is implemented for `RequiredContext>` + --> src/target/input.rs + | + | impl FromCaller for RequiredContext> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = note: required for `NoCallerContext` to implement `FromCaller` note: required by a bound in `Encryption::<'s, S, T, K, Ctx, M>::under` --> src/target/operations.rs | | pub fn under( | ----- required by a bound in this associated function ... - | Ctx: From, - | ^^^^^^^^^^^^^^^^^^^ required by this bound in `Encryption::<'s, S, T, K, Ctx, M>::under` + | Ctx: FromCaller, + | ^^^^^^^^^^ required by this bound in `Encryption::<'s, S, T, K, Ctx, M>::under` error[E0277]: the trait bound `u64: From` is not satisfied --> tests/ui/owned_record_field_context.rs:55:12 @@ -30,11 +38,12 @@ error[E0277]: the trait bound `u64: From` is not satisfied `u64` implements `From` and $N others = note: required for `RequiredContext` to implement `From` + = note: required for `RequiredContext` to implement `FromCaller` note: required by a bound in `Encryption::<'s, S, T, K, Ctx, M>::under` --> src/target/operations.rs | | pub fn under( | ----- required by a bound in this associated function ... - | Ctx: From, - | ^^^^^^^^^^^^^^^^^^^ required by this bound in `Encryption::<'s, S, T, K, Ctx, M>::under` + | Ctx: FromCaller, + | ^^^^^^^^^^ required by this bound in `Encryption::<'s, S, T, K, Ctx, M>::under` From 1753e56f2a41615db7a0818f10eb1a8ee33ae6ef Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 14 Nov 2026 23:16:24 +1100 Subject: [PATCH 2/6] feat(eql-bindings): EQL types as record fields An EQL v4 type can now be a field of a derived struct record. The field's column is the record's scope and the field's name, and the field writes the bytes the type writes on its own under that column. - Identifier implements FromFieldContext. It takes a context of two text parts with no binding, and only when it gives back the same context, so the stored bytes cannot differ. - JsonTreeDocument implements EncryptTarget and DecryptSource under the default options, and DecryptInto, the form a record field opens through. - tests/record_fields.rs: a record with EQL fields round-trips; each field writes the terms the type writes on its own and each opens the other's value; a marker query gives the stored term; the derive and a data plan, the path Go takes, write the same terms and open each other's values; a caller scope or binding is refused before any key is requested; so is a field that stores another column; and the JSON document works on its own and as a field. - The API plan, the eql-bindings README and CHANGELOG, and a @cipherstash/eql changeset. Closes #1658. --- .changeset/eql-types-as-record-fields.md | 12 + .../2026-10-24-stack-encrypt-api-plan.md | 47 +- packages/eql/Cargo.lock | 1 + packages/eql/crates/eql-bindings/CHANGELOG.md | 23 + packages/eql/crates/eql-bindings/README.md | 44 ++ .../eql/crates/eql-bindings/src/encryption.rs | 31 +- .../crates/eql-bindings/src/v4/json_tree.rs | 71 ++- packages/eql/tests/encryption/Cargo.toml | 1 + .../tests/encryption/tests/record_fields.rs | 504 ++++++++++++++++++ 9 files changed, 704 insertions(+), 30 deletions(-) create mode 100644 .changeset/eql-types-as-record-fields.md create mode 100644 packages/eql/tests/encryption/tests/record_fields.rs diff --git a/.changeset/eql-types-as-record-fields.md b/.changeset/eql-types-as-record-fields.md new file mode 100644 index 0000000000..108b733619 --- /dev/null +++ b/.changeset/eql-types-as-record-fields.md @@ -0,0 +1,12 @@ +--- +'@cipherstash/eql': minor +--- + +`eql-bindings`, the Rust crate released with this package: an EQL v4 type can now be a field of a Stack Encrypt record. + +- A `#[derive(EncryptTarget, DecryptSource)]` record with `struct = ..` can have fields such as `email: TextEq` or `profile: JsonTreeDocument`. The field's column is the record's scope and the field's name, so `scope = "members"` and the field `email` store `{"t": "members", "c": "email"}` in `i`. +- A field writes the same terms as the type written on its own under that column, and as a data plan that names the type. A data plan is how the Go module writes the type. +- A record that lists a caller scope or a binding cannot have an EQL field, because an EQL value stores only a table and a column. Every call is refused with `PlanError::ContextNotStorable`, before any key is requested. +- `JsonTreeDocument` now implements `EncryptTarget` and `DecryptSource` for `JsonPlaintext`. It encrypts under the default options, with the column as its context. + +The stored format, the SQL bundle, and the TypeScript types do not change. diff --git a/docs/plans/2026-10-24-stack-encrypt-api-plan.md b/docs/plans/2026-10-24-stack-encrypt-api-plan.md index 0a0b8cc362..a6a1c7da88 100644 --- a/docs/plans/2026-10-24-stack-encrypt-api-plan.md +++ b/docs/plans/2026-10-24-stack-encrypt-api-plan.md @@ -253,7 +253,8 @@ The field is then stored as one EQL column, such as Go records already do this through the dynamic interface. A field there names its EQL type, such as `"target": "TextEq"`, and the engine runs that -type for the field. The Rust record derive does not support it yet. +type for the field. The Rust record derive does the same (#1658). The +field's column is the record's scope and the field's name. ```rust use eql_bindings::v4::{integer::IntegerOrd, text::TextEq}; @@ -265,8 +266,6 @@ struct Member { age: i32, } -// Not on `next` yet in Rust: EQL types as fields of a derived record. -// Go records do this today through the dynamic interface. #[derive(EncryptTarget, DecryptSource)] #[stash(struct = Member, scope = "members")] struct EncryptedMember { @@ -401,9 +400,8 @@ use stack_encrypt::json::JsonPlaintext; let column = Identifier::for_column("customers", "profile")?; -// Not on `next` yet: the new traits for EQL types, and the public -// `JsonPlaintext`. Today this needs `keyset.encrypt_as(&value, context)`, -// a `serde_json::Value`, and an owned context. +// Not on `next` yet: a context built with `Scope::new`. Today this +// passes the column itself: `.encrypt_into_with_context(&keyset, column.clone())`. let profile: JsonPlaintext = json!({ "name": "Ada", "age": 35 }).into(); let encrypted: JsonTreeDocument = profile .encrypt_into_with_context(&keyset, Scope::new(&column)) @@ -1483,27 +1481,26 @@ Base64. #### EQL types as fields - **What to build.** EQL types such as `TextEq` and `JsonTreeDocument` as - field types of the Rust record derive. In Rust today, an EQL type works - only on its own, through the older structural API, with a - `NonEmpty` context. The EQL types do derive the structural - `EncryptFrom` and `DecryptInto`. A record still cannot use them as - fields. - A record with a `TextEq` field, built against `next` on 7 November 2026, - fails with three errors. `NonEmpty` and - `ExpectedContext` do not implement `From`, and - the record derive gives each field a `CallerContext`. -- **The smallest fix.** Convert a `CallerContext` of two parts into an - `Identifier`, with the table first and the column second. `Identifier` - already gives the same context as that pair, so the stored bytes do not - change. + field types of the Rust record derive. +- **How it works.** The record derive gives each field a `CallerContext`. + A field type that stores its context implements + `target::FromFieldContext`, and takes that context as the one it + stores. `Identifier` takes a context of two parts, the table first and + the column second, and gives the same context as that pair, so the + stored bytes do not change. It refuses a caller scope or a binding, + which an EQL payload cannot store, with + `PlanError::ContextNotStorable`, before any key is requested. - **Why.** This gives the [EQL field example](#store-a-field-as-an-eql-type) and the [JSON example](#encrypt-a-json-document). -- **Done already.** The dynamic interface supports EQL fields, so Go - records use them today. A field there names its EQL type, and the engine - looks the type up by name through `eql-bindings` (the `TargetResolver` - in `dynamic/record.rs`). `Identifier` already gives the same context as - the `(table, column)` pair that a record derive builds. -- **Tracked in.** Issue #1486 is open, in part. No dedicated issue. Pull request #1504 defers the EQL owned implementations. `eql-bindings` builds against the crates.io `stack-encrypt`, so a `stack-encrypt` release must come first. +- **Done already.** All of it, in #1658. A Rust record with EQL fields + encrypts, decrypts and queries through the new API, and writes the + terms a data plan writes for the same declaration, which is the path + Go takes (`packages/eql/tests/encryption/tests/record_fields.rs`). + `JsonTreeDocument` implements `EncryptTarget` and + `DecryptSource`. Order queries from a marker are not + answered by the EQL types: only the equality and match types implement + `Answers`. +- **Tracked in.** Issue #1658. `eql-bindings` builds against the crates.io `stack-encrypt`, so a `stack-encrypt` release with `FromFieldContext` must come before the `eql-bindings` release that uses it. - **Finished when** these are all true: - a Rust record with EQL fields encrypts, decrypts, and queries through the new API; diff --git a/packages/eql/Cargo.lock b/packages/eql/Cargo.lock index 870d240704..942be0b7d1 100644 --- a/packages/eql/Cargo.lock +++ b/packages/eql/Cargo.lock @@ -1224,6 +1224,7 @@ dependencies = [ "tokio", "uuid", "vitaminc-aead 0.9.0", + "zeroize", ] [[package]] diff --git a/packages/eql/crates/eql-bindings/CHANGELOG.md b/packages/eql/crates/eql-bindings/CHANGELOG.md index d7ba017969..6914a81da4 100644 --- a/packages/eql/crates/eql-bindings/CHANGELOG.md +++ b/packages/eql/crates/eql-bindings/CHANGELOG.md @@ -9,6 +9,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **EQL types as record fields** (#1658). An EQL v4 type can be a field + of a Stack Encrypt `#[derive(EncryptTarget, DecryptSource)]` record + with `struct = ..`. The field's column is the record's scope and the + field's name: `scope = "members"` and the field `email` store + `{"t": "members", "c": "email"}` in `i`. The field writes the bytes the + type writes on its own under `Identifier::for_column("members", + "email")`, and what a data plan that names the type writes. + - `Identifier` implements `stack_encrypt::target::FromFieldContext`. + - A record that lists a caller scope or a binding cannot have an EQL + field: an EQL payload stores a table and a column, and nothing more. + Every call is refused with `PlanError::ContextNotStorable`, before + any key is requested. + - A query from the field's marker, such as `EncryptedMember::EMAIL.eq(..)`, + gives the term stored in the row, for the types that answer it. + +- **`JsonTreeDocument` implements `EncryptTarget` and + `DecryptSource`** (#1658). It encrypts from a + `JsonPlaintext` under the default options, with the column as its + context, and writes the search terms that `JsonTree::default()` writes + for the same document. It can be a record field too. It also + implements `DecryptInto`, the form a record field opens + through, as every other EQL type does. + - **`Target::leaf_query_sql_domain`** (`targets` feature, #1350): the domain a document type's ordering at a path binds as, `eql_v4.query_json_leaf` for `JsonSearch` and `None` for every diff --git a/packages/eql/crates/eql-bindings/README.md b/packages/eql/crates/eql-bindings/README.md index 37defa996f..f862dd3208 100644 --- a/packages/eql/crates/eql-bindings/README.md +++ b/packages/eql/crates/eql-bindings/README.md @@ -395,6 +395,50 @@ the domain check on insert. A plain `=` between a v3 column and a v4 query term still returns zero rows with no error, so keep each column to one version. +### EQL types as record fields + +An EQL type can be a field of a Stack Encrypt record. The record derive gives +each field the record's scope and the field's name, and the EQL type stores +that pair as its column in `i`: + +```rust,ignore +use eql_bindings::v4::{integer::IntegerOrd, json_tree::JsonTreeDocument, text::TextEq}; +use stack_encrypt::json::JsonPlaintext; +use stack_encrypt::{DecryptFrom, DecryptSource, EncryptInto, EncryptTarget}; + +#[derive(zeroize::Zeroize)] +struct Member { + id: u64, + email: String, + age: i32, + profile: JsonPlaintext, +} + +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = Member, scope = "members")] +struct EncryptedMember { + #[stash(passthrough)] + id: u64, + email: TextEq, // i = {"t": "members", "c": "email"} + age: IntegerOrd, // i = {"t": "members", "c": "age"} + profile: JsonTreeDocument, // i = {"t": "members", "c": "profile"} +} + +let stored: EncryptedMember = member.encrypt_into(&keyset).await?; +let query = EncryptedMember::EMAIL.eq("alice@example.com").encrypt(&keyset).await?; +let opened = Member::decrypt_from(stored, &keyset).await?; +``` + +Each field writes what its type writes on its own under +`Identifier::for_column("members", )`, and what a data plan that +names the type writes. On decrypt, each field's stored `i` must be its own +column, checked before any key is requested. + +An EQL value stores a table and a column, and nothing more. So a record that +lists a caller scope (`scope(tenant: u64)`) or a binding (`bind(row: u128)`) +cannot have an EQL field. Every call to it is refused with +`PlanError::ContextNotStorable`, before any key is requested. + ### EQL types as plan field targets A binding has no Rust type to name: its plan arrives as data, and a field of diff --git a/packages/eql/crates/eql-bindings/src/encryption.rs b/packages/eql/crates/eql-bindings/src/encryption.rs index 98452b07bd..210ef70a11 100644 --- a/packages/eql/crates/eql-bindings/src/encryption.rs +++ b/packages/eql/crates/eql-bindings/src/encryption.rs @@ -113,7 +113,8 @@ use stack_encrypt::sem::{ }; use stack_encrypt::target::transcode::{Reader, Transcode, Visitor}; use stack_encrypt::target::{ - self, AeadContext, CallerContext, DecryptSource, EncryptTarget, IndexSpec, RequiredContext, + self, AeadContext, CallerContext, DecryptSource, EncryptTarget, FromFieldContext, IndexSpec, + RequiredContext, }; use stack_encrypt::{ CipherText, ContextPiece, Decrypt, DecryptField, DecryptInto, Decryptable, Decryption, @@ -200,6 +201,34 @@ impl<'a> IntoContext<'a> for &'a Identifier { } } +/// An EQL type can be a field of a derived `struct = ..` record. The record +/// gives the field its scope and the field's name, `("members", "email")`, +/// and the EQL type stores that pair as its column: table `members`, column +/// `email`. It is the context the record gave, so the field writes what the +/// type writes on its own under `Identifier::for_column("members", "email")`. +/// +/// An EQL payload stores a table and a column, and nothing more. So a +/// record that lists a caller scope or a binding cannot have an EQL field: +/// the field refuses the context, before any key is requested. +impl FromFieldContext for Identifier { + fn from_field_context(context: &CallerContext) -> Option> { + if context.has_bindings() { + return None; + } + let query = context.query(); + let ContextPiece::List(parts) = &query else { + return None; + }; + let [ContextPiece::Text(table), ContextPiece::Text(column)] = parts.as_slice() else { + return None; + }; + let identifier = Self::for_column(table.as_ref(), column.as_ref()).ok()?; + // Only the context the record gave: a pair that reads as a column + // but encodes differently would seal under another context. + (identifier.get().to_context() == query).then_some(identifier) + } +} + /// Builds the final EQL ciphertext string from a native sealed leaf. #[doc(hidden)] pub struct CiphertextVisitor; diff --git a/packages/eql/crates/eql-bindings/src/v4/json_tree.rs b/packages/eql/crates/eql-bindings/src/v4/json_tree.rs index e7f4ab1a44..19d1ca5cd3 100644 --- a/packages/eql/crates/eql-bindings/src/v4/json_tree.rs +++ b/packages/eql/crates/eql-bindings/src/v4/json_tree.rs @@ -23,10 +23,13 @@ use schemars::{schema_for, JsonSchema, Schema}; use serde::{Deserialize, Serialize}; use serde_json::Value; use stack_encrypt::json::{ - JsonDocument, JsonLeafQuery, JsonNode, JsonOptions, JsonPath, JsonQuery, JsonSelector, - JsonSubtree, JsonSubtreeKey, + JsonDocument, JsonLeafQuery, JsonNode, JsonOptions, JsonPath, JsonPlaintext, JsonQuery, + JsonSelector, JsonSubtree, JsonSubtreeKey, +}; +use stack_encrypt::target::{ + AeadContext, DecryptSource, EncryptTarget, ExpectedContext, IndexSpec, Json, Layout, + RequiredContext, }; -use stack_encrypt::target::{AeadContext, ExpectedContext, IndexSpec, Json, Layout}; use stack_encrypt::{ DecryptField, DecryptInto, Decryptable, Decryption, EncryptFrom, Encryption, Error, NonEmpty, }; @@ -524,6 +527,14 @@ impl EncryptFrom for JsonTreeDocument { impl DecryptInto for JsonTreeDocument { type Context = ExpectedContext; fn decryption(self, context: Self::Context) -> Decryption { + self.open(context) + } +} + +impl JsonTreeDocument { + /// Open the document under the stored column, once it has passed the + /// caller's check. The check runs before any key is requested. + fn open(self, context: ExpectedContext) -> Decryption { let column = match context.validate(self.i.clone()) { Ok(column) => column, Err(error) => return Decryption::failed(error), @@ -535,6 +546,58 @@ impl DecryptInto for JsonTreeDocument { } } +/// A [`JsonPlaintext`] seals into a document as a `serde_json::Value` does, +/// under the default options ([`JsonTree::default`]), with the column as +/// its context: +/// +/// ```ignore +/// let document: JsonTreeDocument = profile +/// .encrypt_into_with_context(&keyset, Identifier::for_column("customers", "profile")?) +/// .await?; +/// ``` +/// +/// It can be a field of a derived `struct = ..` record. The field's column +/// is then the record's scope and the field's name. +impl EncryptTarget for JsonTreeDocument { + type ContextInput = RequiredContext>; + fn encryption<'s, K: 'static>() -> Encryption<'s, JsonPlaintext, Self, K, Self::ContextInput> + where + JsonPlaintext: 's, + { + Layout::::encryption::(JsonTree::default().index()) + .project(JsonPlaintext::value) + .accepting::() + .map_with_context(|document, column: Self::ContextInput| (column, document)) + .try_map(|(column, document)| { + JsonTreeDocument::envelope(column.into_inner().into_inner(), &document) + }) + } + fn indexes() -> Vec { + JsonTree::default().specs() + } +} + +/// Decrypts to the plaintext document, under the stored column. Pass the +/// column you expect, `column.into()`, and the stored column must equal +/// it. Pass `().into()`, or decrypt with `decrypt_from`, and the document +/// opens under the stored column without that check. Either way the check +/// runs before any key is requested. +impl DecryptSource for JsonTreeDocument { + type ContextInput = ExpectedContext; + fn decryption(self, context: Self::ContextInput) -> Decryption { + self.open(context).map(JsonPlaintext::from) + } +} + +/// The structural form of [`DecryptSource`], which a record +/// field opens through, as it is for every other EQL type. +impl DecryptInto for JsonTreeDocument { + type Context = ExpectedContext; + fn decryption(self, context: Self::Context) -> Decryption { + self.open(context).map(JsonPlaintext::from) + } +} + impl Decryptable for JsonTreeDocument { const DECRYPTABLE: bool = true; } @@ -544,7 +607,7 @@ where Ctx: Into<>::Context>, { fn decryption_field(self, context: Ctx) -> Option> { - Some(self.decryption(context.into())) + Some(>::decryption(self, context.into())) } } diff --git a/packages/eql/tests/encryption/Cargo.toml b/packages/eql/tests/encryption/Cargo.toml index 9bdd3167b6..b3e259ad7b 100644 --- a/packages/eql/tests/encryption/Cargo.toml +++ b/packages/eql/tests/encryption/Cargo.toml @@ -38,6 +38,7 @@ rust_decimal = { version = "1", default-features = false, features = ["std"] } stack-kms = { path = "../../../stack-kms", default-features = false, features = ["test-support"] } serde = "1" serde_json = "1" +zeroize = { version = "1", features = ["derive"] } uuid = "1" tokio = { version = "1", features = ["rt", "macros"] } postgres = "0.19" diff --git a/packages/eql/tests/encryption/tests/record_fields.rs b/packages/eql/tests/encryption/tests/record_fields.rs new file mode 100644 index 0000000000..3dd0b28c53 --- /dev/null +++ b/packages/eql/tests/encryption/tests/record_fields.rs @@ -0,0 +1,504 @@ +//! EQL types as fields of a derived `struct = ..` record. +//! +//! A record gives each field its scope and the field's name, such as +//! `("members", "email")`. An EQL field stores that pair as its column, `i`. +//! So the field must write what the same type writes on its own under +//! `Identifier::for_column("members", "email")`, and what a data plan writes +//! for a field that names the type. A data plan is the path the Go module +//! takes. Here: +//! +//! - a record with EQL fields encrypts and decrypts through the new API; +//! - each field writes the terms the type writes on its own, and each value +//! opens through the other path; +//! - a query from the field's marker gives the stored term; +//! - the derive and a data plan write the same terms, and each opens the +//! other's value; +//! - a record that lists a caller scope or a binding cannot store an EQL +//! field, and is refused before any key is requested; +//! - a field that stores another column is refused before any key is +//! requested; +//! - a JSON document, `JsonTreeDocument`, encrypts from a `JsonPlaintext` +//! through the new API, writes the search terms the old path writes, and +//! is a record field too. + +mod common; + +use std::collections::BTreeMap; + +use eql_bindings::encryption::EqlTargets; +use eql_bindings::v4::integer::IntegerOrd; +use eql_bindings::v4::json_tree::{JsonTree, JsonTreeDocument}; +use eql_bindings::v4::text::{Text, TextEq}; +use eql_bindings::Identifier; +use serde::Serialize; +use serde_json::{json, Value}; +use stack_encrypt::dynamic::{self, record, ValueFrame}; +use stack_encrypt::json::JsonPlaintext; +use stack_encrypt::target::{Bind, Layout, Scope}; +use stack_encrypt::{ + AsProtectedRef as _, CipherText, Controlled as _, DecryptFrom, DecryptSource, Encrypt, + EncryptInto, EncryptTarget, Error, NonEmpty, PlanError, StackCipherText, +}; +use zeroize::Zeroize; + +#[derive(Clone, Debug, PartialEq, Zeroize)] +struct Member { + id: u64, + email: String, + age: i32, +} + +/// The plan's example "Store a field as an EQL type". +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = Member, scope = "members")] +struct EncryptedMember { + #[stash(passthrough)] + id: u64, + email: TextEq, + age: IntegerOrd, +} + +fn ada() -> Member { + Member { + id: 7, + email: "ada@example.com".into(), + age: 36, + } +} + +fn column(name: &str) -> NonEmpty { + Identifier::for_column("members", name).unwrap() +} + +/// A stored payload without its ciphertext: the envelope and every term. +/// `c` is sealed under a new nonce each time, so its bytes differ. +fn terms_of(stored: &T) -> serde_json::Value { + let mut json = serde_json::to_value(stored).unwrap(); + let object = json.as_object_mut().unwrap(); + assert!(object.remove("c").is_some(), "a stored value carries `c`"); + json +} + +#[tokio::test] +async fn a_record_with_eql_fields_round_trips() { + let (cipher, calls) = common::cipher().await; + let keyset = cipher.default_keyset(); + let stored: EncryptedMember = ada().encrypt_into(&keyset).await.unwrap(); + assert_eq!(stored.id, 7, "the passthrough field is carried as it is"); + assert_eq!(stored.email.i, *column("email").get()); + assert_eq!(stored.age.i, *column("age").get()); + assert_eq!( + calls.lock().unwrap().generate.len(), + 1, + "both fields' keys in one request" + ); + + let opened = Member::decrypt_from(stored, &keyset).await.unwrap(); + assert_eq!(opened, ada()); +} + +#[tokio::test] +async fn an_eql_field_writes_what_the_type_writes_on_its_own() { + let (cipher, _) = common::cipher().await; + let keyset = cipher.default_keyset(); + let member = ada(); + let stored: EncryptedMember = member.clone().encrypt_into(&keyset).await.unwrap(); + let email: TextEq = member + .email + .clone() + .encrypt_into_with_context(&keyset, column("email")) + .await + .unwrap(); + let age: IntegerOrd = member + .age + .encrypt_into_with_context(&keyset, column("age")) + .await + .unwrap(); + assert_eq!( + terms_of(&stored.email), + terms_of(&email), + "the same email terms" + ); + assert_eq!(terms_of(&stored.age), terms_of(&age), "the same age terms"); + + // The record's fields open on their own, under the column. + let opened = String::decrypt_from_with_context(stored.email, &keyset, column("email")) + .await + .unwrap(); + assert_eq!(opened, member.email); + let opened = i32::decrypt_from_with_context(stored.age, &keyset, column("age")) + .await + .unwrap(); + assert_eq!(opened, member.age); + + // And the values written on their own open as the record's fields. + let record = EncryptedMember { + id: member.id, + email, + age, + }; + assert_eq!(Member::decrypt_from(record, &keyset).await.unwrap(), member); +} + +#[tokio::test] +async fn a_query_from_the_field_marker_gives_the_stored_term() { + let (cipher, _) = common::cipher().await; + let keyset = cipher.default_keyset(); + let stored: EncryptedMember = ada().encrypt_into(&keyset).await.unwrap(); + + let query = EncryptedMember::EMAIL + .eq("ada@example.com") + .encrypt(&keyset) + .await + .unwrap(); + assert_eq!( + query.i, stored.email.i, + "the query names the field's column" + ); + assert_eq!(query.hm, stored.email.hm, "and carries the stored term"); + + let other = EncryptedMember::EMAIL + .eq("bob@example.com") + .encrypt(&keyset) + .await + .unwrap(); + assert_ne!(other.hm, stored.email.hm); +} + +#[derive(Clone, Debug, PartialEq, Zeroize)] +struct Contact { + email: String, + name: String, +} + +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = Contact, scope = "contacts")] +struct EncryptedContact { + email: TextEq, + name: Text, +} + +/// The data plan that declares the record's fields with EQL targets, as a +/// binding writes it. +fn contact_plan() -> record::Plan { + let wire = serde_json::json!({ + "email": {"context": ["contacts", "email"], "target": "TextEq"}, + "name": {"context": ["contacts", "name"], "target": "Text"}, + }); + record::plan_with( + dynamic::frame::serde::to_vec(&wire) + .unwrap() + .as_protected_ref(), + &EqlTargets, + ) + .unwrap() +} + +fn frame(value: T) -> ValueFrame { + ValueFrame::of(value).unwrap() +} + +/// The EQL value of `field`, in a row a data plan sealed. +fn eql_payload<'a>(row: &'a mut StackCipherText, field: &str) -> &'a mut Vec { + let CipherText::Map(fields) = row else { + panic!("a row is a map") + }; + let Some((_, CipherText::Map(outputs))) = fields.iter_mut().find(|(name, _)| name == field) + else { + panic!("{field} is a map of outputs"); + }; + let Some((_, CipherText::Passthrough(payload))) = + outputs.iter_mut().find(|(key, _)| key == "eql") + else { + panic!("{field}'s EQL value is a passthrough"); + }; + &mut payload.0 +} + +/// An EQL value in a row: its JSON text, framed as bytes. +struct Bytes(Vec); +impl Serialize for Bytes { + fn serialize(&self, s: S) -> Result { + s.serialize_bytes(&self.0) + } +} +impl<'de> serde::Deserialize<'de> for Bytes { + fn deserialize>(d: D) -> Result { + struct V; + impl serde::de::Visitor<'_> for V { + type Value = Bytes; + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("bytes") + } + fn visit_bytes(self, v: &[u8]) -> Result { + Ok(Bytes(v.to_vec())) + } + fn visit_byte_buf(self, v: Vec) -> Result { + Ok(Bytes(v)) + } + } + d.deserialize_bytes(V) + } +} + +fn read_eql(row: &mut StackCipherText, field: &str) -> T { + let bytes: Bytes = dynamic::frame::serde::from_slice(eql_payload(row, field)).unwrap(); + serde_json::from_slice(&bytes.0).unwrap() +} + +fn write_eql(row: &mut StackCipherText, field: &str, value: &T) { + let json = serde_json::to_vec(value).unwrap(); + *eql_payload(row, field) = dynamic::frame::serde::to_vec(&Bytes(json)) + .unwrap() + .risky_unwrap(); +} + +#[tokio::test] +async fn the_derive_and_a_data_plan_write_the_same_row() { + let (cipher, _) = common::cipher().await; + let keyset = cipher.default_keyset(); + let plan = contact_plan(); + let contact = Contact { + email: "ada@example.com".into(), + name: "Ada".into(), + }; + let derived: EncryptedContact = contact.clone().encrypt_into(&keyset).await.unwrap(); + let source = BTreeMap::from([("email", "ada@example.com"), ("name", "Ada")]); + let mut row = record::encrypt_with(&keyset, frame(&source), &plan, &EqlTargets) + .unwrap() + .await + .unwrap(); + + let planned_email: TextEq = read_eql(&mut row, "email"); + let planned_name: Text = read_eql(&mut row, "name"); + assert_eq!(terms_of(&derived.email), terms_of(&planned_email)); + assert_eq!(terms_of(&derived.name), terms_of(&planned_name)); + + // The data plan's values open as the record's fields. + let record = EncryptedContact { + email: planned_email, + name: planned_name, + }; + assert_eq!( + Contact::decrypt_from(record, &keyset).await.unwrap(), + contact + ); + + // The record's values, in the data plan's row, open through the plan. + write_eql(&mut row, "email", &derived.email); + write_eql(&mut row, "name", &derived.name); + let opened = record::decrypt_with(&cipher, row, &plan, None, &EqlTargets) + .unwrap() + .await + .unwrap(); + let opened: BTreeMap = + dynamic::frame::serde::from_slice(opened.risky_ref()).unwrap(); + assert_eq!(opened["email"], contact.email); + assert_eq!(opened["name"], contact.name); +} + +#[derive(Clone, Zeroize)] +struct Tenanted { + email: String, +} + +/// A record whose caller supplies a tenant scope. An EQL payload has +/// nowhere to store the tenant. +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = Tenanted, scope = "members", scope(tenant: u64))] +struct EncryptedTenanted { + email: TextEq, +} + +/// A record whose caller supplies a row binding. +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = Tenanted, scope = "members", bind(row: u128))] +struct EncryptedBound { + email: TextEq, +} + +fn assert_not_storable(result: Result) { + match result { + Err(Error::Plan(PlanError::ContextNotStorable)) => {} + Err(other) => panic!("expected ContextNotStorable, got {other:?}"), + Ok(_) => panic!("expected ContextNotStorable, got a value"), + } +} + +#[tokio::test] +async fn a_record_with_a_caller_scope_or_binding_cannot_store_an_eql_field() { + let (cipher, calls) = common::cipher().await; + let keyset = cipher.default_keyset(); + let tenanted = || Tenanted { + email: "ada@example.com".into(), + }; + + let scoped: Result = tenanted() + .encrypt_into_with_context(&keyset, Scope::new(42u64)) + .await; + assert_not_storable(scoped); + let bound: Result = tenanted() + .encrypt_into_with_context(&keyset, Bind::new(9u128)) + .await; + assert_not_storable(bound); + assert!( + calls.lock().unwrap().generate.is_empty(), + "refused before any key is requested" + ); + + // A value written on its own under the column does not open in such a + // record either. + let email: TextEq = "ada@example.com" + .to_owned() + .encrypt_into_with_context(&keyset, column("email")) + .await + .unwrap(); + let record = EncryptedTenanted { email }; + let opened = Tenanted::decrypt_from_with_context(record, &keyset, Scope::new(42u64)).await; + assert_not_storable(opened); + assert!( + calls.lock().unwrap().retrieve.is_empty(), + "refused before any key is requested" + ); +} + +#[tokio::test] +async fn a_field_that_stores_another_column_is_refused() { + let (cipher, calls) = common::cipher().await; + let keyset = cipher.default_keyset(); + let mut stored: EncryptedMember = ada().encrypt_into(&keyset).await.unwrap(); + stored.email = "ada@example.com" + .to_owned() + .encrypt_into_with_context(&keyset, column("other")) + .await + .unwrap(); + + match Member::decrypt_from(stored, &keyset).await { + Err(Error::ContextMismatch { .. }) => {} + Err(other) => panic!("expected ContextMismatch, got {other:?}"), + Ok(_) => panic!("expected ContextMismatch, got a value"), + } + assert!( + calls.lock().unwrap().retrieve.is_empty(), + "refused before any key is requested" + ); +} + +fn profile() -> Value { + json!({"name": "Ada", "age": 36, "tags": ["maths", "engines"]}) +} + +/// What each stored entry shows a query, as a sorted list: its selector, +/// its ordering term and whether it carries a parent tag. Entries are +/// stored in a random order, so two seals of one document compare as +/// multisets. +fn search_terms(document: &JsonTreeDocument) -> Vec<(String, String, bool)> { + let mut terms: Vec<_> = document + .sv + .iter() + .map(|entry| { + ( + serde_json::to_string(&entry.s).unwrap(), + serde_json::to_string(&entry.op).unwrap(), + entry.p.is_some(), + ) + }) + .collect(); + terms.sort(); + terms +} + +#[tokio::test] +async fn a_json_document_encrypts_from_a_json_plaintext() { + let (cipher, calls) = common::cipher().await; + let keyset = cipher.default_keyset(); + let column = || Identifier::for_column("customers", "profile").unwrap(); + + let stored: JsonTreeDocument = JsonPlaintext::from(profile()) + .encrypt_into_with_context(&keyset, column()) + .await + .unwrap(); + let old: JsonTreeDocument = keyset + .run(JsonTree::default().encryption(), &profile(), column()) + .await + .unwrap(); + assert_eq!(stored.i, old.i); + assert_eq!( + search_terms(&stored), + search_terms(&old), + "the same search terms" + ); + + // Each path opens what the other sealed. + let opened = JsonPlaintext::decrypt_from_with_context(old, &keyset, column()) + .await + .unwrap(); + assert_eq!(opened.value(), &profile()); + let opened: Value = cipher + .decrypt_as(stored.clone(), column().into()) + .await + .unwrap(); + assert_eq!(opened, profile()); + + // Without a column, the document opens under the one it stores. + let opened = JsonPlaintext::decrypt_from(stored.clone(), &keyset) + .await + .unwrap(); + assert_eq!(opened.value(), &profile()); + + // Under another column, it is refused before any key is requested. + let before = calls.lock().unwrap().retrieve.len(); + let other = Identifier::for_column("customers", "bio").unwrap(); + match JsonPlaintext::decrypt_from_with_context(stored, &keyset, other).await { + Err(Error::ContextMismatch { .. }) => {} + Err(other) => panic!("expected ContextMismatch, got {other:?}"), + Ok(_) => panic!("expected ContextMismatch, got a value"), + } + assert_eq!(calls.lock().unwrap().retrieve.len(), before); +} + +#[derive(Clone, Zeroize)] +struct Customer { + id: u64, + profile: JsonPlaintext, +} + +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = Customer, scope = "customers")] +struct EncryptedCustomer { + #[stash(passthrough)] + id: u64, + profile: JsonTreeDocument, +} + +#[tokio::test] +async fn a_json_document_is_a_record_field() { + let (cipher, _) = common::cipher().await; + let keyset = cipher.default_keyset(); + let customer = Customer { + id: 3, + profile: profile().into(), + }; + let stored: EncryptedCustomer = customer.encrypt_into(&keyset).await.unwrap(); + assert_eq!( + stored.profile.i, + Identifier { + t: "customers".into(), + c: "profile".into() + }, + "the field's column is the record's scope and the field's name" + ); + let old: JsonTreeDocument = keyset + .run( + JsonTree::default().encryption(), + &profile(), + Identifier::for_column("customers", "profile").unwrap(), + ) + .await + .unwrap(); + assert_eq!(search_terms(&stored.profile), search_terms(&old)); + + let opened = Customer::decrypt_from(stored, &keyset).await.unwrap(); + assert_eq!(opened.id, 3); + assert_eq!(opened.profile.value(), &profile()); +} From b088bf182fa29af4c27c29910cb45e112c24849e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 15 Nov 2026 09:20:02 +1100 Subject: [PATCH 3/6] fix(stack-encrypt,eql-bindings): address the review of EQL types as fields The refusal of a context a field cannot store now names the field, as issue #1658 asks. A record's context carries the field's own context, which comes from the declaration and never from the caller, so `PlanError::ContextNotStorable { field }` reads `members/email` and its payload holds no caller data. The tests now compare the derive with a data plan for a field of each kind (equality, storage only, order and a JSON document). They compare a JSON document's whole envelope except its key header, and they show the refusal on decrypt for a binding and for an EQL field of a nested record. `JsonTree::sealing` holds the one encryption chain both JSON paths use. The docs say "terms" where they said "bytes", and no longer point callers at the hidden `ExpectedContext`. Refs #1658 --- .changeset/eql-types-as-record-fields.md | 2 +- .../2026-10-24-stack-encrypt-api-plan.md | 24 ++- packages/eql/crates/eql-bindings/CHANGELOG.md | 8 +- packages/eql/crates/eql-bindings/README.md | 8 +- .../eql/crates/eql-bindings/src/encryption.rs | 2 + .../crates/eql-bindings/src/v4/json_tree.rs | 52 ++++-- .../tests/encryption/tests/record_fields.rs | 176 +++++++++++++++--- packages/stack-encrypt/CHANGELOG.md | 3 +- packages/stack-encrypt/src/codes.rs | 4 +- packages/stack-encrypt/src/dynamic/record.rs | 2 +- packages/stack-encrypt/src/plan/error.rs | 15 +- packages/stack-encrypt/src/target/context.rs | 49 +++-- packages/stack-encrypt/src/target/input.rs | 8 +- packages/stack-encrypt/tests/field_context.rs | 5 +- 14 files changed, 271 insertions(+), 87 deletions(-) diff --git a/.changeset/eql-types-as-record-fields.md b/.changeset/eql-types-as-record-fields.md index 108b733619..01fe9d7a26 100644 --- a/.changeset/eql-types-as-record-fields.md +++ b/.changeset/eql-types-as-record-fields.md @@ -6,7 +6,7 @@ - A `#[derive(EncryptTarget, DecryptSource)]` record with `struct = ..` can have fields such as `email: TextEq` or `profile: JsonTreeDocument`. The field's column is the record's scope and the field's name, so `scope = "members"` and the field `email` store `{"t": "members", "c": "email"}` in `i`. - A field writes the same terms as the type written on its own under that column, and as a data plan that names the type. A data plan is how the Go module writes the type. -- A record that lists a caller scope or a binding cannot have an EQL field, because an EQL value stores only a table and a column. Every call is refused with `PlanError::ContextNotStorable`, before any key is requested. +- A record that lists a caller scope or a binding cannot have an EQL field, because an EQL value stores only a table and a column. Every call is refused with `PlanError::ContextNotStorable`, which names the field, before any key is requested. - `JsonTreeDocument` now implements `EncryptTarget` and `DecryptSource` for `JsonPlaintext`. It encrypts under the default options, with the column as its context. The stored format, the SQL bundle, and the TypeScript types do not change. diff --git a/docs/plans/2026-10-24-stack-encrypt-api-plan.md b/docs/plans/2026-10-24-stack-encrypt-api-plan.md index a6a1c7da88..4941475c76 100644 --- a/docs/plans/2026-10-24-stack-encrypt-api-plan.md +++ b/docs/plans/2026-10-24-stack-encrypt-api-plan.md @@ -1492,19 +1492,23 @@ Base64. `PlanError::ContextNotStorable`, before any key is requested. - **Why.** This gives the [EQL field example](#store-a-field-as-an-eql-type) and the [JSON example](#encrypt-a-json-document). -- **Done already.** All of it, in #1658. A Rust record with EQL fields - encrypts, decrypts and queries through the new API, and writes the - terms a data plan writes for the same declaration, which is the path - Go takes (`packages/eql/tests/encryption/tests/record_fields.rs`). - `JsonTreeDocument` implements `EncryptTarget` and - `DecryptSource`. Order queries from a marker are not - answered by the EQL types: only the equality and match types implement - `Answers`. +- **Done already.** In #1658: + - A Rust record with EQL fields encrypts and decrypts through the new + API. + - For a field of each kind (equality, storage only, order, and a JSON + document), it writes the terms that a data plan writes for the same + declaration. A data plan is the path Go takes. Each path opens the + other's values (`packages/eql/tests/encryption/tests/record_fields.rs`). + - `JsonTreeDocument` implements `EncryptTarget` and + `DecryptSource`. + - A query from a field's marker works for the equality and match + types. The order types do not implement `Answers` yet, so a marker + cannot query them. - **Tracked in.** Issue #1658. `eql-bindings` builds against the crates.io `stack-encrypt`, so a `stack-encrypt` release with `FromFieldContext` must come before the `eql-bindings` release that uses it. - **Finished when** these are all true: - a Rust record with EQL fields encrypts, decrypts, and queries through - the new API; - - it writes the same bytes as a Go record with the same declaration. + the new API, including order queries; + - it writes the same terms as a Go record with the same declaration. #### A ready-made EQL resolver diff --git a/packages/eql/crates/eql-bindings/CHANGELOG.md b/packages/eql/crates/eql-bindings/CHANGELOG.md index 6914a81da4..40582da6bd 100644 --- a/packages/eql/crates/eql-bindings/CHANGELOG.md +++ b/packages/eql/crates/eql-bindings/CHANGELOG.md @@ -13,14 +13,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 of a Stack Encrypt `#[derive(EncryptTarget, DecryptSource)]` record with `struct = ..`. The field's column is the record's scope and the field's name: `scope = "members"` and the field `email` store - `{"t": "members", "c": "email"}` in `i`. The field writes the bytes the + `{"t": "members", "c": "email"}` in `i`. The field writes the terms the type writes on its own under `Identifier::for_column("members", - "email")`, and what a data plan that names the type writes. + "email")`, and the terms a data plan that names the type writes. - `Identifier` implements `stack_encrypt::target::FromFieldContext`. - A record that lists a caller scope or a binding cannot have an EQL field: an EQL payload stores a table and a column, and nothing more. - Every call is refused with `PlanError::ContextNotStorable`, before - any key is requested. + Every call is refused with `PlanError::ContextNotStorable`, which + names the field (`members/email`), before any key is requested. - A query from the field's marker, such as `EncryptedMember::EMAIL.eq(..)`, gives the term stored in the row, for the types that answer it. diff --git a/packages/eql/crates/eql-bindings/README.md b/packages/eql/crates/eql-bindings/README.md index f862dd3208..1f3338fb9f 100644 --- a/packages/eql/crates/eql-bindings/README.md +++ b/packages/eql/crates/eql-bindings/README.md @@ -429,15 +429,17 @@ let query = EncryptedMember::EMAIL.eq("alice@example.com").encrypt(&keyset).awai let opened = Member::decrypt_from(stored, &keyset).await?; ``` -Each field writes what its type writes on its own under -`Identifier::for_column("members", )`, and what a data plan that +Each field writes the terms its type writes on its own under +`Identifier::for_column("members", )`, and the terms a data plan that names the type writes. On decrypt, each field's stored `i` must be its own column, checked before any key is requested. An EQL value stores a table and a column, and nothing more. So a record that lists a caller scope (`scope(tenant: u64)`) or a binding (`bind(row: u128)`) cannot have an EQL field. Every call to it is refused with -`PlanError::ContextNotStorable`, before any key is requested. +`PlanError::ContextNotStorable`, which names the field, before any key is +requested. The same goes for an EQL field of a record nested in another: its +context has more parts than a table and a column. ### EQL types as plan field targets diff --git a/packages/eql/crates/eql-bindings/src/encryption.rs b/packages/eql/crates/eql-bindings/src/encryption.rs index 210ef70a11..395fb2be17 100644 --- a/packages/eql/crates/eql-bindings/src/encryption.rs +++ b/packages/eql/crates/eql-bindings/src/encryption.rs @@ -215,6 +215,8 @@ impl FromFieldContext for Identifier { if context.has_bindings() { return None; } + // A caller scope nests the field's pair inside a longer list, so a + // context with one is not two text parts and is refused below. let query = context.query(); let ContextPiece::List(parts) = &query else { return None; diff --git a/packages/eql/crates/eql-bindings/src/v4/json_tree.rs b/packages/eql/crates/eql-bindings/src/v4/json_tree.rs index 19d1ca5cd3..94c4123f8c 100644 --- a/packages/eql/crates/eql-bindings/src/v4/json_tree.rs +++ b/packages/eql/crates/eql-bindings/src/v4/json_tree.rs @@ -27,8 +27,8 @@ use stack_encrypt::json::{ JsonSelector, JsonSubtree, JsonSubtreeKey, }; use stack_encrypt::target::{ - AeadContext, DecryptSource, EncryptTarget, ExpectedContext, IndexSpec, Json, Layout, - RequiredContext, + AeadContext, CallerContext, DecryptSource, EncryptTarget, ExpectedContext, IndexSpec, Json, + Layout, RequiredContext, }; use stack_encrypt::{ DecryptField, DecryptInto, Decryptable, Decryption, EncryptFrom, Encryption, Error, NonEmpty, @@ -485,6 +485,25 @@ impl JsonTree { } } +impl JsonTree { + /// Seal a document under these options, under the column that the + /// caller's context `C` holds. `column` takes the column out of it. + fn sealing<'s, K: 'static, C>( + &self, + column: fn(C) -> Identifier, + ) -> Encryption<'s, Value, JsonTreeDocument, K, C> + where + C: Into + Clone + Send + 'static, + { + Layout::::encryption::(&self.index) + .accepting::() + .map_with_context(|document, context: C| (context, document)) + .try_map(move |(context, document)| { + JsonTreeDocument::envelope(column(context), &document) + }) + } +} + /// A document seals as the JSON index lays it out under the layout's /// options, under the column it is stored in: the [`Identifier`] is the /// context the data key is minted under and every selector derives @@ -495,10 +514,7 @@ impl Layout for JsonTree { fn encryption<'s, K: 'static>( &self, ) -> Encryption<'s, Value, JsonTreeDocument, K, NonEmpty> { - Layout::::encryption::(&self.index) - .accepting::>() - .map_with_context(|document, i: NonEmpty| (i, document)) - .try_map(|(i, document)| JsonTreeDocument::envelope(i.into_inner(), &document)) + self.sealing(NonEmpty::into_inner) } fn index(&self) -> &Json { &self.index @@ -564,24 +580,22 @@ impl EncryptTarget for JsonTreeDocument { where JsonPlaintext: 's, { - Layout::::encryption::(JsonTree::default().index()) + JsonTree::default() + .sealing(|column: Self::ContextInput| column.into_inner().into_inner()) .project(JsonPlaintext::value) - .accepting::() - .map_with_context(|document, column: Self::ContextInput| (column, document)) - .try_map(|(column, document)| { - JsonTreeDocument::envelope(column.into_inner().into_inner(), &document) - }) } fn indexes() -> Vec { JsonTree::default().specs() } } -/// Decrypts to the plaintext document, under the stored column. Pass the -/// column you expect, `column.into()`, and the stored column must equal -/// it. Pass `().into()`, or decrypt with `decrypt_from`, and the document -/// opens under the stored column without that check. Either way the check -/// runs before any key is requested. +/// Decrypts to the plaintext document, under the stored column. +/// +/// - `decrypt_from_with_context(document, &keyset, column)` checks that the +/// stored column is `column`. The check runs before any key is +/// requested. +/// - `decrypt_from(document, &keyset)` opens the document under the column +/// it stores, without that check. impl DecryptSource for JsonTreeDocument { type ContextInput = ExpectedContext; fn decryption(self, context: Self::ContextInput) -> Decryption { @@ -589,8 +603,8 @@ impl DecryptSource for JsonTreeDocument { } } -/// The structural form of [`DecryptSource`], which a record -/// field opens through, as it is for every other EQL type. +/// The same as [`DecryptSource`]. A field of a derived +/// record opens through this trait, as every other EQL type does. impl DecryptInto for JsonTreeDocument { type Context = ExpectedContext; fn decryption(self, context: Self::Context) -> Decryption { diff --git a/packages/eql/tests/encryption/tests/record_fields.rs b/packages/eql/tests/encryption/tests/record_fields.rs index 3dd0b28c53..5bb3bc9b1e 100644 --- a/packages/eql/tests/encryption/tests/record_fields.rs +++ b/packages/eql/tests/encryption/tests/record_fields.rs @@ -11,10 +11,12 @@ //! - each field writes the terms the type writes on its own, and each value //! opens through the other path; //! - a query from the field's marker gives the stored term; -//! - the derive and a data plan write the same terms, and each opens the -//! other's value; +//! - the derive and a data plan write the same terms for a field of each +//! kind (equality, storage only, order, JSON document), and each opens +//! the other's value; //! - a record that lists a caller scope or a binding cannot store an EQL -//! field, and is refused before any key is requested; +//! field, nor can a record nested in another, and each is refused before +//! any key is requested; //! - a field that stores another column is refused before any key is //! requested; //! - a JSON document, `JsonTreeDocument`, encrypts from a `JsonPlaintext` @@ -23,8 +25,6 @@ mod common; -use std::collections::BTreeMap; - use eql_bindings::encryption::EqlTargets; use eql_bindings::v4::integer::IntegerOrd; use eql_bindings::v4::json_tree::{JsonTree, JsonTreeDocument}; @@ -36,8 +36,8 @@ use stack_encrypt::dynamic::{self, record, ValueFrame}; use stack_encrypt::json::JsonPlaintext; use stack_encrypt::target::{Bind, Layout, Scope}; use stack_encrypt::{ - AsProtectedRef as _, CipherText, Controlled as _, DecryptFrom, DecryptSource, Encrypt, - EncryptInto, EncryptTarget, Error, NonEmpty, PlanError, StackCipherText, + AsProtectedRef as _, CipherText, Controlled as _, DecryptFrom, DecryptSource, EncryptInto, + EncryptTarget, Error, NonEmpty, PlanError, StackCipherText, }; use zeroize::Zeroize; @@ -165,17 +165,32 @@ async fn a_query_from_the_field_marker_gives_the_stored_term() { assert_ne!(other.hm, stored.email.hm); } -#[derive(Clone, Debug, PartialEq, Zeroize)] +#[derive(Clone, Zeroize)] struct Contact { email: String, name: String, + age: i32, + profile: JsonPlaintext, +} + +fn contact() -> Contact { + Contact { + email: "ada@example.com".into(), + name: "Ada".into(), + age: 36, + profile: profile().into(), + } } +/// A field of each kind: equality, storage only, order, and a JSON +/// document. #[derive(EncryptTarget, DecryptSource)] #[stash(struct = Contact, scope = "contacts")] struct EncryptedContact { email: TextEq, name: Text, + age: IntegerOrd, + profile: JsonTreeDocument, } /// The data plan that declares the record's fields with EQL targets, as a @@ -184,6 +199,8 @@ fn contact_plan() -> record::Plan { let wire = serde_json::json!({ "email": {"context": ["contacts", "email"], "target": "TextEq"}, "name": {"context": ["contacts", "name"], "target": "Text"}, + "age": {"context": ["contacts", "age"], "target": "IntegerOrd"}, + "profile": {"context": ["contacts", "profile"], "target": "JsonSearch"}, }); record::plan_with( dynamic::frame::serde::to_vec(&wire) @@ -194,8 +211,24 @@ fn contact_plan() -> record::Plan { .unwrap() } -fn frame(value: T) -> ValueFrame { - ValueFrame::of(value).unwrap() +/// A contact as a binding sends it to a data plan: the JSON document as +/// its text. +#[derive(Serialize)] +struct ContactRow { + email: String, + name: String, + age: i32, + profile: String, +} + +/// A contact as a data plan opens it: the JSON document as the bytes of +/// its text. +#[derive(serde::Deserialize)] +struct OpenedContact { + email: String, + name: String, + age: i32, + profile: Bytes, } /// The EQL value of `field`, in a row a data plan sealed. @@ -258,43 +291,66 @@ async fn the_derive_and_a_data_plan_write_the_same_row() { let (cipher, _) = common::cipher().await; let keyset = cipher.default_keyset(); let plan = contact_plan(); - let contact = Contact { + let derived: EncryptedContact = contact().encrypt_into(&keyset).await.unwrap(); + let source = ContactRow { email: "ada@example.com".into(), name: "Ada".into(), + age: 36, + profile: profile().to_string(), }; - let derived: EncryptedContact = contact.clone().encrypt_into(&keyset).await.unwrap(); - let source = BTreeMap::from([("email", "ada@example.com"), ("name", "Ada")]); - let mut row = record::encrypt_with(&keyset, frame(&source), &plan, &EqlTargets) + let source = ValueFrame::from_bytes( + dynamic::frame::serde::to_vec(&source) + .unwrap() + .as_protected_ref(), + ) + .unwrap(); + let mut row = record::encrypt_with(&keyset, source, &plan, &EqlTargets) .unwrap() .await .unwrap(); let planned_email: TextEq = read_eql(&mut row, "email"); let planned_name: Text = read_eql(&mut row, "name"); + let planned_age: IntegerOrd = read_eql(&mut row, "age"); + let planned_profile: JsonTreeDocument = read_eql(&mut row, "profile"); assert_eq!(terms_of(&derived.email), terms_of(&planned_email)); assert_eq!(terms_of(&derived.name), terms_of(&planned_name)); + assert_eq!(terms_of(&derived.age), terms_of(&planned_age)); + assert_eq!( + document_terms(&derived.profile), + document_terms(&planned_profile) + ); // The data plan's values open as the record's fields. let record = EncryptedContact { email: planned_email, name: planned_name, + age: planned_age, + profile: planned_profile, }; - assert_eq!( - Contact::decrypt_from(record, &keyset).await.unwrap(), - contact - ); + let opened = Contact::decrypt_from(record, &keyset).await.unwrap(); + assert_eq!(opened.email, "ada@example.com"); + assert_eq!(opened.name, "Ada"); + assert_eq!(opened.age, 36); + assert_eq!(opened.profile.value(), &profile()); // The record's values, in the data plan's row, open through the plan. write_eql(&mut row, "email", &derived.email); write_eql(&mut row, "name", &derived.name); + write_eql(&mut row, "age", &derived.age); + write_eql(&mut row, "profile", &derived.profile); let opened = record::decrypt_with(&cipher, row, &plan, None, &EqlTargets) .unwrap() .await .unwrap(); - let opened: BTreeMap = - dynamic::frame::serde::from_slice(opened.risky_ref()).unwrap(); - assert_eq!(opened["email"], contact.email); - assert_eq!(opened["name"], contact.name); + let opened: OpenedContact = dynamic::frame::serde::from_slice(opened.risky_ref()).unwrap(); + assert_eq!(opened.email, "ada@example.com"); + assert_eq!(opened.name, "Ada"); + assert_eq!(opened.age, 36); + assert_eq!( + serde_json::from_slice::(&opened.profile.0).unwrap(), + profile() + ); } #[derive(Clone, Zeroize)] @@ -317,9 +373,12 @@ struct EncryptedBound { email: TextEq, } +/// The refusal names the field by the record's scope and its name. fn assert_not_storable(result: Result) { match result { - Err(Error::Plan(PlanError::ContextNotStorable)) => {} + Err(Error::Plan(PlanError::ContextNotStorable { field })) => { + assert_eq!(field, "members/email"); + } Err(other) => panic!("expected ContextNotStorable, got {other:?}"), Ok(_) => panic!("expected ContextNotStorable, got a value"), } @@ -353,15 +412,57 @@ async fn a_record_with_a_caller_scope_or_binding_cannot_store_an_eql_field() { .encrypt_into_with_context(&keyset, column("email")) .await .unwrap(); - let record = EncryptedTenanted { email }; + let record = EncryptedTenanted { + email: email.clone(), + }; let opened = Tenanted::decrypt_from_with_context(record, &keyset, Scope::new(42u64)).await; assert_not_storable(opened); + let record = EncryptedBound { email }; + let opened = Tenanted::decrypt_from_with_context(record, &keyset, Bind::new(9u128)).await; + assert_not_storable(opened); assert!( calls.lock().unwrap().retrieve.is_empty(), "refused before any key is requested" ); } +/// A record with an EQL field and nothing the caller supplies. +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = Tenanted, scope = "members")] +struct EncryptedPlain { + email: TextEq, +} + +#[derive(Clone, Zeroize)] +struct Holder { + member: Tenanted, +} + +/// That record as a field of another. Its EQL field's context is then its +/// own pair, extended by the outer record's. +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = Holder, scope = "holders")] +struct EncryptedHolder { + member: EncryptedPlain, +} + +#[tokio::test] +async fn an_eql_field_of_a_nested_record_is_refused() { + let (cipher, calls) = common::cipher().await; + let keyset = cipher.default_keyset(); + let holder = Holder { + member: Tenanted { + email: "ada@example.com".into(), + }, + }; + let nested: Result = holder.encrypt_into(&keyset).await; + assert_not_storable(nested); + assert!( + calls.lock().unwrap().generate.is_empty(), + "refused before any key is requested" + ); +} + #[tokio::test] async fn a_field_that_stores_another_column_is_refused() { let (cipher, calls) = common::cipher().await; @@ -408,6 +509,24 @@ fn search_terms(document: &JsonTreeDocument) -> Vec<(String, String, bool)> { terms } +/// A stored document without what differs between two seals of it: the +/// key header `h`, sealed under a new key each time, and the entries' own +/// ciphertexts. The search terms of every entry and the query group are +/// kept, sorted, since both are stored in a random order. +fn document_terms(document: &JsonTreeDocument) -> (Value, Vec<(String, String, bool)>) { + let mut json = serde_json::to_value(document).unwrap(); + let object = json.as_object_mut().unwrap(); + assert!(object.remove("h").is_some(), "a document carries `h`"); + assert!(object.remove("sv").is_some(), "a document carries `sv`"); + let group = object["q"].as_object_mut().unwrap(); + for list in group.values_mut() { + list.as_array_mut() + .unwrap() + .sort_by_key(|entry| entry.to_string()); + } + (json, search_terms(document)) +} + #[tokio::test] async fn a_json_document_encrypts_from_a_json_plaintext() { let (cipher, calls) = common::cipher().await; @@ -422,11 +541,10 @@ async fn a_json_document_encrypts_from_a_json_plaintext() { .run(JsonTree::default().encryption(), &profile(), column()) .await .unwrap(); - assert_eq!(stored.i, old.i); assert_eq!( - search_terms(&stored), - search_terms(&old), - "the same search terms" + document_terms(&stored), + document_terms(&old), + "the same envelope and search terms" ); // Each path opens what the other sealed. @@ -496,7 +614,7 @@ async fn a_json_document_is_a_record_field() { ) .await .unwrap(); - assert_eq!(search_terms(&stored.profile), search_terms(&old)); + assert_eq!(document_terms(&stored.profile), document_terms(&old)); let opened = Customer::decrypt_from(stored, &keyset).await.unwrap(); assert_eq!(opened.id, 3); diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index f3bd363c00..6c980903df 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -20,7 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A context the type cannot store is refused before any key is requested, on encrypt and on decrypt, with the new `PlanError::ContextNotStorable` - (`stack_encrypt::plan_context_not_storable`). A record that lists a + (`stack_encrypt::plan_context_not_storable`), which names the field + by the record's scope and its name (`members/email`). A record that lists a caller scope or a binding gives its fields such a context. - On decrypt, the stored context must equal the field's, as a standalone decrypt with an expected context checks it. diff --git a/packages/stack-encrypt/src/codes.rs b/packages/stack-encrypt/src/codes.rs index 30bb912597..71a077e934 100644 --- a/packages/stack-encrypt/src/codes.rs +++ b/packages/stack-encrypt/src/codes.rs @@ -175,7 +175,9 @@ mod tests { }, PlanError::EmptyIndexes => PlanError::EmptyIndexes, PlanError::EmptyPart => PlanError::EmptyPart, - PlanError::ContextNotStorable => PlanError::ContextNotStorable, + PlanError::ContextNotStorable { .. } => PlanError::ContextNotStorable { + field: "users/email".into(), + }, PlanError::NotInPlan { .. } => PlanError::NotInPlan { field: field() }, PlanError::NotInValue { .. } => PlanError::NotInValue { field: field() }, PlanError::FieldType { .. } => PlanError::FieldType { diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 9984ca6507..94db5f690a 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -676,7 +676,7 @@ fn refusal(error: crate::Error) -> (Option, Reason) { | P::MixedCiphers | P::TwoContextSources { .. } | P::NoContext - | P::ContextNotStorable => (None, Reason::Refused), + | P::ContextNotStorable { .. } => (None, Reason::Refused), } } diff --git a/packages/stack-encrypt/src/plan/error.rs b/packages/stack-encrypt/src/plan/error.rs index 5f95ea0c80..4a21034b0a 100644 --- a/packages/stack-encrypt/src/plan/error.rs +++ b/packages/stack-encrypt/src/plan/error.rs @@ -256,12 +256,17 @@ pub enum PlanError { /// table and a column, so it takes the record's scope and the field's /// name, and nothing more: no caller scope and no binding. Refused /// before any key is requested. - #[error("the field's type cannot store the context the record gives it")] + #[error("field {field:?}: its type cannot store the context the record gives it")] #[diagnostic( code(stack_encrypt::plan_context_not_storable), help("A field whose type stores its context, such as an EQL type, takes the record's scope and the field's name alone. Remove the record's caller scope and binding, or give the field a type that does not store its context.") )] - ContextNotStorable, + ContextNotStorable { + /// The field, as its own context renders: the record's scope and + /// the field's name, `members/email`. Empty when the context was + /// not built for a record field. + field: String, + }, /// A field was declared both as a typed target (`encrypt_into`) and with /// a data verb. A field is one or the other: the target's type decides /// its layout and its queries. @@ -288,7 +293,8 @@ impl crate::ErrorPayload for PlanError { | Self::NotInPlan { field } | Self::NotInValue { field } | Self::NoSuchField { field } - | Self::TargetWithVerbs { field } => payload([("field", field.as_str().into())]), + | Self::TargetWithVerbs { field } + | Self::ContextNotStorable { field } => payload([("field", field.as_str().into())]), Self::SharedIdentity { identity, first, @@ -337,8 +343,7 @@ impl crate::ErrorPayload for PlanError { | Self::MixedCiphers | Self::NoContext | Self::BindInQuery - | Self::EmptyPart - | Self::ContextNotStorable => serde_json::Map::new(), + | Self::EmptyPart => serde_json::Map::new(), } } } diff --git a/packages/stack-encrypt/src/target/context.rs b/packages/stack-encrypt/src/target/context.rs index 57e4c35edd..e530606b05 100644 --- a/packages/stack-encrypt/src/target/context.rs +++ b/packages/stack-encrypt/src/target/context.rs @@ -69,6 +69,11 @@ pub struct CallerContext { /// binding spelled as data ([`from_piece`](Self::from_piece)) is not /// checked here: the dynamic grammar allows an empty extension part. empty_part: bool, + /// The record field this context was built for: the field's own + /// context, as [`DeclaredContext::under`] and [`extend`](Self::extend) + /// were given it. It comes from the declaration, never from the + /// caller, so an error can name the field with it. + field: Option, } impl<'c, T> From> for CallerContext where @@ -97,6 +102,7 @@ impl From> for CallerContext { scopes, bindings: binding.into_iter().collect(), empty_part: empty_scope || empty_bind, + field: None, } } } @@ -132,6 +138,7 @@ impl CallerContext { scopes: Some(piece), bindings: Vec::new(), empty_part: false, + field: None, } } /// Refuse an empty part. A context with no scope passes: a record @@ -212,6 +219,7 @@ impl CallerContext { scopes: None, bindings: vec![piece], empty_part: false, + field: None, } } /// The parts a record stores in its row, as the caller context the @@ -223,6 +231,7 @@ impl CallerContext { scopes: stored.scope, bindings: stored.bind.into_iter().collect(), empty_part: stored.empty_part, + field: None, } } /// The own context `own`, extended by this caller context: the field's @@ -234,6 +243,7 @@ impl CallerContext { pub fn extend<'c, O: IntoContext<'c>>(self, own: NonEmpty) -> Self { let own = own.into_context().into_owned(); Self { + field: Some(Descriptor::from_piece(&own)), scopes: Some(match self.scopes { Some(scopes) => nest(own, scopes), None => own, @@ -254,8 +264,21 @@ impl CallerContext { } self.bindings.extend(part.bindings); self.empty_part |= part.empty_part; + self.field = self.field.or(part.field); self } + /// The refusal of a record field whose type cannot store this context, + /// naming the field by its own context. + pub(crate) fn not_storable(&self) -> Error { + crate::plan::PlanError::ContextNotStorable { + field: self + .field + .as_ref() + .map(|field| field.as_str().to_owned()) + .unwrap_or_default(), + } + .into() + } /// The scopes and the bindings, for [`Contexts`](crate::Contexts). #[cfg_attr(not(feature = "dynamic"), allow(dead_code))] pub(crate) fn pieces(&self) -> (Option>, Vec>) { @@ -725,6 +748,7 @@ impl StoredParts { scopes: None, bindings: Vec::new(), empty_part: empty_scope || empty_bind, + field: None, }; match (&self.scope, scope) { (Some(stored), Some(passed)) if *stored != passed => { @@ -868,9 +892,11 @@ impl DeclaredContext { /// `((("users", "age"), id), region)` under `id` then `region`. The /// bindings of every part follow the scopes, in order. pub fn under<'c, O: IntoContext<'c>>(self, own: NonEmpty) -> CallerContext { - self.0 - .into_iter() - .fold(CallerContext::from(own), CallerContext::then) + let own = own.into_context().into_owned(); + let field = Descriptor::from_piece(&own); + let mut context = CallerContext::scopes(own); + context.field = Some(field); + self.0.into_iter().fold(context, CallerContext::then) } /// [`under`](Self::under), refusing a part the caller passed empty. @@ -931,8 +957,9 @@ enum Expected { /// A stored context equal to this one. Exactly(NonEmpty), /// Nothing: a record gave the field a context its type cannot store, so - /// no stored context can be the one expected. - NotStorable, + /// no stored context can be the one expected. The context names the + /// field in the refusal. + NotStorable(CallerContext), } impl Default for ExpectedContext { fn default() -> Self { @@ -956,7 +983,7 @@ impl From for ExpectedContext { fn from(context: CallerContext) -> Self { Self(match T::from_field_context(&context) { Some(expected) => Expected::Exactly(expected), - None => Expected::NotStorable, + None => Expected::NotStorable(context), }) } } @@ -968,12 +995,12 @@ impl From for ExpectedContext { /// the field's name, extended by the caller's scopes and bindings, as a /// [`CallerContext`]. A field type that stores its context takes that /// context as the type it stores, so it can be a field of a record. -/// `eql_bindings::Identifier` implements this: an EQL type stores a table -/// and a column, so it takes `("members", "email")` and refuses a context -/// with a caller scope or a binding. +/// For example, a type that stores a table and a column takes +/// `("members", "email")`, and refuses a context with a caller scope or a +/// binding, since it has nowhere to store them. /// /// The rebuilt context must give the same context as the one the record -/// gave. Then the field writes the bytes it writes on its own under that +/// gave. Then the field writes the terms it writes on its own under that /// context. pub trait FromFieldContext: Sized { /// The context the field's type stores, rebuilt from the context the @@ -1000,7 +1027,7 @@ impl<'c, T: MaybeEmpty + PartialEq + IntoContext<'c>> ExpectedContext { return Err(Error::ContextMismatch { stored }); } } - Expected::NotStorable => return Err(crate::plan::PlanError::ContextNotStorable.into()), + Expected::NotStorable(context) => return Err(context.not_storable()), } nonempty(stored) } diff --git a/packages/stack-encrypt/src/target/input.rs b/packages/stack-encrypt/src/target/input.rs index 28560fd9e5..5bbf6e18fa 100644 --- a/packages/stack-encrypt/src/target/input.rs +++ b/packages/stack-encrypt/src/target/input.rs @@ -177,11 +177,17 @@ impl> FromCaller for C { } // A field whose type stores its context takes the record's context for the // field as that type, or refuses it. +// +// This impl and the blanket one above do not overlap only because +// `RequiredContext>` does not implement `From`. +// The `From` impl for `RequiredContext` needs +// `C: From`, and `NonEmpty` has no such impl. Adding one +// would stop this from compiling. impl FromCaller for RequiredContext> { fn from_caller(context: CallerContext) -> Result { T::from_field_context(&context) .map(Self::supplied) - .ok_or_else(|| crate::plan::PlanError::ContextNotStorable.into()) + .ok_or_else(|| context.not_storable()) } } // A record that derives terms gives its ciphertext fields the AEAD part of the diff --git a/packages/stack-encrypt/tests/field_context.rs b/packages/stack-encrypt/tests/field_context.rs index d73a61d579..4e8f112d0c 100644 --- a/packages/stack-encrypt/tests/field_context.rs +++ b/packages/stack-encrypt/tests/field_context.rs @@ -116,9 +116,12 @@ struct BoundUser { email: Stored, } +/// The refusal names the field by the record's scope and its name. fn assert_not_storable(result: Result) { match result { - Err(Error::Plan(PlanError::ContextNotStorable)) => {} + Err(Error::Plan(PlanError::ContextNotStorable { field })) => { + assert_eq!(field, "users/email"); + } Err(other) => panic!("expected ContextNotStorable, got {other:?}"), Ok(_) => panic!("expected ContextNotStorable, got a value"), } From 21f2d16b1b9b5c8ec2e5c8fd0559db100a7c2a9e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 15 Nov 2026 13:45:45 +1100 Subject: [PATCH 4/6] fix(stack-encrypt,eql-bindings): an EQL field takes a scope and a binding Second round of review on EQL types as record fields. An EQL field refused every record that lists a scope or a binding, and every record nested in another, because `i` stores only a table and a column. The extra context does not need to be stored. A field type that stores its context now stores the field's own context alone: the record's scope and the field's name. The scopes and bindings of the record come after it. The ciphertext and the terms derive under all of it, as they do for a field of any other type, and the reader supplies the scopes and bindings again. stack-encrypt: - `ContextFromField` replaces `FromFieldContext`. It builds the stored context from the field's own context. The hidden `FromCaller` is gone: `Encryption::under` takes `From` again. - `CallerContext` keeps the field's own context apart, in place of the `Descriptor` that only named the field in an error. - The encrypt input of a `context_field` record is the hidden `StoredContext`: the context to store, and the context to run under. `ExpectedContext::opening` gives the same on decrypt. - `PlanError::ContextNotStorable` now means that the type cannot store the field's own context. It names that context, and is never empty. - The derive emits `Encryption::under_field`, which wipes an owned plaintext when a caller's part is empty. `under` dropped it unwiped. eql-bindings: - `Identifier` implements `ContextFromField`. - `JsonTreeDocument` takes the same input, so a document field takes a scope and a binding too. Its doc example is now compiled. - A query for an EQL type takes a scope. `QueryLabelError::Extended` is removed. --- .changeset/eql-types-as-record-fields.md | 5 +- .../2026-10-24-stack-encrypt-api-plan.md | 44 +- packages/eql/crates/eql-bindings/CHANGELOG.md | 29 +- packages/eql/crates/eql-bindings/README.md | 38 +- .../eql/crates/eql-bindings/src/encryption.rs | 38 +- .../eql-bindings/src/encryption/query.rs | 52 +- .../crates/eql-bindings/src/v4/json_tree.rs | 78 ++- .../tests/encryption/tests/migration_shim.rs | 5 +- .../tests/encryption/tests/record_fields.rs | 313 +++++++++--- .../tests/encryption/tests/typed_queries.rs | 127 +++-- .../eql/tests/encryption/tests/v4_owned.rs | 10 +- .../stack-encrypt-derive/docs/attributes.md | 6 +- .../stack-encrypt-derive/src/owned_decrypt.rs | 8 +- .../stack-encrypt-derive/src/owned_encrypt.rs | 33 +- packages/stack-encrypt/CHANGELOG.md | 47 +- packages/stack-encrypt/src/contexts.rs | 9 +- packages/stack-encrypt/src/op/mod.rs | 4 +- packages/stack-encrypt/src/plan/error.rs | 23 +- packages/stack-encrypt/src/target/context.rs | 483 +++++++++++++++--- packages/stack-encrypt/src/target/input.rs | 91 +--- packages/stack-encrypt/src/target/mod.rs | 23 +- .../stack-encrypt/src/target/operations.rs | 96 +++- packages/stack-encrypt/tests/field_context.rs | 353 ++++++++++--- packages/stack-encrypt/tests/owned_records.rs | 51 +- .../ui/owned_aead_record_with_term.stderr | 19 +- .../ui/owned_record_field_context.stderr | 33 +- .../ui/owned_record_field_stores_context.rs | 30 ++ .../owned_record_field_stores_context.stderr | 52 ++ 28 files changed, 1577 insertions(+), 523 deletions(-) create mode 100644 packages/stack-encrypt/tests/ui/owned_record_field_stores_context.rs create mode 100644 packages/stack-encrypt/tests/ui/owned_record_field_stores_context.stderr diff --git a/.changeset/eql-types-as-record-fields.md b/.changeset/eql-types-as-record-fields.md index 01fe9d7a26..1bc407caeb 100644 --- a/.changeset/eql-types-as-record-fields.md +++ b/.changeset/eql-types-as-record-fields.md @@ -5,8 +5,9 @@ `eql-bindings`, the Rust crate released with this package: an EQL v4 type can now be a field of a Stack Encrypt record. - A `#[derive(EncryptTarget, DecryptSource)]` record with `struct = ..` can have fields such as `email: TextEq` or `profile: JsonTreeDocument`. The field's column is the record's scope and the field's name, so `scope = "members"` and the field `email` store `{"t": "members", "c": "email"}` in `i`. -- A field writes the same terms as the type written on its own under that column, and as a data plan that names the type. A data plan is how the Go module writes the type. -- A record that lists a caller scope or a binding cannot have an EQL field, because an EQL value stores only a table and a column. Every call is refused with `PlanError::ContextNotStorable`, which names the field, before any key is requested. +- With no scope and no binding, a field writes the same terms as the type written on its own under that column, and as a data plan that names the type. A data plan is how the Go module writes the type. +- A record can list a scope or a binding, such as `scope(tenant: u64)` or `bind(row: u128)`. The ciphertext and the terms of an EQL field then derive under the column, the scope and the binding, as they do for a field of any other type. `i` stores the column alone, so the reader supplies the scope and the binding again. The same is true of an EQL field of a record nested in another. +- A query can carry a scope: `.scope(..)` on a query from a field's marker, or `.extend(..)` on a plan's query. It gives the term of the values stored under that scope. - `JsonTreeDocument` now implements `EncryptTarget` and `DecryptSource` for `JsonPlaintext`. It encrypts under the default options, with the column as its context. The stored format, the SQL bundle, and the TypeScript types do not change. diff --git a/docs/plans/2026-10-24-stack-encrypt-api-plan.md b/docs/plans/2026-10-24-stack-encrypt-api-plan.md index 4941475c76..04a397658f 100644 --- a/docs/plans/2026-10-24-stack-encrypt-api-plan.md +++ b/docs/plans/2026-10-24-stack-encrypt-api-plan.md @@ -1482,33 +1482,45 @@ Base64. - **What to build.** EQL types such as `TextEq` and `JsonTreeDocument` as field types of the Rust record derive. -- **How it works.** The record derive gives each field a `CallerContext`. - A field type that stores its context implements - `target::FromFieldContext`, and takes that context as the one it - stores. `Identifier` takes a context of two parts, the table first and - the column second, and gives the same context as that pair, so the - stored bytes do not change. It refuses a caller scope or a binding, - which an EQL payload cannot store, with - `PlanError::ContextNotStorable`, before any key is requested. +- **How it works.** The record derive gives each field a context of its + own: the record's scope, then the field's name. A field type that stores + its context stores that own context, as the type it stores. The stored + type implements `target::ContextFromField`. `Identifier` takes an own + context of two parts, the table first and the column second, and gives + the same context as that pair, so the stored bytes do not change. +- **What `i` stores.** The declared names only: the table and the column. + A record can also list a scope or a binding, supplied by the caller or + stored in the row. The ciphertext and the terms of an EQL field derive + under the column, then the scope, then the binding, as they do for a + field of any other type. `i` does not store them, so the reader supplies + them again, and a query supplies the scope. An EQL field of a record + nested in another stores its own column in the same way. - **Why.** This gives the [EQL field example](#store-a-field-as-an-eql-type) and the [JSON example](#encrypt-a-json-document). - **Done already.** In #1658: - A Rust record with EQL fields encrypts and decrypts through the new - API. + API, with or without a scope and a binding, and when it is nested in + another record. - For a field of each kind (equality, storage only, order, and a JSON - document), it writes the terms that a data plan writes for the same - declaration. A data plan is the path Go takes. Each path opens the - other's values (`packages/eql/tests/encryption/tests/record_fields.rs`). + document), a record with no scope writes the terms that a data plan + writes for the same declaration. A data plan is the path Go takes. + Each path opens the other's values + (`packages/eql/tests/encryption/tests/record_fields.rs`). - `JsonTreeDocument` implements `EncryptTarget` and `DecryptSource`. - A query from a field's marker works for the equality and match - types. The order types do not implement `Answers` yet, so a marker - cannot query them. -- **Tracked in.** Issue #1658. `eql-bindings` builds against the crates.io `stack-encrypt`, so a `stack-encrypt` release with `FromFieldContext` must come before the `eql-bindings` release that uses it. + types, with the record's scope. A plan's query for an EQL type takes + `.extend(..)`. The order types do not implement `Answers` yet, so a + marker cannot query them. +- **Not done.** A data plan still refuses a scope or a binding on a field + that names an EQL type (`TargetError::Extended`). So a Go record cannot + yet write an EQL field under a scope. +- **Tracked in.** Issue #1658. `eql-bindings` builds against the crates.io `stack-encrypt`, so a `stack-encrypt` release with `ContextFromField` must come before the `eql-bindings` release that uses it. - **Finished when** these are all true: - a Rust record with EQL fields encrypts, decrypts, and queries through the new API, including order queries; - - it writes the same terms as a Go record with the same declaration. + - it writes the same terms as a Go record with the same declaration, + with and without a scope. #### A ready-made EQL resolver diff --git a/packages/eql/crates/eql-bindings/CHANGELOG.md b/packages/eql/crates/eql-bindings/CHANGELOG.md index 40582da6bd..ca3783c41a 100644 --- a/packages/eql/crates/eql-bindings/CHANGELOG.md +++ b/packages/eql/crates/eql-bindings/CHANGELOG.md @@ -16,13 +16,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `{"t": "members", "c": "email"}` in `i`. The field writes the terms the type writes on its own under `Identifier::for_column("members", "email")`, and the terms a data plan that names the type writes. - - `Identifier` implements `stack_encrypt::target::FromFieldContext`. - - A record that lists a caller scope or a binding cannot have an EQL - field: an EQL payload stores a table and a column, and nothing more. - Every call is refused with `PlanError::ContextNotStorable`, which - names the field (`members/email`), before any key is requested. + - `Identifier` implements `stack_encrypt::target::ContextFromField`. + - A record can list a scope or a binding, such as `scope(tenant: u64)` + or `bind(row: u128)`, supplied by the caller or stored in the row. The + ciphertext and the terms of an EQL field then derive under the column, + the scope and the binding, as they do for a field of any other type. + `i` stores the column alone, and nothing a caller supplies. The reader + supplies the scope and the binding again. + - An EQL field of a record nested in another stores its own column too. + The outer record's names come after it in the field's context. - A query from the field's marker, such as `EncryptedMember::EMAIL.eq(..)`, - gives the term stored in the row, for the types that answer it. + gives the term stored in the row, for the types that answer it. For a + record with a scope, pass the scope: `.scope(Scope::new(tenant))`. + - A plan's query for an EQL type can carry a scope too, with + `.extend(..)`. It gives the term of the values stored under that + scope. `QueryLabelError` has no `Extended` variant. + - The encrypt input of an EQL type is the hidden + `stack_encrypt::target::StoredContext`. A caller passes a + `NonEmpty`. Generic code names the input as + `ContextInput: AcceptsContext>`. - **`JsonTreeDocument` implements `EncryptTarget` and `DecryptSource`** (#1658). It encrypts from a @@ -161,9 +173,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 does not answer, or another index named with `.via(..)`, does not compile. Each answering type is an `op::LabelledTarget`. The label must name the column, table then column, - and the query must not be extended, or the awaited query is refused - before any keyset is resolved, with a `QueryLabelError` in - `Error::Other`. `eql_bindings::encryption::query` holds `TwinIndex` + or the awaited query is refused before any keyset is resolved, with a + `QueryLabelError` in `Error::Other`. `eql_bindings::encryption::query` holds `TwinIndex` (Stack Encrypt's index, its query sent as the twin, for the twin's own plaintext only), `MatchTwin`, `QueryTwin` and `QueryLabelError`. `Hmac256` and `BloomFilter` now declare their indexes (`EncryptFrom::indexes`), diff --git a/packages/eql/crates/eql-bindings/README.md b/packages/eql/crates/eql-bindings/README.md index 1f3338fb9f..a283b24ee5 100644 --- a/packages/eql/crates/eql-bindings/README.md +++ b/packages/eql/crates/eql-bindings/README.md @@ -434,12 +434,38 @@ Each field writes the terms its type writes on its own under names the type writes. On decrypt, each field's stored `i` must be its own column, checked before any key is requested. -An EQL value stores a table and a column, and nothing more. So a record that -lists a caller scope (`scope(tenant: u64)`) or a binding (`bind(row: u128)`) -cannot have an EQL field. Every call to it is refused with -`PlanError::ContextNotStorable`, which names the field, before any key is -requested. The same goes for an EQL field of a record nested in another: its -context has more parts than a table and a column. +A record can also list a scope or a binding, such as a tenant or a row ID: + +```rust,ignore +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = Member, scope = "members", scope(tenant: u64), bind(row: u128))] +struct EncryptedMember { + #[stash(passthrough)] + id: u64, + email: TextEq, // i = {"t": "members", "c": "email"} +} + +let parts = || Scope::new(tenant).bind(row); +let stored: EncryptedMember = member.encrypt_into_with_context(&keyset, parts()).await?; +let query = EncryptedMember::EMAIL + .eq("alice@example.com") + .scope(Scope::new(tenant)) + .encrypt(&keyset) + .await?; +let opened = Member::decrypt_from_with_context(stored, &keyset, parts()).await?; +``` + +`i` stores the table and the column, and nothing a caller supplies. The +ciphertext and the terms of the field derive under the column, then the +scope, then the binding, as they do for a field of any other type. So: + +- the reader supplies the scope and the binding again, and a value does not + open without them; +- a query supplies the scope, and finds only the values stored under it; +- a binding is in no query, so it does not change the terms. + +An EQL field of a record nested in another stores its own column in the same +way. The outer record's names come after the column in the field's context. ### EQL types as plan field targets diff --git a/packages/eql/crates/eql-bindings/src/encryption.rs b/packages/eql/crates/eql-bindings/src/encryption.rs index 395fb2be17..bca643dea9 100644 --- a/packages/eql/crates/eql-bindings/src/encryption.rs +++ b/packages/eql/crates/eql-bindings/src/encryption.rs @@ -113,7 +113,7 @@ use stack_encrypt::sem::{ }; use stack_encrypt::target::transcode::{Reader, Transcode, Visitor}; use stack_encrypt::target::{ - self, AeadContext, CallerContext, DecryptSource, EncryptTarget, FromFieldContext, IndexSpec, + self, AeadContext, CallerContext, ContextFromField, DecryptSource, EncryptTarget, IndexSpec, RequiredContext, }; use stack_encrypt::{ @@ -202,32 +202,28 @@ impl<'a> IntoContext<'a> for &'a Identifier { } /// An EQL type can be a field of a derived `struct = ..` record. The record -/// gives the field its scope and the field's name, `("members", "email")`, -/// and the EQL type stores that pair as its column: table `members`, column -/// `email`. It is the context the record gave, so the field writes what the -/// type writes on its own under `Identifier::for_column("members", "email")`. +/// gives the field a context of its own: the record's scope, then the +/// field's name, such as `("members", "email")`. The EQL type stores that +/// pair in `i` as its column: table `members`, column `email`. /// -/// An EQL payload stores a table and a column, and nothing more. So a -/// record that lists a caller scope or a binding cannot have an EQL field: -/// the field refuses the context, before any key is requested. -impl FromFieldContext for Identifier { - fn from_field_context(context: &CallerContext) -> Option> { - if context.has_bindings() { - return None; - } - // A caller scope nests the field's pair inside a longer list, so a - // context with one is not two text parts and is refused below. - let query = context.query(); - let ContextPiece::List(parts) = &query else { +/// `i` holds those two declared names, and nothing a caller supplies. A +/// record can also list a scope or a binding, such as a tenant or a row ID. +/// The field's ciphertext and terms derive under them, after the column, +/// as a field of any other type does. They are not stored in `i`, so the +/// reader supplies them again. The names of a record that holds this one +/// as a field come after the column in the same way. +/// +/// With no scope and no binding, the field writes the terms that the type +/// writes on its own under `Identifier::for_column("members", "email")`. +impl ContextFromField for Identifier { + fn from_field(own: &ContextPiece<'_>) -> Option> { + let ContextPiece::List(parts) = own else { return None; }; let [ContextPiece::Text(table), ContextPiece::Text(column)] = parts.as_slice() else { return None; }; - let identifier = Self::for_column(table.as_ref(), column.as_ref()).ok()?; - // Only the context the record gave: a pair that reads as a column - // but encodes differently would seal under another context. - (identifier.get().to_context() == query).then_some(identifier) + Self::for_column(table.as_ref(), column.as_ref()).ok() } } diff --git a/packages/eql/crates/eql-bindings/src/encryption/query.rs b/packages/eql/crates/eql-bindings/src/encryption/query.rs index 38ac1bdadf..0d67f4f978 100644 --- a/packages/eql/crates/eql-bindings/src/encryption/query.rs +++ b/packages/eql/crates/eql-bindings/src/encryption/query.rs @@ -36,9 +36,14 @@ //! instance, though `Identifier::for_column` takes such a name. A column //! whose table or column name is not a plain segment can be written but //! not named on a plan, so it cannot be queried this way. A label of any other length -//! names no column, and a query extended with `.extend(..)` is derived under -//! more than the column the twin stores, so the awaited query refuses both, -//! before any keyset is resolved or term derived. +//! names no column, so the awaited query refuses it, before any keyset is +//! resolved or term derived. +//! +//! A query can carry a scope: `.extend(..)` on a plan's query, or +//! `.scope(..)` on a query from a record field's marker. Its term is then +//! derived under the column and the scope, and matches the values a record +//! stored under the same scope. The twin stores the column alone, as the +//! stored value does: a scope is never stored in `i`. //! //! The plan is for queries. Stack Encrypt runs a plan's encryption under //! the label, and an EQL type is sealed under its column instead, so write @@ -182,10 +187,10 @@ pub trait QueryTwin: Sized { /// The index an EQL v4 type answers an operator through: Stack Encrypt's /// index `I`, whose query term is sent as the type's query twin `Q`. /// -/// It derives exactly what `I` derives, under the plan's label, and checks -/// and refuses what `I` refuses, as well as a label that names no column -/// and any extension; it then writes the term into the twin, with the -/// column the label names. It answers for the twin's own plaintext +/// It derives exactly what `I` derives, under the plan's label and any +/// scope the query carries, and checks and refuses what `I` refuses, as +/// well as a label that names no column; it then writes the term into the +/// twin, with the column the label names. It answers for the twin's own plaintext /// ([`QueryTwin::Plaintext`]) only. `TextEq` answers [`op::Eq`](struct@op::Eq) through /// `TwinIndex`, and `TextMatch` answers /// [`op::Matches`](struct@op::Matches) through @@ -239,20 +244,19 @@ impl, Q> Index for TwinIndex { } } -/// Refuse a query context no EQL query value can record: a label that -/// names no column (it must be two segments, table then column), or any -/// extension (`.extend(..)`), since the terms would be derived under the -/// column and the extension while the twin stores the column alone, and -/// would match nothing. Before any keyset is resolved or term derived. -fn check_column(label: &Label, extended: bool) -> Result<(), Error> { +/// Refuse a label no EQL query value can record: one that names no column +/// (it must be two segments, table then column). Before any keyset is +/// resolved or term derived. +/// +/// A scope the query carries is not refused. The twin stores the column +/// alone, and so does a value a record stored under that scope: the scope +/// is in the term, and in neither `i`. +fn check_column(label: &Label) -> Result<(), Error> { if Identifier::column_of(label).is_none() { return Err(Error::Other(Box::new(QueryLabelError::NotAColumn { label: label.to_string(), }))); } - if extended { - return Err(Error::Other(Box::new(QueryLabelError::Extended))); - } Ok(()) } @@ -269,10 +273,6 @@ pub enum QueryLabelError { /// The label, rendered. label: String, }, - /// The query was extended with `.extend(..)`, so its terms would be - /// derived under more than the column the twin stores, and would match - /// nothing. - Extended, } impl fmt::Display for QueryLabelError { @@ -282,10 +282,6 @@ impl fmt::Display for QueryLabelError { f, "{label:?} is not an EQL column identifier: expected two segments, table and column" ), - Self::Extended => f.write_str( - "an EQL query value stores the column it queries and nothing else, so its query \ - cannot be extended", - ), } } } @@ -317,8 +313,8 @@ where fn prepare(&self, value: &Q::Plaintext) -> Result<(), Error> { Supports::::prepare(&self.index, value) } - fn check_label(&self, label: &Label, extended: bool) -> Result<(), Error> { - check_column(label, extended) + fn check_label(&self, label: &Label, _extended: bool) -> Result<(), Error> { + check_column(label) } fn query((): (), term: EqualityTerm, token: QueryToken) -> Q { Q::assemble(column(&token), hmac256(&term)) @@ -341,8 +337,8 @@ where fn prepare(&self, value: &Q::Plaintext) -> Result { Supports::::prepare(&self.index, value) } - fn check_label(&self, label: &Label, extended: bool) -> Result<(), Error> { - check_column(label, extended) + fn check_label(&self, label: &Label, _extended: bool) -> Result<(), Error> { + check_column(label) } fn query(tokens: QueryTokens, terms: MatchTerms, token: QueryToken) -> MatchTwin { let column = column(&token); diff --git a/packages/eql/crates/eql-bindings/src/v4/json_tree.rs b/packages/eql/crates/eql-bindings/src/v4/json_tree.rs index 94c4123f8c..9d620aac94 100644 --- a/packages/eql/crates/eql-bindings/src/v4/json_tree.rs +++ b/packages/eql/crates/eql-bindings/src/v4/json_tree.rs @@ -27,8 +27,8 @@ use stack_encrypt::json::{ JsonSelector, JsonSubtree, JsonSubtreeKey, }; use stack_encrypt::target::{ - AeadContext, CallerContext, DecryptSource, EncryptTarget, ExpectedContext, IndexSpec, Json, - Layout, RequiredContext, + AeadContext, DecryptSource, EncryptTarget, ExpectedContext, IndexSpec, Json, Layout, + StoredContext, }; use stack_encrypt::{ DecryptField, DecryptInto, Decryptable, Decryption, EncryptFrom, Encryption, Error, NonEmpty, @@ -486,21 +486,17 @@ impl JsonTree { } impl JsonTree { - /// Seal a document under these options, under the column that the - /// caller's context `C` holds. `column` takes the column out of it. - fn sealing<'s, K: 'static, C>( + /// Seal a document under these options, and write its column to `i`. + /// + /// On its own, the document stores the column the caller passes, and + /// seals under it. As a field of a record, it stores the field's own + /// column, and seals under the scopes and bindings of the record too. + fn sealing<'s, K: 'static>( &self, - column: fn(C) -> Identifier, - ) -> Encryption<'s, Value, JsonTreeDocument, K, C> - where - C: Into + Clone + Send + 'static, - { + ) -> Encryption<'s, Value, JsonTreeDocument, K, StoredContext> { Layout::::encryption::(&self.index) - .accepting::() - .map_with_context(|document, context: C| (context, document)) - .try_map(move |(context, document)| { - JsonTreeDocument::envelope(column(context), &document) - }) + .storing(|document, column: Identifier| (column, document)) + .try_map(|(column, document)| JsonTreeDocument::envelope(column, &document)) } } @@ -514,7 +510,7 @@ impl Layout for JsonTree { fn encryption<'s, K: 'static>( &self, ) -> Encryption<'s, Value, JsonTreeDocument, K, NonEmpty> { - self.sealing(NonEmpty::into_inner) + self.sealing().accepting() } fn index(&self) -> &Json { &self.index @@ -548,15 +544,19 @@ impl DecryptInto for JsonTreeDocument { } impl JsonTreeDocument { - /// Open the document under the stored column, once it has passed the - /// caller's check. The check runs before any key is requested. + /// Open the document, once its stored column has passed the caller's + /// check. The check runs before any key is requested. + /// + /// On its own, the document opens under the stored column. As a field + /// of a record, it opens under the scopes and bindings of the record + /// too. fn open(self, context: ExpectedContext) -> Decryption { - let column = match context.validate(self.i.clone()) { - Ok(column) => column, + let context = match context.opening(self.i.clone()) { + Ok(context) => context, Err(error) => return Decryption::failed(error), }; match self.document() { - Ok(document) => document.decryption(AeadContext::from(column)), + Ok(document) => document.decryption(AeadContext::from(context)), Err(error) => Decryption::failed(error), } } @@ -566,23 +566,45 @@ impl JsonTreeDocument { /// under the default options ([`JsonTree::default`]), with the column as /// its context: /// -/// ```ignore +/// ```no_run +/// use eql_bindings::{v4::json_tree::JsonTreeDocument, Identifier}; +/// use stack_encrypt::json::JsonPlaintext; +/// use stack_encrypt::kms::{DataKeySource, IndexKeySource}; +/// use stack_encrypt::{DecryptFrom, EncryptInto, KeysetCipher}; +/// +/// # async fn example(keyset: &KeysetCipher<'_, K>) -> Result<(), Box> +/// # where +/// # K: DataKeySource + IndexKeySource + Sync + 'static, +/// # { +/// let column = Identifier::for_column("customers", "profile")?; +/// let profile = JsonPlaintext::from(serde_json::json!({"name": "Ada", "age": 36})); /// let document: JsonTreeDocument = profile -/// .encrypt_into_with_context(&keyset, Identifier::for_column("customers", "profile")?) +/// .encrypt_into_with_context(keyset, column.clone()) /// .await?; +/// assert_eq!(document.i, *column.get()); +/// +/// // The column is checked against the one the document stores. +/// let opened = JsonPlaintext::decrypt_from_with_context(document, keyset, column).await?; +/// assert_eq!(opened.value()["name"], "Ada"); +/// # Ok(()) +/// # } /// ``` /// +/// The example is compiled, and not run: this crate's doctests have no key +/// source. `record_fields.rs` in the encryption test crate runs the same +/// calls. +/// /// It can be a field of a derived `struct = ..` record. The field's column -/// is then the record's scope and the field's name. +/// is then the record's scope and the field's name. A scope or a binding +/// that the record lists is not stored in the document: the reader supplies +/// it again. impl EncryptTarget for JsonTreeDocument { - type ContextInput = RequiredContext>; + type ContextInput = StoredContext; fn encryption<'s, K: 'static>() -> Encryption<'s, JsonPlaintext, Self, K, Self::ContextInput> where JsonPlaintext: 's, { - JsonTree::default() - .sealing(|column: Self::ContextInput| column.into_inner().into_inner()) - .project(JsonPlaintext::value) + JsonTree::default().sealing().project(JsonPlaintext::value) } fn indexes() -> Vec { JsonTree::default().specs() diff --git a/packages/eql/tests/encryption/tests/migration_shim.rs b/packages/eql/tests/encryption/tests/migration_shim.rs index ecc013b744..85c66614ea 100644 --- a/packages/eql/tests/encryption/tests/migration_shim.rs +++ b/packages/eql/tests/encryption/tests/migration_shim.rs @@ -18,7 +18,7 @@ use std::cmp::Ordering; use eql_bindings::v4::integer::{IntegerOrdOre, IntegerOrdOreQuery}; use eql_bindings::v4::text::{TextEq, TextEqQuery}; use eql_bindings::Identifier; -use stack_encrypt::target::{EncryptTarget, Encryption, NoCallerContext, RequiredContext, Zeroize}; +use stack_encrypt::target::{AcceptsContext, EncryptTarget, Encryption, NoCallerContext, Zeroize}; use stack_encrypt::{DecryptFrom, EncryptInto, Error, NonEmpty}; /// A value decrypted from EQL v3, with the column it was stored in. @@ -39,7 +39,8 @@ struct Shim(T); impl EncryptTarget> for Shim where P: Zeroize, - T: EncryptTarget>>, + T: EncryptTarget

, + T::ContextInput: AcceptsContext>, { type ContextInput = NoCallerContext; fn encryption<'s, K: 'static>() -> Encryption<'s, V3Plaintext

, Self, K, NoCallerContext> diff --git a/packages/eql/tests/encryption/tests/record_fields.rs b/packages/eql/tests/encryption/tests/record_fields.rs index 5bb3bc9b1e..f56eefb369 100644 --- a/packages/eql/tests/encryption/tests/record_fields.rs +++ b/packages/eql/tests/encryption/tests/record_fields.rs @@ -14,9 +14,11 @@ //! - the derive and a data plan write the same terms for a field of each //! kind (equality, storage only, order, JSON document), and each opens //! the other's value; -//! - a record that lists a caller scope or a binding cannot store an EQL -//! field, nor can a record nested in another, and each is refused before -//! any key is requested; +//! - a scope or a binding that the record lists, supplied by the caller or +//! stored in the row, comes after the column in the field's context. The +//! ciphertext and the terms derive under it, and `i` does not store it. +//! The reader supplies it again, and a query supplies the scope; +//! - an EQL field of a record nested in another stores its own column too; //! - a field that stores another column is refused before any key is //! requested; //! - a JSON document, `JsonTreeDocument`, encrypts from a `JsonPlaintext` @@ -25,7 +27,7 @@ mod common; -use eql_bindings::encryption::EqlTargets; +use eql_bindings::encryption::{EqlTargets, NativeCiphertextVisitor as NativeLeaf}; use eql_bindings::v4::integer::IntegerOrd; use eql_bindings::v4::json_tree::{JsonTree, JsonTreeDocument}; use eql_bindings::v4::text::{Text, TextEq}; @@ -34,10 +36,12 @@ use serde::Serialize; use serde_json::{json, Value}; use stack_encrypt::dynamic::{self, record, ValueFrame}; use stack_encrypt::json::JsonPlaintext; -use stack_encrypt::target::{Bind, Layout, Scope}; +use stack_encrypt::target::transcode::Reader as _; +use stack_encrypt::target::{AeadContext, Bind, Layout, Parts, Scope}; +use stack_encrypt::testing::{bind, scope, Expect}; use stack_encrypt::{ - AsProtectedRef as _, CipherText, Controlled as _, DecryptFrom, DecryptSource, EncryptInto, - EncryptTarget, Error, NonEmpty, PlanError, StackCipherText, + assert_context, nonempty, AsProtectedRef as _, CipherText, Controlled as _, DecryptFrom, + DecryptSource, EncryptInto, EncryptTarget, Error, NonEmpty, StackCipherText, }; use zeroize::Zeroize; @@ -353,77 +357,235 @@ async fn the_derive_and_a_data_plan_write_the_same_row() { ); } -#[derive(Clone, Zeroize)] +#[derive(Clone, Debug, PartialEq, Zeroize)] struct Tenanted { email: String, } -/// A record whose caller supplies a tenant scope. An EQL payload has -/// nowhere to store the tenant. +fn tenanted() -> Tenanted { + Tenanted { + email: "ada@example.com".into(), + } +} + +/// A record whose caller supplies a tenant scope and a row binding. An EQL +/// field stores its column in `i`, and neither the tenant nor the row. #[derive(EncryptTarget, DecryptSource)] -#[stash(struct = Tenanted, scope = "members", scope(tenant: u64))] +#[stash(struct = Tenanted, scope = "members", scope(tenant: u64), bind(row: u128))] struct EncryptedTenanted { email: TextEq, } -/// A record whose caller supplies a row binding. -#[derive(EncryptTarget, DecryptSource)] -#[stash(struct = Tenanted, scope = "members", bind(row: u128))] -struct EncryptedBound { - email: TextEq, +/// The tenant and the row the tests seal under. +fn tenant_and_row() -> Parts, Bind> { + Scope::new(42u64).bind(9u128) +} + +/// The record shows the contexts of an EQL field's ciphertext and term +/// before any key is requested: the column, the tenant, then the row. +#[test] +fn a_record_shows_the_scope_and_the_binding_of_an_eql_field() { + assert_context!( + EncryptedTenanted::contexts(), + "email.c" = { + query = [["members", "email"], scope::("tenant")], + stored = [ + [ + Expect::from(["members", "email"]), + Expect::from(scope::("tenant")), + ], + bind::("row"), + ], + }, + "email.hm" = { + query = [["members", "email"], scope::("tenant")], + stored = [ + [ + Expect::from(["members", "email"]), + Expect::from(scope::("tenant")), + ], + bind::("row"), + ], + }, + ); } -/// The refusal names the field by the record's scope and its name. -fn assert_not_storable(result: Result) { - match result { - Err(Error::Plan(PlanError::ContextNotStorable { field })) => { - assert_eq!(field, "members/email"); - } - Err(other) => panic!("expected ContextNotStorable, got {other:?}"), - Ok(_) => panic!("expected ContextNotStorable, got a value"), - } +#[tokio::test] +async fn an_eql_field_stores_its_column_and_not_the_scope_or_the_binding() { + let (cipher, _) = common::cipher().await; + let keyset = cipher.default_keyset(); + let stored: EncryptedTenanted = tenanted() + .encrypt_into_with_context(&keyset, tenant_and_row()) + .await + .unwrap(); + assert_eq!( + stored.email.i, + *column("email").get(), + "`i` holds the declared names alone" + ); + + // The ciphertext is sealed under the column, then the tenant, then the + // row: it opens as a Stack Encrypt leaf under exactly that context. + let leaf = stored.email.c.clone().read(NativeLeaf).unwrap(); + let context = AeadContext::from(tenant_and_row()).extend(nonempty!("members").with("email")); + let opened: String = cipher.decrypt_as(leaf, context).await.unwrap(); + assert_eq!(opened, "ada@example.com"); + + // The term is derived under the column, then the tenant. A query from + // the field's marker under the same tenant gives the stored term, and + // names the column alone. + let query = EncryptedTenanted::EMAIL + .eq("ada@example.com") + .scope(Scope::new(42u64)) + .encrypt(&keyset) + .await + .unwrap(); + assert_eq!(query.i, stored.email.i); + assert_eq!(query.hm, stored.email.hm); + let other_tenant = EncryptedTenanted::EMAIL + .eq("ada@example.com") + .scope(Scope::new(43u64)) + .encrypt(&keyset) + .await + .unwrap(); + assert_ne!(other_tenant.hm, stored.email.hm); + + // So it is not the term of the column alone. + let alone: TextEq = "ada@example.com" + .to_owned() + .encrypt_into_with_context(&keyset, column("email")) + .await + .unwrap(); + assert_ne!(alone.hm, stored.email.hm); + + // A binding is in no query context: another row has the same term. + let other_row: EncryptedTenanted = tenanted() + .encrypt_into_with_context(&keyset, Scope::new(42u64).bind(10u128)) + .await + .unwrap(); + assert_eq!(other_row.email.hm, stored.email.hm); + + // The reader supplies the tenant and the row again. + let opened = Tenanted::decrypt_from_with_context(stored, &keyset, tenant_and_row()) + .await + .unwrap(); + assert_eq!(opened, tenanted()); } #[tokio::test] -async fn a_record_with_a_caller_scope_or_binding_cannot_store_an_eql_field() { +async fn an_eql_field_opens_only_under_the_scope_and_the_binding_it_was_sealed_under() { let (cipher, calls) = common::cipher().await; let keyset = cipher.default_keyset(); - let tenanted = || Tenanted { - email: "ada@example.com".into(), + let stored: EncryptedTenanted = tenanted() + .encrypt_into_with_context(&keyset, tenant_and_row()) + .await + .unwrap(); + let again = || EncryptedTenanted { + email: stored.email.clone(), }; - let scoped: Result = tenanted() - .encrypt_into_with_context(&keyset, Scope::new(42u64)) - .await; - assert_not_storable(scoped); - let bound: Result = tenanted() - .encrypt_into_with_context(&keyset, Bind::new(9u128)) - .await; - assert_not_storable(bound); - assert!( - calls.lock().unwrap().generate.is_empty(), - "refused before any key is requested" - ); - - // A value written on its own under the column does not open in such a - // record either. + let other_tenant = + Tenanted::decrypt_from_with_context(again(), &keyset, Scope::new(43u64).bind(9u128)); + assert!(other_tenant.await.is_err(), "another tenant"); + let other_row = + Tenanted::decrypt_from_with_context(again(), &keyset, Scope::new(42u64).bind(10u128)); + assert!(other_row.await.is_err(), "another row"); + + // On its own, the field's value stores the same column. It was sealed + // under the tenant and the row too, so the column alone does not open + // it. + let alone = + String::decrypt_from_with_context(stored.email.clone(), &keyset, column("email")).await; + assert!(alone.is_err(), "the column alone"); + + // And a value sealed under the column alone does not open as the + // record's field. let email: TextEq = "ada@example.com" .to_owned() .encrypt_into_with_context(&keyset, column("email")) .await .unwrap(); - let record = EncryptedTenanted { - email: email.clone(), + let record = EncryptedTenanted { email }; + let opened = Tenanted::decrypt_from_with_context(record, &keyset, tenant_and_row()).await; + assert!(opened.is_err(), "sealed without the tenant and the row"); + + // The stored column is checked first, before any key is requested. + let email: TextEq = "ada@example.com" + .to_owned() + .encrypt_into_with_context(&keyset, column("other")) + .await + .unwrap(); + let record = EncryptedTenanted { email }; + let before = calls.lock().unwrap().retrieve.len(); + match Tenanted::decrypt_from_with_context(record, &keyset, tenant_and_row()).await { + Err(Error::ContextMismatch { .. }) => {} + Err(other) => panic!("expected ContextMismatch, got {other:?}"), + Ok(_) => panic!("expected ContextMismatch, got a value"), + } + assert_eq!(calls.lock().unwrap().retrieve.len(), before); +} + +#[derive(Clone, Zeroize)] +struct Note { + id: u128, + tenant: u64, + title: String, + body: JsonPlaintext, +} + +/// A record that stores its scope and its binding in the row, in fields of +/// its own. Its EQL fields still store their columns alone. +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = Note, scope = "notes", scope(field = "tenant"), bind(field = "id"))] +struct EncryptedNote { + #[stash(passthrough)] + id: u128, + #[stash(passthrough)] + tenant: u64, + title: TextEq, + body: JsonTreeDocument, +} + +#[tokio::test] +async fn an_eql_field_of_a_record_that_stores_its_scope_and_binding() { + let (cipher, _) = common::cipher().await; + let keyset = cipher.default_keyset(); + let note = Note { + id: 9, + tenant: 42, + title: "Renewal".into(), + body: profile().into(), }; - let opened = Tenanted::decrypt_from_with_context(record, &keyset, Scope::new(42u64)).await; - assert_not_storable(opened); - let record = EncryptedBound { email }; - let opened = Tenanted::decrypt_from_with_context(record, &keyset, Bind::new(9u128)).await; - assert_not_storable(opened); - assert!( - calls.lock().unwrap().retrieve.is_empty(), - "refused before any key is requested" - ); + let stored: EncryptedNote = note.encrypt_into(&keyset).await.unwrap(); + let notes = |name: &str| Identifier::for_column("notes", name).unwrap().into_inner(); + assert_eq!(stored.title.i, notes("title")); + assert_eq!(stored.body.i, notes("body")); + + // A query supplies the tenant, since it cannot read a row. + let query = EncryptedNote::TITLE + .eq("Renewal") + .scope(Scope::new(42u64)) + .encrypt(&keyset) + .await + .unwrap(); + assert_eq!(query.i, stored.title.i); + assert_eq!(query.hm, stored.title.hm); + + // The fields of this row do not open in another row. + let moved = EncryptedNote { + id: 10, + tenant: stored.tenant, + title: stored.title.clone(), + body: stored.body.clone(), + }; + assert!(Note::decrypt_from(moved, &keyset).await.is_err()); + + // The row supplies its tenant and its ID, so the reader passes none. + let opened = Note::decrypt_from(stored, &keyset).await.unwrap(); + assert_eq!(opened.id, 9); + assert_eq!(opened.tenant, 42); + assert_eq!(opened.title, "Renewal"); + assert_eq!(opened.body.value(), &profile()); } /// A record with an EQL field and nothing the caller supplies. @@ -433,13 +595,13 @@ struct EncryptedPlain { email: TextEq, } -#[derive(Clone, Zeroize)] +#[derive(Clone, Debug, PartialEq, Zeroize)] struct Holder { member: Tenanted, } /// That record as a field of another. Its EQL field's context is then its -/// own pair, extended by the outer record's. +/// own pair, followed by the outer field's pair. #[derive(EncryptTarget, DecryptSource)] #[stash(struct = Holder, scope = "holders")] struct EncryptedHolder { @@ -447,20 +609,33 @@ struct EncryptedHolder { } #[tokio::test] -async fn an_eql_field_of_a_nested_record_is_refused() { - let (cipher, calls) = common::cipher().await; +async fn an_eql_field_of_a_nested_record_stores_its_own_column() { + let (cipher, _) = common::cipher().await; let keyset = cipher.default_keyset(); - let holder = Holder { - member: Tenanted { - email: "ada@example.com".into(), - }, - }; - let nested: Result = holder.encrypt_into(&keyset).await; - assert_not_storable(nested); - assert!( - calls.lock().unwrap().generate.is_empty(), - "refused before any key is requested" - ); + let holder = Holder { member: tenanted() }; + let stored: EncryptedHolder = holder.clone().encrypt_into(&keyset).await.unwrap(); + + // The inner record's scope and the field's name. The outer record's + // names come after them in the context, and are not stored. + assert_eq!(stored.member.email.i, *column("email").get()); + + // A query from the nested field's marker gives the stored term. + let query = EncryptedHolder::MEMBER + .field(EncryptedPlain::EMAIL) + .eq("ada@example.com") + .encrypt(&keyset) + .await + .unwrap(); + assert_eq!(query.i, stored.member.email.i); + assert_eq!(query.hm, stored.member.email.hm); + + // The inner record on its own writes another term: its field has no + // outer names in its context. + let plain: EncryptedPlain = tenanted().encrypt_into(&keyset).await.unwrap(); + assert_eq!(plain.email.i, stored.member.email.i); + assert_ne!(plain.email.hm, stored.member.email.hm); + + assert_eq!(Holder::decrypt_from(stored, &keyset).await.unwrap(), holder); } #[tokio::test] diff --git a/packages/eql/tests/encryption/tests/typed_queries.rs b/packages/eql/tests/encryption/tests/typed_queries.rs index 67791aeedb..b2b71e177e 100644 --- a/packages/eql/tests/encryption/tests/typed_queries.rs +++ b/packages/eql/tests/encryption/tests/typed_queries.rs @@ -23,7 +23,10 @@ use eql_bindings::Identifier; use serde::Serialize; use stack_encrypt::dynamic::{self, record, FieldPlan, Op, Operand, TargetDescriptor, ValueFrame}; use stack_encrypt::op::{self, Probabilistic, Stats}; +use stack_encrypt::target::Scope; use stack_encrypt::{ContextPiece, Encrypt, Error, NonEmpty, Plan, StackCipher}; +use stack_encrypt::{DecryptSource, EncryptInto, EncryptTarget}; +use zeroize::Zeroize; type Cipher = StackCipher; @@ -441,59 +444,105 @@ async fn a_typed_query_refuses_what_the_data_path_refuses() { assert_eq!(calls.lock().unwrap().loads, 0, "refused before any keyset"); } -/// A query extended with `.extend(..)` derives its terms under the column -/// and the extension, while the twin stores the column alone, so it could -/// match nothing: it is refused, before the named keyset is loaded, for an +#[derive(Clone, Zeroize)] +struct Person { + email: String, + name: String, +} + +/// A record whose caller supplies a tenant scope, with an exact field and a +/// match field. Each field stores its column, and not the tenant. +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = Person, scope = "users", scope(tenant: i64))] +struct EncryptedPerson { + email: TextEq, + name: TextMatch, +} + +/// A query extended with `.extend(..)` derives its term under the column +/// and the extension. A record stores its fields the same way under a +/// scope. So the extended query is the term of the row stored under that +/// scope, and the twin stores the column alone, as the row does. For an /// exact type and for `TextMatch`. #[tokio::test] -async fn an_extended_typed_query_is_refused_before_any_keyset() { - let (cipher, calls) = common::cipher().await; +async fn an_extended_typed_query_finds_a_value_stored_under_the_same_scope() { + let (cipher, _) = common::cipher().await; + let keyset = cipher.default_keyset(); + let stored: EncryptedPerson = Person { + email: "bob@example.com".into(), + name: "Bob Jones".into(), + } + .encrypt_into_with_context(&keyset, Scope::new(7i64)) + .await + .unwrap(); + let email = Plan::value::() .context("users/email") .encrypt_into::() .build() .unwrap(); + let query = cipher + .query(op::Eq, "bob@example.com") + .extend(7i64) + .on(&email) + .await + .unwrap(); + assert_eq!(query.i, stored.email.i, "the twin stores the column alone"); + assert_eq!(query.hm, stored.email.hm, "the term of the stored row"); + + // Another scope, or none, gives another term. + let other = cipher + .query(op::Eq, "bob@example.com") + .extend(8i64) + .on(&email) + .await + .unwrap(); + assert_ne!(other.hm, stored.email.hm); + let unscoped = cipher + .query(op::Eq, "bob@example.com") + .on(&email) + .await + .unwrap(); + assert_eq!(unscoped.i, stored.email.i); + assert_ne!(unscoped.hm, stored.email.hm); + let name = Plan::value::() .context("users/name") .encrypt_into::() .build() .unwrap(); - let refusals = [ - cipher - .query(op::Eq, "bob") - .extend(7i64) - .keyset("tenant") - .on(&email) - .await - .map(drop), - cipher - .query(op::Matches, "alice") - .extend(7i64) - .keyset("tenant") - .on(&name) - .await - .map(drop), - ]; - for refused in refusals { - let Err(Error::Other(reason)) = &refused else { - panic!("{refused:?}"); - }; - assert_eq!( - reason.downcast_ref::(), - Some(&QueryLabelError::Extended), - "{reason}" - ); - } - assert_eq!(calls.lock().unwrap().loads, 0, "refused before any keyset"); - - // Unextended, the same query loads the keyset and runs. - cipher - .query(op::Eq, "bob") - .keyset("tenant") - .on(&email) + let query = cipher + .query(op::Matches, "jones") + .extend(7i64) + .on(&name) + .await + .unwrap(); + assert_eq!(query.twin().i, stored.name.i); + // Every position the query sets is set in the stored filter. + assert!( + query + .twin() + .bf + .0 + .iter() + .all(|position| stored.name.bf.0.contains(position)), + "the stored filter contains the query's" + ); + let other = cipher + .query(op::Matches, "jones") + .extend(8i64) + .on(&name) .await .unwrap(); - assert_eq!(calls.lock().unwrap().loads, 1); + assert!( + !other + .twin() + .bf + .0 + .iter() + .all(|position| stored.name.bf.0.contains(position)), + "under another scope the filter differs" + ); } /// A record field naming a storage-only EQL type may store a null as the diff --git a/packages/eql/tests/encryption/tests/v4_owned.rs b/packages/eql/tests/encryption/tests/v4_owned.rs index c796564b52..732e76e379 100644 --- a/packages/eql/tests/encryption/tests/v4_owned.rs +++ b/packages/eql/tests/encryption/tests/v4_owned.rs @@ -653,9 +653,15 @@ async fn a_value_is_refused_under_another_column_before_any_key_is_requested() { #[test] fn the_traits_the_compile_fail_examples_refuse_exist_for_the_right_shapes() { use eql_bindings::Identifier; - use stack_encrypt::target::{DecryptSource, EncryptTarget, RequiredContext}; + use stack_encrypt::target::{AcceptsContext, DecryptSource, EncryptTarget}; fn decrypts>() {} - fn encrypts>>>() {} + // The context a caller passes is the column identifier. + fn encrypts() + where + T: EncryptTarget, + T::ContextInput: AcceptsContext>, + { + } decrypts::(); encrypts::(); encrypts::(); diff --git a/packages/stack-encrypt-derive/docs/attributes.md b/packages/stack-encrypt-derive/docs/attributes.md index d238460265..7e3f0ba70f 100644 --- a/packages/stack-encrypt-derive/docs/attributes.md +++ b/packages/stack-encrypt-derive/docs/attributes.md @@ -108,7 +108,7 @@ pass one, or must not pass one. This table shows which: |---|---|---|---| | `struct = User, scope = "users"` | `DeclaredScopes`, which takes no context from the caller | The same as for encryption | `user.encrypt_into(&keyset)` | | `plaintext = T` | `RequiredContext`, or `RequiredContext` with `context_type = AeadContext` | The same as for encryption | `value.encrypt_into_with_context(&keyset, nonempty!("users/email"))` | -| With a `context_field` of type `C` | `RequiredContext>` | Nothing, or the `NonEmpty` the caller expects | `T::decrypt_from_with_context(record, &cipher, nonempty!(..))`, which checks the stored context before it decrypts | +| With a `context_field` of type `C` | The `NonEmpty` that the record stores, which the caller must pass | Nothing, or the `NonEmpty` the caller expects | `T::decrypt_from_with_context(record, &cipher, nonempty!(..))`, which checks the stored context before it decrypts | #### Check where a record with a `context_field` came from @@ -163,6 +163,10 @@ context from the record. These targets accept one: `RequiredContext`. - `StackCipherText`, whose input is `RequiredContext`. - A `plaintext` record. +- A record with a `context_field`, when the stored type implements + `ContextFromField`. An EQL type is one. It stores the field's own + context, `("users", "")`, and not the record's scopes or + bindings. The reader supplies those again. - Another `struct` record. The outer field's context then extends the inner record's own contexts. diff --git a/packages/stack-encrypt-derive/src/owned_decrypt.rs b/packages/stack-encrypt-derive/src/owned_decrypt.rs index ca08c18a00..cf35737c54 100644 --- a/packages/stack-encrypt-derive/src/owned_decrypt.rs +++ b/packages/stack-encrypt-derive/src/owned_decrypt.rs @@ -227,8 +227,10 @@ fn struct_body( } /// A `plaintext = T` record. Its one recoverable field opens under the -/// required context. A `context_field` record first builds that context from -/// the context it stores, and checks it against the caller's expectation. +/// required context. A `context_field` record first checks the context it +/// stores against the caller's expectation, and builds that context from it. +/// A record that is a field of a `struct = ..` record also opens under the +/// scopes and bindings of that record (`ExpectedContext::opening`). fn value_body( record: &Record, plaintext: &Type, @@ -242,7 +244,7 @@ fn value_body( let stored = record.context_field().map(|field| { let member = &field.member; quote! { - let __context = match __context.validate(self.#member) { + let __context = match __context.opening(self.#member) { ::core::result::Result::Ok(context) => <#threaded as #krate::target::AcceptsContext>::supplied(::core::convert::Into::into(context)), ::core::result::Result::Err(error) => return #krate::target::Decryption::failed(error), }; diff --git a/packages/stack-encrypt-derive/src/owned_encrypt.rs b/packages/stack-encrypt-derive/src/owned_encrypt.rs index d62cbdbe09..b91efdc264 100644 --- a/packages/stack-encrypt-derive/src/owned_encrypt.rs +++ b/packages/stack-encrypt-derive/src/owned_encrypt.rs @@ -102,9 +102,10 @@ fn markers(input: &DeriveInput, record: &Record) -> TokenStream { } /// The caller's input on the encrypt side. A record whose fields have their -/// own contexts takes an extension. A record that stores its context requires -/// the `NonEmpty` it stores. Any other record requires the caller context, -/// or the type `context_type` names. +/// own contexts takes an extension. A record that stores its context takes a +/// `StoredContext`: the caller passes the `NonEmpty` it stores. Any +/// other record requires the caller context, or the type `context_type` +/// names. pub(crate) fn encrypt_input(record: &Record) -> Type { let krate = &record.krate; if record.parts.caller_any() { @@ -113,7 +114,7 @@ pub(crate) fn encrypt_input(record: &Record) -> Type { } if let Some(field) = record.context_field() { let ty = &field.ty; - parse_quote!(#krate::target::RequiredContext<#krate::NonEmpty<#ty>>) + parse_quote!(#krate::target::StoredContext<#ty>) } else if record.by_field { parse_quote!(#krate::target::DeclaredScopes) } else { @@ -335,8 +336,8 @@ fn struct_derive(input: &DeriveInput, record: &Record) -> TokenStream { quote_spanned! {ty.span()=> #check let #local = <#ty as #krate::target::EncryptTarget<_>>::encryption::<__K>() + .under_field(#own) .project(|__source: &#source| &__source.#from) - .under(#own) .named(#name); } } @@ -369,9 +370,11 @@ fn struct_derive(input: &DeriveInput, record: &Record) -> TokenStream { } => { let own = own_expr(krate, context); let name = field_name(field); + // `under_field` wipes the field's plaintext when the + // context is refused before the field's target runs. quote_spanned! {ty.span()=> let #local = <#ty as #krate::target::EncryptTarget<_>>::owned_encryption::<__K>() - .under(#own) + .under_field(#own) .named(#name); } } @@ -511,8 +514,9 @@ fn own_expr(krate: &syn::Path, context: &OwnContext) -> TokenStream { /// `#[stash(plaintext = T)]`, or no record attribute. Every field's target /// runs over the whole value, under the one required context, and the results -/// are zipped. A record with a `context_field` requires the `NonEmpty` it -/// stores, and returns it beside the outputs. +/// are zipped. A record with a `context_field` stores its context +/// (`Encryption::storing`): the `NonEmpty` the caller passes, or the +/// field's own context when the record is a field of a `struct = ..` record. fn value_derive(input: &DeriveInput, record: &Record, fields: &[&Field]) -> TokenStream { let krate = &record.krate; let input_type = encrypt_input(record); @@ -570,17 +574,10 @@ fn value_derive(input: &DeriveInput, record: &Record, fields: &[&Field]) -> Toke let value = literal(record, None); quote!(#zipped.map(move |#zip_pattern| #value).accepting::()) } - Some(_) => { - let value = literal( - record, - Some( - quote!(#krate::target::RequiredContext::into_inner(__stored).into_inner()), - ), - ); + Some(stored) => { + let value = literal(record, Some(quote!(__stored))); quote! { - #zipped - .accepting::() - .map_with_context(move |#zip_pattern, __stored: Self::ContextInput| #value) + #zipped.storing(move |#zip_pattern, __stored: #stored| #value) } } }; diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 6c980903df..9bbde968f7 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -10,25 +10,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **A field type that stores its context can be a record field** (#1658). - A derived `struct = ..` record gives each field its scope and the - field's name. A type that stores its context in the row, such as an EQL - type that stores a table and a column, implements the new trait - `target::FromFieldContext` to take that context as the one it stores. - - `FromFieldContext::from_field_context` rebuilds the stored context - from the field's `CallerContext`, or returns `None` when the type - cannot store it. - - A context the type cannot store is refused before any key is - requested, on encrypt and on decrypt, with the new - `PlanError::ContextNotStorable` - (`stack_encrypt::plan_context_not_storable`), which names the field - by the record's scope and its name (`members/email`). A record that lists a - caller scope or a binding gives its fields such a context. - - On decrypt, the stored context must equal the field's, as a + A derived `struct = ..` record gives each field a context of its own: + the record's scope, then the field's name. A type that stores its + context in the row (`#[stash(context_field)]`), such as an EQL type that + stores a table and a column, stores that own context. The stored type + implements the new trait `target::ContextFromField`. + - `ContextFromField::from_field` builds the stored context from the + field's own context, or returns `None` when the type cannot store it. + - The scopes and the bindings that the record lists, and the names of a + record that holds this one, come after the own context. The field + runs under all of it, as a field of any other type does, and does not + store them. The reader supplies them again. + - On decrypt, the stored context must equal the field's own, as a standalone decrypt with an expected context checks it. - - `Encryption::under` now converts the field's context through the - hidden trait `target::FromCaller`, which every - `From` type implements. A caller of `under` compiles - unchanged. + - An own context that the type cannot store is refused before any key + is requested, on encrypt and on decrypt, with the new + `PlanError::ContextNotStorable` + (`stack_encrypt::plan_context_not_storable`), which names that + context (`members/email`). + - The encrypt input of a `context_field` record is the hidden type + `target::StoredContext`, in place of + `RequiredContext>`. A caller passes a `NonEmpty`. + Generic code names the input as + `ContextInput: AcceptsContext>`. - **`json::JsonPlaintext`, a JSON document as one ciphertext** (#1636). `JsonPlaintext` holds a `serde_json::Value` and wipes it when it is @@ -691,6 +695,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **A derived record wipes an owned plaintext when a caller's part is + empty.** An empty scope or binding is refused where a field is given + its context, before the field's own target runs. The plaintext of that + field was then dropped without being wiped. The derive now emits the + hidden `Encryption::under_field`, which wipes it. `Encryption::under` + is unchanged: its plaintext need not be `Zeroize`. + - The docs of `dynamic::read` said the engine never calls it. `dynamic::query_term` does: it reads a query's input as the field's kind. - **A refused context no longer drops a copy of the plaintext unwiped** diff --git a/packages/stack-encrypt/src/contexts.rs b/packages/stack-encrypt/src/contexts.rs index e81849aae9..5864a1d387 100644 --- a/packages/stack-encrypt/src/contexts.rs +++ b/packages/stack-encrypt/src/contexts.rs @@ -750,9 +750,10 @@ pub trait SuppliedContext: sealed::Sealed { mod sealed { use crate::target::{ AeadContext, CallerContext, DeclaredContext, DeclaredScopes, NoCallerContext, - OptionalContext, RequiredContext, + OptionalContext, RequiredContext, StoredContext, }; pub trait Sealed {} + impl Sealed for StoredContext {} impl Sealed for NoCallerContext {} impl Sealed for DeclaredScopes {} impl Sealed for DeclaredContext {} @@ -782,6 +783,12 @@ impl SuppliedContext for RequiredContext { C::parts_shape() } } +/// A record that stores its context takes it as a `NonEmpty`. +impl SuppliedContext for crate::target::StoredContext { + fn supplies() -> CallerShape { + NonEmpty::::parts_shape() + } +} impl SuppliedContext for OptionalContext { fn supplies() -> CallerShape { let shape = C::parts_shape(); diff --git a/packages/stack-encrypt/src/op/mod.rs b/packages/stack-encrypt/src/op/mod.rs index e1d90d47d9..c66bee90ed 100644 --- a/packages/stack-encrypt/src/op/mod.rs +++ b/packages/stack-encrypt/src/op/mod.rs @@ -768,8 +768,8 @@ pub trait Supports>: Index { /// (`.extend(..)`) when `extended` is true: for an index whose query term /// records the context it was derived under, a context it cannot record. /// An EQL query value stores the column it queries, so its index refuses - /// a label that names no column, and any extension, which the column - /// cannot hold. Run when the query is awaited, before [`prepare`](Self::prepare) + /// a label that names no column. It takes a scope: the stored value does + /// not store one either. Run when the query is awaited, before [`prepare`](Self::prepare) /// and before any keyset is resolved; every index in this crate takes /// any context and returns `Ok(())`. fn check_label(&self, _label: &Label, _extended: bool) -> Result<(), Error> { diff --git a/packages/stack-encrypt/src/plan/error.rs b/packages/stack-encrypt/src/plan/error.rs index 4a21034b0a..173c6d6f54 100644 --- a/packages/stack-encrypt/src/plan/error.rs +++ b/packages/stack-encrypt/src/plan/error.rs @@ -251,20 +251,23 @@ pub enum PlanError { help("Give the scope or binding a value. An empty part would put every row under one context.") )] EmptyPart, - /// A record field's type stores its context in the row, and the record - /// gives the field a context it cannot store. An EQL type stores a - /// table and a column, so it takes the record's scope and the field's - /// name, and nothing more: no caller scope and no binding. Refused - /// before any key is requested. - #[error("field {field:?}: its type cannot store the context the record gives it")] + /// A field's type stores the field's own context in the row, and + /// cannot store this one. The own context is the declared part: the + /// record's scope and the field's name. An EQL type stores it as a + /// table and a column, so the own context must be two text parts. + /// + /// The scopes and bindings of the record are not stored, so they never + /// cause this error. Refused before any key is requested. + #[error("field {field:?}: its type cannot store the field's own context")] #[diagnostic( code(stack_encrypt::plan_context_not_storable), - help("A field whose type stores its context, such as an EQL type, takes the record's scope and the field's name alone. Remove the record's caller scope and binding, or give the field a type that does not store its context.") + help("A field type that stores its context, such as an EQL type, stores the field's own context: the record's scope and the field's name. Declare the field in a `struct = ..` record with a `scope = \"..\"`, which gives it an own context of two text parts, or give the field a type that does not store its context.") )] ContextNotStorable { - /// The field, as its own context renders: the record's scope and - /// the field's name, `members/email`. Empty when the context was - /// not built for a record field. + /// The field's own context, as it renders: the record's scope and + /// the field's name, `members/email`. A context that no record + /// built for a field has no own part. This is then the whole query + /// context that the type was given. field: String, }, /// A field was declared both as a typed target (`encrypt_into`) and with diff --git a/packages/stack-encrypt/src/target/context.rs b/packages/stack-encrypt/src/target/context.rs index e530606b05..7fdd510418 100644 --- a/packages/stack-encrypt/src/target/context.rs +++ b/packages/stack-encrypt/src/target/context.rs @@ -69,11 +69,13 @@ pub struct CallerContext { /// binding spelled as data ([`from_piece`](Self::from_piece)) is not /// checked here: the dynamic grammar allows an empty extension part. empty_part: bool, - /// The record field this context was built for: the field's own - /// context, as [`DeclaredContext::under`] and [`extend`](Self::extend) - /// were given it. It comes from the declaration, never from the - /// caller, so an error can name the field with it. - field: Option, + /// The field's own context, when a record built this context for one + /// of its fields: the record's scope and the field's name, as + /// [`DeclaredContext::under`] and [`extend`](Self::extend) were given + /// them, before any scope or binding. It is the part that a field type + /// which stores its context stores ([`ContextFromField`]). `None` for a + /// context the caller built. + own: Option>, } impl<'c, T> From> for CallerContext where @@ -102,7 +104,7 @@ impl From> for CallerContext { scopes, bindings: binding.into_iter().collect(), empty_part: empty_scope || empty_bind, - field: None, + own: None, } } } @@ -138,7 +140,7 @@ impl CallerContext { scopes: Some(piece), bindings: Vec::new(), empty_part: false, - field: None, + own: None, } } /// Refuse an empty part. A context with no scope passes: a record @@ -219,7 +221,7 @@ impl CallerContext { scopes: None, bindings: vec![piece], empty_part: false, - field: None, + own: None, } } /// The parts a record stores in its row, as the caller context the @@ -231,7 +233,7 @@ impl CallerContext { scopes: stored.scope, bindings: stored.bind.into_iter().collect(), empty_part: stored.empty_part, - field: None, + own: None, } } /// The own context `own`, extended by this caller context: the field's @@ -243,7 +245,7 @@ impl CallerContext { pub fn extend<'c, O: IntoContext<'c>>(self, own: NonEmpty) -> Self { let own = own.into_context().into_owned(); Self { - field: Some(Descriptor::from_piece(&own)), + own: Some(own.clone()), scopes: Some(match self.scopes { Some(scopes) => nest(own, scopes), None => own, @@ -264,18 +266,32 @@ impl CallerContext { } self.bindings.extend(part.bindings); self.empty_part |= part.empty_part; - self.field = self.field.or(part.field); self } - /// The refusal of a record field whose type cannot store this context, - /// naming the field by its own context. - pub(crate) fn not_storable(&self) -> Error { + /// The context a field type that stores its context stores for this + /// field: the field's own context, as `T`. + /// + /// `None` when `T` cannot store the own context, when `T` stores a + /// context that encodes differently from it, or when no record built + /// this context for a field. A stored context that encoded differently + /// would name one context in the row while the field ran under + /// another. + fn stored_as<'c, T>(&self) -> Option> + where + T: ContextFromField + Clone + IntoContext<'c>, + { + let own = self.own.as_ref()?; + let stored = T::from_field(own)?; + (stored.get().clone().into_context() == *own).then_some(stored) + } + /// The refusal of a field whose type cannot store this context's own + /// part. It names the field by its own context, `members/email`. A + /// context that no record built for a field has no own part, so the + /// refusal names the whole query context. + fn not_storable(&self) -> Error { + let named = self.own.clone().unwrap_or_else(|| self.query()); crate::plan::PlanError::ContextNotStorable { - field: self - .field - .as_ref() - .map(|field| field.as_str().to_owned()) - .unwrap_or_default(), + field: Descriptor::from_piece(&named).as_str().to_owned(), } .into() } @@ -748,7 +764,7 @@ impl StoredParts { scopes: None, bindings: Vec::new(), empty_part: empty_scope || empty_bind, - field: None, + own: None, }; match (&self.scope, scope) { (Some(stored), Some(passed)) if *stored != passed => { @@ -893,9 +909,8 @@ impl DeclaredContext { /// bindings of every part follow the scopes, in order. pub fn under<'c, O: IntoContext<'c>>(self, own: NonEmpty) -> CallerContext { let own = own.into_context().into_owned(); - let field = Descriptor::from_piece(&own); - let mut context = CallerContext::scopes(own); - context.field = Some(field); + let mut context = CallerContext::scopes(own.clone()); + context.own = Some(own); self.0.into_iter().fold(context, CallerContext::then) } @@ -956,10 +971,15 @@ enum Expected { Any, /// A stored context equal to this one. Exactly(NonEmpty), - /// Nothing: a record gave the field a context its type cannot store, so - /// no stored context can be the one expected. The context names the - /// field in the refusal. - NotStorable(CallerContext), + /// The context of a record field. The stored context must be the + /// field's own context, `own`. The field then opens under `context`: + /// its own context, then the scopes and bindings of the record, which + /// the field does not store. `own` is `None` when the field's type + /// cannot store the own context. Nothing opens then. + Field { + own: Option>, + context: CallerContext, + }, } impl Default for ExpectedContext { fn default() -> Self { @@ -976,61 +996,229 @@ impl From> for ExpectedContext { Self(Expected::Exactly(value)) } } -/// A record field whose type stores its context expects the context the -/// record gives the field. A context the type cannot store is refused when -/// the field is opened, before any key is requested. -impl From for ExpectedContext { +/// A record field whose type stores its context expects the field's own +/// context in the row: the record's scope and the field's name. The field +/// opens under that context, then the scopes and bindings of the record. +/// The field does not store those, so the reader supplies them again. +impl<'c, T> From for ExpectedContext +where + T: ContextFromField + Clone + IntoContext<'c>, +{ fn from(context: CallerContext) -> Self { - Self(match T::from_field_context(&context) { - Some(expected) => Expected::Exactly(expected), - None => Expected::NotStorable(context), + Self(Expected::Field { + own: context.stored_as(), + context, }) } } - -/// A context type that a record field's type stores in the row, and can -/// rebuild from the context the record gives the field. -/// -/// A derived `struct = ..` record gives each field the record's scope and -/// the field's name, extended by the caller's scopes and bindings, as a -/// [`CallerContext`]. A field type that stores its context takes that -/// context as the type it stores, so it can be a field of a record. -/// For example, a type that stores a table and a column takes -/// `("members", "email")`, and refuses a context with a caller scope or a -/// binding, since it has nowhere to store them. -/// -/// The rebuilt context must give the same context as the one the record -/// gave. Then the field writes the terms it writes on its own under that -/// context. -pub trait FromFieldContext: Sized { - /// The context the field's type stores, rebuilt from the context the - /// record gives the field, or `None` when the type cannot store it. - fn from_field_context(context: &CallerContext) -> Option>; -} impl<'c, T: MaybeEmpty + PartialEq + IntoContext<'c>> ExpectedContext { /// Check the stored context against this expectation and prove it - /// nonempty, yielding the context the record is opened under. + /// nonempty. It returns the stored context alone. A record opens under + /// the context [`opening`](Self::opening) returns: a record field also + /// opens under the scopes and bindings of its record. /// /// # Errors /// /// [`Error::ContextMismatch`] if an expected context was given and the /// stored one differs from it; the error carries the stored context's - /// descriptor. Otherwise fails if the stored context is empty. A stored + /// descriptor. [`PlanError::ContextNotStorable`](crate::PlanError::ContextNotStorable) + /// if the record is a field whose type cannot store the field's own + /// context. Otherwise fails if the stored context is empty. A stored /// context is data the record was handed, not something the cipher has - /// authenticated yet: both checks happen before any key is requested. + /// authenticated yet: every check happens before any key is requested. pub fn validate(self, stored: T) -> Result, Error> { - match self.0 { - Expected::Any => {} - Expected::Exactly(expected) => { - if expected.into_inner() != stored { - let stored = Descriptor::from_piece(&stored.into_context()); - return Err(Error::ContextMismatch { stored }); - } + let expected = match self.0 { + Expected::Any => None, + Expected::Exactly(expected) => Some(expected), + Expected::Field { own: Some(own), .. } => Some(own), + Expected::Field { own: None, context } => return Err(context.not_storable()), + }; + if let Some(expected) = expected { + if expected.into_inner() != stored { + let stored = Descriptor::from_piece(&stored.into_context()); + return Err(Error::ContextMismatch { stored }); } - Expected::NotStorable(context) => return Err(context.not_storable()), } nonempty(stored) } + + /// Check the stored context as [`validate`](Self::validate) does, and + /// return the context the record opens under. + /// + /// On its own, a record opens under the context it stores. As a field + /// of another record, it opens under that context, then the scopes and + /// bindings of the record, which the field does not store. + /// + /// # Errors + /// + /// As [`validate`](Self::validate), before any key is requested. + pub fn opening(self, stored: T) -> Result { + let context = match &self.0 { + Expected::Field { context, .. } => Some(context.clone()), + Expected::Any | Expected::Exactly(_) => None, + }; + let stored = self.validate(stored)?; + Ok(context.unwrap_or_else(|| CallerContext::from(stored))) + } +} + +/// What a record that stores its context is given when it encrypts: the +/// context it stores, and the context its fields run under. +/// +/// A `#[stash(context_field)]` record, such as an EQL type, writes its +/// context into the row. +/// +/// - On its own, the caller passes that context as a `NonEmpty`. It is +/// the whole context. +/// - As a field of a `struct = ..` record, it stores the field's own +/// context: the record's scope and the field's name +/// ([`ContextFromField`]). The scopes and bindings of the record come +/// after it. They are not stored in the field. The reader supplies them +/// again, as for a field of any other type. +// `#[doc(hidden)]` is on the definition for the reason given on +// `ExpectedContext`. +#[doc(hidden)] +#[derive(Clone, Debug)] +pub struct StoredContext { + /// The context the record stores. `None` when the record is a field + /// whose type cannot store the field's own context. + stored: Option>, + /// The context the record's fields run under: the stored context, then + /// the scopes and bindings that are not stored. + context: CallerContext, +} +impl<'c, T: Clone + IntoContext<'c>> From> for StoredContext { + fn from(stored: NonEmpty) -> Self { + Self { + context: CallerContext::from(stored.clone()), + stored: Some(stored), + } + } +} +/// A record field whose type stores its context stores the field's own +/// context, and runs under the whole context the record gives the field. +impl<'c, T> From for StoredContext +where + T: ContextFromField + Clone + IntoContext<'c>, +{ + fn from(context: CallerContext) -> Self { + Self { + stored: context.stored_as(), + context, + } + } +} +impl StoredContext { + /// The context to store, and the context the record's fields run + /// under. + /// + /// # Errors + /// + /// [`PlanError::ContextNotStorable`](crate::PlanError::ContextNotStorable) + /// when the record is a field whose type cannot store the field's own + /// context. + pub(super) fn into_parts(self) -> Result<(T, CallerContext), Error> { + match self.stored { + Some(stored) => Ok((stored.into_inner(), self.context)), + None => Err(self.context.not_storable()), + } + } +} + +/// A context type that a field's type stores in the row, built from the +/// field's own context. +/// +/// A `struct = ..` record gives each field a context of its own: the +/// record's scope, then the field's name, such as `("members", "email")`. +/// A field type that stores its context writes that own context into the +/// row, as the type it stores. For example, a type that stores a table and +/// a column stores the table `members` and the column `email`. Implement +/// this trait for the stored type. A type that stores it can then be a +/// field of a record. +/// +/// Only the own context is stored. A record can also list scopes and +/// bindings, such as a tenant or a row ID. They come after the own +/// context, and the data key, the AAD and the search terms derive under +/// all of it. They are not stored in the field, so whoever reads the field +/// supplies them again. The same is true of the names of a record that +/// holds this record as a field. +/// +/// The context that `from_field` returns must encode as `own` does +/// ([`IntoContext`]). The field then writes the terms that its type writes +/// on its own under that context. A context that encodes differently is +/// refused, as `None` is, with +/// [`PlanError::ContextNotStorable`](crate::PlanError::ContextNotStorable), +/// before any key is requested. +/// +/// # Example +/// +/// A type that stores a table and a column, as an EQL type does: +/// +/// ``` +/// use stack_encrypt::target::ContextFromField; +/// use stack_encrypt::{nonempty, ContextPiece, IntoContext, MaybeEmpty, NonEmpty}; +/// +/// #[derive(Clone, Debug, PartialEq)] +/// struct Column { +/// table: String, +/// column: String, +/// } +/// +/// impl MaybeEmpty for Column { +/// fn is_empty(&self) -> bool { +/// self.table.is_empty() || self.column.is_empty() +/// } +/// } +/// +/// // The context of a column: its table, then its column. +/// impl<'a> IntoContext<'a> for Column { +/// fn into_context(self) -> ContextPiece<'a> { +/// ContextPiece::List(vec![ +/// ContextPiece::Text(self.table.into()), +/// ContextPiece::Text(self.column.into()), +/// ]) +/// } +/// } +/// +/// // A field's own context is the record's scope, then the field's name. +/// // A column stores the scope as its table and the name as its column. +/// impl ContextFromField for Column { +/// fn from_field(own: &ContextPiece<'_>) -> Option> { +/// let ContextPiece::List(parts) = own else { +/// return None; +/// }; +/// let [ContextPiece::Text(table), ContextPiece::Text(column)] = parts.as_slice() else { +/// return None; +/// }; +/// NonEmpty::new(Self { +/// table: table.to_string(), +/// column: column.to_string(), +/// }) +/// .ok() +/// } +/// } +/// +/// // The field `email` of a record whose scope is `members`. +/// let own = nonempty!("members").with("email").into_context(); +/// let stored = Column::from_field(&own).expect("a table and a column"); +/// assert_eq!(stored.get().table, "members"); +/// assert_eq!(stored.get().column, "email"); +/// // The stored column encodes as the field's own context does. +/// assert_eq!(stored.into_inner().into_context(), own); +/// +/// // A column cannot store a context of three parts. +/// let longer = nonempty!("members").with("email").with("work").into_context(); +/// assert!(Column::from_field(&longer).is_none()); +/// ``` +#[diagnostic::on_unimplemented( + message = "a field whose type stores a `{Self}` as its context cannot be a field of a `struct = ..` record", + label = "a `{Self}` cannot be built from the field's own context", + note = "the record gives each field a context of its own: the record's scope, then the field's name. Implement `ContextFromField` for `{Self}` to store that context" +)] +pub trait ContextFromField: Sized { + /// The context this type stores for a field whose own context is + /// `own`, or `None` when this type cannot store `own`. + fn from_field(own: &ContextPiece<'_>) -> Option>; } macro_rules! integer_contexts { @@ -1085,6 +1273,171 @@ mod tests { ); } + /// A stored context of two text parts, for the tests of a field type + /// that stores its context. + #[derive(Clone, Debug, PartialEq)] + struct Pair(String, String); + impl MaybeEmpty for Pair { + fn is_empty(&self) -> bool { + self.0.is_empty() || self.1.is_empty() + } + } + impl<'a> IntoContext<'a> for Pair { + fn into_context(self) -> ContextPiece<'a> { + ContextPiece::List(vec![text(&self.0), text(&self.1)]) + } + } + fn pair_of(own: &ContextPiece<'_>) -> Option<(String, String)> { + let ContextPiece::List(parts) = own else { + return None; + }; + let [ContextPiece::Text(first), ContextPiece::Text(second)] = parts.as_slice() else { + return None; + }; + Some((first.to_string(), second.to_string())) + } + impl ContextFromField for Pair { + fn from_field(own: &ContextPiece<'_>) -> Option> { + let (first, second) = pair_of(own)?; + NonEmpty::new(Self(first, second)).ok() + } + } + + /// A stored context that does not encode as the field's own context + /// does: it swaps the two parts. + #[derive(Clone, Debug, PartialEq)] + struct Swapped(Pair); + impl MaybeEmpty for Swapped { + fn is_empty(&self) -> bool { + self.0.is_empty() + } + } + impl<'a> IntoContext<'a> for Swapped { + fn into_context(self) -> ContextPiece<'a> { + self.0.into_context() + } + } + impl ContextFromField for Swapped { + fn from_field(own: &ContextPiece<'_>) -> Option> { + let (first, second) = pair_of(own)?; + NonEmpty::new(Self(Pair(second, first))).ok() + } + } + + fn not_storable(error: Error) -> String { + match error { + Error::Plan(crate::plan::PlanError::ContextNotStorable { field }) => field, + other => panic!("expected ContextNotStorable, got {other:?}"), + } + } + + /// A field stores its own context, and runs under the whole context + /// the record gives it: the scopes and the bindings come after the own + /// context, and are not stored. + #[test] + fn a_field_stores_its_own_context_and_runs_under_the_whole_one() { + let own = || nonempty!("users").with("email"); + let field = DeclaredContext::default() + .with(CallerContext::from(Scope::new(7u64).bind(9u128))) + .under(own()); + let (stored, context) = StoredContext::::from(field.clone()) + .into_parts() + .unwrap(); + assert_eq!(stored, Pair("users".into(), "email".into())); + assert_eq!(context.query(), field.query()); + assert_eq!(context.stored(), field.stored()); + assert_ne!(context.stored(), stored.clone().into_context()); + + // On decrypt the stored context must be the field's own, and the + // field opens under the same whole context. + let opening = ExpectedContext::::from(field.clone()) + .opening(stored) + .unwrap(); + assert_eq!(opening.stored(), field.stored()); + let elsewhere = ExpectedContext::::from(field) + .opening(Pair("users".into(), "name".into())) + .unwrap_err(); + assert!(matches!(elsewhere, Error::ContextMismatch { .. })); + } + + /// On its own, a record stores the context the caller passes, and runs + /// under it. + #[test] + fn a_context_passed_on_its_own_is_stored_and_is_the_whole_context() { + let passed = NonEmpty::new(Pair("users".into(), "email".into())).unwrap(); + let (stored, context) = StoredContext::from(passed.clone()).into_parts().unwrap(); + assert_eq!(stored, *passed.get()); + assert_eq!(context.stored(), passed.clone().into_context()); + + let opening = ExpectedContext::from(passed.clone()) + .opening(stored) + .unwrap(); + assert_eq!(opening.stored(), passed.into_context()); + } + + /// A type that cannot store the field's own context is refused, and the + /// refusal names that context. So is a type whose stored context + /// encodes differently from it: the row would name one context while + /// the field ran under another. + #[test] + fn an_own_context_the_type_cannot_store_is_refused() { + let three = DeclaredContext::default().under(nonempty!("users").with("email").with("x")); + let refused = StoredContext::::from(three.clone()) + .into_parts() + .unwrap_err(); + assert_eq!(not_storable(refused), "(users/email)/x"); + let stored = Pair("users".into(), "email".into()); + let refused = ExpectedContext::::from(three) + .opening(stored.clone()) + .unwrap_err(); + assert_eq!(not_storable(refused), "(users/email)/x"); + + let field = DeclaredContext::default().under(nonempty!("users").with("email")); + let refused = StoredContext::::from(field.clone()) + .into_parts() + .unwrap_err(); + assert_eq!(not_storable(refused), "users/email"); + let refused = ExpectedContext::::from(field) + .opening(Swapped(stored)) + .unwrap_err(); + assert_eq!(not_storable(refused), "users/email"); + } + + /// A context that no record built for a field has no own part to store. + /// The refusal names the context the type was given. + #[test] + fn a_context_not_built_for_a_field_has_nothing_to_store() { + let caller = CallerContext::from(Scope::new(7u64)); + let refused = StoredContext::::from(caller) + .into_parts() + .unwrap_err(); + assert_eq!(not_storable(refused), "7u64"); + } + + /// The own context is the field's, whatever the parts that follow it + /// were built for: a record nested in another passes its fields the + /// outer field's context, which has an own part too. + #[test] + fn the_own_context_of_a_nested_field_is_the_inner_one() { + let outer = DeclaredContext::default().under(nonempty!("teams").with("lead")); + let inner = DeclaredContext::from(outer).under(nonempty!("users").with("email")); + let (stored, context) = StoredContext::::from(inner).into_parts().unwrap(); + assert_eq!(stored, Pair("users".into(), "email".into())); + assert_eq!( + context.query(), + nest( + nonempty!("users").with("email").into_context().into_owned(), + nonempty!("teams").with("lead").into_context().into_owned(), + ) + ); + + // `extend` puts its own context first too. + let extended = + CallerContext::from(Scope::new(7u64)).extend(nonempty!("users").with("email")); + let (stored, _) = StoredContext::::from(extended).into_parts().unwrap(); + assert_eq!(stored, Pair("users".into(), "email".into())); + } + #[test] fn a_declared_context_knows_whether_it_carries_a_binding() { assert!(!DeclaredContext::from(Scope::new(7u64)).has_bindings()); diff --git a/packages/stack-encrypt/src/target/input.rs b/packages/stack-encrypt/src/target/input.rs index 5bbf6e18fa..991ad6e659 100644 --- a/packages/stack-encrypt/src/target/input.rs +++ b/packages/stack-encrypt/src/target/input.rs @@ -6,6 +6,7 @@ //! | [`RequiredContext`] | Refused | `C` | The caller supplies the context, or the part of it that the declaration selects. | //! | [`OptionalContext`] | Allowed | `C` | For an opaque scalar. A context left out is the empty context. | //! | [`DeclaredScopes`] | Allowed | Refused | A derived `struct = ..` record. Each field has its own context. A record that contains it, or a plan, passes that field's context. | +//! | The encrypt input of a `#[stash(context_field)]` record | Refused | `NonEmpty` | The record stores the context passed. | //! | The decrypt input of a `#[stash(context_field)]` record | Allowed | `NonEmpty` | A context left out opens under the stored context, unchecked. A context passed is checked against the stored one. | //! //! A trait bound decides whether a method is available ([`AllowsNoContext`] @@ -13,8 +14,8 @@ //! example, `RequiredContext` stays required, although `u64` has a //! default of zero. -use super::context::{CallerContext, DeclaredContext, ExpectedContext, FromFieldContext}; -use crate::{ContextPiece, Error, IntoContext, NonEmpty}; +use super::context::{CallerContext, DeclaredContext, ExpectedContext, StoredContext}; +use crate::{ContextPiece, IntoContext, NonEmpty}; mod sealed { pub trait Sealed {} @@ -145,51 +146,6 @@ impl> From for RequiredContext { } } -/// The context a record field's declaration takes, built from the context -/// the record gives the field. [`Encryption::under`](super::Encryption::under) -/// converts through it. -/// -/// A context that converts from a [`CallerContext`] cannot fail. A field -/// type that stores its context ([`FromFieldContext`]) can refuse the one -/// the record gives it, before any key is requested. -/// -/// **This trait is for generated code.** A field type implements -/// [`FromFieldContext`], not this. -#[doc(hidden)] -#[diagnostic::on_unimplemented( - message = "a field whose type takes `{Self}` cannot be a field of a `struct = ..` record", - label = "this field's type cannot take the context the record gives it", - note = "the record gives each field its scope and the field's name as a `CallerContext`; a field's type takes it if its context input converts from `CallerContext`, or is `RequiredContext>` where `T` implements `FromFieldContext`" -)] -pub trait FromCaller: Sized { - /// Convert the field's context, or refuse it. - /// - /// # Errors - /// - /// [`PlanError::ContextNotStorable`](crate::PlanError::ContextNotStorable) - /// when the field's type cannot store the context. - fn from_caller(context: CallerContext) -> Result; -} -impl> FromCaller for C { - fn from_caller(context: CallerContext) -> Result { - Ok(C::from(context)) - } -} -// A field whose type stores its context takes the record's context for the -// field as that type, or refuses it. -// -// This impl and the blanket one above do not overlap only because -// `RequiredContext>` does not implement `From`. -// The `From` impl for `RequiredContext` needs -// `C: From`, and `NonEmpty` has no such impl. Adding one -// would stop this from compiling. -impl FromCaller for RequiredContext> { - fn from_caller(context: CallerContext) -> Result { - T::from_field_context(&context) - .map(Self::supplied) - .ok_or_else(|| context.not_storable()) - } -} // A record that derives terms gives its ciphertext fields the AEAD part of the // context it requires. impl From> for RequiredContext { @@ -197,28 +153,6 @@ impl From> for RequiredContext` it stores. It runs its fields under the caller context built -// from that value. -impl<'c, T: IntoContext<'c>> From>> for RequiredContext { - fn from(context: RequiredContext>) -> Self { - Self::supplied(context.into_inner().into()) - } -} -impl<'c, T: IntoContext<'c>> From>> - for RequiredContext -{ - fn from(context: RequiredContext>) -> Self { - Self::supplied(context.into_inner().into()) - } -} -// A record that stores its context takes the `NonEmpty` it stores from its -// structural trait as the required input. -impl From> for RequiredContext> { - fn from(context: NonEmpty) -> Self { - Self::supplied(context) - } -} // The contexts of the structural declarations, wrapped as the required input // of the owned leaf that replaces each one. A plan or a structural record // gives an owned field its context this way. @@ -242,8 +176,23 @@ impl> From> for CallerContext { } } -/// The input of a record that stores its context: the context the caller -/// expects, or none. +/// The encrypt input of a record that stores its context: the context the +/// caller passes, which the record stores. The context is required. +/// +/// As a field of a `struct = ..` record, the record stores the field's own +/// context instead, and runs under the scopes and bindings of the record +/// too ([`ContextFromField`](super::ContextFromField)). +impl sealed::Sealed for StoredContext {} +impl ContextInput for StoredContext {} +impl<'c, T: Clone + IntoContext<'c>> AcceptsContext for StoredContext { + type Value = NonEmpty; + fn supplied(value: NonEmpty) -> Self { + Self::from(value) + } +} + +/// The decrypt input of a record that stores its context: the context the +/// caller expects, or none. /// /// - If the caller passes a `NonEmpty`, the stored context must equal it. A /// different stored context is refused before any key is retrieved. diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 0e526f9f14..c54307b6dd 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -183,25 +183,26 @@ pub mod transcode; mod tuples; pub(crate) use self::core::{bind_pending, seal_pending}; -/// The check that a `#[stash(context_field)]` record runs on decrypt. -/// -/// `ExpectedContext` is not part of the API. Only the derives and the EQL -/// types name it, and a caller passes a `NonEmpty` or nothing. The type -/// is public only because the derives emit code in other crates that -/// names it. The definition is hidden too. -#[doc(hidden)] -pub use context::ExpectedContext; pub use context::{ - AeadContext, Bind, BindPart, CallerContext, CheckedInput, DeclaredContext, Extends, - FromFieldContext, NoBind, NoScope, Parts, Scope, ScopePart, Scopes, StoredParts, + AeadContext, Bind, BindPart, CallerContext, CheckedInput, ContextFromField, DeclaredContext, + Extends, NoBind, NoScope, Parts, Scope, ScopePart, Scopes, StoredParts, }; +/// The inputs of a `#[stash(context_field)]` record: the context it stores +/// on encrypt, and the check it runs on decrypt. +/// +/// `StoredContext` and `ExpectedContext` are not part of the API. Only the +/// derives and the EQL types name them, and a caller passes a `NonEmpty` +/// or nothing. They are public only because the derives emit code in other +/// crates that names them. The definitions are hidden too. +#[doc(hidden)] +pub use context::{ExpectedContext, StoredContext}; pub use conversion::{DecryptFrom, DecryptSource, EncryptInto, EncryptTarget}; pub use index::{ indexed, At, Encrypted, Equality, Index, IndexSpec, Indexes, Json, Match, Ope, Ore, OreBlock, Select, TermSet, Whole, }; pub use input::{ - AcceptsContext, AllowsNoContext, ContextInput, DeclaredScopes, FromCaller, NoCallerContext, + AcceptsContext, AllowsNoContext, ContextInput, DeclaredScopes, NoCallerContext, OptionalContext, RequiredContext, ScalarContext, }; #[cfg(feature = "dynamic")] diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index abe7cd17f7..10598a3c09 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -14,11 +14,13 @@ // One context per target, threaded through the declaration tree: // packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md. -use super::context::{AeadContext, CallerContext, DeclaredContext, Extends, StoredParts}; +use super::context::{ + AeadContext, CallerContext, DeclaredContext, Extends, StoredContext, StoredParts, +}; use super::conversion::{DecryptSource, EncryptTarget}; use super::core::{encrypt_native, open_native, Term}; use super::index::{Index, IndexSpec, Json}; -use super::input::{AcceptsContext, FromCaller, NoCallerContext, RequiredContext}; +use super::input::{AcceptsContext, NoCallerContext, RequiredContext}; use super::source::{Borrowed, ConsumeSource, Owned, ShareSource, SourceMode}; use super::{CipherRef, Pending}; use crate::contexts::{self, Contexts, Placeholder, Shape, Shapes, SuppliedContext}; @@ -358,21 +360,64 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's, M: SourceMode<'s, S>> /// its own cannot be run under `()`. Available wherever a /// [`CallerContext`] can become what the subtree needs: a leaf of either /// kind, or a record whose own contexts a caller's extends. + /// + /// A part the caller passed empty is refused here, before the subtree + /// runs. In [`Owned`] mode that refusal drops the plaintext without + /// wiping it, because `S` need not be `Zeroize`. A derived record wipes + /// it instead. pub fn under( + self, + own: NonEmpty + MaybeSend + 's>, + ) -> Encryption<'s, S, T, K, DeclaredContext, M> + where + Ctx: From, + { + self.under_refusing(own, drop) + } + /// [`under`](Self::under), for a field of a derived `struct = ..` + /// record. A context that is refused here wipes a plaintext that was + /// passed by value. `under` drops it as it is. + /// + /// **This method is for generated code.** The derive emits it for each + /// sealed field, in the borrowed form and in the owned form. The + /// plaintext is `Zeroize`, as the source of every + /// [`EncryptTarget`] is. + #[doc(hidden)] + pub fn under_field( + self, + own: NonEmpty + MaybeSend + 's>, + ) -> Encryption<'s, S, T, K, DeclaredContext, M> + where + Ctx: From, + S: zeroize::Zeroize, + { + self.under_refusing(own, |source| { + M::release(source, |value| { + drop(vitaminc_protected::Protected::new(value)) + }) + }) + } + /// The body of [`under`](Self::under). `refused` is given the source + /// when the context is refused, before the subtree has seen it. + fn under_refusing( mut self, own: NonEmpty + MaybeSend + 's>, + refused: fn(M::Source), ) -> Encryption<'s, S, T, K, DeclaredContext, M> where - Ctx: FromCaller, + Ctx: From, { let own = contexts::piece_of(own); self.shapes = contexts::substitute(self.shapes, &Shape::under(own.get().clone())); let Self { build, shapes } = self; Encryption { build: Box::new(move |source, cipher, cx: DeclaredContext| { - match cx.checked_under(own).and_then(Ctx::from_caller) { - Ok(context) => build(source, cipher, context), - Err(error) => Pending::failed(cipher, error), + match cx.checked_under(own) { + Ok(context) => build(source, cipher, context.into()), + Err(error) => { + refused(source); + Pending::failed(cipher, error) + } } }), shapes, @@ -468,6 +513,45 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { shapes, } } + /// Store the context this description runs under, for a record that + /// writes its context into the row (`#[stash(context_field)]`). + /// + /// The result takes a [`StoredContext`]: the context to store, and the + /// context to run under. `store` builds the output from this + /// description's output and the context to store. + /// + /// - On its own, the record stores the context the caller passes, and + /// runs under it. + /// - As a field of a `struct = ..` record, it stores the field's own + /// context, and runs under the scopes and bindings of the record too. + /// + /// A field whose type cannot store the field's own context fails here, + /// before any key is requested + /// ([`PlanError::ContextNotStorable`](crate::PlanError::ContextNotStorable)). + /// The plaintext is borrowed, so the refusal drops no plaintext. + /// + /// **This method is for generated code.** The derive emits it at the + /// root of a `#[stash(context_field)]` record. + #[doc(hidden)] + pub fn storing(self, store: F) -> Encryption<'s, S, U, K, StoredContext> + where + Ctx: From, + C: MaybeSend + 'static, + F: FnOnce(T, C) -> U + MaybeSend + 'static, + { + let Self { build, shapes } = self; + Encryption { + build: Box::new( + move |source, cipher, cx: StoredContext| match cx.into_parts() { + Ok((stored, context)) => { + build(source, cipher, context.into()).map(move |value| store(value, stored)) + } + Err(error) => Pending::failed(cipher, error), + }, + ), + shapes, + } + } /// Takes the context from a field of the plaintext. The caller then /// supplies no context. /// diff --git a/packages/stack-encrypt/tests/field_context.rs b/packages/stack-encrypt/tests/field_context.rs index 4e8f112d0c..14efc035a7 100644 --- a/packages/stack-encrypt/tests/field_context.rs +++ b/packages/stack-encrypt/tests/field_context.rs @@ -1,26 +1,30 @@ //! A field type that stores its context, as a field of a derived `struct = -//! ..` record (`FromFieldContext`). +//! ..` record (`ContextFromField`). //! -//! The record gives each field its scope and the field's name. A field type -//! that stores a table and a column takes that pair as the context it -//! stores, as an EQL type in `eql-bindings` does. Here `Column` stands in -//! for EQL's `Identifier`: +//! The record gives each field a context of its own: the record's scope, +//! then the field's name. A field type that stores a table and a column +//! stores that pair, as an EQL type in `eql-bindings` does. Here `Column` +//! stands in for EQL's `Identifier`: //! -//! - the field stores the pair, and writes the term it writes on its own -//! under that column; +//! - the field stores its own context, and writes the term it writes on its +//! own under that column; //! - on decrypt, the stored column must be the field's; -//! - a record that lists a caller scope or a binding gives the field a -//! context it cannot store, and every call is refused before any key is -//! requested. +//! - a scope or a binding that the record lists comes after the own +//! context. The field runs under it, as a field of any other type does, +//! and does not store it. The reader supplies it again; +//! - a field of a record nested in another stores its own context too; +//! - a type that cannot store the field's own context is refused before any +//! key is requested. use std::sync::atomic::Ordering; use stack_encrypt::sem::EqualityTerm; use stack_encrypt::target::{ - Bind, CallerContext, DecryptSource, EncryptTarget, FromFieldContext, Scope, + Bind, CallerContext, ContextFromField, DecryptField, DecryptSource, EncryptTarget, Encryption, + NoCallerContext, Scope, }; use stack_encrypt::{ - Ciphertext, ContextPiece, DecryptFrom, EncryptInto, Error, IntoContext, MaybeEmpty, NonEmpty, - PlanError, + nonempty, Ciphertext, ContextPiece, DecryptFrom, Descriptor, EncryptInto, Encrypted, Error, + IntoContext, MaybeEmpty, NonEmpty, PlanError, }; use zeroize::Zeroize; mod common; @@ -58,12 +62,9 @@ impl<'a> IntoContext<'a> for Column { } } -impl FromFieldContext for Column { - fn from_field_context(context: &CallerContext) -> Option> { - if context.has_bindings() { - return None; - } - let ContextPiece::List(parts) = context.query() else { +impl ContextFromField for Column { + fn from_field(own: &ContextPiece<'_>) -> Option> { + let ContextPiece::List(parts) = own else { return None; }; let [ContextPiece::Text(table), ContextPiece::Text(column)] = parts.as_slice() else { @@ -98,35 +99,25 @@ fn ada() -> User { } } -#[derive(EncryptTarget, DecryptSource)] -#[stash(struct = User, scope = "users")] -struct EncryptedUser { - email: Stored, -} - -#[derive(EncryptTarget, DecryptSource)] -#[stash(struct = User, scope = "users", scope(tenant: u64))] -struct TenantUser { - email: Stored, +/// A user's email on its own under the `users/email` column, with no record +/// around it. +async fn alone(keyset: &stack_encrypt::KeysetCipher<'_, K>) -> Stored +where + K: stack_encrypt::kms::DataKeySource + stack_encrypt::kms::IndexKeySource + Sync + 'static, +{ + ada() + .email + .encrypt_into_with_context(keyset, Column::of("users", "email")) + .await + .unwrap() } #[derive(EncryptTarget, DecryptSource)] -#[stash(struct = User, scope = "users", bind(row: u128))] -struct BoundUser { +#[stash(struct = User, scope = "users")] +struct EncryptedUser { email: Stored, } -/// The refusal names the field by the record's scope and its name. -fn assert_not_storable(result: Result) { - match result { - Err(Error::Plan(PlanError::ContextNotStorable { field })) => { - assert_eq!(field, "users/email"); - } - Err(other) => panic!("expected ContextNotStorable, got {other:?}"), - Ok(_) => panic!("expected ContextNotStorable, got a value"), - } -} - #[tokio::test] async fn a_field_stores_the_record_scope_and_its_name() { let cipher = common::stack_cipher().await; @@ -135,11 +126,7 @@ async fn a_field_stores_the_record_scope_and_its_name() { assert_eq!(stored.email.column, *Column::of("users", "email").get()); // The term the field writes on its own under the column. - let alone: Stored = ada() - .email - .encrypt_into_with_context(&keyset, Column::of("users", "email")) - .await - .unwrap(); + let alone = alone(&keyset).await; assert_eq!(stored.email.hm, alone.hm); let opened = User::decrypt_from(stored, &keyset).await.unwrap(); @@ -168,34 +155,266 @@ async fn a_field_that_stores_another_column_is_refused() { assert_eq!(retrieves.load(Ordering::SeqCst), before, "no key requested"); } +// --- A scope and a binding come after the own context, and are not stored --- + +/// The caller supplies a tenant scope and a row binding. +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = User, scope = "users", scope(tenant: u64), bind(row: u128))] +struct TenantUser { + email: Stored, +} + +/// The same record with a field type that stores nothing: the term and the +/// ciphertext, under the context the record gives the field. +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = User, scope = "users", scope(tenant: u64), bind(row: u128))] +struct NativeTenantUser { + email: Encrypted, +} + #[tokio::test] -async fn a_caller_scope_or_binding_is_refused_before_any_key() { - let (cipher, generates, retrieves) = common::counting_cipher().await; +async fn a_scope_and_a_binding_are_used_and_not_stored() { + let cipher = common::stack_cipher().await; let keyset = cipher.default_keyset(); + let parts = || Scope::new(42u64).bind(9u128); + let stored: TenantUser = ada() + .encrypt_into_with_context(&keyset, parts()) + .await + .unwrap(); - let scoped: Result = ada() - .encrypt_into_with_context(&keyset, Scope::new(42u64)) - .await; - assert_not_storable(scoped); - let bound: Result = ada() - .encrypt_into_with_context(&keyset, Bind::new(9u128)) - .await; - assert_not_storable(bound); - assert_eq!(generates.load(Ordering::SeqCst), 0, "no key requested"); + // Only the field's own context is stored. + assert_eq!(stored.email.column, *Column::of("users", "email").get()); - // A value stored under the field's own column does not open in such a - // record either: the record's context is not the one it stores. - let alone: Stored = ada() + // The term derives under the tenant too, as a field of any other type + // does. So it is not the term of the column alone. + let native: NativeTenantUser = ada() + .encrypt_into_with_context(&keyset, parts()) + .await + .unwrap(); + assert_eq!(stored.email.hm, native.email.terms); + assert_ne!(stored.email.hm, alone(&keyset).await.hm); + + // The binding is in no query context: another row has the same term. + let other_row: TenantUser = ada() + .encrypt_into_with_context(&keyset, Scope::new(42u64).bind(10u128)) + .await + .unwrap(); + assert_eq!(stored.email.hm, other_row.email.hm); + // Another tenant has another term. + let other_tenant: TenantUser = ada() + .encrypt_into_with_context(&keyset, Scope::new(43u64).bind(9u128)) + .await + .unwrap(); + assert_ne!(stored.email.hm, other_tenant.email.hm); + + // The reader supplies the scope and the binding again. + let opened = User::decrypt_from_with_context(stored, &keyset, parts()) + .await + .unwrap(); + assert_eq!(opened, ada()); +} + +#[tokio::test] +async fn a_field_opens_only_under_the_scope_and_binding_it_was_sealed_under() { + let (cipher, _, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let sealed = || async { + let stored: TenantUser = ada() + .encrypt_into_with_context(&keyset, Scope::new(42u64).bind(9u128)) + .await + .unwrap(); + stored + }; + + let other_tenant = + User::decrypt_from_with_context(sealed().await, &keyset, Scope::new(43u64).bind(9u128)); + assert!(other_tenant.await.is_err(), "another tenant"); + let other_row = + User::decrypt_from_with_context(sealed().await, &keyset, Scope::new(42u64).bind(10u128)); + assert!(other_row.await.is_err(), "another row"); + + // A value written on its own stores the same column. It was sealed + // under the column alone, so it does not open as the record's field. + let record = TenantUser { + email: alone(&keyset).await, + }; + let opened = User::decrypt_from_with_context(record, &keyset, Scope::new(42u64).bind(9u128)); + assert!(opened.await.is_err(), "sealed without the scope"); + + // The stored column is still checked first, before any key. + let elsewhere: Stored = ada() .email - .encrypt_into_with_context(&keyset, Column::of("users", "email")) + .encrypt_into_with_context(&keyset, Column::of("users", "name")) .await .unwrap(); + let record = TenantUser { email: elsewhere }; let before = retrieves.load(Ordering::SeqCst); - let scoped = TenantUser { - email: alone.clone(), + assert!(matches!( + User::decrypt_from_with_context(record, &keyset, Scope::new(42u64).bind(9u128)).await, + Err(Error::ContextMismatch { .. }) + )); + assert_eq!(retrieves.load(Ordering::SeqCst), before, "no key requested"); +} + +#[derive(Clone, Debug, PartialEq, Zeroize)] +struct Account { + id: u128, + tenant: u64, + email: String, +} + +/// The scope and the binding are stored in the row, in fields of the +/// record. The field that stores its context still stores its own alone. +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = Account, scope = "accounts", scope(field = "tenant"), bind(field = "id"))] +struct EncryptedAccount { + #[stash(passthrough)] + id: u128, + #[stash(passthrough)] + tenant: u64, + email: Stored, +} + +#[tokio::test] +async fn a_scope_and_a_binding_stored_in_the_row_are_not_stored_in_the_field() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let account = Account { + id: 9, + tenant: 42, + email: "ada@example.com".into(), + }; + let stored: EncryptedAccount = account.clone().encrypt_into(&keyset).await.unwrap(); + assert_eq!(stored.email.column, *Column::of("accounts", "email").get()); + + // The row supplies its scope and its binding, so the reader passes none. + let moved = EncryptedAccount { + id: 10, + tenant: stored.tenant, + email: stored.email.clone(), }; - assert_not_storable(User::decrypt_from_with_context(scoped, &keyset, Scope::new(42u64)).await); - let bound = BoundUser { email: alone }; - assert_not_storable(User::decrypt_from_with_context(bound, &keyset, Bind::new(9u128)).await); + assert_eq!( + Account::decrypt_from(stored, &keyset).await.unwrap(), + account + ); + // The same field in another row does not open. + assert!(Account::decrypt_from(moved, &keyset).await.is_err()); +} + +#[derive(Clone, Debug, PartialEq, Zeroize)] +struct Team { + lead: User, +} + +/// A record that holds another. The inner field's context is its own pair, +/// then the outer field's. +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = Team, scope = "teams")] +struct EncryptedTeam { + lead: EncryptedUser, +} + +#[tokio::test] +async fn a_field_of_a_nested_record_stores_its_own_context() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let team = Team { lead: ada() }; + let stored: EncryptedTeam = team.clone().encrypt_into(&keyset).await.unwrap(); + + // The inner record's scope and the field's name. The outer record's + // names come after them, and are not stored. + assert_eq!( + stored.lead.email.column, + *Column::of("users", "email").get() + ); + assert_ne!(stored.lead.email.hm, alone(&keyset).await.hm); + + assert_eq!(Team::decrypt_from(stored, &keyset).await.unwrap(), team); +} + +// --- A context the type cannot store ---------------------------------------- + +/// `Stored`, under an own context of three parts. A `Column` stores two. +struct Misplaced(#[allow(dead_code)] Stored); +impl EncryptTarget for Misplaced { + type ContextInput = NoCallerContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, String, Self, K, Self::ContextInput> { + >::encryption() + .under(nonempty!("users").with("email").with("work")) + .map(Self) + .accepting() + } +} + +/// The refusal names the context the type could not store. +fn assert_not_storable(result: Result, named: &str) { + match result { + Err(Error::Plan(PlanError::ContextNotStorable { field })) => assert_eq!(field, named), + Err(other) => panic!("expected ContextNotStorable, got {other:?}"), + Ok(_) => panic!("expected ContextNotStorable, got a value"), + } +} + +#[tokio::test] +async fn an_own_context_the_type_cannot_store_is_refused_before_any_key() { + let (cipher, generates, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let own = Descriptor::of(nonempty!("users").with("email").with("work")); + + let sealed: Result = ada().email.encrypt_into(&keyset).await; + assert_not_storable(sealed, own.as_str()); + assert_eq!(generates.load(Ordering::SeqCst), 0, "no key requested"); + + // The same on decrypt: no stored column can be the field's own. + let stored = alone(&keyset).await; + let before = retrieves.load(Ordering::SeqCst); + let context = + CallerContext::from(Scope::new(7u64)).extend(nonempty!("users").with("email").with("work")); + let opening = + >::decryption_field(stored.clone(), context).unwrap(); + assert_not_storable(cipher.run_decryption(opening).await, own.as_str()); + + // A context that no record built for a field has no own part. The + // refusal names the context the type was given. + let opening = >::decryption_field( + stored, + CallerContext::from(Scope::new(7u64)), + ) + .unwrap(); + assert_not_storable(cipher.run_decryption(opening).await, "7u64"); assert_eq!(retrieves.load(Ordering::SeqCst), before, "no key requested"); } + +/// A scope or a binding alone, with no field that stores its context, is +/// unchanged by all this: the record still requires it. +#[tokio::test] +async fn a_binding_alone_still_binds_the_field() { + #[derive(EncryptTarget, DecryptSource)] + #[stash(struct = User, scope = "users", bind(row: u128))] + struct BoundUser { + email: Stored, + } + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let stored: BoundUser = ada() + .encrypt_into_with_context(&keyset, Bind::new(9u128)) + .await + .unwrap(); + assert_eq!(stored.email.column, *Column::of("users", "email").get()); + // A binding is in no query context, so the term is the column's. + assert_eq!(stored.email.hm, alone(&keyset).await.hm); + + let moved = BoundUser { + email: stored.email.clone(), + }; + assert!( + User::decrypt_from_with_context(moved, &keyset, Bind::new(10u128)) + .await + .is_err(), + "another row" + ); + let opened = User::decrypt_from_with_context(stored, &keyset, Bind::new(9u128)) + .await + .unwrap(); + assert_eq!(opened, ada()); +} diff --git a/packages/stack-encrypt/tests/owned_records.rs b/packages/stack-encrypt/tests/owned_records.rs index 0076f9c43c..1cf16006b3 100644 --- a/packages/stack-encrypt/tests/owned_records.rs +++ b/packages/stack-encrypt/tests/owned_records.rs @@ -14,9 +14,9 @@ use std::sync::Arc; use serde::{Deserialize, Serialize}; use stack_encrypt::sem::{EqualityTerm, MatchTerms, OreTerm}; use stack_encrypt::target::{ - AcceptsContext, AeadContext, DecryptField, DecryptSource, Decryptable, Decryption, + AcceptsContext, AeadContext, Bind, DecryptField, DecryptSource, Decryptable, Decryption, EncryptTarget, Encryption, NoCallerContext, OptionalContext, Owned, RequiredContext, - ScalarContext, + ScalarContext, Scope, }; use stack_encrypt::{ nonempty, Ciphertext, Controlled, DecryptFrom, EncryptInto, Encrypted, Error, NonEmpty, @@ -441,6 +441,53 @@ async fn a_sealed_field_is_not_leaked_when_the_scope_is_refused() { .await; } +/// A record whose caller supplies a scope. An empty scope is refused where +/// the field is given its context, before the field's own target runs. +#[allow(dead_code)] // These tests only encrypt. +#[derive(EncryptTarget)] +#[stash(struct = ProbeRow, scope = "rows", scope(tenant: String))] +struct TenantProbeRow { + secret: StackCipherText, +} + +#[tokio::test] +async fn a_sealed_field_is_not_leaked_when_a_caller_part_is_empty() { + assert_refused_without_a_leak(|leaked, keyset| { + ProbeRow { + secret: probe(leaked), + } + .encrypt_into_with_context::(keyset, Scope::new(String::new())) + }) + .await; +} + +#[derive(Zeroize)] +struct ProbePair { + first: Probe, + second: Probe, +} +/// Two sealed fields: the record divides the plaintext between them, and +/// each is refused in turn. +#[allow(dead_code)] // These tests only encrypt. +#[derive(EncryptTarget)] +#[stash(struct = ProbePair, scope = "pairs", bind(row: String))] +struct BoundProbePair { + first: StackCipherText, + second: StackCipherText, +} + +#[tokio::test] +async fn no_sealed_field_is_leaked_when_a_caller_part_is_empty() { + assert_refused_without_a_leak(|leaked, keyset| { + ProbePair { + first: probe(leaked), + second: probe(leaked), + } + .encrypt_into_with_context::(keyset, Bind::new(String::new())) + }) + .await; +} + #[tokio::test] async fn a_nested_sealed_field_is_not_leaked_when_the_outer_scope_is_refused() { assert_refused_without_a_leak(|leaked, keyset| { diff --git a/packages/stack-encrypt/tests/ui/owned_aead_record_with_term.stderr b/packages/stack-encrypt/tests/ui/owned_aead_record_with_term.stderr index 5e130aaf90..d73640a1be 100644 --- a/packages/stack-encrypt/tests/ui/owned_aead_record_with_term.stderr +++ b/packages/stack-encrypt/tests/ui/owned_aead_record_with_term.stderr @@ -7,13 +7,22 @@ error[E0277]: the trait bound `RequiredContext: From>` is not implemented for `RequiredContext` - but trait `From>>` is implemented for it +help: the following other types implement trait `From` --> src/target/input.rs | - | impl<'c, T: IntoContext<'c>> From>> for RequiredContext { - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - = help: for that trait implementation, expected `NonEmpty<_>`, found `AeadContext` + | impl> From for RequiredContext { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `RequiredContext` implements `From` +... + | impl From> for RequiredContext { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `RequiredContext` implements `From>` +... + | impl From for RequiredContext { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `RequiredContext` implements `From` + | + ::: src/target/context.rs + | + | impl From> for super::input::RequiredContext> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `RequiredContext>` implements `From>` = note: required for `RequiredContext` to implement `Into>` note: required by a bound in `Encryption::<'s, S, T, K, Ctx, M>::accepting` --> src/target/operations.rs diff --git a/packages/stack-encrypt/tests/ui/owned_record_field_context.stderr b/packages/stack-encrypt/tests/ui/owned_record_field_context.stderr index 9c21f46407..fef2ec6183 100644 --- a/packages/stack-encrypt/tests/ui/owned_record_field_context.stderr +++ b/packages/stack-encrypt/tests/ui/owned_record_field_context.stderr @@ -1,25 +1,17 @@ -error[E0277]: a field whose type takes `NoCallerContext` cannot be a field of a `struct = ..` record +error[E0277]: the trait bound `NoCallerContext: From` is not satisfied --> tests/ui/owned_record_field_context.rs:54:12 | 54 | fixed: Fixed, - | ^^^^^ this field's type cannot take the context the record gives it + | ^^^^^ the trait `From` is not implemented for `NoCallerContext` | - = help: the trait `From` is not implemented for `NoCallerContext` - = note: the record gives each field its scope and the field's name as a `CallerContext`; a field's type takes it if its context input converts from `CallerContext`, or is `RequiredContext>` where `T` implements `FromFieldContext` -help: the trait `FromCaller` is implemented for `RequiredContext>` - --> src/target/input.rs - | - | impl FromCaller for RequiredContext> { - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - = note: required for `NoCallerContext` to implement `FromCaller` -note: required by a bound in `Encryption::<'s, S, T, K, Ctx, M>::under` +note: required by a bound in `Encryption::<'s, S, T, K, Ctx, M>::under_field` --> src/target/operations.rs | - | pub fn under( - | ----- required by a bound in this associated function + | pub fn under_field( + | ----------- required by a bound in this associated function ... - | Ctx: FromCaller, - | ^^^^^^^^^^ required by this bound in `Encryption::<'s, S, T, K, Ctx, M>::under` + | Ctx: From, + | ^^^^^^^^^^^^^^^^^^^ required by this bound in `Encryption::<'s, S, T, K, Ctx, M>::under_field` error[E0277]: the trait bound `u64: From` is not satisfied --> tests/ui/owned_record_field_context.rs:55:12 @@ -38,12 +30,11 @@ error[E0277]: the trait bound `u64: From` is not satisfied `u64` implements `From` and $N others = note: required for `RequiredContext` to implement `From` - = note: required for `RequiredContext` to implement `FromCaller` -note: required by a bound in `Encryption::<'s, S, T, K, Ctx, M>::under` +note: required by a bound in `Encryption::<'s, S, T, K, Ctx, M>::under_field` --> src/target/operations.rs | - | pub fn under( - | ----- required by a bound in this associated function + | pub fn under_field( + | ----------- required by a bound in this associated function ... - | Ctx: FromCaller, - | ^^^^^^^^^^ required by this bound in `Encryption::<'s, S, T, K, Ctx, M>::under` + | Ctx: From, + | ^^^^^^^^^^^^^^^^^^^ required by this bound in `Encryption::<'s, S, T, K, Ctx, M>::under_field` diff --git a/packages/stack-encrypt/tests/ui/owned_record_field_stores_context.rs b/packages/stack-encrypt/tests/ui/owned_record_field_stores_context.rs new file mode 100644 index 0000000000..c090c73f27 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_record_field_stores_context.rs @@ -0,0 +1,30 @@ +//! A field type that stores its context (`#[stash(context_field)]`) can be a +//! field of a `struct = ..` record only when the stored type can be built +//! from the field's own context (`ContextFromField`). A `String` cannot, so +//! the field is a compile error. +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{DecryptSource, EncryptTarget}; +use stack_encrypt::Ciphertext; + +/// A value that stores its context as text. +#[derive(EncryptTarget, DecryptSource)] +#[stash(plaintext = String)] +struct Stored { + #[stash(context_field)] + column: String, + c: Ciphertext, + hm: EqualityTerm, +} + +#[derive(zeroize::Zeroize)] +struct User { + email: String, +} + +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = User, scope = "users")] +struct EncryptedUser { + email: Stored, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/owned_record_field_stores_context.stderr b/packages/stack-encrypt/tests/ui/owned_record_field_stores_context.stderr new file mode 100644 index 0000000000..a7cab809a6 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_record_field_stores_context.stderr @@ -0,0 +1,52 @@ +error[E0277]: a field whose type stores a `std::string::String` as its context cannot be a field of a `struct = ..` record + --> tests/ui/owned_record_field_stores_context.rs:27:12 + | +27 | email: Stored, + | ^^^^^^ a `std::string::String` cannot be built from the field's own context + | + = help: the trait `ContextFromField` is not implemented for `std::string::String` + = note: the record gives each field a context of its own: the record's scope, then the field's name. Implement `ContextFromField` for `std::string::String` to store that context +help: the following other types implement trait `From` + --> src/target/context.rs + | + | impl<'c, T: Clone + IntoContext<'c>> From> for StoredContext { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `StoredContext` implements `From>` +... + | / impl<'c, T> From for StoredContext + | | where + | | T: ContextFromField + Clone + IntoContext<'c>, + | |__________________________________________________^ `StoredContext` implements `From` + = note: required for `StoredContext` to implement `From` +note: required by a bound in `Encryption::<'s, S, T, K, Ctx, M>::under_field` + --> src/target/operations.rs + | + | pub fn under_field( + | ----------- required by a bound in this associated function +... + | Ctx: From, + | ^^^^^^^^^^^^^^^^^^^ required by this bound in `Encryption::<'s, S, T, K, Ctx, M>::under_field` + +error[E0277]: a field whose type stores a `std::string::String` as its context cannot be a field of a `struct = ..` record + --> tests/ui/owned_record_field_stores_context.rs:27:12 + | +27 | email: Stored, + | ^^^^^^ a `std::string::String` cannot be built from the field's own context + | + = help: the trait `ContextFromField` is not implemented for `std::string::String` + = note: the record gives each field a context of its own: the record's scope, then the field's name. Implement `ContextFromField` for `std::string::String` to store that context +help: the trait `DecryptField<__P, __Ctx>` is implemented for `Stored` + --> tests/ui/owned_record_field_stores_context.rs:10:25 + | +10 | #[derive(EncryptTarget, DecryptSource)] + | ^^^^^^^^^^^^^ + = note: required for `ExpectedContext` to implement `From` + = note: required for `CallerContext` to implement `Into>` +note: required for `Stored` to implement `DecryptField` + --> tests/ui/owned_record_field_stores_context.rs:10:25 + | +10 | #[derive(EncryptTarget, DecryptSource)] + | ^^^^^^^^^^^^^ unsatisfied trait bound introduced in this `derive` macro +11 | #[stash(plaintext = String)] +12 | struct Stored { + | ^^^^^^ + = note: this error originates in the derive macro `DecryptSource` (in Nightly builds, run with -Z macro-backtrace for more info) From 59bd58af87191402e992b2d048140dc6a7e82a57 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 15 Nov 2026 14:13:49 +1100 Subject: [PATCH 5/6] docs(stack-encrypt,eql-bindings): a binding is in the ciphertext, not the terms The docs of EQL types as record fields said that the terms of a field derive under the binding. They do not. Search terms derive under the query context: the field's own context, then the scopes. The data key and the AAD derive under the stored context, which adds the bindings. The tests already held that: two rows under one scope have one term. Say so in the trait doc, the `Identifier` impl, both CHANGELOGs, the README, the changeset and the API plan. The README's record with a scope and a binding left out two fields of `Member`, so it would not compile. It now names them all. --- .changeset/eql-types-as-record-fields.md | 2 +- .../2026-10-24-stack-encrypt-api-plan.md | 12 +++++++----- packages/eql/crates/eql-bindings/CHANGELOG.md | 9 +++++---- packages/eql/crates/eql-bindings/README.md | 19 +++++++++++-------- .../eql/crates/eql-bindings/src/encryption.rs | 10 ++++++---- .../tests/encryption/tests/record_fields.rs | 5 +++-- packages/stack-encrypt/CHANGELOG.md | 8 +++++--- packages/stack-encrypt/src/target/context.rs | 15 +++++++++++---- 8 files changed, 49 insertions(+), 31 deletions(-) diff --git a/.changeset/eql-types-as-record-fields.md b/.changeset/eql-types-as-record-fields.md index 1bc407caeb..ba2e8dd7de 100644 --- a/.changeset/eql-types-as-record-fields.md +++ b/.changeset/eql-types-as-record-fields.md @@ -6,7 +6,7 @@ - A `#[derive(EncryptTarget, DecryptSource)]` record with `struct = ..` can have fields such as `email: TextEq` or `profile: JsonTreeDocument`. The field's column is the record's scope and the field's name, so `scope = "members"` and the field `email` store `{"t": "members", "c": "email"}` in `i`. - With no scope and no binding, a field writes the same terms as the type written on its own under that column, and as a data plan that names the type. A data plan is how the Go module writes the type. -- A record can list a scope or a binding, such as `scope(tenant: u64)` or `bind(row: u128)`. The ciphertext and the terms of an EQL field then derive under the column, the scope and the binding, as they do for a field of any other type. `i` stores the column alone, so the reader supplies the scope and the binding again. The same is true of an EQL field of a record nested in another. +- A record can list a scope or a binding, such as `scope(tenant: u64)` or `bind(row: u128)`. The terms of an EQL field then derive under the column and the scope. The ciphertext is sealed under the column, the scope and the binding. A field of any other type works the same way. `i` stores the column alone, so the reader supplies the scope and the binding again. The same is true of an EQL field of a record nested in another. - A query can carry a scope: `.scope(..)` on a query from a field's marker, or `.extend(..)` on a plan's query. It gives the term of the values stored under that scope. - `JsonTreeDocument` now implements `EncryptTarget` and `DecryptSource` for `JsonPlaintext`. It encrypts under the default options, with the column as its context. diff --git a/docs/plans/2026-10-24-stack-encrypt-api-plan.md b/docs/plans/2026-10-24-stack-encrypt-api-plan.md index 04a397658f..2f8d3d86fa 100644 --- a/docs/plans/2026-10-24-stack-encrypt-api-plan.md +++ b/docs/plans/2026-10-24-stack-encrypt-api-plan.md @@ -1490,11 +1490,13 @@ Base64. the same context as that pair, so the stored bytes do not change. - **What `i` stores.** The declared names only: the table and the column. A record can also list a scope or a binding, supplied by the caller or - stored in the row. The ciphertext and the terms of an EQL field derive - under the column, then the scope, then the binding, as they do for a - field of any other type. `i` does not store them, so the reader supplies - them again, and a query supplies the scope. An EQL field of a record - nested in another stores its own column in the same way. + stored in the row. The terms of an EQL field derive under the query + context: the column, then the scope. The ciphertext is sealed under the + stored context: the column, then the scope, then the binding. A field + of any other type works the same way. `i` does not store the scope or + the binding, so the reader supplies them again, and a query supplies + the scope. An EQL field of a record nested in another stores its own + column in the same way. - **Why.** This gives the [EQL field example](#store-a-field-as-an-eql-type) and the [JSON example](#encrypt-a-json-document). - **Done already.** In #1658: diff --git a/packages/eql/crates/eql-bindings/CHANGELOG.md b/packages/eql/crates/eql-bindings/CHANGELOG.md index ca3783c41a..487397970e 100644 --- a/packages/eql/crates/eql-bindings/CHANGELOG.md +++ b/packages/eql/crates/eql-bindings/CHANGELOG.md @@ -19,10 +19,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `Identifier` implements `stack_encrypt::target::ContextFromField`. - A record can list a scope or a binding, such as `scope(tenant: u64)` or `bind(row: u128)`, supplied by the caller or stored in the row. The - ciphertext and the terms of an EQL field then derive under the column, - the scope and the binding, as they do for a field of any other type. - `i` stores the column alone, and nothing a caller supplies. The reader - supplies the scope and the binding again. + terms of an EQL field then derive under the column and the scope. The + ciphertext is sealed under the column, the scope and the binding. A + field of any other type works the same way. `i` stores the column + alone, and nothing a caller supplies. The reader supplies the scope + and the binding again. - An EQL field of a record nested in another stores its own column too. The outer record's names come after it in the field's context. - A query from the field's marker, such as `EncryptedMember::EMAIL.eq(..)`, diff --git a/packages/eql/crates/eql-bindings/README.md b/packages/eql/crates/eql-bindings/README.md index a283b24ee5..e6a19ee32d 100644 --- a/packages/eql/crates/eql-bindings/README.md +++ b/packages/eql/crates/eql-bindings/README.md @@ -442,7 +442,9 @@ A record can also list a scope or a binding, such as a tenant or a row ID: struct EncryptedMember { #[stash(passthrough)] id: u64, - email: TextEq, // i = {"t": "members", "c": "email"} + email: TextEq, // i = {"t": "members", "c": "email"} + age: IntegerOrd, // i = {"t": "members", "c": "age"} + profile: JsonTreeDocument, // i = {"t": "members", "c": "profile"} } let parts = || Scope::new(tenant).bind(row); @@ -455,14 +457,15 @@ let query = EncryptedMember::EMAIL let opened = Member::decrypt_from_with_context(stored, &keyset, parts()).await?; ``` -`i` stores the table and the column, and nothing a caller supplies. The -ciphertext and the terms of the field derive under the column, then the -scope, then the binding, as they do for a field of any other type. So: +`i` stores the table and the column, and nothing a caller supplies. A field +of any other type uses the scope and the binding in the same way: -- the reader supplies the scope and the binding again, and a value does not - open without them; -- a query supplies the scope, and finds only the values stored under it; -- a binding is in no query, so it does not change the terms. +- the terms of the field derive under the column, then the scope. So a + query supplies the scope, and finds only the values stored under it; +- a binding is in no query, so it does not change the terms; +- the ciphertext is sealed under the column, then the scope, then the + binding. So the reader supplies the scope and the binding again, and a + value does not open without them. An EQL field of a record nested in another stores its own column in the same way. The outer record's names come after the column in the field's context. diff --git a/packages/eql/crates/eql-bindings/src/encryption.rs b/packages/eql/crates/eql-bindings/src/encryption.rs index bca643dea9..9e98f1f95b 100644 --- a/packages/eql/crates/eql-bindings/src/encryption.rs +++ b/packages/eql/crates/eql-bindings/src/encryption.rs @@ -208,10 +208,12 @@ impl<'a> IntoContext<'a> for &'a Identifier { /// /// `i` holds those two declared names, and nothing a caller supplies. A /// record can also list a scope or a binding, such as a tenant or a row ID. -/// The field's ciphertext and terms derive under them, after the column, -/// as a field of any other type does. They are not stored in `i`, so the -/// reader supplies them again. The names of a record that holds this one -/// as a field come after the column in the same way. +/// They come after the column, as they do for a field of any other type. +/// The field's terms derive under the column and the scope. Its +/// ciphertext is sealed under the column, the scope and the binding. None +/// of that is stored in `i`, so the reader supplies the scope and the +/// binding again. The names of a record that holds this one as a field +/// come after the column as a scope does. /// /// With no scope and no binding, the field writes the terms that the type /// writes on its own under `Identifier::for_column("members", "email")`. diff --git a/packages/eql/tests/encryption/tests/record_fields.rs b/packages/eql/tests/encryption/tests/record_fields.rs index f56eefb369..68d73bac1f 100644 --- a/packages/eql/tests/encryption/tests/record_fields.rs +++ b/packages/eql/tests/encryption/tests/record_fields.rs @@ -16,8 +16,9 @@ //! the other's value; //! - a scope or a binding that the record lists, supplied by the caller or //! stored in the row, comes after the column in the field's context. The -//! ciphertext and the terms derive under it, and `i` does not store it. -//! The reader supplies it again, and a query supplies the scope; +//! terms derive under the column and the scope. The ciphertext is sealed +//! under the binding too. `i` stores neither. The reader supplies both +//! again, and a query supplies the scope; //! - an EQL field of a record nested in another stores its own column too; //! - a field that stores another column is refused before any key is //! requested; diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 9bbde968f7..4fe21e3d53 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -18,9 +18,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ContextFromField::from_field` builds the stored context from the field's own context, or returns `None` when the type cannot store it. - The scopes and the bindings that the record lists, and the names of a - record that holds this one, come after the own context. The field - runs under all of it, as a field of any other type does, and does not - store them. The reader supplies them again. + record that holds this one, come after the own context, as they do + for a field of any other type. The search terms derive under the own + context and the scopes. The data key and the AAD derive under the + bindings too. The field does not store them, so the reader supplies + them again. - On decrypt, the stored context must equal the field's own, as a standalone decrypt with an expected context checks it. - An own context that the type cannot store is refused before any key diff --git a/packages/stack-encrypt/src/target/context.rs b/packages/stack-encrypt/src/target/context.rs index 7fdd510418..cb440cafba 100644 --- a/packages/stack-encrypt/src/target/context.rs +++ b/packages/stack-encrypt/src/target/context.rs @@ -1138,10 +1138,17 @@ impl StoredContext { /// /// Only the own context is stored. A record can also list scopes and /// bindings, such as a tenant or a row ID. They come after the own -/// context, and the data key, the AAD and the search terms derive under -/// all of it. They are not stored in the field, so whoever reads the field -/// supplies them again. The same is true of the names of a record that -/// holds this record as a field. +/// context, as they do for a field of any other type: +/// +/// - the search terms derive under the query context: the own context, +/// then the scopes. A binding is not in it, so a binding does not +/// separate search terms; +/// - the data key and the AAD derive under the stored context: the query +/// context, then the bindings. +/// +/// The scopes and the bindings are not stored in the field, so whoever +/// reads the field supplies them again. The names of a record that holds +/// this record as a field come after the own context as scopes do. /// /// The context that `from_field` returns must encode as `own` does /// ([`IntoContext`]). The field then writes the terms that its type writes From 09a647be3b1a2379866b48f1bfffc8019fc94ee6 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 15 Nov 2026 14:32:53 +1100 Subject: [PATCH 6/6] docs(eql-bindings): do not teach `.extend(..)` in the release notes The plan chain's `.extend(..)` is to be removed with the chain. The changeset, the eql-bindings CHANGELOG and the API plan named it as a way to scope a query. They now name the marker's `.scope(..)` alone, and say that a plan's query takes a scope without naming the chain's method. The module doc of `encryption::query` still names it, after the marker form: that module documents the chain as it is today. --- .changeset/eql-types-as-record-fields.md | 2 +- docs/plans/2026-10-24-stack-encrypt-api-plan.md | 4 ++-- packages/eql/crates/eql-bindings/CHANGELOG.md | 6 +++--- .../eql/crates/eql-bindings/src/encryption/query.rs | 10 +++++----- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.changeset/eql-types-as-record-fields.md b/.changeset/eql-types-as-record-fields.md index ba2e8dd7de..899cf065cb 100644 --- a/.changeset/eql-types-as-record-fields.md +++ b/.changeset/eql-types-as-record-fields.md @@ -7,7 +7,7 @@ - A `#[derive(EncryptTarget, DecryptSource)]` record with `struct = ..` can have fields such as `email: TextEq` or `profile: JsonTreeDocument`. The field's column is the record's scope and the field's name, so `scope = "members"` and the field `email` store `{"t": "members", "c": "email"}` in `i`. - With no scope and no binding, a field writes the same terms as the type written on its own under that column, and as a data plan that names the type. A data plan is how the Go module writes the type. - A record can list a scope or a binding, such as `scope(tenant: u64)` or `bind(row: u128)`. The terms of an EQL field then derive under the column and the scope. The ciphertext is sealed under the column, the scope and the binding. A field of any other type works the same way. `i` stores the column alone, so the reader supplies the scope and the binding again. The same is true of an EQL field of a record nested in another. -- A query can carry a scope: `.scope(..)` on a query from a field's marker, or `.extend(..)` on a plan's query. It gives the term of the values stored under that scope. +- A query from a field's marker takes the record's scope with `.scope(..)`. It gives the term of the values stored under that scope. - `JsonTreeDocument` now implements `EncryptTarget` and `DecryptSource` for `JsonPlaintext`. It encrypts under the default options, with the column as its context. The stored format, the SQL bundle, and the TypeScript types do not change. diff --git a/docs/plans/2026-10-24-stack-encrypt-api-plan.md b/docs/plans/2026-10-24-stack-encrypt-api-plan.md index 2f8d3d86fa..a082c287ae 100644 --- a/docs/plans/2026-10-24-stack-encrypt-api-plan.md +++ b/docs/plans/2026-10-24-stack-encrypt-api-plan.md @@ -1512,8 +1512,8 @@ Base64. `DecryptSource`. - A query from a field's marker works for the equality and match types, with the record's scope. A plan's query for an EQL type takes - `.extend(..)`. The order types do not implement `Answers` yet, so a - marker cannot query them. + a scope too, through the plan chain that #1488 removes. The order + types do not implement `Answers` yet, so a marker cannot query them. - **Not done.** A data plan still refuses a scope or a binding on a field that names an EQL type (`TargetError::Extended`). So a Go record cannot yet write an EQL field under a scope. diff --git a/packages/eql/crates/eql-bindings/CHANGELOG.md b/packages/eql/crates/eql-bindings/CHANGELOG.md index 487397970e..ec7925b1cb 100644 --- a/packages/eql/crates/eql-bindings/CHANGELOG.md +++ b/packages/eql/crates/eql-bindings/CHANGELOG.md @@ -29,9 +29,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A query from the field's marker, such as `EncryptedMember::EMAIL.eq(..)`, gives the term stored in the row, for the types that answer it. For a record with a scope, pass the scope: `.scope(Scope::new(tenant))`. - - A plan's query for an EQL type can carry a scope too, with - `.extend(..)`. It gives the term of the values stored under that - scope. `QueryLabelError` has no `Extended` variant. + - A plan's query for an EQL type can carry a scope too. It gives the + term of the values stored under that scope, so `QueryLabelError` has + no `Extended` variant. - The encrypt input of an EQL type is the hidden `stack_encrypt::target::StoredContext`. A caller passes a `NonEmpty`. Generic code names the input as diff --git a/packages/eql/crates/eql-bindings/src/encryption/query.rs b/packages/eql/crates/eql-bindings/src/encryption/query.rs index 0d67f4f978..0f31e62ae0 100644 --- a/packages/eql/crates/eql-bindings/src/encryption/query.rs +++ b/packages/eql/crates/eql-bindings/src/encryption/query.rs @@ -39,11 +39,11 @@ //! names no column, so the awaited query refuses it, before any keyset is //! resolved or term derived. //! -//! A query can carry a scope: `.extend(..)` on a plan's query, or -//! `.scope(..)` on a query from a record field's marker. Its term is then -//! derived under the column and the scope, and matches the values a record -//! stored under the same scope. The twin stores the column alone, as the -//! stored value does: a scope is never stored in `i`. +//! A query can carry a scope: `.scope(..)` on a query from a record +//! field's marker, or the chain's `.extend(..)` on a plan's query. Its term +//! is then derived under the column and the scope, and matches the values +//! a record stored under the same scope. The twin stores the column alone, +//! as the stored value does: a scope is never stored in `i`. //! //! The plan is for queries. Stack Encrypt runs a plan's encryption under //! the label, and an EQL type is sealed under its column instead, so write