From 090de62afb947080340b23a51aba438f43d83cda Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 9 Nov 2026 20:18:36 +1100 Subject: [PATCH 1/3] feat(stack-encrypt): let a target take its context from the plaintext it encrypts Every context so far came from the caller. A value that arrives with the context it is stored under could not say so: a value decrypted from EQL v3 comes with its column, and re-encrypting it as v4 should read that column rather than ask for it again. The engine could already read a context from the source (`under_by`), but only for the plan builder, and only as a `Label` routed through `CallerContext`, which an EQL type cannot accept. `Encryption::context_from` reads the target's own context input out of the borrowed plaintext through a plain function pointer, so it cannot reach a cipher, and discharges the requirement into `NoCallerContext`. If the read fails, the description fails before any key is requested. It is borrowed-mode only; an owned target's default owned form wraps the plaintext and runs the borrowed one. `tests/context_from.rs` covers a term and a ciphertext under the carried context, the refusal with no key request, and a column whose rows carry different contexts in one batch. `migration_shim.rs` in the EQL encryption tests writes the v3-to-v4 shim on top of it: one generic `EncryptTarget` impl over every v4 type whose context is its column, checked against the usual query, the usual decryption, a renamed column and an empty one. Closes #1586. --- .../tests/encryption/tests/migration_shim.rs | 172 ++++++++++++++++++ packages/stack-encrypt/CHANGELOG.md | 9 + .../stack-encrypt/src/target/operations.rs | 96 +++++++++- packages/stack-encrypt/tests/context_from.rs | 147 +++++++++++++++ 4 files changed, 419 insertions(+), 5 deletions(-) create mode 100644 packages/eql/tests/encryption/tests/migration_shim.rs create mode 100644 packages/stack-encrypt/tests/context_from.rs diff --git a/packages/eql/tests/encryption/tests/migration_shim.rs b/packages/eql/tests/encryption/tests/migration_shim.rs new file mode 100644 index 000000000..e6e11f772 --- /dev/null +++ b/packages/eql/tests/encryption/tests/migration_shim.rs @@ -0,0 +1,172 @@ +//! A migration shim: an EQL v4 type encrypted from a value that carries its +//! own column, with no context argument. +//! +//! Moving EQL v3 data to v4 decrypts each v3 value and encrypts it again as +//! v4. The decrypted value arrives with the column it was stored in, `i`, so +//! the shim takes the context from there (`Encryption::context_from`) and the +//! value from `p`. Renaming the column is an edit to `i` before the call. +//! +//! One generic impl covers every v4 type whose context is its column. What +//! it writes must be the v4 type itself, byte for byte where bytes are +//! deterministic: a query built the usual way finds it, and it opens as the +//! original plaintext. + +mod common; + +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::{DecryptFrom, EncryptInto, Error, NonEmpty}; + +/// A value decrypted from EQL v3, with the column it was stored in. +struct V3Plaintext

{ + i: Identifier, + p: P, +} +impl Zeroize for V3Plaintext

