From 393e8e03909be911b12cebf3854d4ea0ef68cec4 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 4 Oct 2026 16:28:25 -0700 Subject: [PATCH 1/2] fix(stack-encrypt)!: let a non-Clone plaintext run a target operation Vitamin C's Encrypt, PrfValue and CllwOreEncrypt consume the value they are given, and every target description was handed its plaintext by reference. So each operation cloned the plaintext, and ciphertext(), equality(), ore(), ope() and their EncryptFrom impls all required S: Clone. A plaintext that is deliberately not Clone, so its one copy is moved and wiped (the zeroizing FfiValue the WASI guest decodes), could not enter the target layer at all, which blocks the plan builder (#1056). An Encryption now carries a mode, M, saying how it is handed the plaintext. Borrowed (the default) hands it &S, as before: an operation that consumes clones once, so EncryptFrom declarations, records and the derive are unchanged. Owned hands it S, through the new KeysetCipher::run: a single operation consumes it with no copy and needs no Clone. zip is the one place an owned plaintext needs Clone; the first side gets a clone and the last takes the value, so n operations make n - 1 copies instead of n. A match term only reads its text and never copies in either mode. encrypt_native and the Term impls now take the plaintext by value, so the clone happens once, where a borrow becomes a value. Refs #1056 BREAKING CHANGE: Encryption gains a last type parameter, M: SourceMode (default Borrowed), and its struct declares S: 's. The five constructors ciphertext, equality, matching, ore and ope gain a source-mode type parameter M, so a turbofish that named their generics must name it too (in matching it comes before O), and a call whose result type does not fix the mode must name one. zip now asks M: ShareSource, which Borrowed always meets. Code that names Encryption<'s, S, T, K, Ctx> without a mode keeps the borrowed behaviour. --- packages/stack-encrypt/CHANGELOG.md | 24 ++ packages/stack-encrypt/src/sem/mod.rs | 20 +- packages/stack-encrypt/src/target/core.rs | 16 +- packages/stack-encrypt/src/target/mod.rs | 9 + .../stack-encrypt/src/target/operations.rs | 190 ++++++++-- packages/stack-encrypt/src/target/source.rs | 102 ++++++ packages/stack-encrypt/tests/source_mode.rs | 326 ++++++++++++++++++ .../ui/divergent_context_in_target.stderr | 6 +- .../ui/nested_leaf_without_context.stderr | 6 +- .../tests/ui/owned_fan_out_needs_clone.rs | 19 + .../tests/ui/owned_fan_out_needs_clone.stderr | 19 + .../tests/ui/pass/owned_without_clone.rs | 29 ++ 12 files changed, 712 insertions(+), 54 deletions(-) create mode 100644 packages/stack-encrypt/src/target/source.rs create mode 100644 packages/stack-encrypt/tests/source_mode.rs create mode 100644 packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.rs create mode 100644 packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.stderr create mode 100644 packages/stack-encrypt/tests/ui/pass/owned_without_clone.rs diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 0ad2ac34d..59e404604 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Breaking + +- **A target description carries a source mode.** `Encryption` gains a + last type parameter, `M: SourceMode = Borrowed`, saying how it is handed + its plaintext. Code that names `Encryption<'s, S, T, K, Ctx>` still + compiles and means the borrowed mode it always ran in. +- `ciphertext`, `equality`, `matching`, `ore` and `ope` gain a source-mode + type parameter, `M`. A turbofish must name it: `ciphertext::()`, + and in `matching` it comes before `O` (`matching::()`). A call + whose result type does not fix the mode must name it; `Borrowed` is the + old behaviour. In return `ciphertext`, `equality`, `ore` and `ope` no + longer ask `S: Clone` themselves: only borrowed mode does. + +### Added + +- `KeysetCipher::run`: run a description held in a variable over a value, + under a context, without an `EncryptFrom` declaration. +- `target::{SourceMode, ConsumeSource, ShareSource, Borrowed, Owned}`. In + `Owned` mode a description is handed the plaintext by value, so a single + operation consumes it with no copy and a plaintext that is not `Clone` + (a zeroizing FFI value) can be sealed or indexed. The traits are sealed. + ## [0.2.0] - 2026-10-04 ### Breaking diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index f8044b222..9e00a8d74 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -341,13 +341,13 @@ where /// carries no requests. impl<'c, S, K, T> Term> for EqualityTerm where - S: PrfValue + Clone, + S: PrfValue, T: IntoPrfContext<'c>, { // Derived locally: no data key, no descriptor. fn encrypt_from<'a>( - source: &S, + source: S, cipher: &'a KeysetCipher<'_, K>, context: NonEmpty, ) -> Pending<'a, Self, K> @@ -355,7 +355,7 @@ where Self: 'a, { let context = context.into_prf_context().into_owned(); - let term = equality(cipher.prf(), source.clone(), context).map_err(Error::from); + let term = equality(cipher.prf(), source, context).map_err(Error::from); Pending::ready(cipher, term) } } @@ -660,7 +660,7 @@ where // Derived locally: no data key, no descriptor. fn encrypt_from<'a>( - source: &S, + source: S, cipher: &'a KeysetCipher<'_, K>, context: NonEmpty, ) -> Pending<'a, Self, K> @@ -942,14 +942,14 @@ where /// carries no requests. impl<'c, S, K, T> Term> for OreTerm where - S: CllwOreEncrypt + Clone + Send + 'static, + S: CllwOreEncrypt + Send + 'static, S::Output: Send + 'static, T: IntoPrfContext<'c>, { // Derived locally: no data key, no descriptor. fn encrypt_from<'a>( - source: &S, + source: S, cipher: &'a KeysetCipher<'_, K>, context: NonEmpty, ) -> Pending<'a, Self, K> @@ -957,7 +957,7 @@ where Self: 'a, { let context = context.into_prf_context().into_owned(); - let term = ore(cipher.prf(), source.clone(), context).map_err(Error::from); + let term = ore(cipher.prf(), source, context).map_err(Error::from); Pending::ready(cipher, term) } } @@ -967,14 +967,14 @@ where /// carries no requests. impl<'c, S, K, T> Term> for OpeTerm where - S: CllwOpeEncrypt + Clone + Send + 'static, + S: CllwOpeEncrypt + Send + 'static, S::Output: Send + 'static, T: IntoPrfContext<'c>, { // Derived locally: no data key, no descriptor. fn encrypt_from<'a>( - source: &S, + source: S, cipher: &'a KeysetCipher<'_, K>, context: NonEmpty, ) -> Pending<'a, Self, K> @@ -982,7 +982,7 @@ where Self: 'a, { let context = context.into_prf_context().into_owned(); - let term = ope(cipher.prf(), source.clone(), context).map_err(Error::from); + let term = ope(cipher.prf(), source, context).map_err(Error::from); Pending::ready(cipher, term) } } diff --git a/packages/stack-encrypt/src/target/core.rs b/packages/stack-encrypt/src/target/core.rs index 5817a8a55..7b76831bd 100644 --- a/packages/stack-encrypt/src/target/core.rs +++ b/packages/stack-encrypt/src/target/core.rs @@ -6,9 +6,13 @@ use vitaminc_aead::{CipherText, Decrypt, Encrypt, IntoAad, IntoContext}; use vitaminc_protected::NonEmpty; /// Internal term operation. It is deliberately inaccessible to target authors. +/// +/// `S` is what the operation is handed, by value: the PRF and ORE schemes +/// consume their input, so a term that needs the plaintext takes it, and one +/// that only reads it (a match term) is handed a reference as its `S`. pub(crate) trait Term: Sized { fn encrypt_from<'a>( - source: &S, + source: S, cipher: &'a KeysetCipher<'_, K>, context: Ctx, ) -> Pending<'a, Self, K> @@ -16,8 +20,12 @@ pub(crate) trait Term: Sized { Self: 'a; } -pub(crate) fn encrypt_native<'a, 'c, S: Encrypt + Clone, K, T: IntoContext<'c>>( - source: &S, +/// Seal `source` into the native tree. It is consumed, as Vitamin C's +/// `Encrypt` consumes it: a caller holding only a borrow clones before it +/// gets here (see [`ConsumeSource`](super::ConsumeSource)), and one holding +/// the value hands it over without a copy. +pub(crate) fn encrypt_native<'a, 'c, S: Encrypt, K, T: IntoContext<'c>>( + source: S, cipher: &'a KeysetCipher<'_, K>, context: NonEmpty, ) -> Pending<'a, StackCipherText, K> { @@ -27,7 +35,7 @@ pub(crate) fn encrypt_native<'a, 'c, S: Encrypt + Clone, K, T: IntoContext<'c>>( return Pending::failed(cipher, error); } let aad = context.into_aad().into_owned(); - match source.clone().encrypt_with_aad(cipher, aad) { + match source.encrypt_with_aad(cipher, aad) { Ok(tree) => seal_pending(cipher, tree, descriptor), Err(_) => Pending::ready(cipher, Err(Error::Aead)), } diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 29b8b9736..038b92c79 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -60,6 +60,13 @@ //! New cryptographic operations belong in core; output adapters cannot install an //! execution callback. The separate cipher-directed API remains public. //! +//! Those schemes consume the plaintext. A description is handed it by +//! reference by default, and an operation clones it before consuming it, so a +//! declaration's plaintext is `Clone`. A plaintext that must not be copied +//! runs in [`Owned`] mode through [`KeysetCipher::run`](crate::KeysetCipher::run): +//! a single operation takes the value with no copy, and only +//! [`Encryption::zip`] asks for `Clone`. See [`SourceMode`]. +//! //! # Collections and authentication //! //! `Vec` describes independently encrypted rows under the same context. @@ -83,6 +90,7 @@ pub(crate) mod core; mod operations; mod pending; mod request; +mod source; pub mod transcode; pub(crate) use self::core::{decipher_pending, seal_pending}; @@ -93,4 +101,5 @@ pub use operations::{ }; pub use pending::{CipherScope, Pending, PendingFuture}; pub use request::{Request, Responses}; +pub use source::{Borrowed, ConsumeSource, Owned, ShareSource, SourceMode}; pub use stack_encrypt_derive::{DecryptInto, EncryptFrom}; diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index ff98af2b3..c4113a281 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -11,6 +11,7 @@ //! by name, with [`Encryption::under`] or [`Encryption::extend`]. use super::context::{AeadContext, CallerContext, DeclaredContext, Extends}; use super::core::{encrypt_native, open_native, Term}; +use super::source::{Borrowed, ConsumeSource, ShareSource, SourceMode}; use super::{CipherScope, Pending}; use crate::{Error, IntoContext, KeysetCipher, NonEmpty, StackCipher, StackCipherText}; use stack_kms::MaybeSend; @@ -53,12 +54,27 @@ pub trait DecryptInto

: Sized { fn decryption(self, context: Self::Context) -> Decryption; } +// What the description is handed is `M::Source`: `&'s S` in the default +// `Borrowed` mode, `S` itself in `Owned` mode (see `SourceMode`). #[cfg(not(target_arch = "wasm32"))] -type Build<'s, S, T, K, Ctx> = - Box FnOnce(&S, &'a KeysetCipher<'k, K>, Ctx) -> Pending<'a, T, K> + Send + 's>; +type Build<'s, S, T, K, Ctx, M> = Box< + dyn for<'a, 'k> FnOnce( + >::Source, + &'a KeysetCipher<'k, K>, + Ctx, + ) -> Pending<'a, T, K> + + Send + + 's, +>; #[cfg(target_arch = "wasm32")] -type Build<'s, S, T, K, Ctx> = - Box FnOnce(&S, &'a KeysetCipher<'k, K>, Ctx) -> Pending<'a, T, K> + 's>; +type Build<'s, S, T, K, Ctx, M> = Box< + dyn for<'a, 'k> FnOnce( + >::Source, + &'a KeysetCipher<'k, K>, + Ctx, + ) -> Pending<'a, T, K> + + 's, +>; #[cfg(not(target_arch = "wasm32"))] type Open = Box FnOnce(&'a StackCipher) -> Pending<'a, T, K> + Send>; #[cfg(target_arch = "wasm32")] @@ -89,9 +105,16 @@ type Open = Box FnOnce(&'a StackCipher) -> Pending<'a, T, K /// only ways to change the context a subtree runs under, and only `under` /// discharges the requirement into a [`DeclaredContext`], which is what /// `()` may satisfy. +/// +/// `M` is how the description is handed its plaintext ([`SourceMode`]): +/// by reference in the default [`Borrowed`] mode, which every +/// [`EncryptFrom`] declaration runs in, or by value in +/// [`Owned`](super::Owned) mode, run with [`KeysetCipher::run`]. In owned mode +/// a single operation consumes the plaintext without copying it, so `S` need +/// not be `Clone`; [`zip`](Self::zip) is the one place that asks for it. #[must_use = "an encryption description does nothing until a keyset cipher executes it"] -pub struct Encryption<'s, S, T, K, Ctx> { - build: Build<'s, S, T, K, Ctx>, +pub struct Encryption<'s, S: 's, T, K, Ctx, M: SourceMode<'s, S> = Borrowed> { + build: Build<'s, S, T, K, Ctx, M>, } /// A composable description of how `T` is recovered from a stored target. /// @@ -109,7 +132,7 @@ enum Opening { Failed(Error), Open(Open), } -impl fmt::Debug for Encryption<'_, S, T, K, Ctx> { +impl<'s, S: 's, T, K, Ctx, M: SourceMode<'s, S>> fmt::Debug for Encryption<'s, S, T, K, Ctx, M> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Encryption").finish_non_exhaustive() } @@ -124,7 +147,9 @@ impl fmt::Debug for Decryption { } } -impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { +impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's, M: SourceMode<'s, S>> + Encryption<'s, S, T, K, Ctx, M> +{ /// A description whose output is already known — metadata a record /// carries, or a declaration rejected before any key request. pub fn ready(result: Result) -> Self @@ -144,7 +169,7 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { } /// Build the destination from the completed output. `f` sees ciphertext /// and terms, never the plaintext. - pub fn map(self, f: F) -> Encryption<'s, S, U, K, Ctx> + pub fn map(self, f: F) -> Encryption<'s, S, U, K, Ctx, M> where F: FnOnce(T) -> U + MaybeSend + 'static, { @@ -154,7 +179,7 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { } /// [`map`](Self::map) for a conversion that can fail, such as reading /// native output into a destination that does not accept every shape. - pub fn try_map(self, f: F) -> Encryption<'s, S, U, K, Ctx> + pub fn try_map(self, f: F) -> Encryption<'s, S, U, K, Ctx, M> where F: FnOnce(T) -> Result + MaybeSend + 'static, { @@ -165,7 +190,9 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { /// Drive the destination's [`Visitor`](super::transcode::Visitor) from /// this operation's native output, moving leaves and markers across /// without an intermediate tree. - pub fn transcode(self) -> Encryption<'s, S, U, K, Ctx> + pub fn transcode( + self, + ) -> Encryption<'s, S, U, K, Ctx, M> where T: super::transcode::Reader, { @@ -180,16 +207,24 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { /// [`extend`](Self::extend) before it got here — that is how a record /// composes fields with different contexts — and `zip` cannot tell that /// from a target's two halves (ADR-0004, decision 1). + /// + /// Both sides also receive the same plaintext. In the default + /// [`Borrowed`] mode that is the same reference, and costs nothing. In + /// [`Owned`](super::Owned) mode this side gets a clone and `other` takes + /// ownership, so a chain of `n` operations makes `n - 1` copies rather + /// than `n`, and only here does an owned plaintext need to be `Clone`. pub fn zip( self, - other: Encryption<'s, S, U, K, Ctx>, - ) -> Encryption<'s, S, (T, U), K, Ctx> + other: Encryption<'s, S, U, K, Ctx, M>, + ) -> Encryption<'s, S, (T, U), K, Ctx, M> where Ctx: Clone, + M: ShareSource<'s, S>, { Encryption { build: Box::new(move |source, cipher, cx| { - (self.build)(source, cipher, cx.clone()).zip((other.build)(source, cipher, cx)) + let (mine, theirs) = M::share(source); + (self.build)(mine, cipher, cx.clone()).zip((other.build)(theirs, cipher, cx)) }), } } @@ -203,7 +238,7 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { /// record storing its own context declares `NonEmpty` while its /// operations want a `CallerContext` — and this adapts the one to the /// other once, at the root. - pub fn accepting(self) -> Encryption<'s, S, T, K, C2> + pub fn accepting(self) -> Encryption<'s, S, T, K, C2, M> where C2: Into + 's, { @@ -212,7 +247,7 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { /// Need a different context, derived from the one supplied by `derive` /// at the root of this subtree. The one place a context changes on its /// way down; every public way of doing so is a closure handed here. - fn needing(self, derive: F) -> Encryption<'s, S, T, K, C2> + fn needing(self, derive: F) -> Encryption<'s, S, T, K, C2, M> where C2: 's, F: FnOnce(C2) -> Ctx + MaybeSend + 's, @@ -233,7 +268,7 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { pub fn under( self, own: NonEmpty + MaybeSend + 's>, - ) -> Encryption<'s, S, T, K, DeclaredContext> + ) -> Encryption<'s, S, T, K, DeclaredContext, M> where Ctx: From, { @@ -251,13 +286,16 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { pub fn extend( self, own: NonEmpty + MaybeSend + 's>, - ) -> Encryption<'s, S, T, K, C> + ) -> Encryption<'s, S, T, K, C, M> where C: Extends + 's, Ctx: From, { self.needing(move |cx: C| cx.extend(own).into()) } +} + +impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { /// Lift a description of a field to a description of the struct that /// holds it, which is how a `struct = T` derive composes its fields. /// @@ -265,6 +303,10 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { /// nothing, so it cannot reach a cipher, and what it returns is encrypted /// under `S`'s own Vitamin C contract. It is a place to pick a field, not /// to re-encode one. + /// + /// A field is picked out of a borrowed struct, so this is a + /// [`Borrowed`]-mode combinator: a field whose operations consume it is + /// cloned, as it always was, and the struct itself never is. pub fn project( self, select: for<'borrow> fn(&'borrow P) -> &'borrow S, @@ -275,8 +317,10 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { } } -impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's + Clone + MaybeSend + 'static> - Encryption<'s, S, T, K, Ctx> +impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's + Clone + MaybeSend + 'static, M> + Encryption<'s, S, T, K, Ctx, M> +where + M: SourceMode<'s, S>, { /// Build the output from the completed operations *and* the context they /// ran under. @@ -284,7 +328,7 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's + Clone + MaybeSend + 'static> /// For a record that stores its own context in a field /// (`#[stash(context_field)]`): the context is supplied when the /// description runs, so the field it populates is filled there too. - pub fn map_with_context(self, f: F) -> Encryption<'s, S, U, K, Ctx> + pub fn map_with_context(self, f: F) -> Encryption<'s, S, U, K, Ctx, M> where F: FnOnce(T, Ctx) -> U + MaybeSend + 'static, { @@ -306,12 +350,16 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's + Clone + MaybeSend + 'static> /// `AeadContext` where a term needs a [`CallerContext`]. Beside a term, /// [`accepting`](Encryption::accepting) lets it take the term's context — /// the AEAD half of the same value — so the two zip under one context. -pub fn ciphertext<'s, S: crate::Encrypt + Clone + 's, K: 'static>( -) -> Encryption<'s, S, StackCipherText, K, AeadContext> { +/// +/// `Encrypt` consumes the plaintext. In [`Owned`](super::Owned) mode it is +/// handed over, so `S` need not be `Clone`; in the default [`Borrowed`] mode +/// it is cloned once, which is what `M: ConsumeSource<'s, S>` asks. +pub fn ciphertext<'s, S: crate::Encrypt + 's, K: 'static, M: ConsumeSource<'s, S>>( +) -> Encryption<'s, S, StackCipherText, K, AeadContext, M> { Encryption { build: Box::new( move |source, cipher, cx: AeadContext| match cx.validated() { - Ok(ctx) => encrypt_native(source, cipher, ctx), + Ok(ctx) => encrypt_native(M::take(source), cipher, ctx), Err(e) => Pending::failed(cipher, e), }, ), @@ -319,21 +367,45 @@ pub fn ciphertext<'s, S: crate::Encrypt + Clone + 's, K: 'static>( } /// A term operation: `$function` produces `$output` from any `S` satisfying /// the bounds, under the [`CallerContext`] the tree hands it. +/// +/// `consume` terms (equality, ORE, OPE) take the plaintext by value, as their +/// scheme does, so they ask the same `M: ConsumeSource<'s, S>` as +/// [`ciphertext`]. A `view` term (match) only reads it, so it works in any +/// mode and never clones. macro_rules! term_operation { ( $(#[$doc:meta])* - $function:ident, $output:ty, [$($generics:tt)*], [$($bounds:tt)*] + $function:ident, $output:ty, consume, [$($generics:tt)*], [$($bounds:tt)*] + ) => { + $(#[$doc])* + pub fn $function<'s, S, K: 'static, M: ConsumeSource<'s, S>, $($generics)*>( + ) -> Encryption<'s, S, $output, K, CallerContext, M> + where + S: 's, + $($bounds)* + { + Encryption { + build: Box::new(move |source, cipher, cx: CallerContext| match cx.validated() { + Ok(ctx) => <$output as Term>::encrypt_from(M::take(source), cipher, ctx), + Err(e) => Pending::failed(cipher, e), + }), + } + } + }; + ( + $(#[$doc:meta])* + $function:ident, $output:ty, view, [$($generics:tt)*], [$($bounds:tt)*] ) => { $(#[$doc])* - pub fn $function<'s, S, K: 'static, $($generics)*>( - ) -> Encryption<'s, S, $output, K, CallerContext> + pub fn $function<'s, S, K: 'static, M: SourceMode<'s, S>, $($generics)*>( + ) -> Encryption<'s, S, $output, K, CallerContext, M> where S: 's, $($bounds)* { Encryption { build: Box::new(move |source, cipher, cx: CallerContext| match cx.validated() { - Ok(ctx) => <$output as Term>::encrypt_from(source, cipher, ctx), + Ok(ctx) => <$output as Term<&S, K, _>>::encrypt_from(M::view(&source), cipher, ctx), Err(e) => Pending::failed(cipher, e), }), } @@ -343,25 +415,25 @@ macro_rules! term_operation { term_operation!( /// The equality term of `S` under the context the tree hands it. Requires /// only `S`'s PRF contract, not recoverable encryption. - equality, crate::sem::EqualityTerm, [], [S: vitaminc_prf::PrfValue + Clone] + equality, crate::sem::EqualityTerm, consume, [], [S: vitaminc_prf::PrfValue] ); term_operation!( /// The match term of any text `S` under the context the tree hands it, /// tokenised and hashed as `O` declares. - matching, crate::sem::MatchTerm, [O: crate::sem::MatchConfig + 'static], [S: AsRef] + matching, crate::sem::MatchTerm, view, [O: crate::sem::MatchConfig + 'static], [S: AsRef] ); term_operation!( /// The order-revealing term of `S` under the context the tree hands it. /// The bounds are the leaf's own: they say which `S` the CLLW ORE scheme /// can order. - ore, crate::sem::OreTerm, [], - [S: cllw_ore::CllwOreEncrypt + Clone + Send + 'static, S::Output: Send + 'static] + ore, crate::sem::OreTerm, consume, [], + [S: cllw_ore::CllwOreEncrypt + Send + 'static, S::Output: Send + 'static] ); term_operation!( /// The order-preserving term of `S` under the context the tree hands it, /// with the same bounds as [`ore`]. - ope, crate::sem::OpeTerm, [], - [S: cllw_ore::CllwOpeEncrypt + Clone + Send + 'static, S::Output: Send + 'static] + ope, crate::sem::OpeTerm, consume, [], + [S: cllw_ore::CllwOpeEncrypt + Send + 'static, S::Output: Send + 'static] ); impl Decryption { @@ -475,6 +547,50 @@ impl KeysetCipher<'_, K> { { (T::encryption().build)(source, self, context) } + /// Run a description held in a variable over `source`, under `context`. + /// + /// `source` is what the description's mode hands its operations: `&S` + /// for a [`Borrowed`]-mode description (the default, and what + /// [`encrypt_as`](Self::encrypt_as) runs for a type), or `S` itself for + /// an [`Owned`](super::Owned)-mode one. Owned mode is how a plaintext + /// that is not `Clone` reaches an operation: a single operation consumes + /// it without a copy. + /// + /// ``` + /// # async fn example() -> Result<(), stack_encrypt::Error> { + /// use stack_encrypt::kms::FakeDataKeySource; + /// use stack_encrypt::target::{self, AeadContext, Owned}; + /// use stack_encrypt::{nonempty, StackCipher, StackCipherText}; + /// use vitaminc_protected::Protected; + /// + /// let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; + /// let keyset = cipher.default_keyset(); + /// // `Protected` is deliberately not `Clone`: it is moved in, and + /// // the one copy is wiped once it is sealed. + /// let card = Protected::new(String::from("4111 1111 1111 1111")); + /// let sealed: StackCipherText = keyset + /// .run( + /// target::ciphertext::<_, _, Owned>(), + /// card, + /// AeadContext::from(nonempty!("cards/number")), + /// ) + /// .await?; + /// # let _ = sealed; + /// # Ok(()) + /// # } + /// # tokio_test_block_on(example()).unwrap(); + /// # fn tokio_test_block_on(f: F) -> F::Output { + /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(f) + /// # } + /// ``` + pub fn run<'a, 's, S: 's, T: 'static, Ctx, M: SourceMode<'s, S>>( + &'a self, + encryption: Encryption<'s, S, T, K, Ctx, M>, + source: M::Source, + context: Ctx, + ) -> Pending<'a, T, K> { + (encryption.build)(source, self, context) + } /// Recover `P` from `source`, as its declaration describes. A leaf sealed /// under another keyset is refused ([`Error::ForeignKeyset`]) before any /// key is retrieved. @@ -586,6 +702,12 @@ pub trait DecryptFrom: Sized + 'static { } impl DecryptFrom for T {} +// The leaf declarations below keep `Clone` on purpose. An `EncryptFrom` +// declaration runs in the `Borrowed` mode (`encrypt_as` hands it `&S`), and an +// operation that consumes a borrowed plaintext must clone it: the bound is +// `Borrowed: ConsumeSource<'s, S>`, spelled out. The constructors themselves +// (`ciphertext`, `equality`, `ore`, `ope`) ask only for the scheme's own +// capability, and run a non-`Clone` plaintext in `Owned` mode. impl EncryptFrom for StackCipherText { type Context = AeadContext; fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> diff --git a/packages/stack-encrypt/src/target/source.rs b/packages/stack-encrypt/src/target/source.rs new file mode 100644 index 000000000..4c9579e29 --- /dev/null +++ b/packages/stack-encrypt/src/target/source.rs @@ -0,0 +1,102 @@ +//! How an [`Encryption`](super::Encryption) receives its plaintext. The +//! explanation is on [`SourceMode`], which is what the public docs show; this +//! module is private and its items are re-exported from `target`. + +/// A description's plaintext is handed to it by reference: the default mode, +/// and the one every [`EncryptFrom`](super::EncryptFrom) declaration runs in. +/// An operation that consumes the plaintext clones it. +#[derive(Debug)] +pub enum Borrowed {} + +/// A description's plaintext is handed to it by value. A single operation +/// consumes it without a copy; only fan-out over it needs `Clone`. +#[derive(Debug)] +pub enum Owned {} + +mod sealed { + pub trait Sealed {} + impl Sealed for super::Borrowed {} + impl Sealed for super::Owned {} +} + +/// How a description receives its plaintext: the `M` parameter of +/// [`Encryption`](super::Encryption). +/// +/// Vitamin C's `Encrypt`, `PrfValue` and `CllwOreEncrypt` consume the value +/// they are given, so an operation needs the plaintext by value. A +/// description can be handed it two ways, and because the mode is a type +/// parameter the difference is settled at compile time: +/// +/// - [`Borrowed`], the default: the description is handed `&S`. This is what +/// [`encrypt_as`](crate::KeysetCipher::encrypt_as) does for every +/// [`EncryptFrom`](super::EncryptFrom) declaration, and what lets a record +/// pick its fields out of a borrowed struct. An operation that consumes the +/// plaintext clones it first ([`ConsumeSource`]), so here, and only here, +/// `S` must be `Clone`. +/// - [`Owned`]: the description is handed `S` itself, through +/// [`KeysetCipher::run`](crate::KeysetCipher::run). A single operation +/// consumes it with no copy, so a plaintext that is deliberately not +/// `Clone` (one that is moved and wiped, such as a zeroizing FFI value) can +/// be sealed or indexed. Running two operations over one value +/// ([`zip`](super::Encryption::zip)) hands a copy to every side but the +/// last, which takes ownership ([`ShareSource`]); that, and only that, +/// needs `Clone`. +/// +/// A match term only reads its text, so it runs in either mode without a +/// copy. The traits are sealed: these two modes are the only ones. +pub trait SourceMode<'s, S: 's>: sealed::Sealed + 's { + /// What the description is handed: `&'s S` or `S`. + type Source; + /// Read the plaintext in place, for an operation that does not consume + /// it (a match term reads text through `AsRef`). + fn view(source: &Self::Source) -> &S; +} + +/// A mode in which an operation can take the plaintext by value: always for +/// [`Owned`]; for [`Borrowed`] when `S: Clone`, by cloning it. +pub trait ConsumeSource<'s, S: 's>: SourceMode<'s, S> { + /// The plaintext, by value. + fn take(source: Self::Source) -> S; +} + +/// A mode in which one plaintext can be handed to two operations: always +/// for [`Borrowed`], where the reference is copied; for [`Owned`] when +/// `S: Clone`, where the first side gets a clone and the second the value. +pub trait ShareSource<'s, S: 's>: SourceMode<'s, S> { + /// The plaintext for the first side, and for the second. + fn share(source: Self::Source) -> (Self::Source, Self::Source); +} + +impl<'s, S: 's> SourceMode<'s, S> for Borrowed { + type Source = &'s S; + fn view(source: &Self::Source) -> &S { + source + } +} +impl<'s, S: Clone + 's> ConsumeSource<'s, S> for Borrowed { + fn take(source: Self::Source) -> S { + source.clone() + } +} +impl<'s, S: 's> ShareSource<'s, S> for Borrowed { + fn share(source: Self::Source) -> (Self::Source, Self::Source) { + (source, source) + } +} + +impl<'s, S: 's> SourceMode<'s, S> for Owned { + type Source = S; + fn view(source: &Self::Source) -> &S { + source + } +} +impl<'s, S: 's> ConsumeSource<'s, S> for Owned { + fn take(source: Self::Source) -> S { + source + } +} +impl<'s, S: Clone + 's> ShareSource<'s, S> for Owned { + fn share(source: Self::Source) -> (Self::Source, Self::Source) { + (source.clone(), source) + } +} diff --git a/packages/stack-encrypt/tests/source_mode.rs b/packages/stack-encrypt/tests/source_mode.rs new file mode 100644 index 000000000..ac8b7a03d --- /dev/null +++ b/packages/stack-encrypt/tests/source_mode.rs @@ -0,0 +1,326 @@ +//! How a description receives its plaintext (`target::{Borrowed, Owned}`). +//! +//! Vitamin C's `Encrypt` and `PrfValue` consume their input. A description +//! handed a borrow (the default, and what every `EncryptFrom` declaration +//! runs in) clones before an operation consumes; one handed the value +//! (`Owned`) gives it to the operation. These tests hold the two claims that +//! make owned mode worth having: +//! +//! - a plaintext that is **not `Clone`** runs a single operation — a +//! ciphertext alone, or one term alone — and the output is the same as the +//! borrowed path's; +//! - the number of copies is exactly what the mode promises: none for one +//! owned operation, `n - 1` for an owned `zip` of `n`, one per consuming +//! operation when borrowed, and none ever for a match term, which only +//! reads its text. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use stack_encrypt::sem::{EqualityTerm, MatchTerm}; +use stack_encrypt::target::{ + ciphertext, equality, matching, AeadContext, Borrowed, CallerContext, Encryption, Owned, +}; +use stack_encrypt::{nonempty, Encrypt, NonEmpty, StackCipherText}; +use stack_kms::FakeDataKeySource; +use vitaminc_aead::{Cipher, IntoAad}; +use vitaminc_prf::{IntoPrfContext, Prf, PrfValue, PrfVisitor}; +use vitaminc_protected::{Controlled, Protected}; + +mod common; +use common::stack_cipher; + +const NUMBER: &str = "4111 1111 1111 1111"; + +fn context() -> NonEmpty<&'static str> { + nonempty!("cards/number") +} +fn aead() -> AeadContext { + AeadContext::from(context()) +} +fn caller() -> CallerContext { + CallerContext::from(context()) +} + +/// A secret that is deliberately not `Clone`: it is moved, never copied, and +/// `Protected` wipes the one copy when it drops. It encrypts, derives terms +/// and reads as text exactly as the `String` it holds does. +struct Secret(Protected); + +impl Secret { + fn new(text: &str) -> Self { + Self(Protected::new(text.to_string())) + } +} +impl Encrypt for Secret { + fn encrypt_with_aad<'a, C, A>(self, cipher: C, aad: A) -> Result + where + C: Cipher, + A: IntoAad<'a>, + { + self.0.encrypt_with_aad(cipher, aad) + } +} +impl PrfValue for Secret { + fn prf_visit_with_context<'a, P, V, C>(self, prf: &P, context: C, visitor: V) -> P::Ok + where + P: Prf, + V: PrfVisitor, + C: IntoPrfContext<'a>, + { + self.0.prf_visit_with_context(prf, context, visitor) + } +} +impl AsRef for Secret { + fn as_ref(&self) -> &str { + self.0.risky_ref() + } +} + +/// A `String` that counts its clones, so a test can say how many copies of +/// the plaintext a description made. +struct Counted { + text: String, + clones: Arc, +} + +impl Counted { + fn new(text: &str) -> (Self, Arc) { + let clones = Arc::new(AtomicUsize::new(0)); + let value = Self { + text: text.to_string(), + clones: clones.clone(), + }; + (value, clones) + } +} +impl Clone for Counted { + fn clone(&self) -> Self { + let _ = self.clones.fetch_add(1, Ordering::SeqCst); + Self { + text: self.text.clone(), + clones: self.clones.clone(), + } + } +} +impl Encrypt for Counted { + fn encrypt_with_aad<'a, C, A>(self, cipher: C, aad: A) -> Result + where + C: Cipher, + A: IntoAad<'a>, + { + self.text.encrypt_with_aad(cipher, aad) + } +} +impl PrfValue for Counted { + fn prf_visit_with_context<'a, P, V, C>(self, prf: &P, context: C, visitor: V) -> P::Ok + where + P: Prf, + V: PrfVisitor, + C: IntoPrfContext<'a>, + { + self.text.prf_visit_with_context(prf, context, visitor) + } +} +impl AsRef for Counted { + fn as_ref(&self) -> &str { + &self.text + } +} + +// --- A plaintext that is not `Clone` ---------------------------------------- + +#[tokio::test] +async fn a_plaintext_that_is_not_clone_seals_through_ciphertext_alone() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + + let sealed: StackCipherText = keyset + .run(ciphertext::<_, _, Owned>(), Secret::new(NUMBER), aead()) + .await + .unwrap(); + + let opened: String = cipher.decrypt_as(sealed, aead()).await.unwrap(); + assert_eq!( + opened, NUMBER, + "an owned, non-Clone plaintext should seal to a ciphertext that opens to its text" + ); +} + +#[tokio::test] +async fn a_plaintext_that_is_not_clone_derives_an_equality_term_alone() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + + let term: EqualityTerm = keyset + .run(equality::<_, _, Owned>(), Secret::new(NUMBER), caller()) + .await + .unwrap(); + + let expected = keyset.equality_term(NUMBER, context()).await.unwrap(); + assert_eq!( + term, expected, + "an owned equality term should be byte-identical to the same text's term" + ); +} + +#[tokio::test] +async fn a_plaintext_that_is_not_clone_derives_a_match_term_alone() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + + let term: MatchTerm = keyset + .run(matching::<_, _, Owned, _>(), Secret::new(NUMBER), caller()) + .await + .unwrap(); + + let expected: MatchTerm = keyset.match_terms(NUMBER, context()).await.unwrap(); + assert_eq!( + term, expected, + "an owned match term should be byte-identical to the same text's term" + ); +} + +/// The case the owned mode exists for: the dynamic value an FFI binding +/// decodes is zeroized on drop and is not `Clone`. +#[cfg(feature = "dynamic")] +#[tokio::test] +async fn a_zeroizing_ffi_value_seals_through_ciphertext_alone() { + use vitaminc_aead_value::FfiValue; + + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + + let sealed: StackCipherText = keyset + .run( + ciphertext::<_, _, Owned>(), + FfiValue::String(NUMBER.into()), + aead(), + ) + .await + .unwrap(); + + let opened: FfiValue = cipher.decrypt_as(sealed, aead()).await.unwrap(); + let FfiValue::String(text) = opened else { + panic!("an FfiValue string should open to a string"); + }; + assert_eq!( + text.risky_ref(), + NUMBER.as_bytes(), + "an FfiValue should seal by value and open to the same text" + ); +} + +// --- How many copies each mode makes ---------------------------------------- + +#[tokio::test] +async fn one_owned_operation_makes_no_copy() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + + let (value, clones) = Counted::new(NUMBER); + let _: StackCipherText = keyset + .run(ciphertext::<_, _, Owned>(), value, aead()) + .await + .unwrap(); + assert_eq!(clones.load(Ordering::SeqCst), 0, "an owned ciphertext"); + + let (value, clones) = Counted::new(NUMBER); + let _: EqualityTerm = keyset + .run(equality::<_, _, Owned>(), value, caller()) + .await + .unwrap(); + assert_eq!(clones.load(Ordering::SeqCst), 0, "an owned equality term"); +} + +/// A ciphertext beside an equality term, as a target composes them. +fn sealed_and_indexed<'s, S, M>( +) -> Encryption<'s, S, (StackCipherText, EqualityTerm), FakeDataKeySource, CallerContext, M> +where + S: Encrypt + PrfValue + 's, + M: stack_encrypt::target::ConsumeSource<'s, S> + stack_encrypt::target::ShareSource<'s, S>, +{ + ciphertext().accepting().zip(equality()) +} + +#[tokio::test] +async fn an_owned_zip_copies_for_every_side_but_the_last() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + + let (value, clones) = Counted::new(NUMBER); + let (sealed, term) = keyset + .run(sealed_and_indexed::<_, Owned>(), value, caller()) + .await + .unwrap(); + assert_eq!( + clones.load(Ordering::SeqCst), + 1, + "two owned operations should share one copy: the last takes the value" + ); + + // And what each side made is what it would have made alone. + let opened: String = cipher.decrypt_as(sealed, aead()).await.unwrap(); + assert_eq!(opened, NUMBER, "the zipped ciphertext opens to the text"); + let expected = keyset.equality_term(NUMBER, context()).await.unwrap(); + assert_eq!(term, expected, "the zipped term is the text's term"); + + let (value, clones) = Counted::new(NUMBER); + let three = sealed_and_indexed::<_, Owned>().zip(equality::<_, _, Owned>()); + let (_, again) = keyset.run(three, value, caller()).await.unwrap(); + assert_eq!( + clones.load(Ordering::SeqCst), + 2, + "three owned operations should make two copies" + ); + assert_eq!(again, expected, "the third side's term is the text's term"); +} + +#[tokio::test] +async fn a_borrowed_operation_copies_once_for_each_operation_that_consumes() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + + let (value, clones) = Counted::new(NUMBER); + let sealed: StackCipherText = keyset + .run(ciphertext::<_, _, Borrowed>(), &value, aead()) + .await + .unwrap(); + assert_eq!(clones.load(Ordering::SeqCst), 1, "a borrowed ciphertext"); + let opened: String = cipher.decrypt_as(sealed, aead()).await.unwrap(); + assert_eq!(opened, NUMBER, "the borrowed ciphertext opens to the text"); + + let (value, clones) = Counted::new(NUMBER); + let (_, term) = keyset + .run(sealed_and_indexed::<_, Borrowed>(), &value, caller()) + .await + .unwrap(); + assert_eq!( + clones.load(Ordering::SeqCst), + 2, + "a borrowed ciphertext beside a borrowed term copies once for each" + ); + let expected = keyset.equality_term(NUMBER, context()).await.unwrap(); + assert_eq!(term, expected, "the borrowed term is the text's term"); +} + +#[tokio::test] +async fn a_match_term_never_copies_its_text() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + + let (value, clones) = Counted::new(NUMBER); + let _: MatchTerm = keyset + .run(matching::<_, _, Borrowed, _>(), &value, caller()) + .await + .unwrap(); + let _: MatchTerm = keyset + .run(matching::<_, _, Owned, _>(), value, caller()) + .await + .unwrap(); + assert_eq!( + clones.load(Ordering::SeqCst), + 0, + "a match term reads its text in either mode" + ); +} diff --git a/packages/stack-encrypt/tests/ui/divergent_context_in_target.stderr b/packages/stack-encrypt/tests/ui/divergent_context_in_target.stderr index fc60d6a2d..b532d4ea4 100644 --- a/packages/stack-encrypt/tests/ui/divergent_context_in_target.stderr +++ b/packages/stack-encrypt/tests/ui/divergent_context_in_target.stderr @@ -2,12 +2,12 @@ error[E0308]: mismatched types --> tests/ui/divergent_context_in_target.rs:30:18 | 30 | .zip(equality()) - | --- ^^^^^^^^^^ expected `Encryption<'_, _, _, _, DeclaredContext>`, found `Encryption<'_, _, EqualityTerm, _, ...>` + | --- ^^^^^^^^^^ expected `Encryption<'_, _, _, _, ..., _>`, found `Encryption<'_, _, ..., _, ..., _>` | | | arguments to this method are incorrect | - = note: expected struct `Encryption<'_, _, _, _, DeclaredContext>` - found struct `Encryption<'_, _, EqualityTerm, _, CallerContext>` + = note: expected struct `Encryption<'_, _, _, _, DeclaredContext, _>` + found struct `Encryption<'_, _, EqualityTerm, _, CallerContext, _>` note: method defined here --> src/target/operations.rs | diff --git a/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr b/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr index e6f1c201d..5bd1addde 100644 --- a/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr +++ b/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr @@ -15,14 +15,14 @@ error[E0277]: the trait bound `AeadContext: From` is not satisf `AeadContext` implements `From` and $N others = note: required for `DeclaredContext` to implement `Into` -note: required by a bound in `Encryption::<'s, S, T, K, Ctx>::accepting` +note: required by a bound in `Encryption::<'s, S, T, K, Ctx, M>::accepting` --> src/target/operations.rs | - | pub fn accepting(self) -> Encryption<'s, S, T, K, C2> + | pub fn accepting(self) -> Encryption<'s, S, T, K, C2, M> | --------- required by a bound in this associated function | where | C2: Into + 's, - | ^^^^^^^^^ required by this bound in `Encryption::<'s, S, T, K, Ctx>::accepting` + | ^^^^^^^^^ required by this bound in `Encryption::<'s, S, T, K, Ctx, M>::accepting` error[E0277]: the trait bound `AeadContext: From` is not satisfied --> tests/ui/nested_leaf_without_context.rs:15:12 diff --git a/packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.rs b/packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.rs new file mode 100644 index 000000000..8c808dca1 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.rs @@ -0,0 +1,19 @@ +//! Owned mode hands the plaintext to an operation by value, so a single +//! operation needs no `Clone`. Two operations over one owned value are the +//! one place a copy is unavoidable — the first side gets a clone, the last +//! the value — and the refusal names `zip`, not the operations. +use stack_encrypt::target::{ciphertext, equality, CallerContext, Owned}; +use stack_encrypt::{nonempty, Encrypt, KeysetCipher}; +use stack_kms::FakeDataKeySource; + +fn seal_and_index( + keyset: &KeysetCipher<'_, FakeDataKeySource>, + value: S, +) { + let both = ciphertext::<_, _, Owned>() + .accepting::() + .zip(equality::<_, _, Owned>()); + let _ = keyset.run(both, value, CallerContext::from(nonempty!("column"))); +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.stderr b/packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.stderr new file mode 100644 index 000000000..aca006b38 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.stderr @@ -0,0 +1,19 @@ +error[E0277]: the trait bound `S: Clone` is not satisfied + --> tests/ui/owned_fan_out_needs_clone.rs:15:10 + | +15 | .zip(equality::<_, _, Owned>()); + | ^^^ the trait `Clone` is not implemented for `S` + | + = note: required for `stack_encrypt::target::Owned` to implement `ShareSource<'_, S>` +note: required by a bound in `Encryption::<'s, S, T, K, Ctx, M>::zip` + --> src/target/operations.rs + | + | pub fn zip( + | --- required by a bound in this associated function +... + | M: ShareSource<'s, S>, + | ^^^^^^^^^^^^^^^^^^ required by this bound in `Encryption::<'s, S, T, K, Ctx, M>::zip` +help: consider further restricting type parameter `S` with trait `Clone` + | + 9 | fn seal_and_index( + | +++++++++++++++++++ diff --git a/packages/stack-encrypt/tests/ui/pass/owned_without_clone.rs b/packages/stack-encrypt/tests/ui/pass/owned_without_clone.rs new file mode 100644 index 000000000..a1fc581f4 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/pass/owned_without_clone.rs @@ -0,0 +1,29 @@ +//! A single operation in owned mode asks nothing of the plaintext beyond the +//! operation's own capability: no `Clone`, so a value that is moved and +//! wiped can be sealed or indexed. +use stack_encrypt::sem::{EqualityTerm, MatchTerm}; +use stack_encrypt::target::{ciphertext, equality, matching, CallerContext, Owned, Pending}; +use stack_encrypt::{nonempty, Encrypt, KeysetCipher, StackCipherText}; +use stack_kms::FakeDataKeySource; + +fn seal<'a, S: Encrypt>( + keyset: &'a KeysetCipher<'_, FakeDataKeySource>, + value: S, +) -> Pending<'a, StackCipherText, FakeDataKeySource> { + keyset.run(ciphertext::<_, _, Owned>(), value, nonempty!("column").into()) +} +fn index<'a, S: vitaminc_prf::PrfValue>( + keyset: &'a KeysetCipher<'_, FakeDataKeySource>, + value: S, +) -> Pending<'a, EqualityTerm, FakeDataKeySource> { + keyset.run(equality::<_, _, Owned>(), value, CallerContext::from(nonempty!("column"))) +} +fn search<'a, S: AsRef>( + keyset: &'a KeysetCipher<'_, FakeDataKeySource>, + value: S, +) -> Pending<'a, MatchTerm, FakeDataKeySource> { + keyset.run(matching::<_, _, Owned, _>(), value, CallerContext::from(nonempty!("column"))) +} +fn main() { + let _ = (seal::, index::, search::); +} From 889bef95a65f65277438b6bf93dfbf1ae146e4ad Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 5 Oct 2026 00:25:40 -0700 Subject: [PATCH 2/2] test(stack-encrypt): pin the Owned-mode drop and the Clone-free ore/ope Owned mode exists so a zeroizing plaintext is moved once and wiped. A new test counts drops and asserts that ciphertext, equality and matching let the value go while the description runs, before run returns its Pending, so a later change that kept it alive across the ZeroKMS round trip fails. The compile-pass file now also runs ore and ope over an S bounded only by CllwOreEncrypt / CllwOpeEncrypt, which do not imply Clone, so Clone returning to either constructor fails the ui suite. Checked by adding it back to ore: trybuild reports `S: Clone` is not satisfied. zip's rustdoc now says that in Owned mode a match term next to a consuming operation still needs S: Clone; a combinator that avoids the copy is left until a non-Clone source with a match index arrives. --- .../stack-encrypt/src/target/operations.rs | 2 + packages/stack-encrypt/tests/source_mode.rs | 115 ++++++++++++++++++ .../tests/ui/pass/owned_without_clone.rs | 30 ++++- 3 files changed, 145 insertions(+), 2 deletions(-) diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index c4113a281..fc9bca95c 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -213,6 +213,8 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's, M: SourceMode<'s, S>> /// [`Owned`](super::Owned) mode this side gets a clone and `other` takes /// ownership, so a chain of `n` operations makes `n - 1` copies rather /// than `n`, and only here does an owned plaintext need to be `Clone`. + /// In Owned mode, a match term next to an operation that consumes the + /// value still needs `S: Clone`. pub fn zip( self, other: Encryption<'s, S, U, K, Ctx, M>, diff --git a/packages/stack-encrypt/tests/source_mode.rs b/packages/stack-encrypt/tests/source_mode.rs index ac8b7a03d..1b2d0f80f 100644 --- a/packages/stack-encrypt/tests/source_mode.rs +++ b/packages/stack-encrypt/tests/source_mode.rs @@ -324,3 +324,118 @@ async fn a_match_term_never_copies_its_text() { "a match term reads its text in either mode" ); } + +// --- When an owned plaintext is dropped ------------------------------------- + +/// A plaintext that counts its drops, so a test can say when an owned value +/// is let go. Its operations take the text out of it, as a zeroizing type +/// would hand its bytes over, and the husk drops at the end of the call. +struct Dropped { + text: String, + drops: Arc, +} + +impl Dropped { + fn new(text: &str) -> (Self, Arc) { + let drops = Arc::new(AtomicUsize::new(0)); + let value = Self { + text: text.to_string(), + drops: drops.clone(), + }; + (value, drops) + } +} +impl Drop for Dropped { + fn drop(&mut self) { + let _ = self.drops.fetch_add(1, Ordering::SeqCst); + } +} +impl Encrypt for Dropped { + fn encrypt_with_aad<'a, C, A>(mut self, cipher: C, aad: A) -> Result + where + C: Cipher, + A: IntoAad<'a>, + { + std::mem::take(&mut self.text).encrypt_with_aad(cipher, aad) + } +} +impl PrfValue for Dropped { + fn prf_visit_with_context<'a, P, V, C>( + mut self, + prf: &P, + context: C, + visitor: V, + ) -> P::Ok + where + P: Prf, + V: PrfVisitor, + C: IntoPrfContext<'a>, + { + std::mem::take(&mut self.text).prf_visit_with_context(prf, context, visitor) + } +} +impl AsRef for Dropped { + fn as_ref(&self) -> &str { + &self.text + } +} + +/// Owned mode exists so a zeroizing plaintext moves once and is wiped. Each +/// operation must let it go while the description runs, before `run` +/// returns its `Pending` — not keep it alive across the key request. +#[tokio::test] +async fn an_owned_plaintext_is_dropped_before_its_key_request_is_sent() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + + let (value, drops) = Dropped::new(NUMBER); + let pending = keyset.run(ciphertext::<_, _, Owned>(), value, aead()); + assert_eq!( + drops.load(Ordering::SeqCst), + 1, + "ciphertext: dropped before the request is sent" + ); + let sealed: StackCipherText = pending.await.unwrap(); + assert_eq!( + drops.load(Ordering::SeqCst), + 1, + "ciphertext: dropped exactly once" + ); + let opened: String = cipher.decrypt_as(sealed, aead()).await.unwrap(); + assert_eq!( + opened, NUMBER, + "the text was sealed before the husk dropped" + ); + + let (value, drops) = Dropped::new(NUMBER); + let pending = keyset.run(equality::<_, _, Owned>(), value, caller()); + assert_eq!( + drops.load(Ordering::SeqCst), + 1, + "equality: dropped before run returns" + ); + let term: EqualityTerm = pending.await.unwrap(); + assert_eq!( + drops.load(Ordering::SeqCst), + 1, + "equality: dropped exactly once" + ); + let expected = keyset.equality_term(NUMBER, context()).await.unwrap(); + assert_eq!(term, expected, "the term is the text's term"); + + let (value, drops) = Dropped::new(NUMBER); + let pending = keyset.run(matching::<_, _, Owned, _>(), value, caller()); + assert_eq!( + drops.load(Ordering::SeqCst), + 1, + "matching: dropped before run returns" + ); + let term: MatchTerm = pending.await.unwrap(); + assert_eq!( + drops.load(Ordering::SeqCst), + 1, + "matching: dropped exactly once" + ); + let expected: MatchTerm = keyset.match_terms(NUMBER, context()).await.unwrap(); + assert_eq!(term, expected, "the term is the text's term"); +} diff --git a/packages/stack-encrypt/tests/ui/pass/owned_without_clone.rs b/packages/stack-encrypt/tests/ui/pass/owned_without_clone.rs index a1fc581f4..ea6331265 100644 --- a/packages/stack-encrypt/tests/ui/pass/owned_without_clone.rs +++ b/packages/stack-encrypt/tests/ui/pass/owned_without_clone.rs @@ -1,8 +1,10 @@ //! A single operation in owned mode asks nothing of the plaintext beyond the //! operation's own capability: no `Clone`, so a value that is moved and //! wiped can be sealed or indexed. -use stack_encrypt::sem::{EqualityTerm, MatchTerm}; -use stack_encrypt::target::{ciphertext, equality, matching, CallerContext, Owned, Pending}; +use stack_encrypt::sem::{CllwOpeEncrypt, CllwOreEncrypt, EqualityTerm, MatchTerm, OpeTerm, OreTerm}; +use stack_encrypt::target::{ + ciphertext, equality, matching, ope, ore, CallerContext, Owned, Pending, +}; use stack_encrypt::{nonempty, Encrypt, KeysetCipher, StackCipherText}; use stack_kms::FakeDataKeySource; @@ -24,6 +26,30 @@ fn search<'a, S: AsRef>( ) -> Pending<'a, MatchTerm, FakeDataKeySource> { keyset.run(matching::<_, _, Owned, _>(), value, CallerContext::from(nonempty!("column"))) } +// Bounded only by the scheme's own trait: `CllwOreEncrypt` and +// `CllwOpeEncrypt` do not imply `Clone`, so `Clone` returning to either +// constructor's bounds fails this file. +fn order<'a, S>( + keyset: &'a KeysetCipher<'_, FakeDataKeySource>, + value: S, +) -> Pending<'a, OreTerm, FakeDataKeySource> +where + S: CllwOreEncrypt + Send + 'static, + S::Output: Send + 'static, +{ + keyset.run(ore::<_, _, Owned>(), value, CallerContext::from(nonempty!("column"))) +} +fn order_preserving<'a, S>( + keyset: &'a KeysetCipher<'_, FakeDataKeySource>, + value: S, +) -> Pending<'a, OpeTerm, FakeDataKeySource> +where + S: CllwOpeEncrypt + Send + 'static, + S::Output: Send + 'static, +{ + keyset.run(ope::<_, _, Owned>(), value, CallerContext::from(nonempty!("column"))) +} fn main() { let _ = (seal::, index::, search::); + let _ = (order::, order_preserving::); }