diff --git a/.changeset/eql-types-as-record-fields.md b/.changeset/eql-types-as-record-fields.md new file mode 100644 index 0000000000..899cf065cb --- /dev/null +++ b/.changeset/eql-types-as-record-fields.md @@ -0,0 +1,13 @@ +--- +'@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`. +- 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 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 0a0b8cc362..a082c287ae 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,31 +1481,48 @@ 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 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 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.** 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.** In #1658: + - A Rust record with EQL fields encrypts and decrypts through the new + 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), 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, with the record's scope. A plan's query for an EQL type takes + 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. +- **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; - - 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, + with and without a scope. #### A ready-made EQL resolver 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..ec7925b1cb 100644 --- a/packages/eql/crates/eql-bindings/CHANGELOG.md +++ b/packages/eql/crates/eql-bindings/CHANGELOG.md @@ -9,6 +9,42 @@ 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 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::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 + 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(..)`, + 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. 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 + `ContextInput: AcceptsContext>`. + +- **`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 @@ -138,9 +174,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 37defa996f..e6a19ee32d 100644 --- a/packages/eql/crates/eql-bindings/README.md +++ b/packages/eql/crates/eql-bindings/README.md @@ -395,6 +395,81 @@ 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 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. + +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"} + age: IntegerOrd, // i = {"t": "members", "c": "age"} + profile: JsonTreeDocument, // i = {"t": "members", "c": "profile"} +} + +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. A field +of any other type uses the scope and the binding in the same way: + +- 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. + ### 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..9e98f1f95b 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, ContextFromField, DecryptSource, EncryptTarget, 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 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`. +/// +/// `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. +/// 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")`. +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; + }; + Self::for_column(table.as_ref(), column.as_ref()).ok() + } +} + /// 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/encryption/query.rs b/packages/eql/crates/eql-bindings/src/encryption/query.rs index 38ac1bdadf..0f31e62ae0 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: `.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 @@ -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 e7f4ab1a44..9d620aac94 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, + StoredContext, }; -use stack_encrypt::target::{AeadContext, ExpectedContext, IndexSpec, Json, Layout}; use stack_encrypt::{ DecryptField, DecryptInto, Decryptable, Decryption, EncryptFrom, Encryption, Error, NonEmpty, }; @@ -482,6 +485,21 @@ impl JsonTree { } } +impl JsonTree { + /// 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, + ) -> Encryption<'s, Value, JsonTreeDocument, K, StoredContext> { + Layout::::encryption::(&self.index) + .storing(|document, column: Identifier| (column, document)) + .try_map(|(column, document)| JsonTreeDocument::envelope(column, &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 @@ -492,10 +510,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().accepting() } fn index(&self) -> &Json { &self.index @@ -524,17 +539,101 @@ impl EncryptFrom for JsonTreeDocument { impl DecryptInto for JsonTreeDocument { type Context = ExpectedContext; fn decryption(self, context: Self::Context) -> Decryption { - let column = match context.validate(self.i.clone()) { - Ok(column) => column, + self.open(context) + } +} + +impl JsonTreeDocument { + /// 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 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), } } } +/// A [`JsonPlaintext`] seals into a document as a `serde_json::Value` does, +/// under the default options ([`JsonTree::default`]), with the column as +/// its context: +/// +/// ```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, 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. 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 = StoredContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, JsonPlaintext, Self, K, Self::ContextInput> + where + JsonPlaintext: 's, + { + JsonTree::default().sealing().project(JsonPlaintext::value) + } + fn indexes() -> Vec { + JsonTree::default().specs() + } +} + +/// 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 { + self.open(context).map(JsonPlaintext::from) + } +} + +/// 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 { + self.open(context).map(JsonPlaintext::from) + } +} + impl Decryptable for JsonTreeDocument { const DECRYPTABLE: bool = true; } @@ -544,7 +643,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/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 new file mode 100644 index 0000000000..68d73bac1f --- /dev/null +++ b/packages/eql/tests/encryption/tests/record_fields.rs @@ -0,0 +1,798 @@ +//! 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 for a field of each +//! kind (equality, storage only, order, JSON document), and each opens +//! 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 +//! 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; +//! - 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 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}; +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::transcode::Reader as _; +use stack_encrypt::target::{AeadContext, Bind, Layout, Parts, Scope}; +use stack_encrypt::testing::{bind, scope, Expect}; +use stack_encrypt::{ + assert_context, nonempty, AsProtectedRef as _, CipherText, Controlled as _, DecryptFrom, + DecryptSource, EncryptInto, EncryptTarget, Error, NonEmpty, 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, 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 +/// 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"}, + "age": {"context": ["contacts", "age"], "target": "IntegerOrd"}, + "profile": {"context": ["contacts", "profile"], "target": "JsonSearch"}, + }); + record::plan_with( + dynamic::frame::serde::to_vec(&wire) + .unwrap() + .as_protected_ref(), + &EqlTargets, + ) + .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. +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 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 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, + }; + 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: 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, Debug, PartialEq, Zeroize)] +struct Tenanted { + email: String, +} + +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), bind(row: u128))] +struct EncryptedTenanted { + 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"), + ], + }, + ); +} + +#[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 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 stored: EncryptedTenanted = tenanted() + .encrypt_into_with_context(&keyset, tenant_and_row()) + .await + .unwrap(); + let again = || EncryptedTenanted { + email: stored.email.clone(), + }; + + 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 }; + 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 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. +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = Tenanted, scope = "members")] +struct EncryptedPlain { + email: TextEq, +} + +#[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, followed by the outer field's pair. +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = Holder, scope = "holders")] +struct EncryptedHolder { + member: EncryptedPlain, +} + +#[tokio::test] +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() }; + 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] +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 +} + +/// 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; + 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!( + document_terms(&stored), + document_terms(&old), + "the same envelope and 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!(document_terms(&stored.profile), document_terms(&old)); + + let opened = Customer::decrypt_from(stored, &keyset).await.unwrap(); + assert_eq!(opened.id, 3); + assert_eq!(opened.profile.value(), &profile()); +} 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 044fbe68df..4fe21e3d53 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -9,6 +9,33 @@ 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 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, 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 + 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 dropped. Build one with `JsonPlaintext::from(value)` or @@ -670,6 +697,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/codes.rs b/packages/stack-encrypt/src/codes.rs index 4fa0b408d6..71a077e934 100644 --- a/packages/stack-encrypt/src/codes.rs +++ b/packages/stack-encrypt/src/codes.rs @@ -175,6 +175,9 @@ mod tests { }, PlanError::EmptyIndexes => PlanError::EmptyIndexes, PlanError::EmptyPart => PlanError::EmptyPart, + 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/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/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 85927b0909..94db5f690a 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/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 d660dfc39b..173c6d6f54 100644 --- a/packages/stack-encrypt/src/plan/error.rs +++ b/packages/stack-encrypt/src/plan/error.rs @@ -251,6 +251,25 @@ pub enum PlanError { help("Give the scope or binding a value. An empty part would put every row under one context.") )] EmptyPart, + /// 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 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'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 /// a data verb. A field is one or the other: the target's type decides /// its layout and its queries. @@ -277,7 +296,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, diff --git a/packages/stack-encrypt/src/target/context.rs b/packages/stack-encrypt/src/target/context.rs index feec30f47b..cb440cafba 100644 --- a/packages/stack-encrypt/src/target/context.rs +++ b/packages/stack-encrypt/src/target/context.rs @@ -69,6 +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 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 @@ -97,6 +104,7 @@ impl From> for CallerContext { scopes, bindings: binding.into_iter().collect(), empty_part: empty_scope || empty_bind, + own: None, } } } @@ -132,6 +140,7 @@ impl CallerContext { scopes: Some(piece), bindings: Vec::new(), empty_part: false, + own: None, } } /// Refuse an empty part. A context with no scope passes: a record @@ -212,6 +221,7 @@ impl CallerContext { scopes: None, bindings: vec![piece], empty_part: false, + own: None, } } /// The parts a record stores in its row, as the caller context the @@ -223,6 +233,7 @@ impl CallerContext { scopes: stored.scope, bindings: stored.bind.into_iter().collect(), empty_part: stored.empty_part, + own: None, } } /// The own context `own`, extended by this caller context: the field's @@ -234,6 +245,7 @@ impl CallerContext { pub fn extend<'c, O: IntoContext<'c>>(self, own: NonEmpty) -> Self { let own = own.into_context().into_owned(); Self { + own: Some(own.clone()), scopes: Some(match self.scopes { Some(scopes) => nest(own, scopes), None => own, @@ -256,6 +268,33 @@ impl CallerContext { self.empty_part |= part.empty_part; self } + /// 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: Descriptor::from_piece(&named).as_str().to_owned(), + } + .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 +764,7 @@ impl StoredParts { scopes: None, bindings: Vec::new(), empty_part: empty_scope || empty_bind, + own: None, }; match (&self.scope, scope) { (Some(stored), Some(passed)) if *stored != passed => { @@ -868,9 +908,10 @@ 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 mut context = CallerContext::scopes(own.clone()); + context.own = Some(own); + self.0.into_iter().fold(context, CallerContext::then) } /// [`under`](Self::under), refusing a part the caller passed empty. @@ -921,10 +962,28 @@ 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), + /// 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 { - Self(None) + Self(Expected::Any) } } impl From<()> for ExpectedContext { @@ -934,22 +993,47 @@ 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 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(Expected::Field { + own: context.stored_as(), + context, + }) } } 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> { - if let Some(expected) = self.0 { + 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 }); @@ -957,6 +1041,191 @@ impl<'c, T: MaybeEmpty + PartialEq + IntoContext<'c>> ExpectedContext { } 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, 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 +/// 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 { @@ -1011,6 +1280,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 9b42236f69..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,7 +14,7 @@ //! example, `RequiredContext` stays required, although `u64` has a //! default of zero. -use super::context::{CallerContext, DeclaredContext, ExpectedContext}; +use super::context::{CallerContext, DeclaredContext, ExpectedContext, StoredContext}; use crate::{ContextPiece, IntoContext, NonEmpty}; mod sealed { @@ -144,6 +145,7 @@ impl> From for RequiredContext { Self::supplied(C::from(context)) } } + // A record that derives terms gives its ciphertext fields the AEAD part of the // context it requires. impl From> for RequiredContext { @@ -151,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. @@ -196,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 6ebfd35709..c54307b6dd 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -183,18 +183,19 @@ 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, 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, diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index c4a972cfdd..10598a3c09 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -14,7 +14,9 @@ // 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}; @@ -358,9 +360,49 @@ 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: From, @@ -372,7 +414,10 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's, M: SourceMode<'s, S>> build: Box::new(move |source, cipher, cx: DeclaredContext| { match cx.checked_under(own) { Ok(context) => build(source, cipher, context.into()), - Err(error) => Pending::failed(cipher, error), + 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 new file mode 100644 index 0000000000..14efc035a7 --- /dev/null +++ b/packages/stack-encrypt/tests/field_context.rs @@ -0,0 +1,420 @@ +//! A field type that stores its context, as a field of a derived `struct = +//! ..` record (`ContextFromField`). +//! +//! 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 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 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, ContextFromField, DecryptField, DecryptSource, EncryptTarget, Encryption, + NoCallerContext, Scope, +}; +use stack_encrypt::{ + nonempty, Ciphertext, ContextPiece, DecryptFrom, Descriptor, EncryptInto, Encrypted, 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 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() + } +} + +/// 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(), + } +} + +/// 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")] +struct EncryptedUser { + email: Stored, +} + +#[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 = alone(&keyset).await; + 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"); +} + +// --- 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_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(); + + // Only the field's own context is stored. + assert_eq!(stored.email.column, *Column::of("users", "email").get()); + + // 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", "name")) + .await + .unwrap(); + let record = TenantUser { email: elsewhere }; + let before = retrieves.load(Ordering::SeqCst); + 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_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 82fca205a9..fef2ec6183 100644 --- a/packages/stack-encrypt/tests/ui/owned_record_field_context.stderr +++ b/packages/stack-encrypt/tests/ui/owned_record_field_context.stderr @@ -4,14 +4,14 @@ error[E0277]: the trait bound `NoCallerContext: From` is not sati 54 | fixed: Fixed, | ^^^^^ the trait `From` is not implemented for `NoCallerContext` | -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: From, - | ^^^^^^^^^^^^^^^^^^^ required by this bound in `Encryption::<'s, S, T, K, Ctx, M>::under` + | ^^^^^^^^^^^^^^^^^^^ 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 @@ -30,11 +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 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: From, - | ^^^^^^^^^^^^^^^^^^^ required by this bound in `Encryption::<'s, S, T, K, Ctx, M>::under` + | ^^^^^^^^^^^^^^^^^^^ 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)