{ + // The column is not secret. + fn zeroize(&mut self) { + self.p.zeroize(); + } +} + +/// A v4 type, encrypted from a v3 plaintext under the column it carries. +struct Shim(T); + +impl EncryptTarget> for Shim +where + P: Zeroize, + T: EncryptTarget>>, +{ + type ContextInput = NoCallerContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, V3Plaintext

, Self, K, NoCallerContext> + where + V3Plaintext

: 's, + { + T::encryption() + .project(|source: &V3Plaintext

| &source.p) + .context_from(|source| { + NonEmpty::new(source.i.clone()).map_err(|e| Error::Other(Box::new(e))) + }) + .map(Shim) + } +} + +fn identifier(table: &str, column: &str) -> Identifier { + Identifier { + t: table.to_owned(), + c: column.to_owned(), + } +} + +fn column(table: &str, column: &str) -> NonEmpty { + Identifier::for_column(table, column).unwrap() +} + +#[tokio::test] +async fn a_v3_value_encrypts_as_the_v4_type_with_no_context_argument() { + let (cipher, _) = common::cipher().await; + let keyset = cipher.default_keyset(); + + let source = V3Plaintext { + i: identifier("customers", "email"), + p: "alan@example.com".to_owned(), + }; + let Shim(migrated): Shim = source.encrypt_into(&keyset).await.unwrap(); + + assert_eq!( + (migrated.i.t.as_str(), migrated.i.c.as_str()), + ("customers", "email") + ); + let query: TextEqQuery = "alan@example.com" + .to_owned() + .encrypt_into_with_context(&keyset, column("customers", "email")) + .await + .unwrap(); + assert_eq!( + migrated.hm, query.hm, + "a query built the usual way finds it" + ); + + let opened = String::decrypt_from_with_context(migrated, &keyset, column("customers", "email")) + .await + .unwrap(); + assert_eq!(opened, "alan@example.com"); +} + +#[tokio::test] +async fn the_same_shim_covers_an_ordered_type() { + let (cipher, _) = common::cipher().await; + let keyset = cipher.default_keyset(); + + let source = V3Plaintext { + i: identifier("customers", "age"), + p: 41_i32, + }; + let Shim(migrated): Shim = source.encrypt_into(&keyset).await.unwrap(); + + // A block ORE term carries a nonce, so it is compared by order. + let query: IntegerOrdOreQuery = 41_i32 + .encrypt_into_with_context(&keyset, column("customers", "age")) + .await + .unwrap(); + assert!( + migrated.ob == query.ob, + "the query orders equal to the migrated value" + ); + + let opened = i32::decrypt_from(migrated, &keyset).await.unwrap(); + assert_eq!(opened, 41); +} + +#[tokio::test] +async fn a_renamed_column_is_the_column_the_value_is_stored_under() { + let (cipher, _) = common::cipher().await; + let keyset = cipher.default_keyset(); + + let mut source = V3Plaintext { + i: identifier("customers", "email"), + p: "alan@example.com".to_owned(), + }; + source.i.c = "contact_email".to_owned(); + let Shim(migrated): Shim = source.encrypt_into(&keyset).await.unwrap(); + + assert_eq!(migrated.i.c, "contact_email"); + let renamed: TextEqQuery = "alan@example.com" + .to_owned() + .encrypt_into_with_context(&keyset, column("customers", "contact_email")) + .await + .unwrap(); + let original: TextEqQuery = "alan@example.com" + .to_owned() + .encrypt_into_with_context(&keyset, column("customers", "email")) + .await + .unwrap(); + assert_eq!(migrated.hm, renamed.hm, "the new column's query finds it"); + assert_ne!(migrated.hm, original.hm, "the old column's query does not"); + + assert!( + String::decrypt_from_with_context(migrated, &keyset, column("customers", "email")) + .await + .is_err(), + "it does not open as the old column" + ); +} + +#[tokio::test] +async fn an_empty_column_is_refused_before_any_key_is_requested() { + let (cipher, calls) = common::cipher().await; + let keyset = cipher.default_keyset(); + + let source = V3Plaintext { + i: identifier("customers", ""), + p: "alan@example.com".to_owned(), + }; + let result = source.encrypt_into::, _>(&keyset).await; + assert!(matches!(result, Err(Error::Other(_)))); + assert!( + calls.lock().unwrap().generate.is_empty(), + "no key was requested" + ); +} diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 9551e84d4..a4c4e280c 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -71,6 +71,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **A target can take its context from the plaintext: + `Encryption::context_from`** (#1586). A value that arrives with the + context it is stored under, such as a value decrypted from an older + format together with its column, encrypts with no context argument. A + type that wraps the target reads the context out of the plaintext once, + in its `EncryptTarget` declaration, and declares `NoCallerContext`. If + the context cannot be read, the encryption fails and no key is + requested. The context is read through a plain function pointer, which + cannot reach a cipher. - **Optional values through the owned API: `Option`** (#1585). An `Option` encrypts into an `Option` for any owned target `T`, and decrypts back. diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index 1e08468a5..1c24ba7d7 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -13,7 +13,7 @@ use super::context::{AeadContext, CallerContext, DeclaredContext, Extends}; use super::conversion::{DecryptSource, EncryptTarget}; use super::core::{encrypt_native, open_native, Term}; use super::index::{Index, IndexSpec, Json}; -use super::input::RequiredContext; +use super::input::{AcceptsContext, NoCallerContext, RequiredContext}; use super::source::{Borrowed, ConsumeSource, Owned, ShareSource, SourceMode}; use super::{CipherScope, Pending}; use crate::json::{JsonDocument, JsonOptions, JsonTerms}; @@ -118,10 +118,11 @@ type Open = Box FnOnce(&'a StackCipher) -> Pending<'a, T, K /// declaration; that is how a record names its fields' contexts, and the /// tree does not tell a record's fields from a target's halves. /// - **A leaf still cannot be reached without a context.** An operation needs -/// a real one. [`under`](Self::under) and [`extend`](Self::extend) are the -/// only ways to change the context a subtree runs under, and only `under` -/// discharges the requirement into a [`DeclaredContext`], which is what -/// `()` may satisfy. +/// a real one. [`under`](Self::under) and [`extend`](Self::extend) change +/// the context a subtree runs under, and `under` discharges the +/// requirement into a [`DeclaredContext`], which is what `()` may satisfy. +/// [`context_from`](Self::context_from) discharges it too, into +/// [`NoCallerContext`], with a context read from the plaintext itself. /// /// `M` is how the description is handed its plaintext ([`SourceMode`]): /// by reference in the default [`Borrowed`] mode, which every @@ -349,6 +350,91 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { build: Box::new(move |source, cipher, cx| (self.build)(select(source), cipher, cx)), } } + /// Take the context from the plaintext itself, for a plaintext that + /// carries the context it is stored under. The caller then supplies none. + /// + /// `select` reads the context out of the borrowed plaintext, as the + /// caller would pass it to `encrypt_into_with_context`. If it fails, the + /// description fails with that error, and no key is requested. + /// + /// This is how a value arrives with its context from somewhere else, such + /// as a value decrypted from an older format together with the column it + /// was stored in. A type that wraps the target declares the mapping once: + /// + /// ``` + /// use stack_encrypt::sem::EqualityTerm; + /// use stack_encrypt::target::{EncryptTarget, Encryption, NoCallerContext}; + /// use stack_encrypt::{nonempty, EncryptInto, Error, NonEmpty, StackCipher}; + /// use stack_encrypt::kms::FakeDataKeySource; + /// use zeroize::Zeroize; + /// + /// /// A value and the column it belongs to. + /// struct Placed { + /// column: String, + /// value: String, + /// } + /// impl Zeroize for Placed { + /// // The column is not secret. + /// fn zeroize(&mut self) { + /// self.value.zeroize(); + /// } + /// } + /// + /// /// An equality term, under the column the value carries. + /// struct PlacedTerm(EqualityTerm); + /// impl EncryptTarget for PlacedTerm { + /// type ContextInput = NoCallerContext; + /// fn encryption<'s, K: 'static>() -> Encryption<'s, Placed, Self, K, NoCallerContext> { + /// >::encryption() + /// .project(|placed: &Placed| &placed.value) + /// .context_from(|placed| { + /// NonEmpty::new(placed.column.clone()) + /// .map(Into::into) + /// .map_err(|e| Error::Other(Box::new(e))) + /// }) + /// .map(PlacedTerm) + /// } + /// } + /// + /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { + /// let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; + /// let keyset = cipher.default_keyset(); + /// let placed = Placed { column: "users/email".into(), value: "ada@example.com".into() }; + /// // No context argument: it comes from `placed.column`. + /// let PlacedTerm(term) = placed.encrypt_into(&keyset).await?; + /// + /// // The same term as passing the column as the context. + /// let direct: EqualityTerm = "ada@example.com" + /// .to_owned() + /// .encrypt_into_with_context(&keyset, nonempty!("users/email")) + /// .await?; + /// assert_eq!(term, direct); + /// # Ok::<(), Box>(()) + /// # }).unwrap(); + /// ``` + /// + /// `select` is a plain function pointer, as the selector of + /// [`project`](Self::project) is. It captures nothing, so it cannot reach + /// a cipher. It reads the plaintext by reference, so this combinator is + /// for the [`Borrowed`] mode, which every `EncryptTarget::encryption` + /// runs in. The owned form of such a target wraps the plaintext and runs + /// the borrowed one, so it needs nothing more. + pub fn context_from( + self, + select: for<'borrow> fn(&'borrow S) -> Result, + ) -> Encryption<'s, S, T, K, NoCallerContext> + where + Ctx: AcceptsContext, + { + Encryption { + build: Box::new( + move |source, cipher, _: NoCallerContext| match select(source) { + Ok(context) => (self.build)(source, cipher, Ctx::supplied(context)), + Err(error) => Pending::failed(cipher, error), + }, + ), + } + } /// [`under`](Encryption::under) for a context read out of the source /// itself, as a plan whose context is a field of the value does: `own` /// reads the label from the borrowed source, or fails, in which case the diff --git a/packages/stack-encrypt/tests/context_from.rs b/packages/stack-encrypt/tests/context_from.rs new file mode 100644 index 000000000..056721b8f --- /dev/null +++ b/packages/stack-encrypt/tests/context_from.rs @@ -0,0 +1,147 @@ +//! A target that takes its context from the plaintext it encrypts +//! (`Encryption::context_from`). The caller supplies no context: the +//! plaintext carries the one it is stored under, such as a value decrypted +//! from an older format together with its column. No service credentials are +//! needed. +use std::sync::atomic::Ordering; + +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{EncryptTarget, Encryption, NoCallerContext}; +use stack_encrypt::{nonempty, EncryptInto, Error, NonEmpty, StackCipherText}; +use zeroize::Zeroize; +mod common; + +/// A value and the context it belongs under. +#[derive(Clone)] +struct Placed { + context: String, + value: String, +} +impl Zeroize for Placed { + // The context is not secret. + fn zeroize(&mut self) { + self.value.zeroize(); + } +} +fn placed(context: &str, value: &str) -> Placed { + Placed { + context: context.to_owned(), + value: value.to_owned(), + } +} + +/// The context `placed` carries, refused if it is empty. +fn context_of>>(placed: &Placed) -> Result { + NonEmpty::new(placed.context.clone()) + .map(C::from) + .map_err(|e| Error::Other(Box::new(e))) +} + +/// An equality term, under the context the value carries. +struct PlacedTerm(EqualityTerm); +impl EncryptTarget for PlacedTerm { + type ContextInput = NoCallerContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, Placed, Self, K, NoCallerContext> { + >::encryption() + .project(|placed: &Placed| &placed.value) + .context_from(context_of) + .map(PlacedTerm) + } +} + +/// A ciphertext, sealed under the context the value carries. +struct PlacedCiphertext(StackCipherText); +impl EncryptTarget for PlacedCiphertext { + type ContextInput = NoCallerContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, Placed, Self, K, NoCallerContext> { + >::encryption() + .project(|placed: &Placed| &placed.value) + .context_from(context_of) + .map(PlacedCiphertext) + } +} + +#[tokio::test] +async fn a_term_is_derived_under_the_context_the_plaintext_carries() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + + let PlacedTerm(term) = placed("users/email", "ada@example.com") + .encrypt_into(&keyset) + .await + .unwrap(); + let direct: EqualityTerm = "ada@example.com" + .to_owned() + .encrypt_into_with_context(&keyset, nonempty!("users/email")) + .await + .unwrap(); + assert_eq!(term, direct, "the same term as passing the context"); + + let PlacedTerm(elsewhere) = placed("users/name", "ada@example.com") + .encrypt_into(&keyset) + .await + .unwrap(); + assert_ne!(term, elsewhere, "another context derives another term"); +} + +#[tokio::test] +async fn a_ciphertext_is_sealed_under_the_context_the_plaintext_carries() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + + let seal = || async { + let PlacedCiphertext(sealed) = placed("users/email", "ada@example.com") + .encrypt_into(&keyset) + .await + .unwrap(); + sealed + }; + let opened: String = cipher + .decrypt(seal().await, nonempty!("users/email")) + .await + .unwrap(); + assert_eq!(opened, "ada@example.com"); + assert!(matches!( + cipher + .decrypt::(seal().await, nonempty!("users/name")) + .await, + Err(Error::Aead) + )); +} + +#[tokio::test] +async fn a_context_that_cannot_be_read_fails_before_any_key_is_requested() { + let (cipher, generates, _) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + + let result = placed("", "ada@example.com") + .encrypt_into::(&keyset) + .await; + assert!(matches!(result, Err(Error::Other(_)))); + assert_eq!(generates.load(Ordering::SeqCst), 0, "no key was requested"); +} + +// Each row carries its own context, and the column still makes one batch. +#[tokio::test] +async fn each_row_of_a_column_is_sealed_under_its_own_context() { + let (cipher, generates, _) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + + let column: Vec = vec![ + placed("users/email", "ada@example.com"), + placed("users/name", "Ada"), + ] + .encrypt_into(&keyset) + .await + .unwrap(); + assert_eq!(generates.load(Ordering::SeqCst), 1, "one batch"); + + let [PlacedCiphertext(email), PlacedCiphertext(name)] = + <[_; 2]>::try_from(column).ok().unwrap(); + let email: String = cipher + .decrypt(email, nonempty!("users/email")) + .await + .unwrap(); + let name: String = cipher.decrypt(name, nonempty!("users/name")).await.unwrap(); + assert_eq!((email.as_str(), name.as_str()), ("ada@example.com", "Ada")); +} From 82496a0e4f256aa46b899f095af69b8c0187b9c2 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 9 Nov 2026 20:44:00 +1100 Subject: [PATCH 2/3] fix(stack-encrypt): context_from picks a stored context field, not any value of the plaintext Review findings on #1588. `select` saw the whole plaintext and returned any context, so a context could be computed from the value being sealed and leave it as a ZeroKMS descriptor, a clear `i`, and an AAD that needed the plaintext to open. It also allowed a context read from the plaintext and never stored, which the API plan rules out: a program must never need the plaintext to learn the context that opens it. `context_from` now takes `pick: fn(&S) -> &C`, a reference to a field the plaintext holds, and applies only to a target whose context input is `NonEmpty`: a record with a `context_field`, or an EQL type, which stores its column in `i`. It checks the context is non-empty itself, so callers no longer map the error. A `# Security` section says the field is sent to ZeroKMS and stored in the clear. ADR-0004 gains an amendment recording the third way to set a context and the limits that keep decision 2's reasons, and the module and type docs that listed only `under` and `extend` now name it. Tests move onto a target that stores its context: the value opens from the row alone; another column is a `ContextMismatch`; an empty column is refused with no key request and the plaintext wiped; each row of a column is sent under its own descriptor in one batch; and one bad row refuses the whole column. The migration shim's rename test now opens the value under the new column and shows a relabelled value fails authentication, and its ORE check orders the value between 40 and 42. --- .../tests/encryption/tests/migration_shim.rs | 100 +++++++--- packages/stack-encrypt/CHANGELOG.md | 16 +- ...t-threaded-through-the-declaration-tree.md | 36 ++++ .../stack-encrypt/src/target/operations.rs | 105 ++++++---- packages/stack-encrypt/tests/context_from.rs | 180 ++++++++++-------- 5 files changed, 279 insertions(+), 158 deletions(-) diff --git a/packages/eql/tests/encryption/tests/migration_shim.rs b/packages/eql/tests/encryption/tests/migration_shim.rs index e6e11f772..f38c7b54a 100644 --- a/packages/eql/tests/encryption/tests/migration_shim.rs +++ b/packages/eql/tests/encryption/tests/migration_shim.rs @@ -13,6 +13,8 @@ mod common; +use std::cmp::Ordering; + use eql_bindings::v4::integer::{IntegerOrdOre, IntegerOrdOreQuery}; use eql_bindings::v4::text::{TextEq, TextEqQuery}; use eql_bindings::Identifier; @@ -25,7 +27,7 @@ struct V3Plaintext

{ p: P, } impl Zeroize for V3Plaintext

{ - // The column is not secret. + // The column is not secret: it is stored in the clear in `i`. fn zeroize(&mut self) { self.p.zeroize(); } @@ -46,9 +48,7 @@ where { T::encryption() .project(|source: &V3Plaintext

| &source.p) - .context_from(|source| { - NonEmpty::new(source.i.clone()).map_err(|e| Error::Other(Box::new(e))) - }) + .context_from(|source: &V3Plaintext

| &source.i) .map(Shim) } } @@ -64,6 +64,26 @@ fn column(table: &str, column: &str) -> NonEmpty { Identifier::for_column(table, column).unwrap() } +async fn email_query( + keyset: &stack_encrypt::KeysetCipher<'_, common::Observed>, + name: &str, +) -> TextEqQuery { + "alan@example.com" + .to_owned() + .encrypt_into_with_context(keyset, column("customers", name)) + .await + .unwrap() +} + +async fn age_query( + keyset: &stack_encrypt::KeysetCipher<'_, common::Observed>, + age: i32, +) -> IntegerOrdOreQuery { + age.encrypt_into_with_context(keyset, column("customers", "age")) + .await + .unwrap() +} + #[tokio::test] async fn a_v3_value_encrypts_as_the_v4_type_with_no_context_argument() { let (cipher, _) = common::cipher().await; @@ -106,22 +126,28 @@ async fn the_same_shim_covers_an_ordered_type() { }; let Shim(migrated): Shim = source.encrypt_into(&keyset).await.unwrap(); - // A block ORE term carries a nonce, so it is compared by order. - let query: IntegerOrdOreQuery = 41_i32 - .encrypt_into_with_context(&keyset, column("customers", "age")) - .await - .unwrap(); - assert!( - migrated.ob == query.ob, - "the query orders equal to the migrated value" + // A block ORE term carries a nonce, so it is compared by order: the + // migrated value sits between the queries for 40 and 42, and orders + // equal to the query for 41. + let compare = |query: &IntegerOrdOreQuery| migrated.ob.partial_cmp(&query.ob); + assert_eq!( + compare(&age_query(&keyset, 40).await), + Some(Ordering::Greater) + ); + assert_eq!( + compare(&age_query(&keyset, 41).await), + Some(Ordering::Equal) ); + assert_eq!(compare(&age_query(&keyset, 42).await), Some(Ordering::Less)); - let opened = i32::decrypt_from(migrated, &keyset).await.unwrap(); + let opened = i32::decrypt_from_with_context(migrated, &keyset, column("customers", "age")) + .await + .unwrap(); assert_eq!(opened, 41); } #[tokio::test] -async fn a_renamed_column_is_the_column_the_value_is_stored_under() { +async fn a_renamed_column_is_the_column_the_value_is_sealed_under() { let (cipher, _) = common::cipher().await; let keyset = cipher.default_keyset(); @@ -131,27 +157,39 @@ async fn a_renamed_column_is_the_column_the_value_is_stored_under() { }; source.i.c = "contact_email".to_owned(); let Shim(migrated): Shim = source.encrypt_into(&keyset).await.unwrap(); - assert_eq!(migrated.i.c, "contact_email"); - let renamed: TextEqQuery = "alan@example.com" - .to_owned() - .encrypt_into_with_context(&keyset, column("customers", "contact_email")) - .await - .unwrap(); - let original: TextEqQuery = "alan@example.com" - .to_owned() - .encrypt_into_with_context(&keyset, column("customers", "email")) - .await - .unwrap(); - assert_eq!(migrated.hm, renamed.hm, "the new column's query finds it"); - assert_ne!(migrated.hm, original.hm, "the old column's query does not"); + // The new column's query finds it, and the old column's does not. + assert_eq!(migrated.hm, email_query(&keyset, "contact_email").await.hm); + assert_ne!(migrated.hm, email_query(&keyset, "email").await.hm); + + // The ciphertext is sealed under the new column: it opens there. + let opened = String::decrypt_from_with_context( + migrated.clone(), + &keyset, + column("customers", "contact_email"), + ) + .await + .unwrap(); + assert_eq!(opened, "alan@example.com"); + + // Asked for the old column, the stored column does not match, so it is + // refused before any key is requested. + let result = + String::decrypt_from_with_context(migrated.clone(), &keyset, column("customers", "email")) + .await; assert!( - String::decrypt_from_with_context(migrated, &keyset, column("customers", "email")) - .await - .is_err(), - "it does not open as the old column" + matches!(result, Err(Error::ContextMismatch { .. })), + "{:?}", + result.err() ); + + // With the stored column changed back to the old one, the ciphertext + // itself refuses to open: it is bound to the new column, not the label. + let mut relabelled = migrated; + relabelled.i.c = "email".to_owned(); + let result = String::decrypt_from(relabelled, &keyset).await; + assert!(matches!(result, Err(Error::Aead)), "{:?}", result.err()); } #[tokio::test] diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index a4c4e280c..494b2bb41 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -71,15 +71,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **A target can take its context from the plaintext: +- **A target can take its context from a field of the plaintext: `Encryption::context_from`** (#1586). A value that arrives with the context it is stored under, such as a value decrypted from an older format together with its column, encrypts with no context argument. A - type that wraps the target reads the context out of the plaintext once, - in its `EncryptTarget` declaration, and declares `NoCallerContext`. If - the context cannot be read, the encryption fails and no key is - requested. The context is read through a plain function pointer, which - cannot reach a cipher. + type that wraps the target names the field once, in its `EncryptTarget` + declaration, and declares `NoCallerContext`. It applies to a target whose + context input is a typed `NonEmpty`, such as a record with a + `context_field` or an EQL type, so the context is also stored in the row + and opening a value never needs its plaintext. The field is picked by + reference, so a context cannot be computed from the sealed value. An + empty context fails the encryption before any key is requested. The + picked field is sent to ZeroKMS and stored in the clear, so it must not + be secret. - **Optional values through the owned API: `Option`** (#1585). An `Option` encrypts into an `Option` for any owned target `T`, and decrypts back. diff --git a/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md b/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md index 111da4472..77a8c1bab 100644 --- a/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md +++ b/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md @@ -113,6 +113,9 @@ literal `"users/age"`. A column is the pair now, which renders the ZeroKMS descriptor `users/age`; the joined literal is one part and renders escaped. See ADR-0006.) +A third way, `context_from`, reads the context from a field of the plaintext. +See the amendment of 2026-11-09 below. + Two further combinators change nothing about *which* context reaches a subtree, only its type at the root. `accepting` converts the context a record declares its caller supplies into the one its operations need, once, at the @@ -354,3 +357,36 @@ language allows; Go-1). The dynamic-to-static dispatch itself is unchanged. Decisions 1 to 4, 7 and 8 do not change. Decision 2's `extend` is what `Cipher.Extend` does from Go: a caller's context appends to the declared one, and never replaces it. + +## Amended 2026-11-09 (#1586) + +Decision 2 gains a third way to set a subtree's context: +`Encryption::context_from`. It records the case `under` and `extend` do not +cover: a value that arrives carrying the context it is stored under, such as a +value decrypted from EQL v3 together with its column. Re-encrypting it as EQL +v4 should read that column, not ask the caller to repeat it. + +`context_from(pick)` reads the context from a field of the plaintext and +discharges the requirement into `NoCallerContext`, so the caller supplies +none. Three limits keep decision 2's reasons intact: + +- **It applies only to a target whose context input is a typed context, + `NonEmpty`.** A record that stores its context in a `context_field` + declares that, and so does an EQL type, which stores its column in `i`. So + the context comes from the plaintext on the way in and from the row on the + way out. Opening a value never needs its plaintext. +- **`pick` returns a reference to a field the plaintext holds.** It cannot + compute a context from the plaintext. Where the context type differs from + the sealed value's type, the sealed field cannot be picked at all. +- **The field is named in the declaration.** It is a plain function pointer, + visible at review, like `project`'s selector. It is not a value supplied at + the call that could reach one side and not another. + +It replaces the surrounding context rather than extending it. A target that +takes its context this way is not a field of a record that declares contexts, +since its context input is `NoCallerContext`. + +As with two `under` literals, two halves of one target zipped after separate +`context_from` calls could each take a different field. That is the +divergence decision 1 already accepts for declared per-side contexts: each +field is named in the declaration. diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index 1c24ba7d7..17c3ba0f5 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -8,7 +8,8 @@ //! of [`Encryption`] (ADR-0004): a target cannot route the context it is //! handed to one operation and something else to another, because there is //! no argument to route. What a subtree may do is take a context of its own, -//! by name, with [`Encryption::under`] or [`Encryption::extend`]. +//! by name, with [`Encryption::under`] or [`Encryption::extend`], or read it +//! from a named field of the plaintext with [`Encryption::context_from`]. use super::context::{AeadContext, CallerContext, DeclaredContext, Extends}; use super::conversion::{DecryptSource, EncryptTarget}; use super::core::{encrypt_native, open_native, Term}; @@ -17,7 +18,7 @@ use super::input::{AcceptsContext, NoCallerContext, RequiredContext}; use super::source::{Borrowed, ConsumeSource, Owned, ShareSource, SourceMode}; use super::{CipherScope, Pending}; use crate::json::{JsonDocument, JsonOptions, JsonTerms}; -use crate::{Error, IntoContext, KeysetCipher, NonEmpty, StackCipher, StackCipherText}; +use crate::{Error, IntoContext, KeysetCipher, MaybeEmpty, NonEmpty, StackCipher, StackCipherText}; use stack_kms::MaybeSend; use std::fmt; @@ -114,15 +115,17 @@ type Open = Box FnOnce(&'a StackCipher) -> Pending<'a, T, K /// other. A ciphertext beside a term takes the term's context through /// [`accepting`](Self::accepting): the [`AeadContext`] it seals under is /// the AEAD half of that one value. A side given a context of its own, -/// with [`under`](Self::under) or [`extend`](Self::extend), says so in the -/// declaration; that is how a record names its fields' contexts, and the -/// tree does not tell a record's fields from a target's halves. +/// with [`under`](Self::under), [`extend`](Self::extend) or +/// [`context_from`](Self::context_from), says so in the declaration; that +/// is how a record names its fields' contexts, and the tree does not tell a +/// record's fields from a target's halves. /// - **A leaf still cannot be reached without a context.** An operation needs /// a real one. [`under`](Self::under) and [`extend`](Self::extend) change /// the context a subtree runs under, and `under` discharges the /// requirement into a [`DeclaredContext`], which is what `()` may satisfy. /// [`context_from`](Self::context_from) discharges it too, into -/// [`NoCallerContext`], with a context read from the plaintext itself. +/// [`NoCallerContext`], with the context read from a named field of the +/// plaintext. /// /// `M` is how the description is handed its plaintext ([`SourceMode`]): /// by reference in the default [`Borrowed`] mode, which every @@ -350,21 +353,43 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { build: Box::new(move |source, cipher, cx| (self.build)(select(source), cipher, cx)), } } - /// Take the context from the plaintext itself, for a plaintext that - /// carries the context it is stored under. The caller then supplies none. + /// Take the context from a field of the plaintext, for a target that + /// stores its context in the row. The caller then supplies none. /// - /// `select` reads the context out of the borrowed plaintext, as the - /// caller would pass it to `encrypt_into_with_context`. If it fails, the - /// description fails with that error, and no key is requested. + /// This is for a value that arrives with the context it is stored under, + /// such as a value decrypted from an older format together with the + /// column it was stored in. A type that wraps the target declares, once, + /// which field holds the context, and its callers encrypt with no context + /// argument. /// - /// This is how a value arrives with its context from somewhere else, such - /// as a value decrypted from an older format together with the column it - /// was stored in. A type that wraps the target declares the mapping once: + /// The target's context input must be a typed context, `NonEmpty`. A + /// record that stores its context in a `#[stash(context_field)]` declares + /// that, and so does an EQL type, whose context is the column it stores + /// in `i`. So the context is read from the plaintext on the way in and + /// from the row on the way out, and opening a value never needs its + /// plaintext. + /// + /// `pick` names the field. It returns a reference into the plaintext, so + /// it can only hand over a value the plaintext already holds, not one + /// computed from it. The context is checked to be non-empty; if it is + /// empty, the description fails and no key is requested. `pick` is a plain + /// function pointer, as the selector of [`project`](Self::project) is, + /// so it cannot reach a cipher. + /// + /// # Security + /// + /// The picked field is not secret. It is sent to ZeroKMS as the data + /// key's descriptor, and a target that stores its context writes it in + /// the clear. Pick a field that names where the value belongs, such as + /// its column. Never pick a field that is also sealed: its plaintext + /// would leave the ciphertext. Where the context type differs from the + /// sealed value's type, as `Identifier` does from `String`, the type + /// checker refuses that mistake. /// /// ``` /// use stack_encrypt::sem::EqualityTerm; /// use stack_encrypt::target::{EncryptTarget, Encryption, NoCallerContext}; - /// use stack_encrypt::{nonempty, EncryptInto, Error, NonEmpty, StackCipher}; + /// use stack_encrypt::{EncryptInto, NonEmpty, StackCipher}; /// use stack_encrypt::kms::FakeDataKeySource; /// use zeroize::Zeroize; /// @@ -380,19 +405,24 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { /// } /// } /// - /// /// An equality term, under the column the value carries. - /// struct PlacedTerm(EqualityTerm); - /// impl EncryptTarget for PlacedTerm { + /// /// The stored form: the column in the clear, and the value's term. + /// #[derive(EncryptTarget)] + /// #[stash(plaintext = String)] + /// struct Stored { + /// #[stash(context_field)] + /// column: String, + /// hm: EqualityTerm, + /// } + /// + /// /// `Stored`, encrypted from a `Placed` under the column it carries. + /// struct FromPlaced(Stored); + /// impl EncryptTarget for FromPlaced { /// type ContextInput = NoCallerContext; /// fn encryption<'s, K: 'static>() -> Encryption<'s, Placed, Self, K, NoCallerContext> { - /// >::encryption() + /// >::encryption() /// .project(|placed: &Placed| &placed.value) - /// .context_from(|placed| { - /// NonEmpty::new(placed.column.clone()) - /// .map(Into::into) - /// .map_err(|e| Error::Other(Box::new(e))) - /// }) - /// .map(PlacedTerm) + /// .context_from(|placed: &Placed| &placed.column) + /// .map(FromPlaced) /// } /// } /// @@ -401,34 +431,35 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { /// let keyset = cipher.default_keyset(); /// let placed = Placed { column: "users/email".into(), value: "ada@example.com".into() }; /// // No context argument: it comes from `placed.column`. - /// let PlacedTerm(term) = placed.encrypt_into(&keyset).await?; + /// let FromPlaced(stored) = placed.encrypt_into(&keyset).await?; + /// assert_eq!(stored.column, "users/email"); /// /// // The same term as passing the column as the context. - /// let direct: EqualityTerm = "ada@example.com" + /// let direct: Stored = "ada@example.com" /// .to_owned() - /// .encrypt_into_with_context(&keyset, nonempty!("users/email")) + /// .encrypt_into_with_context(&keyset, NonEmpty::new("users/email".to_owned())?) /// .await?; - /// assert_eq!(term, direct); + /// assert_eq!(stored.hm, direct.hm); /// # Ok::<(), Box>(()) /// # }).unwrap(); /// ``` /// - /// `select` is a plain function pointer, as the selector of - /// [`project`](Self::project) is. It captures nothing, so it cannot reach - /// a cipher. It reads the plaintext by reference, so this combinator is - /// for the [`Borrowed`] mode, which every `EncryptTarget::encryption` + /// It is for the [`Borrowed`] mode, which every `EncryptTarget::encryption` /// runs in. The owned form of such a target wraps the plaintext and runs /// the borrowed one, so it needs nothing more. - pub fn context_from( + pub fn context_from( self, - select: for<'borrow> fn(&'borrow S) -> Result, + pick: for<'borrow> fn(&'borrow S) -> &'borrow C, ) -> Encryption<'s, S, T, K, NoCallerContext> where - Ctx: AcceptsContext, + Ctx: AcceptsContext>, + C: Clone + MaybeEmpty + 's, { Encryption { build: Box::new( - move |source, cipher, _: NoCallerContext| match select(source) { + move |source, cipher, _: NoCallerContext| match super::context::nonempty( + pick(source).clone(), + ) { Ok(context) => (self.build)(source, cipher, Ctx::supplied(context)), Err(error) => Pending::failed(cipher, error), }, diff --git a/packages/stack-encrypt/tests/context_from.rs b/packages/stack-encrypt/tests/context_from.rs index 056721b8f..abdf6f3ef 100644 --- a/packages/stack-encrypt/tests/context_from.rs +++ b/packages/stack-encrypt/tests/context_from.rs @@ -1,147 +1,159 @@ -//! A target that takes its context from the plaintext it encrypts +//! A target that takes its context from a field of the plaintext //! (`Encryption::context_from`). The caller supplies no context: the //! plaintext carries the one it is stored under, such as a value decrypted -//! from an older format together with its column. No service credentials are -//! needed. -use std::sync::atomic::Ordering; +//! from an older format together with its column, and the target stores it +//! in the row, so opening the value never needs its plaintext. No service +//! credentials are needed. +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use stack_encrypt::sem::EqualityTerm; -use stack_encrypt::target::{EncryptTarget, Encryption, NoCallerContext}; -use stack_encrypt::{nonempty, EncryptInto, Error, NonEmpty, StackCipherText}; +use stack_encrypt::target::{DecryptSource, EncryptTarget, Encryption, NoCallerContext}; +use stack_encrypt::{DecryptFrom, EncryptInto, Error, NonEmpty, StackCipherText}; use zeroize::Zeroize; mod common; -/// A value and the context it belongs under. -#[derive(Clone)] +/// A value and the column it belongs to. Records when its value is wiped. struct Placed { - context: String, + column: String, value: String, + wiped: Arc, } impl Zeroize for Placed { - // The context is not secret. + // The column is not secret. fn zeroize(&mut self) { self.value.zeroize(); + self.wiped.store(true, Ordering::SeqCst); } } -fn placed(context: &str, value: &str) -> Placed { - Placed { - context: context.to_owned(), +fn placed(column: &str, value: &str) -> (Placed, Arc) { + let wiped = Arc::new(AtomicBool::new(false)); + let placed = Placed { + column: column.to_owned(), value: value.to_owned(), - } + wiped: wiped.clone(), + }; + (placed, wiped) } -/// The context `placed` carries, refused if it is empty. -fn context_of>>(placed: &Placed) -> Result { - NonEmpty::new(placed.context.clone()) - .map(C::from) - .map_err(|e| Error::Other(Box::new(e))) +/// The stored form: the column in the clear, the ciphertext and its term. +#[derive(EncryptTarget, DecryptSource)] +#[stash(plaintext = String)] +struct Stored { + #[stash(context_field)] + column: String, + c: StackCipherText, + hm: EqualityTerm, } -/// An equality term, under the context the value carries. -struct PlacedTerm(EqualityTerm); -impl EncryptTarget for PlacedTerm { +/// `Stored`, encrypted from a `Placed` under the column it carries. +struct FromPlaced(Stored); +impl EncryptTarget for FromPlaced { type ContextInput = NoCallerContext; fn encryption<'s, K: 'static>() -> Encryption<'s, Placed, Self, K, NoCallerContext> { - >::encryption() + >::encryption() .project(|placed: &Placed| &placed.value) - .context_from(context_of) - .map(PlacedTerm) + .context_from(|placed: &Placed| &placed.column) + .map(FromPlaced) } } -/// A ciphertext, sealed under the context the value carries. -struct PlacedCiphertext(StackCipherText); -impl EncryptTarget for PlacedCiphertext { - type ContextInput = NoCallerContext; - fn encryption<'s, K: 'static>() -> Encryption<'s, Placed, Self, K, NoCallerContext> { - >::encryption() - .project(|placed: &Placed| &placed.value) - .context_from(context_of) - .map(PlacedCiphertext) - } +fn column(name: &str) -> NonEmpty { + NonEmpty::new(name.to_owned()).unwrap() } #[tokio::test] -async fn a_term_is_derived_under_the_context_the_plaintext_carries() { +async fn the_value_is_stored_under_the_column_it_carries() { let cipher = common::stack_cipher().await; let keyset = cipher.default_keyset(); - let PlacedTerm(term) = placed("users/email", "ada@example.com") - .encrypt_into(&keyset) - .await - .unwrap(); - let direct: EqualityTerm = "ada@example.com" + let (source, wiped) = placed("users/email", "ada@example.com"); + let FromPlaced(stored) = source.encrypt_into(&keyset).await.unwrap(); + assert!(wiped.load(Ordering::SeqCst), "the plaintext is wiped"); + assert_eq!(stored.column, "users/email", "the column is stored"); + + let direct: Stored = "ada@example.com" .to_owned() - .encrypt_into_with_context(&keyset, nonempty!("users/email")) + .encrypt_into_with_context(&keyset, column("users/email")) .await .unwrap(); - assert_eq!(term, direct, "the same term as passing the context"); + assert_eq!(stored.hm, direct.hm, "the same term as passing the column"); - let PlacedTerm(elsewhere) = placed("users/name", "ada@example.com") - .encrypt_into(&keyset) + // It opens from the row alone, and under the column it names. + let opened = String::decrypt_from_with_context(stored, &keyset, column("users/email")) .await .unwrap(); - assert_ne!(term, elsewhere, "another context derives another term"); + assert_eq!(opened, "ada@example.com"); + let opened = String::decrypt_from(direct, &keyset).await.unwrap(); + assert_eq!(opened, "ada@example.com"); } #[tokio::test] -async fn a_ciphertext_is_sealed_under_the_context_the_plaintext_carries() { +async fn another_column_derives_another_term_and_does_not_open_as_the_first() { let cipher = common::stack_cipher().await; let keyset = cipher.default_keyset(); - let seal = || async { - let PlacedCiphertext(sealed) = placed("users/email", "ada@example.com") - .encrypt_into(&keyset) - .await - .unwrap(); - sealed - }; - let opened: String = cipher - .decrypt(seal().await, nonempty!("users/email")) - .await - .unwrap(); - assert_eq!(opened, "ada@example.com"); - assert!(matches!( - cipher - .decrypt::(seal().await, nonempty!("users/name")) - .await, - Err(Error::Aead) - )); + let (email, _) = placed("users/email", "ada@example.com"); + let (name, _) = placed("users/name", "ada@example.com"); + let FromPlaced(email) = email.encrypt_into(&keyset).await.unwrap(); + let FromPlaced(name) = name.encrypt_into(&keyset).await.unwrap(); + assert_ne!(email.hm, name.hm); + + let result = String::decrypt_from_with_context(name, &keyset, column("users/email")).await; + assert!( + matches!(result, Err(Error::ContextMismatch { .. })), + "{:?}", + result.err() + ); } #[tokio::test] -async fn a_context_that_cannot_be_read_fails_before_any_key_is_requested() { +async fn an_empty_column_is_refused_before_any_key_is_requested() { let (cipher, generates, _) = common::counting_cipher().await; let keyset = cipher.default_keyset(); - let result = placed("", "ada@example.com") - .encrypt_into::(&keyset) - .await; + let (source, wiped) = placed("", "ada@example.com"); + let result = source.encrypt_into::(&keyset).await; assert!(matches!(result, Err(Error::Other(_)))); assert_eq!(generates.load(Ordering::SeqCst), 0, "no key was requested"); + assert!( + wiped.load(Ordering::SeqCst), + "the refused plaintext is wiped" + ); } -// Each row carries its own context, and the column still makes one batch. +// Each row carries its own column, and the column of rows is one batch. #[tokio::test] -async fn each_row_of_a_column_is_sealed_under_its_own_context() { - let (cipher, generates, _) = common::counting_cipher().await; +async fn each_row_of_a_column_is_sealed_under_its_own_column() { + let (cipher, sent) = common::recording_cipher().await; let keyset = cipher.default_keyset(); - let column: Vec = vec![ - placed("users/email", "ada@example.com"), - placed("users/name", "Ada"), + let rows: Vec = vec![ + placed("email", "ada@example.com").0, + placed("name", "Ada").0, ] .encrypt_into(&keyset) .await .unwrap(); - assert_eq!(generates.load(Ordering::SeqCst), 1, "one batch"); + let sent = sent.lock().unwrap().clone(); + assert_eq!(sent.generate.len(), 1, "one batch"); + assert_eq!(sent.generated(), ["email", "name"]); - let [PlacedCiphertext(email), PlacedCiphertext(name)] = - <[_; 2]>::try_from(column).ok().unwrap(); - let email: String = cipher - .decrypt(email, nonempty!("users/email")) - .await - .unwrap(); - let name: String = cipher.decrypt(name, nonempty!("users/name")).await.unwrap(); - assert_eq!((email.as_str(), name.as_str()), ("ada@example.com", "Ada")); + let columns: Vec<&str> = rows.iter().map(|FromPlaced(s)| s.column.as_str()).collect(); + assert_eq!(columns, ["email", "name"]); +} + +#[tokio::test] +async fn one_row_with_an_empty_column_refuses_the_whole_column() { + let (cipher, generates, _) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + + let (first, first_wiped) = placed("users/email", "ada@example.com"); + let (second, second_wiped) = placed("", "Ada"); + let result = vec![first, second] + .encrypt_into::, _>(&keyset) + .await; + assert!(matches!(result, Err(Error::Other(_)))); + assert_eq!(generates.load(Ordering::SeqCst), 0, "no key was requested"); + assert!(first_wiped.load(Ordering::SeqCst) && second_wiped.load(Ordering::SeqCst)); } From a9239e08304f9a3c2978f3db43c7493b2cb0ff90 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 9 Nov 2026 21:18:05 +1100 Subject: [PATCH 3/3] docs(stack-encrypt): rewrite the context_from prose in plain language The rustdoc of Encryption::context_from, the ADR-0004 amendment, the CHANGELOG entry and the test docs now follow the plain-language skill (#1528): one idea per sentence, sentences of 25 words or fewer, literal verbs, and terms spelled out where they first appear. No facts were added or removed, and no code changed. Refs #1586 --- .../tests/encryption/tests/migration_shim.rs | 39 ++++----- packages/stack-encrypt/CHANGELOG.md | 26 +++--- ...t-threaded-through-the-declaration-tree.md | 62 +++++++------- .../stack-encrypt/src/target/operations.rs | 84 ++++++++++--------- packages/stack-encrypt/tests/context_from.rs | 21 ++--- 5 files changed, 122 insertions(+), 110 deletions(-) diff --git a/packages/eql/tests/encryption/tests/migration_shim.rs b/packages/eql/tests/encryption/tests/migration_shim.rs index f38c7b54a..ecc013b74 100644 --- a/packages/eql/tests/encryption/tests/migration_shim.rs +++ b/packages/eql/tests/encryption/tests/migration_shim.rs @@ -1,15 +1,15 @@ -//! A migration shim: an EQL v4 type encrypted from a value that carries its +//! A migration shim: an EQL v4 type encrypted from a value that contains its //! own column, with no context argument. //! -//! Moving EQL v3 data to v4 decrypts each v3 value and encrypts it again as -//! v4. The decrypted value arrives with the column it was stored in, `i`, so -//! the shim takes the context from there (`Encryption::context_from`) and the -//! value from `p`. Renaming the column is an edit to `i` before the call. +//! To move EQL v3 data to v4, each v3 value is decrypted, then encrypted again +//! as v4. The decrypted value arrives with the column it was stored in, `i`. +//! The shim takes the context from `i`, through `Encryption::context_from`, +//! and the value from `p`. To rename the column, edit `i` before the call. //! -//! One generic impl covers every v4 type whose context is its column. What -//! it writes must be the v4 type itself, byte for byte where bytes are -//! deterministic: a query built the usual way finds it, and it opens as the -//! original plaintext. +//! One generic impl covers every v4 type whose context is its column. The +//! shim must write the v4 type itself, byte for byte where the bytes are +//! deterministic. A query built the usual way finds the value, and the value +//! opens as the original plaintext. mod common; @@ -33,7 +33,7 @@ impl Zeroize for V3Plaintext

{ } } -/// A v4 type, encrypted from a v3 plaintext under the column it carries. +/// A v4 type, encrypted from a v3 plaintext, under the column in the plaintext. struct Shim(T); impl EncryptTarget> for Shim @@ -126,9 +126,9 @@ async fn the_same_shim_covers_an_ordered_type() { }; let Shim(migrated): Shim = source.encrypt_into(&keyset).await.unwrap(); - // A block ORE term carries a nonce, so it is compared by order: the - // migrated value sits between the queries for 40 and 42, and orders - // equal to the query for 41. + // A block ORE term contains a nonce, so the test compares it by order. + // The migrated value orders after the query for 40, equal to the query + // for 41, and before the query for 42. let compare = |query: &IntegerOrdOreQuery| migrated.ob.partial_cmp(&query.ob); assert_eq!( compare(&age_query(&keyset, 40).await), @@ -159,11 +159,12 @@ async fn a_renamed_column_is_the_column_the_value_is_sealed_under() { let Shim(migrated): Shim = source.encrypt_into(&keyset).await.unwrap(); assert_eq!(migrated.i.c, "contact_email"); - // The new column's query finds it, and the old column's does not. + // The query for the new column finds the value. The query for the old + // column does not. assert_eq!(migrated.hm, email_query(&keyset, "contact_email").await.hm); assert_ne!(migrated.hm, email_query(&keyset, "email").await.hm); - // The ciphertext is sealed under the new column: it opens there. + // The ciphertext is sealed under the new column, so it opens there. let opened = String::decrypt_from_with_context( migrated.clone(), &keyset, @@ -173,8 +174,8 @@ async fn a_renamed_column_is_the_column_the_value_is_sealed_under() { .unwrap(); assert_eq!(opened, "alan@example.com"); - // Asked for the old column, the stored column does not match, so it is - // refused before any key is requested. + // Opened as the old column, the value is refused before any key is + // requested, because the stored column does not match. let result = String::decrypt_from_with_context(migrated.clone(), &keyset, column("customers", "email")) .await; @@ -184,8 +185,8 @@ async fn a_renamed_column_is_the_column_the_value_is_sealed_under() { result.err() ); - // With the stored column changed back to the old one, the ciphertext - // itself refuses to open: it is bound to the new column, not the label. + // Change the stored column back to the old one. The ciphertext still + // fails to open, because it is bound to the new column, not to the label. let mut relabelled = migrated; relabelled.i.c = "email".to_owned(); let result = String::decrypt_from(relabelled, &keyset).await; diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 494b2bb41..acb151898 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -72,18 +72,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **A target can take its context from a field of the plaintext: - `Encryption::context_from`** (#1586). A value that arrives with the - context it is stored under, such as a value decrypted from an older - format together with its column, encrypts with no context argument. A - type that wraps the target names the field once, in its `EncryptTarget` - declaration, and declares `NoCallerContext`. It applies to a target whose - context input is a typed `NonEmpty`, such as a record with a - `context_field` or an EQL type, so the context is also stored in the row - and opening a value never needs its plaintext. The field is picked by - reference, so a context cannot be computed from the sealed value. An - empty context fails the encryption before any key is requested. The - picked field is sent to ZeroKMS and stored in the clear, so it must not - be secret. + `Encryption::context_from`** (#1586). Some values arrive together with the + context they are stored under. For example, a value decrypted from an older + format comes with its column. Such a value now encrypts with no context + argument. + - A type that wraps the target names the field once, in its + `EncryptTarget` declaration. The type declares `NoCallerContext`. + - The target must store its context in the row, so its context input must + be a typed `NonEmpty`. Records with a `context_field`, and EQL types, + qualify. Opening a value never needs its plaintext. + - The field is picked by reference, so a context cannot be computed from + the sealed value. + - An empty context fails the encryption. No key is requested. + - The picked field is sent to ZeroKMS and stored in the clear. The field + must not be secret. - **Optional values through the owned API: `Option`** (#1585). An `Option` encrypts into an `Option` for any owned target `T`, and decrypts back. diff --git a/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md b/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md index 77a8c1bab..f87632adc 100644 --- a/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md +++ b/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md @@ -360,33 +360,35 @@ and never replaces it. ## Amended 2026-11-09 (#1586) -Decision 2 gains a third way to set a subtree's context: -`Encryption::context_from`. It records the case `under` and `extend` do not -cover: a value that arrives carrying the context it is stored under, such as a -value decrypted from EQL v3 together with its column. Re-encrypting it as EQL -v4 should read that column, not ask the caller to repeat it. - -`context_from(pick)` reads the context from a field of the plaintext and -discharges the requirement into `NoCallerContext`, so the caller supplies -none. Three limits keep decision 2's reasons intact: - -- **It applies only to a target whose context input is a typed context, - `NonEmpty`.** A record that stores its context in a `context_field` - declares that, and so does an EQL type, which stores its column in `i`. So - the context comes from the plaintext on the way in and from the row on the - way out. Opening a value never needs its plaintext. -- **`pick` returns a reference to a field the plaintext holds.** It cannot - compute a context from the plaintext. Where the context type differs from - the sealed value's type, the sealed field cannot be picked at all. -- **The field is named in the declaration.** It is a plain function pointer, - visible at review, like `project`'s selector. It is not a value supplied at - the call that could reach one side and not another. - -It replaces the surrounding context rather than extending it. A target that -takes its context this way is not a field of a record that declares contexts, -since its context input is `NoCallerContext`. - -As with two `under` literals, two halves of one target zipped after separate -`context_from` calls could each take a different field. That is the -divergence decision 1 already accepts for declared per-side contexts: each -field is named in the declaration. +Decision 2 gains a third way to set the context of a subtree: +`Encryption::context_from`. `under` and `extend` do not cover one case: a +value that arrives together with the context it is stored under. For +example, a value decrypted from EQL v3 comes with its column. Re-encrypting +it as EQL v4 should read that column, not ask the caller to supply it again. + +`context_from(pick)` reads the context from a field of the plaintext. +`context_from` discharges the requirement into `NoCallerContext`, so the +caller supplies no context. Three limits keep the reasons for decision 2: + +- **The target must store its context.** The context input of the target + must be a typed context, `NonEmpty`. A record that stores its context in + a `context_field` declares such an input. An EQL type declares one too, and + stores its column in `i`. So the context comes from the plaintext on the + way in, and from the row on the way out. Opening a value never needs its + plaintext. +- **`pick` returns a reference to a field of the plaintext.** So `pick` + cannot compute a context from the plaintext. When the context type differs + from the type of the sealed value, the sealed field cannot be picked at + all. +- **The declaration names the field.** `pick` is a plain function pointer, + visible at review, like the selector of `project`. A value supplied at the + call could reach one side of a target and not the other. A named field + cannot. + +`context_from` replaces the surrounding context, and does not extend it. A +target that takes its context this way cannot be a field of a record that +declares contexts, because its context input is `NoCallerContext`. + +Two halves of one target, zipped after two `context_from` calls, could each +take a different field. Decision 1 already accepts that difference for +contexts declared per side, because the declaration names each field. diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index 17c3ba0f5..504f8ee0f 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -8,8 +8,9 @@ //! of [`Encryption`] (ADR-0004): a target cannot route the context it is //! handed to one operation and something else to another, because there is //! no argument to route. What a subtree may do is take a context of its own, -//! by name, with [`Encryption::under`] or [`Encryption::extend`], or read it -//! from a named field of the plaintext with [`Encryption::context_from`]. +//! by name, with [`Encryption::under`] or [`Encryption::extend`]. A subtree +//! can also read its context from a named field of the plaintext, with +//! [`Encryption::context_from`]. use super::context::{AeadContext, CallerContext, DeclaredContext, Extends}; use super::conversion::{DecryptSource, EncryptTarget}; use super::core::{encrypt_native, open_native, Term}; @@ -115,17 +116,17 @@ type Open = Box FnOnce(&'a StackCipher) -> Pending<'a, T, K /// other. A ciphertext beside a term takes the term's context through /// [`accepting`](Self::accepting): the [`AeadContext`] it seals under is /// the AEAD half of that one value. A side given a context of its own, -/// with [`under`](Self::under), [`extend`](Self::extend) or +/// with [`under`](Self::under), [`extend`](Self::extend), or /// [`context_from`](Self::context_from), says so in the declaration; that /// is how a record names its fields' contexts, and the tree does not tell a /// record's fields from a target's halves. /// - **A leaf still cannot be reached without a context.** An operation needs /// a real one. [`under`](Self::under) and [`extend`](Self::extend) change -/// the context a subtree runs under, and `under` discharges the -/// requirement into a [`DeclaredContext`], which is what `()` may satisfy. -/// [`context_from`](Self::context_from) discharges it too, into -/// [`NoCallerContext`], with the context read from a named field of the -/// plaintext. +/// the context that a subtree runs under. `under` discharges the +/// requirement into a [`DeclaredContext`], and `()` may satisfy that. +/// [`context_from`](Self::context_from) also discharges the requirement, +/// into [`NoCallerContext`], with the context read from a named field of +/// the plaintext. /// /// `M` is how the description is handed its plaintext ([`SourceMode`]): /// by reference in the default [`Borrowed`] mode, which every @@ -353,38 +354,42 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { build: Box::new(move |source, cipher, cx| (self.build)(select(source), cipher, cx)), } } - /// Take the context from a field of the plaintext, for a target that - /// stores its context in the row. The caller then supplies none. + /// Takes the context from a field of the plaintext. The caller then + /// supplies no context. /// - /// This is for a value that arrives with the context it is stored under, - /// such as a value decrypted from an older format together with the - /// column it was stored in. A type that wraps the target declares, once, - /// which field holds the context, and its callers encrypt with no context + /// Use it for a value that arrives together with its context. For example, + /// a value decrypted from an older format comes with the column it was + /// stored in. A type that wraps the target names the field that contains + /// the context, once. Callers of that type then encrypt without a context /// argument. /// - /// The target's context input must be a typed context, `NonEmpty`. A - /// record that stores its context in a `#[stash(context_field)]` declares - /// that, and so does an EQL type, whose context is the column it stores - /// in `i`. So the context is read from the plaintext on the way in and - /// from the row on the way out, and opening a value never needs its - /// plaintext. + /// The target must store its context in the row. In the types, its context + /// input must be a typed context, `NonEmpty`. A record with a + /// `#[stash(context_field)]` has such an input. An EQL type has one too, + /// and stores its column in `i`. So the context is read from the plaintext + /// on the way in, and from the row on the way out. /// - /// `pick` names the field. It returns a reference into the plaintext, so - /// it can only hand over a value the plaintext already holds, not one - /// computed from it. The context is checked to be non-empty; if it is - /// empty, the description fails and no key is requested. `pick` is a plain - /// function pointer, as the selector of [`project`](Self::project) is, - /// so it cannot reach a cipher. + /// Opening a value never needs its plaintext. + /// + /// `pick` returns a reference to a field of the plaintext. So `pick` can + /// only pass on a value that the plaintext contains. It cannot compute a + /// context from the plaintext. `pick` is a plain function pointer, like the + /// selector of [`project`](Self::project), so it cannot reach a cipher. + /// + /// The context must not be empty. If it is empty, the description fails. + /// No key is requested. /// /// # Security /// - /// The picked field is not secret. It is sent to ZeroKMS as the data - /// key's descriptor, and a target that stores its context writes it in - /// the clear. Pick a field that names where the value belongs, such as - /// its column. Never pick a field that is also sealed: its plaintext - /// would leave the ciphertext. Where the context type differs from the - /// sealed value's type, as `Identifier` does from `String`, the type - /// checker refuses that mistake. + /// The picked field is not secret. ZeroKMS receives it as the descriptor + /// of the data key. A target that stores its context also writes it in + /// the clear. + /// + /// Pick a field that says where the value belongs, such as its column. + /// Never pick a field that is also sealed: its plaintext would then be + /// stored outside the ciphertext. The compiler refuses that mistake when + /// the context type differs from the type of the sealed value, as + /// `Identifier` differs from `String`. /// /// ``` /// use stack_encrypt::sem::EqualityTerm; @@ -405,7 +410,7 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { /// } /// } /// - /// /// The stored form: the column in the clear, and the value's term. + /// /// The stored form: the column in the clear, and the term of the value. /// #[derive(EncryptTarget)] /// #[stash(plaintext = String)] /// struct Stored { @@ -414,7 +419,7 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { /// hm: EqualityTerm, /// } /// - /// /// `Stored`, encrypted from a `Placed` under the column it carries. + /// /// `Stored`, encrypted from a `Placed`, under the column in the `Placed`. /// struct FromPlaced(Stored); /// impl EncryptTarget for FromPlaced { /// type ContextInput = NoCallerContext; @@ -430,7 +435,7 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { /// let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; /// let keyset = cipher.default_keyset(); /// let placed = Placed { column: "users/email".into(), value: "ada@example.com".into() }; - /// // No context argument: it comes from `placed.column`. + /// // No context argument: the context comes from `placed.column`. /// let FromPlaced(stored) = placed.encrypt_into(&keyset).await?; /// assert_eq!(stored.column, "users/email"); /// @@ -444,9 +449,10 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { /// # }).unwrap(); /// ``` /// - /// It is for the [`Borrowed`] mode, which every `EncryptTarget::encryption` - /// runs in. The owned form of such a target wraps the plaintext and runs - /// the borrowed one, so it needs nothing more. + /// `context_from` works in the [`Borrowed`] mode. Every + /// `EncryptTarget::encryption` runs in that mode. The owned form of such a + /// target wraps the plaintext and runs the borrowed form, so the owned + /// form needs nothing more. pub fn context_from( self, pick: for<'borrow> fn(&'borrow S) -> &'borrow C, diff --git a/packages/stack-encrypt/tests/context_from.rs b/packages/stack-encrypt/tests/context_from.rs index abdf6f3ef..3afaf3379 100644 --- a/packages/stack-encrypt/tests/context_from.rs +++ b/packages/stack-encrypt/tests/context_from.rs @@ -1,9 +1,10 @@ -//! A target that takes its context from a field of the plaintext -//! (`Encryption::context_from`). The caller supplies no context: the -//! plaintext carries the one it is stored under, such as a value decrypted -//! from an older format together with its column, and the target stores it -//! in the row, so opening the value never needs its plaintext. No service -//! credentials are needed. +//! Tests for a target that takes its context from a field of the plaintext, +//! through `Encryption::context_from`. +//! +//! The caller supplies no context. The plaintext contains the context it is +//! stored under, such as the column of a value decrypted from an older +//! format. The target stores the context in the row, so opening the value +//! never needs its plaintext. These tests need no service credentials. use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -36,7 +37,7 @@ fn placed(column: &str, value: &str) -> (Placed, Arc) { (placed, wiped) } -/// The stored form: the column in the clear, the ciphertext and its term. +/// The stored form: the column in the clear, the ciphertext, and its term. #[derive(EncryptTarget, DecryptSource)] #[stash(plaintext = String)] struct Stored { @@ -46,7 +47,7 @@ struct Stored { hm: EqualityTerm, } -/// `Stored`, encrypted from a `Placed` under the column it carries. +/// `Stored`, encrypted from a `Placed`, under the column in the `Placed`. struct FromPlaced(Stored); impl EncryptTarget for FromPlaced { type ContextInput = NoCallerContext; @@ -79,7 +80,7 @@ async fn the_value_is_stored_under_the_column_it_carries() { .unwrap(); assert_eq!(stored.hm, direct.hm, "the same term as passing the column"); - // It opens from the row alone, and under the column it names. + // The value opens from the row alone, and under the column the row names. let opened = String::decrypt_from_with_context(stored, &keyset, column("users/email")) .await .unwrap(); @@ -122,7 +123,7 @@ async fn an_empty_column_is_refused_before_any_key_is_requested() { ); } -// Each row carries its own column, and the column of rows is one batch. +// Each row has its own column. The rows are encrypted in one batch. #[tokio::test] async fn each_row_of_a_column_is_sealed_under_its_own_column() { let (cipher, sent) = common::recording_cipher().await;