From bd64b2e249cfa11b72f948423426789325356db8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 2 Nov 2026 23:51:20 +1100 Subject: [PATCH 01/10] feat(stack-encrypt)!: derive owned record declarations with EncryptTarget and DecryptSource Records, derives and plans still spoke the structural `EncryptFrom` / `DecryptInto` pair over a borrowed plaintext while the scalar API had moved to `EncryptTarget` / `DecryptSource` with explicit caller-context capabilities. A user could not write the mixed record the public examples show (`id` in the clear, `name` and `email` sealed) as an owned conversion that consumes and wipes the plaintext, and nothing said how the two pairs relate. `EncryptTarget` gains `owned_encryption()`, the description over the plaintext by value, defaulting to guard-and-wipe over the borrowed `encryption()`, and `indexes()`. `EncryptInto` and the owned column run it. A new `#[derive(EncryptTarget, DecryptSource)]` composes the field targets directly: a `struct = User` record projects each field out of a borrow in its borrowed form and divides the struct between its fields with the new `Encryption::split` in its owned form, so a `#[stash(passthrough)]` field is moved into the record (never cloned, never encrypted or authenticated) and each sealed field is guarded by its own target's owned description. Caller context is a capability composed through the tree: `ContextExtension` for a record whose fields carry their own pairs, `ExpectedContext` (now a `ContextInput`) for a stored context, and `RequiredContext` of the core contexts for `plaintext` records and the core leaves, which all implement the owned pair now (terms as `EncryptTarget` alone). A field whose target cannot take the record's pair is refused at that field. Both owned derives also emit the structural declarations as a bridge, so an owned record is a field of a structural record or a typed plan field; `FieldsBuilder::target` admits an owned-only target such as a `Ciphertext`. The dynamic lowering is untouched: the equivalence test seals one record through a hand-written target, the derive and the data plan against one recording provider and compares descriptors, term bytes and cross-opening. Breaking: the structural `Vec: DecryptInto>` and `DecryptField, _>` impls open through the wiping collection path and so require `P: Zeroize`; before, the elements recovered before a late element failure were dropped unwiped (the CodeRabbit finding on the owned collections PR). A drop-witness test fails without the wipe. ADR-0011 records the signature and ownership map. The structural derives stay for the plans and the dynamic engine; their removal is #1488. Closes #1483 Refs #1488 --- Cargo.lock | 1 + conformance/stack-encrypt/coverage.md | 4 +- packages/stack-encrypt-derive/Cargo.toml | 4 +- .../stack-encrypt-derive/docs/attributes.md | 96 ++- packages/stack-encrypt-derive/src/attrs.rs | 15 +- packages/stack-encrypt-derive/src/decrypt.rs | 9 +- packages/stack-encrypt-derive/src/encrypt.rs | 9 +- packages/stack-encrypt-derive/src/lib.rs | 42 +- .../stack-encrypt-derive/src/owned_decrypt.rs | 286 +++++++ .../stack-encrypt-derive/src/owned_encrypt.rs | 400 ++++++++++ packages/stack-encrypt-derive/src/shape.rs | 81 +- packages/stack-encrypt/CHANGELOG.md | 69 ++ .../adr/0011-owned-record-declarations.md | 127 ++++ .../examples/encrypted_record.rs | 4 +- packages/stack-encrypt/src/lib.rs | 84 ++- packages/stack-encrypt/src/plan/build.rs | 82 +- packages/stack-encrypt/src/scalar.rs | 12 + .../stack-encrypt/src/target/collections.rs | 23 + .../stack-encrypt/src/target/conversion.rs | 65 +- packages/stack-encrypt/src/target/index.rs | 34 +- packages/stack-encrypt/src/target/input.rs | 149 +++- packages/stack-encrypt/src/target/mod.rs | 56 +- .../stack-encrypt/src/target/operations.rs | 144 +++- packages/stack-encrypt/tests/collections.rs | 52 ++ packages/stack-encrypt/tests/derive.rs | 4 +- .../stack-encrypt/tests/owned_equivalence.rs | 329 +++++++++ packages/stack-encrypt/tests/owned_records.rs | 698 ++++++++++++++++++ .../tests/ui/aead_context_with_term.stderr | 2 +- .../tests/ui/bare_context.stderr | 10 +- .../tests/ui/context_field_conflicts.stderr | 2 +- .../tests/ui/leaf_without_context.stderr | 2 +- .../tests/ui/owned_aead_record_with_term.rs | 15 + .../ui/owned_aead_record_with_term.stderr | 25 + .../tests/ui/owned_collection_context.stderr | 24 + .../tests/ui/owned_collection_elements.stderr | 4 +- .../tests/ui/owned_record_field_context.rs | 58 ++ .../ui/owned_record_field_context.stderr | 40 + .../tests/ui/owned_record_required_context.rs | 19 + .../ui/owned_record_required_context.stderr | 59 ++ .../ui/owned_struct_plaintext_not_zeroize.rs | 17 + .../owned_struct_plaintext_not_zeroize.stderr | 119 +++ .../tests/ui/pass/owned_records.rs | 160 ++++ .../tests/ui/passthrough_misuse.rs | 49 ++ .../tests/ui/passthrough_misuse.stderr | 23 + .../tests/ui/scalar_no_caller_context.stderr | 12 + .../tests/ui/scalar_required_context.stderr | 12 + 46 files changed, 3458 insertions(+), 73 deletions(-) create mode 100644 packages/stack-encrypt-derive/src/owned_decrypt.rs create mode 100644 packages/stack-encrypt-derive/src/owned_encrypt.rs create mode 100644 packages/stack-encrypt/docs/adr/0011-owned-record-declarations.md create mode 100644 packages/stack-encrypt/tests/owned_equivalence.rs create mode 100644 packages/stack-encrypt/tests/owned_records.rs create mode 100644 packages/stack-encrypt/tests/ui/owned_aead_record_with_term.rs create mode 100644 packages/stack-encrypt/tests/ui/owned_aead_record_with_term.stderr create mode 100644 packages/stack-encrypt/tests/ui/owned_record_field_context.rs create mode 100644 packages/stack-encrypt/tests/ui/owned_record_field_context.stderr create mode 100644 packages/stack-encrypt/tests/ui/owned_record_required_context.rs create mode 100644 packages/stack-encrypt/tests/ui/owned_record_required_context.stderr create mode 100644 packages/stack-encrypt/tests/ui/owned_struct_plaintext_not_zeroize.rs create mode 100644 packages/stack-encrypt/tests/ui/owned_struct_plaintext_not_zeroize.stderr create mode 100644 packages/stack-encrypt/tests/ui/pass/owned_records.rs create mode 100644 packages/stack-encrypt/tests/ui/passthrough_misuse.rs create mode 100644 packages/stack-encrypt/tests/ui/passthrough_misuse.stderr diff --git a/Cargo.lock b/Cargo.lock index e14762064..a3c9190c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3456,6 +3456,7 @@ dependencies = [ "stack-encrypt", "syn 3.0.9", "tokio", + "zeroize", ] [[package]] diff --git a/conformance/stack-encrypt/coverage.md b/conformance/stack-encrypt/coverage.md index 6080d4e74..15bccd68a 100644 --- a/conformance/stack-encrypt/coverage.md +++ b/conformance/stack-encrypt/coverage.md @@ -7,8 +7,8 @@ Paths are relative to the repository root. New profile cases run from `v1/`. | Requirement | Existing evidence | Remaining work | |---|---|---| | Scalar value widths and lossless transport | Initial scalar cases and [value-kind requirements](fixture-contract.md#value-kind-requirements), executed by the existing Rust and generated Go runners | Complete per-kind boundary matrices, bytes and broader floats; Python/TS mappings | -| Owned opaque ciphertext and storage envelope | `packages/stack-encrypt/tests/scalar.rs`, `frozen_bytes.rs`; initial cross-opening cases | Blob profile; encoded targets; supported version matrix | -| Record declaration and lowering | [Declaration grammar](declaration-grammar.md); `v1/record-declarations-v1.cases.json` shared by native Rust and Go; native normalization observations; existing record lowering suites | EQL-resolver profile; SDK-to-declaration mappings; runtime source/read shapes | +| Owned opaque ciphertext and storage envelope | `packages/stack-encrypt/tests/scalar.rs`, `frozen_bytes.rs`; initial cross-opening cases; `owned_records.rs` (owned record derives: passthrough ownership transfer, sealed-field wiping, nested and renamed fields, stored context, one batch per column) | Blob profile; encoded targets; supported version matrix | +| Record declaration and lowering | [Declaration grammar](declaration-grammar.md); `v1/record-declarations-v1.cases.json` shared by native Rust and Go; native normalization observations; existing record lowering suites; `packages/stack-encrypt/tests/owned_equivalence.rs` seals one mixed, searchable record through a hand-written owned target, the owned derive and the data plan against one recording provider and compares descriptors, term bytes and cross-opening | EQL-resolver profile; SDK-to-declaration mappings; runtime source/read shapes | | Context binding and composition | `packages/stack-encrypt/tests/target.rs`, `descriptor.rs`, `record_lowering.rs`; `languages/golang/encrypt/context_field_test.go`; initial wrong-context cases | Shared composition vectors; expected-context and required-input matrix | | Query terms and diagnostics | [Query-operator requirements](fixture-contract.md#query-operator-requirements), existing Rust/Go runners and initial equality/query-error cases | Common rejection corpus for every operator, capability and input shape; wider kind/index combinations | | Missing, SQL NULL and sealed null | `packages/stack-encrypt/src/dynamic/record.rs` null tests; `languages/golang/encrypt/null_test.go` | Explicit language mappings and common missing/null fixtures | diff --git a/packages/stack-encrypt-derive/Cargo.toml b/packages/stack-encrypt-derive/Cargo.toml index 0e90b04d0..98b495471 100644 --- a/packages/stack-encrypt-derive/Cargo.toml +++ b/packages/stack-encrypt-derive/Cargo.toml @@ -24,7 +24,7 @@ syn = { version = "3", features = ["full", "extra-traits"] } # The dev-dependencies below are used only by the crate-level doctests. # `cargo-udeps` cannot see into rustdoc, so it reports them as unused. [package.metadata.cargo-udeps.ignore] -development = ["stack-encrypt", "tokio"] +development = ["stack-encrypt", "tokio", "zeroize"] [dev-dependencies] # The plain-segment rule is copied from stack-encrypt (a proc-macro crate cannot @@ -37,3 +37,5 @@ serde_json = { workspace = true } # these doctests are what exercises that forwarding. stack-encrypt = { path = "../stack-encrypt", features = ["test-support"] } tokio = { workspace = true, features = ["rt", "macros"] } +# The owned-record doctest derives `Zeroize` on its plaintext, as a user does. +zeroize = { workspace = true } diff --git a/packages/stack-encrypt-derive/docs/attributes.md b/packages/stack-encrypt-derive/docs/attributes.md index ed09cfa97..f0cf92873 100644 --- a/packages/stack-encrypt-derive/docs/attributes.md +++ b/packages/stack-encrypt-derive/docs/attributes.md @@ -1,6 +1,11 @@ # Attributes -All attributes live under `#[stash(...)]`. +All attributes live under `#[stash(...)]`. The four derives read the same +attributes: `EncryptFrom` / `DecryptInto` emit the structural declarations, +run over a borrowed plaintext; `EncryptTarget` / `DecryptSource` emit the +owned declarations (and the structural ones beside them as a bridge), run +over a plaintext handed over by value, and alone accept `passthrough`. Derive +one pair or the other on a record, not both. ## On the struct @@ -32,7 +37,8 @@ literal. | `identity = "..."` | With `struct` only: key this field under the segment `identity` instead of the plaintext field's name, so it is derived under `("", "")`. One plain segment. The plan builder's `.identity(segment)`. | | `from = field` / `from = 0` | With `struct` only: derive this field from `plaintext.field` (or `plaintext.0` for a tuple struct) when its name differs from its plaintext field's. A plaintext field has one output: two fields `from` one plaintext field are refused (a ciphertext with its terms is one `Encrypted` field). | | `default` / `default = expr` | Not derived: filled with `Default::default()` or `expr`. Never encrypted, never authenticated. | -| `decrypt` | Decryption opens this field (`DecryptInto` only). Needed only when the field types cannot decide it — see below. | +| `decrypt` | Decryption opens this field (`DecryptInto` / `DecryptSource` only). Needed only when the field types cannot decide it — see below. | +| `passthrough` | With `struct` on the owned derives only: carry the plaintext field of this name (or `from = field`) into the record as it is, **not encrypted and not authenticated**, moved rather than cloned, and move it back on decryption. Keyed under nothing; excludes `identity`, `default` and `decrypt`. | A field takes no `context = ".."` of its own, on any derive: it is a compile error. A plan has exactly one context source, and every output sits under it. @@ -64,6 +70,92 @@ from a `NonEmpty` or a supported integer. Both encodings and the descriptor's structured identity are preserved when borrowing context data is converted into an owned declaration. No context is inferred from a Rust type's name. +## Owned records + +`#[derive(EncryptTarget, DecryptSource)]` takes the attributes above and +declares the record the owned way: `value.encrypt_into(&keyset)` consumes +the plaintext, and `Plaintext::decrypt_from(record, &cipher)` returns it. +The plaintext type is `Zeroize` (the trait bound says where the wiping +obligation lies), and the conversion wipes every plaintext field it seals. +What the caller may or must pass is the record's `ContextInput`: + +| Record | Encrypt input | Decrypt input | A call | +|---|---|---|---| +| `struct = User, context = "users"` | `ContextExtension` | the same | `user.encrypt_into(&keyset)`, or `.encrypt_into_with_context(&keyset, 7u64)` to extend every field's pair by the row id | +| `plaintext = T` | `RequiredContext` (`RequiredContext` with `context_type = AeadContext`) | the same | `value.encrypt_into_with_context(&keyset, nonempty!("users/email"))` | +| with a `context_field` of type `C` | `RequiredContext>` | `ExpectedContext` | `T::decrypt_from(record, &cipher)` opens under the stored context; `decrypt_from_with_context(record, &cipher, nonempty!(..))` also checks it | + +A `struct` record's field is handed its pair, extended by the caller's part +if one was given, so the field's target must accept a caller context: a +`Ciphertext` (`OptionalContext`), a term or an `Encrypted` +(`RequiredContext`), a `StackCipherText` +(`RequiredContext`), a `plaintext` record, or another `struct` +record (whose own pairs the outer pair then extends). A target that declares +its whole context itself (`NoCallerContext`) or requires a context of another +type (`RequiredContext`) is refused at that field. A `#[stash(passthrough)]` +field is moved into the record by the owned conversion; its type is `Clone` +too, since the record's borrowed form, which a plan or a structural record +runs, copies it. + +A `plaintext` record's fields are all handed the one context the record +requires, so a term beside an `AeadContext`-only record is refused at the +term. Omitting a required context is refused at the call +(`AllowsNoContext`), whatever `Default` the context type has. + +Both owned derives also emit the structural declarations: `EncryptTarget` +emits `EncryptFrom` (the record's legacy context — `DeclaredContext`, +`CallerContext` or `NonEmpty` — converted into its owned input) and +`Decryptable`; `DecryptSource` emits `DecryptInto` and `DecryptField`. An +owned record is therefore a field of a `#[derive(EncryptFrom)]` struct +record and a typed field of a plan (`encrypt_into::`) without +a second derive. A hand-written owned target that is to be a record field +implements `Decryptable` and `DecryptField` beside `DecryptSource`. + +```rust +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{DecryptSource, EncryptTarget}; +use stack_encrypt::{Ciphertext, DecryptFrom, EncryptInto, StackCipher}; + +#[derive(Debug, PartialEq, zeroize::Zeroize)] +struct User { id: u64, name: String, email: String } + +/// A searchable scalar: the ciphertext and an equality term, under the one +/// context the field (or a caller) supplies. +#[derive(EncryptTarget, DecryptSource)] +#[stash(plaintext = String)] +struct SearchableEmail { ciphertext: Ciphertext, equality: EqualityTerm } + +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = User, context = "users")] +struct SearchableUser { + #[stash(passthrough)] + id: u64, + name: Ciphertext, // ("users", "name") + email: SearchableEmail, // ("users", "email") +} + +# tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +let cipher = StackCipher::builder() + .kms(stack_encrypt::kms::FakeDataKeySource::new()).init().await?; +let keyset = cipher.default_keyset(); +let users = vec![ + User { id: 42, name: "Alice".into(), email: "alice@example.com".into() }, + User { id: 43, name: "Bob".into(), email: "bob@example.com".into() }, +]; +// One batched key request for both rows' four sealed fields. +let rows: Vec = users.encrypt_into(&keyset).await?; +// A query term is the same derivation under the same context. +let wanted: EqualityTerm = String::from("alice@example.com") + .encrypt_into_with_context(&keyset, stack_encrypt::nonempty!("users").with("email")) + .await?; +let matched: Vec = rows.into_iter().filter(|r| r.email.equality == wanted).collect(); +let opened = Vec::::decrypt_from(matched, &cipher).await?; +assert_eq!(opened.len(), 1); +assert_eq!(opened[0].id, 42); +# Ok::<(), stack_encrypt::Error>(()) +# }).unwrap(); +``` + ## Which context a record takes `CallerContext` holds both of Vitamin C's encodings of the context — the AEAD diff --git a/packages/stack-encrypt-derive/src/attrs.rs b/packages/stack-encrypt-derive/src/attrs.rs index 511ef2f7c..5c27a7843 100644 --- a/packages/stack-encrypt-derive/src/attrs.rs +++ b/packages/stack-encrypt-derive/src/attrs.rs @@ -223,6 +223,10 @@ pub(crate) struct FieldAttrs { pub(crate) default: Option>, /// `#[stash(decrypt)]`: decryption opens this field. pub(crate) decrypt: bool, + /// `#[stash(passthrough)]`: with `struct = ..` on the owned derives, + /// the plaintext field is carried into the record in the clear, moved + /// rather than encrypted, and moved back when the record is decrypted. + pub(crate) passthrough: bool, } impl FieldAttrs { @@ -296,6 +300,13 @@ impl FieldAttrs { parsed.decrypt = true; return Ok(()); } + if meta.path.is_ident("passthrough") { + if parsed.passthrough { + return Err(meta.error("`passthrough` is given twice")); + } + parsed.passthrough = true; + return Ok(()); + } // Removed rather than unknown, so the message can say what // to write instead. if meta.path.is_ident("nested") { @@ -310,8 +321,8 @@ impl FieldAttrs { } Err(meta.error( "unsupported field attribute; expected `context_field`, \ - `identity = \"...\"`, `from = field`, `default`, `default = expr` or \ - `decrypt`", + `identity = \"...\"`, `from = field`, `default`, `default = expr`, \ + `decrypt` or `passthrough`", )) })?; } diff --git a/packages/stack-encrypt-derive/src/decrypt.rs b/packages/stack-encrypt-derive/src/decrypt.rs index 1f9b12517..47d32dce8 100644 --- a/packages/stack-encrypt-derive/src/decrypt.rs +++ b/packages/stack-encrypt-derive/src/decrypt.rs @@ -148,7 +148,7 @@ pub(crate) fn derive(input: DeriveInput) -> Result { ); Ok(quote!(#(#impls)* #field #definition_check)) } -fn struct_literal_path(plaintext: &Type) -> Result { +pub(crate) fn struct_literal_path(plaintext: &Type) -> Result { let Type::Path(type_path) = plaintext else { return Err(syn::Error::new_spanned( plaintext, @@ -204,7 +204,12 @@ impl<'a> Auto<'a> { } } -fn check(krate: &Path, name: &Ident, from: Option<&Member>, fields: &[&Field]) -> TokenStream { +pub(crate) fn check( + krate: &Path, + name: &Ident, + from: Option<&Member>, + fields: &[&Field], +) -> TokenStream { let terms = fields.iter().map(|field| { let ty = &field.ty; // Spanned at the field type: a type that is not `Decryptable` is diff --git a/packages/stack-encrypt-derive/src/encrypt.rs b/packages/stack-encrypt-derive/src/encrypt.rs index f7e3ad459..e16aee447 100644 --- a/packages/stack-encrypt-derive/src/encrypt.rs +++ b/packages/stack-encrypt-derive/src/encrypt.rs @@ -35,6 +35,9 @@ fn assignments<'a>( Kind::Default(Some(expr)) => quote!(#expr), Kind::Default(None) => quote!(::core::default::Default::default()), Kind::Context => stored.clone(), + Kind::Passthrough { .. } => { + unreachable!("the structural derive refuses a passthrough field") + } }; quote!(#member: #value) }) @@ -272,7 +275,11 @@ fn value_derive(input: &DeriveInput, record: &Record, fields: &[&Field]) -> Toke quote!(#plan #(#impls)*) } -fn decryptable_impl(input: &DeriveInput, record: &Record, derived: &[&Field]) -> TokenStream { +pub(crate) fn decryptable_impl( + input: &DeriveInput, + record: &Record, + derived: &[&Field], +) -> TokenStream { let krate = &record.krate; let name = &input.ident; let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); diff --git a/packages/stack-encrypt-derive/src/lib.rs b/packages/stack-encrypt-derive/src/lib.rs index 7861385d4..ae667d03a 100644 --- a/packages/stack-encrypt-derive/src/lib.rs +++ b/packages/stack-encrypt-derive/src/lib.rs @@ -1,7 +1,13 @@ -//! Derive operation declarations for encrypted records. `EncryptFrom

` -//! declares how to produce a target; `DecryptInto

` selects ciphertext for -//! recovery. Stack Encrypt owns execution through Vitamin C's plaintext traits. -//! Generated code receives context and encrypted outputs, never a cipher. +//! Derive operation declarations for encrypted records. The owned pair, +//! `EncryptTarget

` / `DecryptSource

`, declares a record the owned +//! conversions (`value.encrypt_into(&keyset)`, `P::decrypt_from(record, &cipher)`) +//! run, consuming and wiping the plaintext; the structural pair, +//! `EncryptFrom

` / `DecryptInto

`, declares one run over a borrowed +//! plaintext by `keyset.encrypt_as` and `cipher.decrypt_as`. Both take the +//! same attributes (see [Owned records](#owned-records) for what only the +//! owned pair adds). Stack Encrypt owns execution through Vitamin C's +//! plaintext traits. Generated code receives context and encrypted outputs, +//! never a cipher. //! //! ``` //! use stack_encrypt::{EncryptFrom, DecryptInto, StackCipher, StackCipherText, nonempty}; @@ -204,6 +210,8 @@ use syn::{parse_macro_input, DeriveInput}; mod attrs; mod decrypt; mod encrypt; +mod owned_decrypt; +mod owned_encrypt; mod shape; /// Derive `EncryptFrom` for a record struct. See the [crate @@ -229,3 +237,29 @@ pub fn derive_decrypt_into(input: TokenStream) -> TokenStream { .unwrap_or_else(syn::Error::into_compile_error) .into() } + +/// Derive the owned `EncryptTarget` declaration for a record struct, with +/// the structural `EncryptFrom` beside it as a bridge. Takes the same +/// attributes as `EncryptFrom`, plus `#[stash(passthrough)]` on a field of +/// a `struct = ..` record. See [Owned records](crate#owned-records). +#[doc = include_str!("../docs/attributes.md")] +#[proc_macro_derive(EncryptTarget, attributes(stash))] +pub fn derive_encrypt_target(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + owned_encrypt::derive(input) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + +/// Derive the owned `DecryptSource

` declaration for a record +/// struct, one impl per `plaintext` type, with the structural `DecryptInto` +/// and `DecryptField` beside it as a bridge. See +/// [Owned records](crate#owned-records). +#[doc = include_str!("../docs/attributes.md")] +#[proc_macro_derive(DecryptSource, attributes(stash))] +pub fn derive_decrypt_source(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + owned_decrypt::derive(input) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} diff --git a/packages/stack-encrypt-derive/src/owned_decrypt.rs b/packages/stack-encrypt-derive/src/owned_decrypt.rs new file mode 100644 index 000000000..ee8b0359e --- /dev/null +++ b/packages/stack-encrypt-derive/src/owned_decrypt.rs @@ -0,0 +1,286 @@ +//! Emit an owned `DecryptSource` declaration, with the structural +//! `DecryptInto` and `DecryptField` beside it as a bridge. +//! +//! The opening mirrors the owned encryption: a `struct = ..` record opens +//! each field through the field type's `DecryptField` under the pair the +//! record infers, extended by the caller's extension, and moves its +//! passthrough fields back into the plaintext; a `plaintext = T` record +//! opens its one recoverable field under the context the caller supplies, +//! or under the context a `context_field` record stores, once that has +//! passed the caller's expectation. +use crate::decrypt::check; +use crate::owned_encrypt::{encrypt_input, spanned_predicate, threaded_input}; +use crate::shape::{trait_impl, zip, Field, Flavor, Record}; +use proc_macro2::{Span, TokenStream}; +use quote::{quote, quote_spanned}; +use syn::spanned::Spanned; +use syn::{parse_quote, DeriveInput, Ident, Member, Result, Type}; + +pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { + let record = Record::parse_as(&input, Flavor::Owned)?; + let krate = &record.krate; + let name = &input.ident; + let explicit = record.fields.iter().any(|f| f.decrypt); + // Each group recovers one plaintext (field): `None` is the whole value. + let groups: Vec<(Option<&Member>, Vec<&Field>)> = if explicit { + let opened: Vec<&Field> = record.fields.iter().filter(|f| f.decrypt).collect(); + if record.by_field { + opened.into_iter().map(|f| (f.from(), vec![f])).collect() + } else { + if opened.len() > 1 { + return Err(syn::Error::new_spanned( + name, + "several fields are marked `decrypt` but the record is one value: one plaintext \ + cannot be recovered from two fields. Mark only the ciphertext field, or encrypt \ + a struct field by field with `#[stash(struct = ..)]`.", + )); + } + vec![(None, opened)] + } + } else if record.by_field { + record + .derived() + .into_iter() + .map(|f| (f.from(), vec![f])) + .collect() + } else { + vec![(None, record.derived())] + }; + let checks = if explicit { + TokenStream::new() + } else { + let checks = groups + .iter() + .map(|(from, fields)| check(krate, name, *from, fields)); + quote!(#(#checks)*) + }; + let (definition_check, body_check) = if input.generics.params.is_empty() { + (quote!(const _: () = { #checks };), TokenStream::new()) + } else { + (TokenStream::new(), quote!(let () = const { #checks };)) + }; + + let input_type = decrypt_input(&record); + let (plaintexts, generic) = record.sources(parse_quote!(__P)); + let mut impls = Vec::new(); + for plaintext in plaintexts { + let mut generics = input.generics.clone(); + if generic { + generics.params.push(parse_quote!(__P)); + } + let predicates = &mut generics.make_where_clause().predicates; + predicates.push(parse_quote!(Self: 'static)); + if generic { + predicates.push(parse_quote!(__P: #krate::target::Zeroize + 'static)); + } + let body = if record.by_field { + struct_body(&record, &plaintext, &groups, explicit)? + } else { + value_body( + &record, + &plaintext, + &groups[0].1, + explicit, + generic, + predicates, + ) + }; + impls.push(trait_impl( + &input, + &generics, + quote!(#krate::target::DecryptSource<#plaintext>), + quote! { + type ContextInput = #input_type; + fn decryption<__K: 'static>(self, __context: Self::ContextInput) -> #krate::target::Decryption<#plaintext, __K> { + #body_check #body + } + }, + )); + impls.push(bridge(&input, &record, &plaintext, generic)); + } + + // `DecryptField`, through the bridge, as the structural derive emits it. + let mut generics = input.generics.clone(); + generics.params.push(parse_quote!(__P)); + generics.params.push(parse_quote!(__Ctx)); + let predicates = &mut generics.make_where_clause().predicates; + predicates.push(parse_quote!(Self: #krate::target::DecryptInto<__P>)); + predicates.push(parse_quote!(__Ctx: Into<<Self as #krate::target::DecryptInto<__P>>::Context>)); + let field = trait_impl( + &input, + &generics, + quote!(#krate::target::DecryptField<__P, __Ctx>), + quote! { + fn decryption_field<__K: 'static>(self, context: __Ctx) -> Option<#krate::target::Decryption<__P, __K>> { + Some(<Self as #krate::target::DecryptInto<__P>>::decryption(self, context.into())) + } + }, + ); + Ok(quote!(#(#impls)* #field #definition_check)) +} + +/// The caller's input on the decrypt side: an expectation of the stored +/// context, an extension for a `struct` record, otherwise the required +/// caller context the encrypt side took. +fn decrypt_input(record: &Record) -> Type { + let krate = &record.krate; + if let Some(field) = record.context_field() { + let ty = &field.ty; + parse_quote!(#krate::target::ExpectedContext<#ty>) + } else { + encrypt_input(record) + } +} + +/// A `struct = ..` record: each field opened under its pair extended by the +/// caller's extension, zipped, and the plaintext rebuilt with the +/// passthrough fields moved back. +fn struct_body( + record: &Record, + plaintext: &Type, + groups: &[(Option<&Member>, Vec<&Field>)], + explicit: bool, +) -> Result<TokenStream> { + let krate = &record.krate; + let literal = crate::decrypt::struct_literal_path(plaintext)?; + let mut operations = Vec::new(); + for (index, (_, fields)) in groups.iter().enumerate() { + let operation = if explicit { + let field = fields[0]; + let ty = &field.ty; + let member = &field.member; + let own = record.context_expr(field); + quote_spanned!(ty.span()=> <#ty as #krate::target::DecryptSource<_>>::decryption::<__K>(self.#member, ::core::convert::Into::into(#own))) + } else { + let field = fields[0]; + let ty = &field.ty; + let member = &field.member; + let own = record.context_expr(field); + quote_spanned!(ty.span()=> <#ty as #krate::target::DecryptField<_, #krate::target::CallerContext>>::decryption_field::<__K>(self.#member, #own).unwrap_or_else(|| #krate::target::Decryption::failed(#krate::Error::NotOpened))) + }; + operations.push(( + operation, + Ident::new(&format!("__group_{index}"), Span::call_site()), + )); + } + let opened = groups + .iter() + .zip(&operations) + .map(|((from, _), (_, local))| quote!(#from: #local)); + let carried = record + .fields + .iter() + .filter(|f| f.is_passthrough()) + .map(|field| { + let from = field.from().unwrap_or_else(|| unreachable!("a `from`")); + let member = &field.member; + quote!(#from: self.#member) + }); + let output = quote!(#literal { #(#opened,)* #(#carried),* }); + let zipped = zip(operations, output); + Ok(quote! { + let __context: #krate::target::DeclaredContext = ::core::convert::Into::into(__context); + #zipped + }) +} + +/// A `plaintext = T` record: its one recoverable field opened under the +/// required context, which a `context_field` record first builds from the +/// context it stores, checked against the caller's expectation. +fn value_body( + record: &Record, + plaintext: &Type, + fields: &[&Field], + explicit: bool, + generic: bool, + predicates: &mut syn::punctuated::Punctuated<syn::WherePredicate, syn::Token![,]>, +) -> TokenStream { + let krate = &record.krate; + let threaded = threaded_input(record); + let stored = record.context_field().map(|field| { + let member = &field.member; + quote! { + let __context = match __context.validate(self.#member) { + ::core::result::Result::Ok(context) => <#threaded as #krate::target::AcceptsContext>::supplied(::core::convert::Into::into(context)), + ::core::result::Result::Err(error) => return #krate::target::Decryption::failed(error), + }; + } + }); + // The where clause states per field what the body is checked against, + // for a generic impl; a concrete one reports at the field alone. + for field in fields.iter().filter(|_| generic) { + let ty = &field.ty; + if explicit { + predicates.push(spanned_predicate( + ty.span(), + quote!(#ty: #krate::target::DecryptSource<#plaintext>), + )); + predicates.push(spanned_predicate( + ty.span(), + quote!(#threaded: ::core::convert::Into<<#ty as #krate::target::DecryptSource<#plaintext>>::ContextInput>), + )); + } else { + predicates.push(spanned_predicate( + ty.span(), + quote!(#ty: #krate::target::DecryptField<#plaintext, #threaded>), + )); + } + } + let operation = if explicit { + let field = fields[0]; + let ty = &field.ty; + let member = &field.member; + quote_spanned!(ty.span()=> <#ty as #krate::target::DecryptSource<#plaintext>>::decryption::<__K>(self.#member, ::core::convert::Into::into(__context))) + } else { + let calls: Vec<_> = fields + .iter() + .map(|field| { + let ty = &field.ty; + let member = &field.member; + quote_spanned!(ty.span()=> <#ty as #krate::target::DecryptField<#plaintext, #threaded>>::decryption_field::<__K>(self.#member, ::core::clone::Clone::clone(&__context))) + }) + .collect(); + // Evaluate every inspection before chaining: a context error belongs + // to the whole declaration, not to an Option::or_else closure. + let locals: Vec<_> = (0..calls.len()) + .map(|n| Ident::new(&format!("__candidate_{n}"), Span::call_site())) + .collect(); + let bindings = calls + .iter() + .zip(&locals) + .map(|(call, local)| quote!(let #local = #call;)); + let first = &locals[0]; + let rest = &locals[1..]; + quote!({ #(#bindings)* #first #(.or(#rest))* .unwrap_or_else(|| #krate::target::Decryption::failed(#krate::Error::NotOpened)) }) + }; + quote!(#stored #operation) +} + +/// The structural `DecryptInto` bridge: the record's legacy context, +/// converted into its owned input. +fn bridge(input: &DeriveInput, record: &Record, plaintext: &Type, generic: bool) -> TokenStream { + let krate = &record.krate; + let legacy = record.context_type(true); + let mut generics = input.generics.clone(); + if generic { + generics + .params + .push(parse_quote!(__P: #krate::target::Zeroize + 'static)); + } + let predicates = &mut generics.make_where_clause().predicates; + predicates.push(parse_quote!(Self: #krate::target::DecryptSource<#plaintext>)); + predicates.push(parse_quote!( + #legacy: ::core::convert::Into<<Self as #krate::target::DecryptSource<#plaintext>>::ContextInput> + )); + trait_impl( + input, + &generics, + quote!(#krate::target::DecryptInto<#plaintext>), + quote! { + type Context = #legacy; + fn decryption<__K: 'static>(self, __context: Self::Context) -> #krate::target::Decryption<#plaintext, __K> { + <Self as #krate::target::DecryptSource<#plaintext>>::decryption::<__K>(self, ::core::convert::Into::into(__context)) + } + }, + ) +} diff --git a/packages/stack-encrypt-derive/src/owned_encrypt.rs b/packages/stack-encrypt-derive/src/owned_encrypt.rs new file mode 100644 index 000000000..749c2b75a --- /dev/null +++ b/packages/stack-encrypt-derive/src/owned_encrypt.rs @@ -0,0 +1,400 @@ +//! Emit an owned `EncryptTarget` declaration, with the structural +//! `EncryptFrom` beside it as a bridge, so the record is a field of a plan +//! or of a structural record as well as an owned target. +//! +//! The derive composes the field declarations directly: a `struct = ..` +//! record picks each field out of the plaintext (`project`, borrowed; a +//! chain of `split`s, owned) and runs the field's own target under the pair +//! the record infers; a `plaintext = T` record runs every field's target +//! over the whole value under the one context the caller supplies. Which +//! context a field may be handed is the field target's `ContextInput`, so a +//! target that cannot take the record's context is refused at that field. +use crate::shape::{fresh_lifetime, trait_impl, ByField, Field, Flavor, Kind, OwnContext, Record}; +use proc_macro2::{Span, TokenStream}; +use quote::{quote, quote_spanned}; +use syn::spanned::Spanned; +use syn::{parse_quote, DeriveInput, Ident, Result, Type, WherePredicate}; + +pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { + let record = Record::parse_as(&input, Flavor::Owned)?; + let derived = record.derived(); + let decryptable = crate::encrypt::decryptable_impl(&input, &record, &derived); + let emitted = if record.by_field { + struct_derive(&input, &record) + } else { + value_derive(&input, &record, &derived) + }; + Ok(quote!(#emitted #decryptable)) +} + +/// The caller's input on the encrypt side: an extension for a record whose +/// fields carry their own contexts, the required `NonEmpty<T>` a record +/// stores, otherwise the required caller context (or the `context_type`). +pub(crate) fn encrypt_input(record: &Record) -> Type { + let krate = &record.krate; + if let Some(field) = record.context_field() { + let ty = &field.ty; + parse_quote!(#krate::target::RequiredContext<#krate::NonEmpty<#ty>>) + } else if record.by_field { + parse_quote!(#krate::target::ContextExtension<#krate::target::DeclaredContext>) + } else { + let caller = caller_context(record); + parse_quote!(#krate::target::RequiredContext<#caller>) + } +} + +/// The context every field of a `plaintext` record is run under: what the +/// record is handed, or the `CallerContext` built from the stored one. +pub(crate) fn threaded_input(record: &Record) -> Type { + let krate = &record.krate; + let caller = caller_context(record); + parse_quote!(#krate::target::RequiredContext<#caller>) +} + +/// The bare context type a `plaintext` record's fields take: the +/// `context_type` named, or `CallerContext`. +fn caller_context(record: &Record) -> Type { + let krate = &record.krate; + record + .context_type + .clone() + .unwrap_or_else(|| parse_quote!(#krate::target::CallerContext)) +} + +/// A where-predicate carrying the span of the field it is about, so what a +/// field's type refuses is reported at that field. +pub(crate) fn spanned_predicate(span: Span, tokens: TokenStream) -> WherePredicate { + let tokens = quote_spanned!(span=> #tokens); + syn::parse2(tokens).unwrap_or_else(|error| { + unreachable!("a predicate the derive wrote does not parse: {error}") + }) +} + +/// The fields the encryption produces, in declaration order: the derived +/// fields and the passthrough fields. Each gets an output local. +fn participating(record: &Record) -> Vec<&Field> { + record + .fields + .iter() + .filter(|field| field.is_derived() || field.is_passthrough()) + .collect() +} + +/// `a.zip(b).zip(c)` over the locals, and the pattern binding each back. +fn zip_chain(locals: &[&Ident]) -> (TokenStream, TokenStream) { + let mut chain = TokenStream::new(); + let mut pattern = TokenStream::new(); + for (index, local) in locals.iter().enumerate() { + if index == 0 { + chain = quote!(#local); + pattern = quote!(#local); + } else { + chain = quote!(#chain.zip(#local)); + pattern = quote!((#pattern, #local)); + } + } + (chain, pattern) +} + +/// The struct literal filling every field: participating fields from their +/// locals, `default` fields with their defaults. +fn literal(record: &Record, stored: Option<TokenStream>) -> TokenStream { + let assignments = record.fields.iter().map(|field| { + let member = &field.member; + let local = &field.local; + let value = match &field.kind { + Kind::Derived { .. } | Kind::Passthrough { .. } => quote!(#local), + Kind::Default(Some(expr)) => quote!(#expr), + Kind::Default(None) => quote!(::core::default::Default::default()), + Kind::Context => stored + .clone() + .unwrap_or_else(|| unreachable!("a context field has a stored value")), + }; + quote!(#member: #value) + }); + quote!(Self { #(#assignments),* }) +} + +/// `#[stash(struct = User, context = "users")]`: each field picked out of +/// the plaintext and run by its own target under the pair the record +/// infers; a passthrough field carried through. Borrowed, the fields are +/// projected; owned, the struct is divided between them with `split`, so a +/// passthrough field is moved and a sealed field is guarded by its target. +fn struct_derive(input: &DeriveInput, record: &Record) -> TokenStream { + let krate = &record.krate; + let source = &record.plaintexts[0]; + let fields = participating(record); + let locals: Vec<&Ident> = fields.iter().map(|field| &field.local).collect(); + + // Borrowed: `project` a reference to the field. + let borrowed: Vec<TokenStream> = fields + .iter() + .map(|field| { + let ty = &field.ty; + let local = &field.local; + match &field.kind { + Kind::Derived { + by_field: Some(ByField { from, context }), + } => { + let own = own_expr(krate, context); + quote_spanned! {ty.span()=> + let #local = <#ty as #krate::target::EncryptTarget<_>>::encryption::<__K>() + .project(|__source: &#source| &__source.#from) + .under(#own); + } + } + Kind::Passthrough { from } => quote_spanned! {ty.span()=> + let #local = #krate::target::passthrough::<_, __K, #krate::target::Borrowed, #krate::target::DeclaredContext>() + .project(|__source: &#source| &__source.#from); + }, + _ => unreachable!("only derived and passthrough fields participate"), + } + }) + .collect(); + let (zipped, zip_pattern) = zip_chain(&locals); + let value = literal(record, None); + let borrowed_body = quote! { + #(#borrowed)* + #zipped.map(move |#zip_pattern| #value).accepting() + }; + + // Owned: each field's own owned description, the struct divided + // between them right to left. + let owned: Vec<TokenStream> = fields + .iter() + .map(|field| { + let ty = &field.ty; + let local = &field.local; + match &field.kind { + Kind::Derived { + by_field: Some(ByField { context, .. }), + } => { + let own = own_expr(krate, context); + quote_spanned! {ty.span()=> + let #local = <#ty as #krate::target::EncryptTarget<_>>::owned_encryption::<__K>() + .under(#own); + } + } + Kind::Passthrough { .. } => quote_spanned! {ty.span()=> + let #local = #krate::target::passthrough::<_, __K, #krate::target::Owned, #krate::target::DeclaredContext>(); + }, + _ => unreachable!("only derived and passthrough fields participate"), + } + }) + .collect(); + // The plaintext destructured into one local per participating field, + // every other field dropped as it is (`..`). + let bindings = fields.iter().map(|field| { + let from = field.from().unwrap_or_else(|| unreachable!("a `from`")); + let local = &field.local; + quote!(#from: #local) + }); + let nested = nested_tuple(&locals); + let divide = quote!(|__source: #source| { + let #source { #(#bindings),*, .. } = __source; + #nested + }); + let split = match locals.as_slice() { + [single] => quote!(#single.project_owned(#divide)), + [first, rest @ ..] => { + // `rest` as one description over the nested tuple of its parts, + // divided by identity; then the struct divided into `first` and + // that tuple. + let mut tail = { + let last = rest[rest.len() - 1]; + quote!(#last) + }; + for local in rest[..rest.len() - 1].iter().rev() { + tail = quote!(#local.split(#tail, |__parts| __parts)); + } + quote!(#first.split(#tail, #divide)) + } + [] => unreachable!("a record has a derived field"), + }; + let owned_body = quote! { + #(#owned)* + #split.map(move |#nested| #value).accepting() + }; + + let lifetime = fresh_lifetime(&input.generics, "__source"); + let mut generics = input.generics.clone(); + generics + .make_where_clause() + .predicates + .push(parse_quote!(Self: 'static)); + let encrypt_target = trait_impl( + input, + &generics, + quote!(#krate::target::EncryptTarget<#source>), + quote! { + type ContextInput = #krate::target::ContextExtension<#krate::target::DeclaredContext>; + fn encryption<#lifetime, __K: 'static>() -> #krate::target::Encryption<#lifetime, #source, Self, __K, Self::ContextInput> where #source: #lifetime { + #borrowed_body + } + fn owned_encryption<#lifetime, __K: 'static>() -> #krate::target::Encryption<#lifetime, #source, Self, __K, Self::ContextInput, #krate::target::Owned> where #source: #lifetime { + #owned_body + } + }, + ); + let bridge = bridge( + input, + record, + source, + parse_quote!(#krate::target::DeclaredContext), + false, + ); + quote!(#encrypt_target #bridge) +} + +/// The nested tuple `(a, (b, (c, d)))` of the locals: how `split` divides +/// a struct, one part per step. +fn nested_tuple(locals: &[&Ident]) -> TokenStream { + match locals { + [] => quote!(()), + [single] => quote!(#single), + [first, rest @ ..] => { + let rest = nested_tuple(rest); + quote!((#first, #rest)) + } + } +} + +fn own_expr(krate: &syn::Path, context: &OwnContext) -> TokenStream { + let OwnContext { prefix, field } = context; + quote!(#krate::nonempty!(#prefix).with(#field)) +} + +/// `#[stash(plaintext = T)]` (or none): every field's target run over the +/// whole value under the one required context, zipped. A record with a +/// `context_field` requires the `NonEmpty<T>` it stores and carries it out +/// beside the outputs. +fn value_derive(input: &DeriveInput, record: &Record, fields: &[&Field]) -> TokenStream { + let krate = &record.krate; + let input_type = encrypt_input(record); + let threaded = threaded_input(record); + let stored = record.context_field().map(|field| field.ty.clone()); + let (sources, generic) = record.sources(parse_quote!(__S)); + let lifetime = fresh_lifetime(&input.generics, "__source"); + let locals: Vec<&Ident> = fields.iter().map(|field| &field.local).collect(); + let (zipped, zip_pattern) = zip_chain(&locals); + let mut impls = Vec::new(); + for source in sources { + let mut generics = input.generics.clone(); + if generic { + generics + .params + .push(parse_quote!(__S: #krate::target::Zeroize)); + } + let predicates = &mut generics.make_where_clause().predicates; + predicates.push(parse_quote!(Self: 'static)); + // Per field, spanned at the field: a target that does not accept + // the record's context is refused there. A generic impl states the + // same per field in its where clause, which the body is checked + // against; a concrete one lets the body report at the field alone, + // since a where clause's failure is reported at the derive. + let descriptions: Vec<TokenStream> = fields + .iter() + .map(|field| { + let ty = &field.ty; + let local = &field.local; + if generic { + predicates.push(spanned_predicate( + ty.span(), + quote!(#ty: #krate::target::EncryptTarget<#source>), + )); + predicates.push(spanned_predicate( + ty.span(), + quote!(#threaded: ::core::convert::Into<<#ty as #krate::target::EncryptTarget<#source>>::ContextInput>), + )); + } + quote_spanned! {ty.span()=> + let #local = <#ty as #krate::target::EncryptTarget<#source>>::encryption::<__K>() + .accepting::<#threaded>(); + } + }) + .collect(); + let indexes = fields.iter().map(|field| { + let ty = &field.ty; + quote_spanned!(ty.span()=> __indexes.extend(<#ty as #krate::target::EncryptTarget<#source>>::indexes());) + }); + let body = match &stored { + None => { + let value = literal(record, None); + quote!(#zipped.map(move |#zip_pattern| #value).accepting::<Self::ContextInput>()) + } + Some(_) => { + let value = literal( + record, + Some( + quote!(#krate::target::RequiredContext::into_inner(__stored).into_inner()), + ), + ); + quote! { + #zipped + .accepting::<Self::ContextInput>() + .map_with_context(move |#zip_pattern, __stored: Self::ContextInput| #value) + } + } + }; + impls.push(trait_impl( + input, + &generics, + quote!(#krate::target::EncryptTarget<#source>), + quote! { + type ContextInput = #input_type; + fn encryption<#lifetime, __K: 'static>() -> #krate::target::Encryption<#lifetime, #source, Self, __K, Self::ContextInput> where #source: #lifetime { + #(#descriptions)* + #body + } + fn indexes() -> ::std::vec::Vec<#krate::target::IndexSpec> { + let mut __indexes = ::std::vec::Vec::new(); + #(#indexes)* + __indexes + } + }, + )); + let legacy = record.context_type(false); + impls.push(bridge(input, record, &source, legacy, generic)); + } + quote!(#(#impls)*) +} + +/// The structural `EncryptFrom` bridge: the record's legacy context, +/// converted into its owned input at the root. Emitted beside every owned +/// declaration so an owned record is a field of a plan or of a structural +/// record without a second derive. +fn bridge( + input: &DeriveInput, + record: &Record, + source: &Type, + legacy: Type, + generic: bool, +) -> TokenStream { + let krate = &record.krate; + let lifetime = fresh_lifetime(&input.generics, "__source"); + let mut generics = input.generics.clone(); + if generic { + generics + .params + .push(parse_quote!(__S: #krate::target::Zeroize)); + } + let predicates = &mut generics.make_where_clause().predicates; + predicates.push(parse_quote!(Self: #krate::target::EncryptTarget<#source>)); + predicates.push(parse_quote!( + #legacy: ::core::convert::Into<<Self as #krate::target::EncryptTarget<#source>>::ContextInput> + )); + trait_impl( + input, + &generics, + quote!(#krate::target::EncryptFrom<#source>), + quote! { + type Context = #legacy; + fn encryption<#lifetime, __K: 'static>() -> #krate::target::Encryption<#lifetime, #source, Self, __K, Self::Context> where #source: #lifetime { + <Self as #krate::target::EncryptTarget<#source>>::encryption::<__K>().accepting::<Self::Context>() + } + fn indexes() -> ::std::vec::Vec<#krate::target::IndexSpec> { + <Self as #krate::target::EncryptTarget<#source>>::indexes() + } + }, + ) +} diff --git a/packages/stack-encrypt-derive/src/shape.rs b/packages/stack-encrypt-derive/src/shape.rs index 568a634cc..50c9e9fe8 100644 --- a/packages/stack-encrypt-derive/src/shape.rs +++ b/packages/stack-encrypt-derive/src/shape.rs @@ -51,6 +51,14 @@ pub(crate) enum Kind { /// Not derived: `Default::default()` or the given expression. Default(Option<Expr>), Context, + /// `#[stash(passthrough)]`, with `struct = ..` on the owned derives: + /// the plaintext field `from` is moved into the record as it is, not + /// encrypted and not authenticated, and moved back on decryption. + Passthrough { + /// The plaintext field carried through: its own name, or the + /// `#[stash(from = field)]` override. + from: Member, + }, } /// What a field of a `struct = ..` derive has and a field of a `plaintext` @@ -71,10 +79,15 @@ impl Field { matches!(self.kind, Kind::Derived { .. }) } - /// The `from` member, if this is a derived field with one. + pub(crate) fn is_passthrough(&self) -> bool { + matches!(self.kind, Kind::Passthrough { .. }) + } + + /// The `from` member, if this is a derived or passthrough field with one. pub(crate) fn from(&self) -> Option<&Member> { match &self.kind { Kind::Derived { by_field } => by_field.as_ref().map(|by_field| &by_field.from), + Kind::Passthrough { from } => Some(from), Kind::Default(_) | Kind::Context => None, } } @@ -99,7 +112,9 @@ impl Field { by_field: Some(by_field), } => FieldContext::Own(&by_field.context), Kind::Derived { by_field: None } => FieldContext::Caller, - Kind::Default(_) | Kind::Context => unreachable!("a `default` field has no context"), + Kind::Default(_) | Kind::Context | Kind::Passthrough { .. } => { + unreachable!("a `default` or `passthrough` field has no context") + } } } } @@ -135,6 +150,18 @@ pub(crate) enum FieldContext<'a> { Caller, } +/// Which pair of declaration traits a derive emits. +#[derive(Clone, Copy, PartialEq, Eq)] +#[cfg_attr(test, derive(Debug))] +pub(crate) enum Flavor { + /// `EncryptFrom` / `DecryptInto`: the structural declarations, run over + /// a borrowed plaintext. + Structural, + /// `EncryptTarget` / `DecryptSource`: the owned declarations, which + /// also emit the structural pair as a bridge. + Owned, +} + /// The record a derive input describes. #[cfg_attr(test, derive(Debug))] pub(crate) struct Record { @@ -159,6 +186,10 @@ impl Record { } pub(crate) fn parse(input: &DeriveInput) -> Result<Self> { + Self::parse_as(input, Flavor::Structural) + } + + pub(crate) fn parse_as(input: &DeriveInput, flavor: Flavor) -> Result<Self> { let attrs = ContainerAttrs::parse(&input.attrs)?; let data = match &input.data { @@ -181,6 +212,16 @@ impl Record { // `ContainerAttrs::parse` has established that `context` is present // exactly when `struct` is. let fields = collect(&data.fields, attrs.context.as_ref())?; + if flavor == Flavor::Structural { + if let Some(field) = fields.iter().find(|f| f.is_passthrough()) { + return Err(syn::Error::new_spanned( + &field.ty, + "`passthrough` is a field of an owned record: derive `EncryptTarget` and \ + `DecryptSource`, which move the plaintext field into the record and back. \ + The structural `EncryptFrom` / `DecryptInto` derives have no form for it", + )); + } + } if fields .iter() .filter(|f| matches!(f.kind, Kind::Context)) @@ -244,7 +285,10 @@ impl Record { /// `Encrypted<Terms>` instead; two fields keyed under one segment would /// share their data's identity. fn one_output_per_plaintext_field(fields: &[Field]) -> Result<()> { - let derived: Vec<&Field> = fields.iter().filter(|f| f.is_derived()).collect(); + let derived: Vec<&Field> = fields + .iter() + .filter(|f| f.is_derived() || f.is_passthrough()) + .collect(); for (at, field) in derived.iter().enumerate() { let name = |f: &Field| f.member.to_token_stream().to_string(); for earlier in &derived[..at] { @@ -265,6 +309,9 @@ fn one_output_per_plaintext_field(fields: &[Field]) -> Result<()> { )); } } + if field.is_passthrough() || earlier.is_passthrough() { + continue; + } if let (FieldContext::Own(own), FieldContext::Own(earlier_own)) = (field.field_context(), earlier.field_context()) { @@ -355,15 +402,39 @@ fn collect(fields: &Fields, prefix: Option<&LitStr>) -> Result<Vec<Field>> { && (attrs.default.is_some() || attrs.identity.is_some() || attrs.from.is_some() - || attrs.decrypt) + || attrs.decrypt + || attrs.passthrough) { return Err(syn::Error::new_spanned( &field.ty, - "`context_field` is metadata and cannot also be derived or defaulted", + "`context_field` is metadata and cannot also be derived, defaulted or \ + passed through", )); } + if attrs.passthrough { + if prefix.is_none() { + return Err(syn::Error::new_spanned( + &field.ty, + "`passthrough` carries one field of the plaintext struct into the record \ + in the clear, which is what `#[stash(struct = ..)]` reaches: a \ + `plaintext` record derives every field from the whole value", + )); + } + if attrs.default.is_some() || attrs.identity.is_some() || attrs.decrypt { + return Err(syn::Error::new_spanned( + &field.ty, + "a `passthrough` field is moved through as it is, so `identity`, \ + `default` and `decrypt` do not apply to it: it is keyed under nothing, \ + filled from the plaintext and moved back on decryption", + )); + } + } let kind = if attrs.context_field { Kind::Context + } else if attrs.passthrough { + Kind::Passthrough { + from: attrs.from.unwrap_or_else(|| member.clone()), + } } else { match attrs.default { Some(default) => { diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index cc3ac27e6..5eded5638 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking +- **The structural `Vec<T>: DecryptInto<Vec<P>>` and `DecryptField<Vec<P>, _>` + impls require `P: Zeroize`** (#1483). They opened through `Decryption::all`, + which dropped the elements recovered before a late element failure as they + were; they now open through the wiping collection path the owned + `Vec<T>: DecryptSource<Vec<P>>` uses, so the recovered prefix is wiped + before the error is returned. A struct plaintext a structural record + opens a column into derives `Zeroize`; every scalar the engine opens + into already carries it. - Repeated plan-chain `.extend(..)` calls now append parts in order, matching Go's `Cipher.Extend`, instead of replacing earlier extensions. `.extend(())` adds nothing, and grouped `NonEmpty` parts retain their grouping. @@ -20,6 +28,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Owned records: `#[derive(EncryptTarget, DecryptSource)]`** (#1483). A + struct encrypts field by field into a record type that states its own + layout: `#[stash(struct = User, context = "users")]` hands each field's + target the pair `("users", "<field>")`, `#[stash(passthrough)]` carries a + plaintext field into the record in the clear — moved, never copied, and + neither encrypted nor authenticated — and `#[stash(from = ..)]` and + `identity = ".."` rename the source field and pin the segment as on the + structural derive. `user.encrypt_into(&keyset)` consumes the plaintext, + wipes every field it seals and moves the passthrough fields; + `User::decrypt_from(record, &cipher)` opens each field under its pair and + moves them back; `encrypt_into_with_context(&keyset, 7u64)` extends every + pair by the row id and the same extension opens it. Records nest (a + record-typed field's own pairs are extended by the outer pair), a + `#[stash(plaintext = String)]` record is a searchable scalar (a + `Ciphertext` beside an `EqualityTerm`) and a field of a `struct` record, + a `context_field` record stores its context and opens under an + `ExpectedContext`, and a `Vec<User>` encrypts into a `Vec<Record>` as + one batch. The plaintext type is `Zeroize`. Both derives also emit the + structural `EncryptFrom` / `DecryptInto` / `DecryptField` declarations as a + bridge, so an owned record is a field of a `#[derive(EncryptFrom)]` + record or a typed plan field without a second derive; derive one pair or + the other, not both. The crate docs' "Records" section and the derive + crate's "Owned records" section show the forms; `tests/ui` pins the ones + that are refused, at the field. +- **`EncryptTarget::owned_encryption` and `indexes`**: the description over + the plaintext by value, in `Owned` source mode, defaulting to guard the + value with `Protected`, run `encryption` over a borrow and wipe it before + the pending is returned; `EncryptInto` and the owned column run it. A + record overrides it to divide the struct between its fields with the new + `Encryption::split` (two descriptions over two parts of one value, one + context) and `Encryption::project_owned`. `indexes` is what a plan field + typed with the target answers queries by; the terms, `Encrypted<Terms>` + and `plaintext` records report theirs. +- **The core leaves implement the owned declarations**: `StackCipherText` + (`RequiredContext<AeadContext>`, both ways), `Encrypted<Terms>` + (`RequiredContext<CallerContext>` to seal, `RequiredContext<AeadContext>` + to open), and `EqualityTerm`, `MatchTerms`, `OreTerm`, `OpeTerm` and + `OreBlockTerm` as `EncryptTarget` alone; `Ciphertext` is `Decryptable` and + a `DecryptField`, so it is a record field. +- **`ContextExtension<C>`**, the caller input of a record whose fields carry + their own contexts: omission keeps them, a supplied part extends each. + **`ExpectedContext<T>` is a `ContextInput`**: omission opens a stored + context as stored, a supplied `NonEmpty<T>` also checks it. Both convert + from the structural contexts (`DeclaredContext`, `CallerContext`), as the + `RequiredContext` wrappers of the core contexts now do, which is how a + record field is handed its pair. +- **`FieldsBuilder::target::<T, _>()`**: a plan field laid out by an owned + target that has no structural declaration, such as a `Ciphertext`. + Lowers to `<T as EncryptTarget<F>>::encryption().accepting::<CallerContext>()`. + +### Deprecated + +- The structural derives `#[derive(EncryptFrom, DecryptInto)]` and the + `target::legacy` conversion traits remain for the plans and the dynamic + engine that run them, and are the migration path's starting point, not its + end: new records derive `EncryptTarget` / `DecryptSource`, which emit the + structural declarations too. Their removal is tracked separately (#1488) + and follows once every consumer has an owned equivalent. No + `#[deprecated]` attribute is applied yet, since the crate's own plan and + dynamic modules still implement them. + - **Owned collections: `Vec<S>` encrypts into `Vec<T>` and back through `EncryptInto` and `DecryptFrom`** (#1482). `Vec<T>` implements `EncryptTarget<Vec<S>>` and `DecryptSource<Vec<P>>` for any owned element diff --git a/packages/stack-encrypt/docs/adr/0011-owned-record-declarations.md b/packages/stack-encrypt/docs/adr/0011-owned-record-declarations.md new file mode 100644 index 000000000..63a7e40fe --- /dev/null +++ b/packages/stack-encrypt/docs/adr/0011-owned-record-declarations.md @@ -0,0 +1,127 @@ +--- +status: accepted +date: 2026-11-03 +extends: ADR-0003, ADR-0004 +--- + +# Records declare themselves as owned targets, and the structural pair becomes a bridge + +The owned API (`EncryptInto`, `DecryptFrom`, over `EncryptTarget` / +`DecryptSource` with explicit `ContextInput` capabilities) covered one +opaque scalar and then a column of them, while every record — the derives, +the plan builder's typed fields, the dynamic lowering — still spoke the +structural pair, `EncryptFrom` / `DecryptInto`, over a borrowed plaintext +with a `Default`-driven context. A user writing the mixed record the public +examples show (`id` in the clear, `name` and `email` sealed) had no derive +that consumed and wiped the plaintext, no way to say "carry this field +through", and no promise that the record's fields and the scalar API agreed +on anything but luck. + +## Decision + +### 1. One trait, two forms of the description + +`EncryptTarget<S>` keeps `encryption()`, the description over `&S`, and +gains `owned_encryption()`, the description over `S` by value, in +`Owned` source mode. The default `owned_encryption` guards the value in +`Protected`, runs the borrowed description over it and wipes it before the +pending request is returned — exactly what `EncryptInto` did for a scalar +by hand — so a declaration that only composes operations implements nothing +new. `EncryptInto` now runs `owned_encryption`, and the collection +declaration runs each element's. + +The signature map the plan asked for: + +| Declaration | Over | Handed | Cleanup | +|---|---|---|---| +| `encryption()` | borrowed `&S` | `Encryption<'s, S, T, K, Input>` | the caller's, or the owner guarding the borrow | +| `owned_encryption()` | `S` by value | `Encryption<'s, S, T, K, Input, Owned>` | the declaration's: default guard-and-wipe | +| `decryption(self, Input)` | the stored value | `Decryption<P, K>` | the engine's until return, then the caller's | + +### 2. A record divides its plaintext between its fields + +A `#[derive(EncryptTarget)]` `struct = User` record composes the field +targets directly. Its borrowed form projects each field out of the borrow +(`Encryption::project`). Its owned form divides the struct with a chain of +`Encryption::split` calls, a new `Owned`-mode combinator that hands two +different parts of one value to two descriptions under one context: a +`#[stash(passthrough)]` field is moved into the record by +`passthrough::<_, _, Owned, _>()` and never copied, and each sealed field +runs its own target's `owned_encryption`, so a `String` field is guarded +and wiped by the `Ciphertext` target that seals it. The divider is a plain +function pointer that destructures the struct: it captures nothing, so like +`project`'s selector it cannot reach a cipher, and it only moves. That is +the accepted plaintext-ownership rule made mechanical: the conversion wipes +what it seals and transfers what the caller chose to keep in the clear. + +A passthrough field is not encrypted and not authenticated (ADR-0010). It +is `Clone` because the borrowed form, which a plan or a structural record +runs, has to copy it; the owned form never does, and a test with a `Clone` +that panics holds it to that. + +### 3. Caller context is a capability, composed through the tree + +Two inputs join `NoCallerContext`, `RequiredContext<C>` and +`OptionalContext<C>`: + +- `ContextExtension<C>`: omission keeps the declared contexts; a supplied + part extends each of them. What a `struct` record takes, both ways. +- `ExpectedContext<T>` (the existing type, now a `ContextInput`): omission + opens under the stored context; a supplied `NonEmpty<T>` also checks it. + What a `context_field` record takes to open. + +A `plaintext = T` record takes `RequiredContext<CallerContext>` (or of +`AeadContext` with `context_type`), and `RequiredContext<NonEmpty<T>>` when +it stores its context. The core leaves take the same wrappers around the +context their operation needs, so a term is an `EncryptTarget` alone, and +nothing is inferred from `Default`. + +A record field is handed its pair extended by the caller's part through +`Encryption::under` (ADR-0004), which asks the field's input to be +`From<CallerContext>`. That one bound is the field-specific diagnostic: an +`OptionalContext`, a `RequiredContext` of a core context and a nested +record's `ContextExtension` satisfy it; a `NoCallerContext` target or a +`RequiredContext<u64>` target does not, and the error is reported at the +field. A `plaintext` record's fields are handed the one context the record +requires, so a term beside an `AeadContext`-only record is refused at the +term, and omitting a required context is refused at the call. + +### 4. The structural pair is a bridge, not a second declaration + +Both owned derives also emit the structural declarations: `EncryptTarget` +emits `EncryptFrom` (the record's legacy context converted into its owned +input at the root) and `Decryptable`; `DecryptSource` emits `DecryptInto` +and `DecryptField`. The core leaves implement both pairs by hand. So an +owned record is a field of a structural record and a typed plan field +without a second derive, and the plan builder gains `target::<T, _>()` for +a target that declares itself the owned way alone (`Ciphertext`). The +dynamic lowering is untouched: the equivalence test seals one record +through a hand-written `EncryptTarget`, the derive and the data plan, +against one recording provider, and finds the same descriptors, the same +term bytes and every author opening every author's record. The structural +derives stay, for the plans and the dynamic engine that still speak them; +their removal is a later decision, once every consumer has an owned +equivalent. + +### 5. The structural `Vec` opens through the wiping path + +`Vec<T>: DecryptInto<Vec<P>>` and `DecryptField<Vec<P>, _>` opened through +`Decryption::all`, which dropped the elements recovered before a late +failure as they were. They now open through `all_wiping`, as the owned +column does, which puts `P: Zeroize` on those public impls: every plaintext +the engine opens into carries it, and a structural caller with a struct +plaintext derives it. + +## Consequences + +- `user.encrypt_into(&keyset)` on a `struct` record wipes `name` and + `email` and moves `id`; `Vec<User>` into `Vec<SearchableUser>` is one + key request; `User::decrypt_from(record, &cipher)` moves `id` back. +- A record's plaintext is `Zeroize`. The bound is where the wiping + obligation is declared, not a promise about caller-retained copies or + compiler temporaries (ADR-0010's limits stand). +- Configured targets (a JSON index with options) are not yet owned-record + fields: the one configurable target's plaintext, `serde_json::Value`, is + not `Zeroize`, so it cannot be an owned source. The attribute for a + typed options factory waits on a zeroizing document plaintext. +- `Option<T>` and tuple targets remain structural-only in this slice. diff --git a/packages/stack-encrypt/examples/encrypted_record.rs b/packages/stack-encrypt/examples/encrypted_record.rs index 3e0c8a978..d6bf06a25 100644 --- a/packages/stack-encrypt/examples/encrypted_record.rs +++ b/packages/stack-encrypt/examples/encrypted_record.rs @@ -48,7 +48,9 @@ struct EncryptedAge { } /// The plaintext. -#[derive(Debug, Clone, PartialEq)] +// `Zeroize`: a column of users opens through the wiping collection path, +// which wipes the rows recovered before a late failure. +#[derive(Debug, Clone, PartialEq, zeroize::Zeroize)] struct User { age: u32, email: String, diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 497e2b024..c0a1b5a26 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -287,11 +287,79 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! own, so `Vec<u8>` neither encrypts byte by byte nor as one `Ciphertext` //! here. //! -//! Existing structural plans and derives still use [`StackCipherText`], a tree -//! of sealed leaves. Their low-level direct entry points are +//! # Records +//! +//! A struct encrypts field by field into a record type that states its own +//! layout: `#[derive(EncryptTarget, DecryptSource)]` with +//! `#[stash(struct = User, context = "users")]`. A field whose type is a +//! target (a [`Ciphertext`], a record, a term) is derived from the plaintext +//! field of its name under the pair `("users", "<field>")`; a +//! `#[stash(passthrough)]` field is carried into the record in the clear, +//! **moved** rather than copied and neither encrypted nor authenticated; a +//! `#[stash(from = ..)]` renames the plaintext field a field is taken from. +//! The conversion wipes the plaintext fields it seals and moves the ones it +//! passes through, so the plaintext type is `Zeroize` and nothing is cloned. +//! A caller's context extends every field's pair +//! ([`ContextExtension`]); omitted, the pairs stand as declared. +//! +//! ``` +//! # async fn example() -> Result<(), stack_encrypt::Error> { +//! use stack_encrypt::target::{DecryptSource, EncryptTarget}; +//! use stack_encrypt::{Ciphertext, DecryptFrom, EncryptInto, StackCipher}; +//! +//! #[derive(Debug, PartialEq, zeroize::Zeroize)] +//! struct User { id: u64, name: String, email: String } +//! +//! #[derive(EncryptTarget, DecryptSource, serde::Serialize, serde::Deserialize)] +//! #[stash(struct = User, context = "users")] +//! struct EncryptedUser { +//! #[stash(passthrough)] +//! id: u64, +//! name: Ciphertext, // under ("users", "name") +//! email: Ciphertext, // under ("users", "email") +//! } +//! +//! let cipher = StackCipher::builder() +//! .kms(stack_encrypt::kms::FakeDataKeySource::new()).init().await?; +//! let keyset = cipher.default_keyset(); +//! let user = User { id: 42, name: "Alice".into(), email: "alice@example.com".into() }; +//! +//! // One conversion, one batched key request for both sealed fields. +//! let encrypted: EncryptedUser = user.encrypt_into(&keyset).await?; +//! assert_eq!(encrypted.id, 42); +//! let stored = serde_json::to_vec(&encrypted).unwrap(); +//! +//! let loaded: EncryptedUser = serde_json::from_slice(&stored).unwrap(); +//! let restored = User::decrypt_from(loaded, &cipher).await?; +//! assert_eq!(restored.email, "alice@example.com"); +//! +//! // Bound to a row: the same extension is needed to open it. +//! let user = User { id: 43, name: "Bob".into(), email: "bob@example.com".into() }; +//! let encrypted: EncryptedUser = user.encrypt_into_with_context(&keyset, 43u64).await?; +//! let restored = User::decrypt_from_with_context(encrypted, &cipher, 43u64).await?; +//! assert_eq!(restored.id, 43); +//! # Ok(()) +//! # } +//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(example()).unwrap(); +//! ``` +//! +//! A `#[stash(plaintext = String)]` record is the other shape: every field +//! is derived from the whole value under the one context the caller must +//! supply ([`target::RequiredContext`]) — a [`Ciphertext`] beside an +//! [`sem::EqualityTerm`] is a searchable scalar — and it is a field of a +//! `struct` record like any target. A `Vec` of plaintext structs encrypts +//! into a `Vec` of records as one batch. The derive crate's documentation +//! lists every attribute; [`target`] describes the declaration traits a +//! hand-written target implements, and how a record field's context is +//! composed. +//! +//! Existing structural plans and derives (`#[derive(EncryptFrom, DecryptInto)]`) +//! still use [`StackCipherText`], a tree +//! of sealed leaves, over a borrowed plaintext. Their low-level direct entry points are //! [`KeysetCipher::encrypt_tree`] and [`KeysetCipher::decrypt_tree`]; their -//! borrowed conversion helpers live in [`target::legacy`]. They are separate -//! from the owned scalar API. There is no generic `Serialize` fallback and +//! borrowed conversion helpers live in [`target::legacy`]. An owned record +//! is also a structural declaration (its derive emits the bridge), so it is +//! a field of a structural record or a typed plan field too. There is no generic `Serialize` fallback and //! no implicit `Vec<u8>` blob. //! //! # What is authenticated @@ -375,10 +443,10 @@ pub use stack_kms as kms; /// crate, so a binding encodes an error from any of them the same way. pub use stack_kms::{diagnostic, ErrorPayload}; pub use target::{ - CallerContext, CipherScope, DecryptField, DecryptFrom, DecryptInto, DecryptSource, Decryptable, - Decryption, EncryptFrom, EncryptInto, EncryptTarget, Encrypted, Encryption, Equality, Index, - IndexSpec, Indexes, Json, Match, Ope, Ore, OreBlock, Pending, PendingFuture, Request, - Responses, TermSet, + CallerContext, CipherScope, ContextExtension, DecryptField, DecryptFrom, DecryptInto, + DecryptSource, Decryptable, Decryption, EncryptFrom, EncryptInto, EncryptTarget, Encrypted, + Encryption, Equality, Index, IndexSpec, Indexes, Json, Match, Ope, Ore, OreBlock, Pending, + PendingFuture, Request, Responses, TermSet, }; // Re-export the vitaminc AEAD surface callers need to drive the cipher, so they diff --git a/packages/stack-encrypt/src/plan/build.rs b/packages/stack-encrypt/src/plan/build.rs index 3cbf85ca1..85754a6e6 100644 --- a/packages/stack-encrypt/src/plan/build.rs +++ b/packages/stack-encrypt/src/plan/build.rs @@ -11,7 +11,7 @@ use super::values::Slot; use super::{FieldValues, PlanError}; use crate::target::{ ciphertext, indexed, inspect, open, passthrough, AeadContext, Borrowed, CallerContext, - DeclaredContext, DecryptField, Decryptable, Decryption, EncryptFrom, Encryption, + DeclaredContext, DecryptField, Decryptable, Decryption, EncryptFrom, EncryptTarget, Encryption, ExpectedContext, IndexSpec, Indexes, Pending, }; use crate::{Error, KeysetCipher, Label, LabelError, NonEmpty, StackCipherText}; @@ -973,6 +973,86 @@ impl<S: 'static, K: 'static> FieldsBuilder<S, K> { ) } + /// Lay the field `field` out as the owned target `T` does: `T`'s own + /// [`EncryptTarget<F>`] decides what is sealed and which terms sit + /// beside it, all under `<context>/<identity>`, and the record holds a + /// `T`. The owned sibling of [`encrypt_into`](Self::encrypt_into), for + /// a target that declares itself through `EncryptTarget` alone — an + /// opaque [`Ciphertext`](crate::Ciphertext), or a record deriving + /// `EncryptTarget` (which also answers through `encrypt_into`, by its + /// bridge). Lowers to + /// `<T as EncryptTarget<F>>::encryption().accepting::<CallerContext>()`, + /// so the field's pair reaches the target as its caller context: the + /// target's `ContextInput` must take a `CallerContext`, which a target + /// that accepts no caller context ([`NoCallerContext`](crate::target::NoCallerContext)) or requires + /// something else does not, and that is refused at the call. + /// + /// The plan runs the target over a borrow of the value, as it does every + /// field, so the value stays the caller's and nothing is wiped here; + /// the owned conversions are [`EncryptInto`](crate::EncryptInto)'s. + /// + /// ``` + /// # use stack_encrypt::plan::pick; + /// # struct User { age: u32 } + /// # async fn example() -> Result<(), stack_encrypt::Error> { + /// use stack_encrypt::kms::FakeDataKeySource; + /// use stack_encrypt::{Ciphertext, Plan, StackCipher}; + /// + /// let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; + /// let users_plan = Plan::context("users") + /// .fields() + /// .target::<Ciphertext, _>(pick("age", |u: &User| &u.age)) + /// .build()?; + /// + /// let user = User { age: 34 }; + /// let mut record = cipher.encrypt(&user).using(&users_plan).await?; + /// let age: Ciphertext = record.take("age")?; + /// // An ordinary opaque ciphertext under the field's pair. + /// assert_eq!(cipher.default_keyset().decrypt_with_context::<u32>(age, ("users", "age")).await?, 34); + /// # Ok(()) + /// # } + /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(example()).unwrap(); + /// ``` + pub fn target<T, F>(self, field: impl FieldRef<S, F>) -> Self + where + F: Send + zeroize::Zeroize + 'static, + T: EncryptTarget<F> + Decryptable + DecryptField<F, CallerContext> + Send, + T::ContextInput: From<CallerContext>, + { + let (name, access) = resolve(field); + let read = Arc::clone(&access.read); + let field = Arc::clone(&name); + let open: Option<FieldOpener<K>> = if T::DECRYPTABLE { + Some(FieldOpener { + holds: holds::<T>, + open: Arc::new(|field, slot, context| { + Ok(stored::<T>(field, slot)? + .decryption_field::<K>(context) + .map_or_else( + || Decryption::failed(Error::NotOpened), + |d| d.map(Slot::new), + )) + }), + }) + } else { + None + }; + self.declare::<F>( + name, + &access, + FieldKind::EncryptInto, + <T as EncryptTarget<F>>::indexes(), + Arc::new(move |_| { + lifted( + <T as EncryptTarget<F>>::encryption::<K>().accepting::<CallerContext>(), + &field, + &read, + ) + }), + open, + ) + } + /// Key the field just declared under `identity` rather than its name. /// /// A field's identity is the label segment its data is keyed under, and diff --git a/packages/stack-encrypt/src/scalar.rs b/packages/stack-encrypt/src/scalar.rs index a31430311..7228fe06e 100644 --- a/packages/stack-encrypt/src/scalar.rs +++ b/packages/stack-encrypt/src/scalar.rs @@ -166,6 +166,18 @@ impl<P: OpaqueDecode> DecryptSource<P> for Ciphertext { }) } } +/// A ciphertext is what a record opens: as a field of a derived record it is +/// the recoverable one among the terms beside it. +impl crate::target::Decryptable for Ciphertext { + const DECRYPTABLE: bool = true; +} +impl<P: OpaqueDecode, Ctx: Into<OptionalContext<ScalarContext>>> crate::target::DecryptField<P, Ctx> + for Ciphertext +{ + fn decryption_field<K: 'static>(self, context: Ctx) -> Option<Decryption<P, K>> { + Some(self.decryption(context.into())) + } +} impl<K: 'static> KeysetCipher<'_, K> { /// Consume and encrypt a scalar as one opaque ciphertext with empty context. /// The owned source is wiped before this returns the batched pending request. diff --git a/packages/stack-encrypt/src/target/collections.rs b/packages/stack-encrypt/src/target/collections.rs index ea1e8056a..2316f9545 100644 --- a/packages/stack-encrypt/src/target/collections.rs +++ b/packages/stack-encrypt/src/target/collections.rs @@ -51,6 +51,29 @@ where }, ) } + /// Each element is moved to the element declaration's own owned form, + /// so an element that moves part of itself into its output (a record's + /// passthrough fields) does so here too, and each element is guarded + /// and wiped by its own declaration before the column's pending is + /// returned. + fn owned_encryption<'s, K: 'static>( + ) -> Encryption<'s, Vec<S>, Self, K, Self::ContextInput, super::Owned> + where + S: 's, + { + Encryption::from_build(move |source: Vec<S>, cipher, context: T::ContextInput| { + // Every element is run, whatever the elements before it did: + // the element's own declaration is what wipes it, so an + // element left unvisited after an earlier failure would be + // dropped as it is. The column still reports a refused + // context once, since `collect` keeps the first failure. + let elements: Vec<_> = source + .into_iter() + .map(|element| cipher.run(T::owned_encryption(), element, context.clone())) + .collect(); + Pending::collect(cipher, elements) + }) + } } impl<P: Zeroize + 'static, T: DecryptSource<P>> DecryptSource<Vec<P>> for Vec<T> diff --git a/packages/stack-encrypt/src/target/conversion.rs b/packages/stack-encrypt/src/target/conversion.rs index 223eb4afb..dd8eac2e8 100644 --- a/packages/stack-encrypt/src/target/conversion.rs +++ b/packages/stack-encrypt/src/target/conversion.rs @@ -1,7 +1,8 @@ //! Owned conversions over core-owned declarations. use super::{ - AcceptsContext, AllowsNoContext, CipherScope, ContextInput, Decryption, Encryption, Pending, + AcceptsContext, AllowsNoContext, CipherScope, ContextInput, Decryption, Encryption, IndexSpec, + Owned, Pending, }; use crate::KeysetCipher; use vitaminc_protected::{Controlled, Protected}; @@ -9,19 +10,55 @@ use zeroize::Zeroize; /// Declare how to encrypt an owned plaintext into this target. /// -/// The library guards the source with [`Protected`], then synchronously runs -/// this description over a borrow of it. The description may select core -/// operations and map their outputs; it never receives the cipher and -/// plaintext together. Source buffers are wiped before the pending request -/// is returned, including when description construction or execution fails. -/// No `Clone` bound is imposed on the source. +/// A declaration has two forms of one description, and the library runs +/// whichever the call supplies the plaintext in: +/// +/// - [`encryption`](Self::encryption) is handed the plaintext **by +/// reference**. It is what a declaration composes when it picks a field +/// out of a borrowed struct ([`Encryption::project`]), and what a +/// collection or a plan runs per element. +/// - [`owned_encryption`](Self::owned_encryption) is handed the plaintext +/// **by value**: what [`EncryptInto`] runs. Its default guards the value +/// with [`Protected`], runs the borrowed description over it and wipes it +/// before the pending request is returned, including when description +/// construction or execution fails. A record overrides it to move the +/// fields it stores in the clear into its output and to guard the ones it +/// seals, field by field ([`Encryption::split`]). +/// +/// Neither form receives the cipher and plaintext together, and no `Clone` +/// bound is imposed on the source. pub trait EncryptTarget<S: Zeroize>: Sized + 'static { /// What the caller must or may supply, independently of declared context. type ContextInput: ContextInput; - /// Describe the encryption; key I/O is deferred to the returned pending. + /// Describe the encryption over a borrowed plaintext; key I/O is deferred + /// to the returned pending. fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::ContextInput> where S: 's; + /// Describe the encryption over the plaintext by value, taking + /// responsibility for wiping what it does not carry into the output. + /// + /// The default guards the whole value and runs + /// [`encryption`](Self::encryption) over a borrow of it, so a declaration + /// that only composes operations over a borrow need not implement this. + fn owned_encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::ContextInput, Owned> + where + S: 's, + { + Encryption::from_build(move |source: S, cipher, context| { + let guarded = Protected::new(source); + let pending = cipher.run(Self::encryption(), guarded.risky_ref(), context); + drop(guarded); + pending + }) + } + /// The indexes this target's terms answer queries by, as data: what a + /// plan field typed with this target lets a query ask for. The default + /// is none, which is right for a ciphertext and for a record whose + /// fields sit under contexts of their own. + fn indexes() -> Vec<IndexSpec> { + vec![] + } } /// Declare how stored output recovers plaintext, without performing key I/O. @@ -40,12 +77,7 @@ pub trait EncryptInto: Zeroize + Sized { T: EncryptTarget<Self>, T::ContextInput: AllowsNoContext, { - let source = Protected::new(self); - cipher.run( - T::encryption(), - source.risky_ref(), - T::ContextInput::omitted(), - ) + cipher.run(T::owned_encryption(), self, T::ContextInput::omitted()) } /// Encrypt with a supplied context, without manually wrapping its presence. @@ -58,9 +90,12 @@ pub trait EncryptInto: Zeroize + Sized { T: EncryptTarget<Self>, T::ContextInput: AcceptsContext, { + // Guarded while the caller's context converts: a conversion that + // panics must not drop the plaintext unwiped. The declaration takes + // over the guard from here. let source = Protected::new(self); let context = T::ContextInput::supplied(context.into()); - cipher.run(T::encryption(), source.risky_ref(), context) + cipher.run(T::owned_encryption(), source.risky_unwrap(), context) } } impl<S: Zeroize> EncryptInto for S {} diff --git a/packages/stack-encrypt/src/target/index.rs b/packages/stack-encrypt/src/target/index.rs index ac86f45fc..b33cf1850 100644 --- a/packages/stack-encrypt/src/target/index.rs +++ b/packages/stack-encrypt/src/target/index.rs @@ -58,7 +58,10 @@ use std::marker::PhantomData; use super::context::{AeadContext, CallerContext}; use super::operations::{ciphertext, equality, json_terms, matching, ope, open, ore, ore_block}; use super::source::{ConsumeSource, ShareSource}; -use super::{DecryptField, DecryptInto, Decryptable, Decryption, EncryptFrom, Encryption}; +use super::{ + DecryptField, DecryptInto, DecryptSource, Decryptable, Decryption, EncryptFrom, EncryptTarget, + Encryption, RequiredContext, +}; use crate::json::{JsonOptions, JsonTerms}; use crate::sem::{ DefaultMatch, EqualityTerm, MatchConfig, MatchOptions, MatchTerms, OpeTerm, OreBlockTerm, @@ -614,6 +617,33 @@ where } } +/// The owned declaration of the same layout: the ciphertext with its terms, +/// under the one caller context the record field or the caller supplies. +impl<S, Terms> EncryptTarget<S> for Encrypted<Terms> +where + S: crate::Encrypt + Clone + zeroize::Zeroize, + Terms: TermSet<S>, +{ + type ContextInput = RequiredContext<CallerContext>; + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::ContextInput> + where + S: 's, + { + indexed(Terms::INDEXES).accepting() + } + fn indexes() -> Vec<IndexSpec> { + Terms::INDEXES.specs() + } +} +impl<P: crate::Decrypt + zeroize::Zeroize + Send + 'static, Terms> DecryptSource<P> + for Encrypted<Terms> +{ + type ContextInput = RequiredContext<AeadContext>; + fn decryption<K: 'static>(self, context: Self::ContextInput) -> Decryption<P, K> { + open(self.ciphertext, context.into_inner()) + } +} + /// The ciphertext is what a record opens; the terms are one-way. impl<Terms> Decryptable for Encrypted<Terms> { const DECRYPTABLE: bool = true; @@ -624,7 +654,7 @@ where Ctx: Into<<Self as DecryptInto<P>>::Context>, { fn decryption_field<K: 'static>(self, context: Ctx) -> Option<Decryption<P, K>> { - Some(self.decryption(context.into())) + Some(<Self as DecryptInto<P>>::decryption(self, context.into())) } } diff --git a/packages/stack-encrypt/src/target/input.rs b/packages/stack-encrypt/src/target/input.rs index 81abebd5e..ab3a9614d 100644 --- a/packages/stack-encrypt/src/target/input.rs +++ b/packages/stack-encrypt/src/target/input.rs @@ -1,6 +1,19 @@ //! Explicit caller-context capabilities. `Default` never grants permission to omit context. +//! +//! | Input | Omitted | Supplied | Meaning | +//! |---|---|---|---| +//! | [`NoCallerContext`] | allowed | refused | the declaration derives every context itself | +//! | [`RequiredContext<C>`] | refused | `C` | the caller supplies the context, or the part the declaration selects | +//! | [`OptionalContext<C>`] | allowed | `C` | for an opaque scalar: omission is the empty context | +//! | [`ContextExtension<C>`] | allowed | `C` | omission keeps the declared contexts; supply extends each of them | +//! | [`ExpectedContext<T>`](super::ExpectedContext) | allowed | `NonEmpty<T>` | omission opens under the stored context; supply also checks it | +//! +//! Whether a method is available is a trait bound ([`AllowsNoContext`], +//! [`AcceptsContext`]), never inferred from `C: Default`: a +//! `RequiredContext<u64>` stays required although `u64` defaults to zero. -use crate::{ContextPiece, IntoContext}; +use super::context::{CallerContext, DeclaredContext, ExpectedContext}; +use crate::{ContextPiece, IntoContext, NonEmpty}; mod sealed { pub trait Sealed {} @@ -75,6 +88,140 @@ impl<C> AcceptsContext for OptionalContext<C> { } } +/// Caller context may extend the contexts a declaration carries itself. +/// +/// What a derived record whose fields each sit under their own pair +/// (`("users", "name")`) accepts: omitted, the declared contexts stand as +/// they are; supplied, every one of them is extended by the value — `7u64` +/// makes the field `(("users", "name"), 7u64)`. Omission appends nothing, not +/// an empty part. Decrypting a record sealed under an extension needs the +/// same extension; the omitted method's availability is not a promise that +/// it opens every stored record of the type. +#[derive(Clone, Debug)] +pub struct ContextExtension<C>(Option<C>); +impl<C> ContextExtension<C> { + /// Inspect presence when interpreting a declaration. + pub fn into_inner(self) -> Option<C> { + self.0 + } +} +impl<C> sealed::Sealed for ContextExtension<C> {} +impl<C> ContextInput for ContextExtension<C> {} +impl<C> AllowsNoContext for ContextExtension<C> { + fn omitted() -> Self { + Self(None) + } +} +impl<C> AcceptsContext for ContextExtension<C> { + type Value = C; + fn supplied(value: C) -> Self { + Self(Some(value)) + } +} +// What a record's declaration tree runs under: its fields' own contexts, +// extended by the caller's part if one was given. +impl<C: Into<DeclaredContext>> From<ContextExtension<C>> for DeclaredContext { + fn from(extension: ContextExtension<C>) -> Self { + extension.0.map_or_else(Self::default, Into::into) + } +} +// A legacy `DeclaredContext` caller (a plan, a `struct = ..` derive) hands +// its extension to an owned record field through here. +impl From<DeclaredContext> for ContextExtension<DeclaredContext> { + fn from(extension: DeclaredContext) -> Self { + if extension.is_extended() { + Self::supplied(extension) + } else { + Self::omitted() + } + } +} +// A record field is handed its own pair extended by the caller's context +// (`Encryption::under`): a nested record takes that as its extension. +impl<C: From<CallerContext>> From<CallerContext> for ContextExtension<C> { + fn from(context: CallerContext) -> Self { + Self::supplied(C::from(context)) + } +} +// A field whose target requires a caller context is given the record's pair, +// extended by the caller's part: a `RequiredContext<CallerContext>` or +// `RequiredContext<AeadContext>` field sits under a `struct = ..` record. +impl<C: From<CallerContext>> From<CallerContext> for RequiredContext<C> { + fn from(context: CallerContext) -> Self { + Self::supplied(C::from(context)) + } +} +// A record that derives terms hands its ciphertext-only fields the AEAD +// half of the context it requires. +impl From<RequiredContext<CallerContext>> for RequiredContext<super::AeadContext> { + fn from(context: RequiredContext<CallerContext>) -> Self { + Self::supplied(context.into_inner().into()) + } +} +// A record that stores its context (`context_field`) requires the +// `NonEmpty<T>` it stores and runs its fields under the caller context +// built from it. +impl<'c, T: IntoContext<'c>> From<RequiredContext<NonEmpty<T>>> for RequiredContext<CallerContext> { + fn from(context: RequiredContext<NonEmpty<T>>) -> Self { + Self::supplied(context.into_inner().into()) + } +} +impl<'c, T: IntoContext<'c>> From<RequiredContext<NonEmpty<T>>> + for RequiredContext<super::AeadContext> +{ + fn from(context: RequiredContext<NonEmpty<T>>) -> Self { + Self::supplied(context.into_inner().into()) + } +} +// A record that stores its context takes the `NonEmpty<T>` it stores from +// its structural bridge as the required input. +impl<T> From<NonEmpty<T>> for RequiredContext<NonEmpty<T>> { + fn from(context: NonEmpty<T>) -> Self { + Self::supplied(context) + } +} +// The legacy declarations' contexts, wrapped as the required input of the +// owned leaf that replaces each: what a plan or a legacy record hands an +// owned field. +impl From<super::AeadContext> for RequiredContext<super::AeadContext> { + fn from(context: super::AeadContext) -> Self { + Self::supplied(context) + } +} +// The core operations take the context the input carries, with no wrapper +// of their own: `ciphertext()` under a `RequiredContext<AeadContext>`, +// a term under a `RequiredContext<CallerContext>`. +impl<C: Into<super::AeadContext>> From<RequiredContext<C>> for super::AeadContext { + fn from(context: RequiredContext<C>) -> Self { + context.into_inner().into() + } +} +impl<C: Into<CallerContext>> From<RequiredContext<C>> for CallerContext { + fn from(context: RequiredContext<C>) -> Self { + context.into_inner().into() + } +} + +/// A record that stores its context accepts an expectation of it, or none. +/// +/// Omitted, the record opens under whatever context it stores, after +/// checking that it is nonempty; supplied, the stored context must also +/// equal the `NonEmpty<T>` the caller names, and a mismatch is refused +/// before any key is retrieved ([`ExpectedContext::validate`]). +impl<T> sealed::Sealed for ExpectedContext<T> {} +impl<T> ContextInput for ExpectedContext<T> {} +impl<T> AllowsNoContext for ExpectedContext<T> { + fn omitted() -> Self { + Self::default() + } +} +impl<T> AcceptsContext for ExpectedContext<T> { + type Value = NonEmpty<T>; + fn supplied(value: NonEmpty<T>) -> Self { + Self::from(value) + } +} + /// Owned AEAD context for an opaque scalar. Empty context is valid. /// /// Contexts use the existing canonical encoding. No extra tuple, prefix or diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 430db8f36..e7418634e 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -8,9 +8,55 @@ //! target, under the element's `ContextInput`, as one logical batch of //! independently authenticated elements; `Vec<T>` decrypts back the same way. //! +//! # The owned declarations +//! +//! | Trait | Implemented on | Methods | Source | +//! |---|---|---|---| +//! | [`EncryptTarget<S>`] | an encrypted output or term | `encryption` (borrowed `&S`), `owned_encryption` (`S` by value, defaulting to guard-and-wipe), `indexes` | `S: Zeroize` | +//! | [`DecryptSource<P>`] | a recoverable encrypted output | `decryption(self, ContextInput)` | opens to `P: Zeroize` | +//! | [`EncryptInto`] | every `Zeroize` plaintext | `encrypt_into`, `encrypt_into_with_context` | consumes and wipes | +//! | [`DecryptFrom`] | every `Zeroize` plaintext | `decrypt_from`, `decrypt_from_with_context` | returns the owned value | +//! +//! The core leaves implement both: [`Ciphertext`](crate::Ciphertext) takes +//! an [`OptionalContext`] (empty is a context); [`StackCipherText`](crate::StackCipherText) +//! and [`Encrypted<Terms>`] require an [`AeadContext`] to open and the +//! terms ([`EqualityTerm`](crate::sem::EqualityTerm), [`MatchTerms`](crate::sem::MatchTerms), +//! the order terms) require a [`CallerContext`] to derive, each as a +//! [`RequiredContext`]. A term implements [`EncryptTarget`] alone. +//! +//! # Records +//! +//! `#[derive(EncryptTarget, DecryptSource)]` composes the field declarations. +//! A `struct = User, context = "users"` record hands each field its pair +//! `("users", "<field>")`, extended by the caller's [`ContextExtension`] if +//! one is given, through [`Encryption::under`]: the field target's +//! `ContextInput` must therefore be `From<CallerContext>`, which an +//! [`OptionalContext`], a [`RequiredContext`] of a core context and a +//! nested record's [`ContextExtension`] are, and a [`NoCallerContext`] or a +//! `RequiredContext<u64>` is not — refused at that field. Its borrowed +//! `encryption` picks each field out of a borrow ([`Encryption::project`], +//! cloning a passthrough field); its `owned_encryption` divides the struct +//! between the fields ([`Encryption::split`]), so a `#[stash(passthrough)]` +//! field is moved into the record and each sealed field is guarded by its +//! own target's owned form. Decryption opens each field under the same pair +//! and moves the passthrough fields back. +//! +//! A `plaintext = T` record runs every field over the whole value under the +//! one [`RequiredContext<CallerContext>`] (or `RequiredContext<AeadContext>` +//! with `context_type = AeadContext`) the caller supplies; a +//! `#[stash(context_field)]` record requires the `NonEmpty<T>` it stores and +//! opens under an [`ExpectedContext`]. Both derives also emit the structural +//! declarations ([`EncryptFrom`], [`DecryptInto`], [`DecryptField`]) as a +//! bridge, so an owned record is a field of a structural record or a typed +//! plan field ([`FieldsBuilder::encrypt_into`](crate::plan::FieldsBuilder::encrypt_into), +//! or [`target`](crate::plan::FieldsBuilder::target) for an owned-only target +//! such as a `Ciphertext`). A hand-written owned target used as a record +//! field implements [`Decryptable`] and [`DecryptField`] beside +//! [`DecryptSource`], as a hand-written structural one does. +//! //! # Structural API during migration //! -//! Existing derives, plans and dynamic targets still use the declarations +//! Existing structural derives, plans and dynamic targets still use the declarations //! below. Their borrowed convenience methods are isolated in [`legacy`]. //! //! [`EncryptFrom<S>`] describes ciphertext and/or term operations and has an @@ -144,6 +190,10 @@ pub mod legacy { } pub use conversion::{DecryptFrom, DecryptSource, EncryptInto, EncryptTarget}; pub use input::{ - AcceptsContext, AllowsNoContext, ContextInput, NoCallerContext, OptionalContext, - RequiredContext, ScalarContext, + AcceptsContext, AllowsNoContext, ContextExtension, ContextInput, NoCallerContext, + OptionalContext, RequiredContext, ScalarContext, }; +pub use stack_encrypt_derive::{DecryptSource, EncryptTarget}; +/// The cleanup bound every owned plaintext carries, named here so the +/// derives can spell it without a `zeroize` dependency of the caller's. +pub use zeroize::Zeroize; diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index 3bfce0e98..c5f28c65e 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -10,9 +10,11 @@ //! no argument to route. What a subtree may do is take a context of its own, //! by name, with [`Encryption::under`] or [`Encryption::extend`]. 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::source::{Borrowed, ConsumeSource, ShareSource, SourceMode}; +use super::input::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}; @@ -124,7 +126,7 @@ type Open<T, K> = Box<dyn for<'a> FnOnce(&'a StackCipher<K>) -> Pending<'a, T, K /// `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 +/// [`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"] @@ -239,7 +241,7 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's, M: SourceMode<'s, S>> /// /// 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 + /// [`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 @@ -389,6 +391,48 @@ 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> Encryption<'s, S, T, K, Ctx, Owned> { + /// [`project`](Self::project) for a plaintext handed over by value: + /// lift a description of a field to a description of the struct that + /// holds it, moving the one field out with `select`. + /// + /// A plain function pointer, as `project`'s selector is: it captures + /// nothing, so it cannot reach a cipher, and it only moves. The rest of + /// the struct is dropped as it is; a struct with more than one field + /// that matters is divided with [`split`](Self::split) instead. + pub fn project_owned<P: 's>(self, select: fn(P) -> S) -> Encryption<'s, P, T, K, Ctx, Owned> { + Encryption { + build: Box::new(move |source, cipher, cx| (self.build)(select(source), cipher, cx)), + } + } + /// Divide a plaintext handed over by value between two descriptions, + /// under the one context this description is handed, settling their key + /// requests in one batch: the owned sibling of [`zip`](Self::zip), for + /// two descriptions over two *different* parts of one value. + /// + /// `divide` is a plain function pointer that destructures `P` into the + /// part `self` takes and the part `other` takes: a record's + /// `owned_encryption` divides the struct into its fields this way, + /// nesting a `split` per field, so a passthrough field is moved into + /// the output and never copied, and a sealed field is guarded by its own + /// target's description. Nothing is cloned, so `P` need not be `Clone`. + pub fn split<P: 's, U: 's, V: 'static>( + self, + other: Encryption<'s, U, V, K, Ctx, Owned>, + divide: fn(P) -> (S, U), + ) -> Encryption<'s, P, (T, V), K, Ctx, Owned> + where + Ctx: Clone, + { + Encryption { + build: Box::new(move |source, cipher, cx| { + let (mine, theirs) = divide(source); + (self.build)(mine, cipher, cx.clone()).zip((other.build)(theirs, cipher, cx)) + }), + } + } +} + /// A description whose operation is chosen by the value it is handed, at /// the moment it runs: `choose` sees the source, the keyset cipher and the /// context, and returns the pending of whichever typed operation applies. @@ -460,7 +504,7 @@ where /// [`accepting`](Encryption::accepting) lets it take the term's context — /// the AEAD half of the same value — so the two zip under one context. /// -/// `Encrypt` consumes the plaintext. In [`Owned`](super::Owned) mode it is +/// `Encrypt` consumes the plaintext. In [`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>>( @@ -511,7 +555,7 @@ pub fn scalar_ciphertext<'s, S: crate::OpaqueEncode, K: 'static>( /// beside it for lookup ([`indexed`](super::indexed) with /// [`Equality`](super::Equality)). /// -/// In [`Owned`](super::Owned) mode the value is moved through; in the +/// In [`Owned`] mode the value is moved through; in the /// default [`Borrowed`] mode it is cloned once, which is what /// `M: ConsumeSource<'s, S>` asks. `Ctx` is whatever the surrounding tree /// hands its fields, so a passthrough zips beside any of them. @@ -912,7 +956,7 @@ impl<K: 'static> KeysetCipher<'_, K> { /// `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 + /// an [`Owned`]-mode one. Owned mode is how a plaintext /// that is not `Clone` reaches an operation: a single operation consumes /// it without a copy. /// @@ -1204,6 +1248,75 @@ where } } +// The owned declarations of the same leaves. Each requires the context its +// operation needs, wrapped as the caller's required input: a ciphertext +// an `AeadContext`, a term a `CallerContext`. Omission is not available — +// a structural leaf takes no empty context — and `Default` grants nothing. +// The borrowed form clones a consumed plaintext, as the legacy declaration +// does; the owned form's default guards and wipes the value instead. +impl<S: crate::Encrypt + Clone + zeroize::Zeroize> EncryptTarget<S> for StackCipherText { + type ContextInput = RequiredContext<AeadContext>; + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::ContextInput> + where + S: 's, + { + ciphertext().accepting() + } +} +impl<P: crate::Decrypt + zeroize::Zeroize + Send + 'static> DecryptSource<P> for StackCipherText { + type ContextInput = RequiredContext<AeadContext>; + fn decryption<K: 'static>(self, context: Self::ContextInput) -> Decryption<P, K> { + open(self, context.into_inner()) + } +} +impl<S: crate::Encrypt + Clone + zeroize::Zeroize> EncryptTarget<S> for crate::sem::EqualityTerm { + type ContextInput = RequiredContext<CallerContext>; + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::ContextInput> + where + S: 's, + { + equality().accepting() + } + fn indexes() -> Vec<super::IndexSpec> { + <Self as EncryptFrom<S>>::indexes() + } +} +impl<S: AsRef<str> + zeroize::Zeroize, O: crate::sem::MatchConfig + 'static> EncryptTarget<S> + for crate::sem::MatchTerms<O> +{ + type ContextInput = RequiredContext<CallerContext>; + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::ContextInput> + where + S: 's, + { + matching().accepting() + } + fn indexes() -> Vec<super::IndexSpec> { + <Self as EncryptFrom<S>>::indexes() + } +} +macro_rules! owned_order_term { + ($($ty:ident, $operation:ident;)+) => {$( + impl<S: crate::sem::Orderable + Clone + zeroize::Zeroize> EncryptTarget<S> for crate::sem::$ty<S> { + type ContextInput = RequiredContext<CallerContext>; + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::ContextInput> + where + S: 's, + { + $operation().accepting() + } + fn indexes() -> Vec<super::IndexSpec> { + <Self as EncryptFrom<S>>::indexes() + } + } + )+}; +} +owned_order_term! { + OreTerm, ore; + OpeTerm, ope; + OreBlockTerm, ore_block; +} + // A `Vec<Target>` is a row per item, each encrypted independently under the // same context and settled in one batch; an `Option<Target>` is one row or // nothing, without I/O. (A plaintext `Vec`/`Option` sealed into a @@ -1247,13 +1360,17 @@ where } } } -impl<P: 'static, T: DecryptInto<P> + 'static> DecryptInto<Vec<P>> for Vec<T> +// Opened through `all_wiping`, as the owned `Vec<T>` is: an element that +// fails after its key arrived fails the column, and the elements recovered +// before it are wiped rather than dropped as they are. That is what the +// `P: Zeroize` bound buys; every plaintext the engine opens into carries it. +impl<P: zeroize::Zeroize + 'static, T: DecryptInto<P> + 'static> DecryptInto<Vec<P>> for Vec<T> where T::Context: Clone + 'static, { type Context = T::Context; fn decryption<K: 'static>(self, context: Self::Context) -> Decryption<Vec<P>, K> { - Decryption::all( + Decryption::all_wiping( self.into_iter() .map(|item| item.decryption(context.clone())), ) @@ -1282,17 +1399,20 @@ where Ctx: Into<<Self as DecryptInto<P>>::Context>, { fn decryption_field<K: 'static>(self, context: Ctx) -> Option<Decryption<P, K>> { - Some(self.decryption(context.into())) + Some(<Self as DecryptInto<P>>::decryption(self, context.into())) } } -impl<P: 'static, T: 'static + Decryptable + DecryptField<P, Ctx>, Ctx: Clone + 'static> - DecryptField<Vec<P>, Ctx> for Vec<T> +impl< + P: zeroize::Zeroize + 'static, + T: 'static + Decryptable + DecryptField<P, Ctx>, + Ctx: Clone + 'static, + > DecryptField<Vec<P>, Ctx> for Vec<T> { fn decryption_field<K: 'static>(self, context: Ctx) -> Option<Decryption<Vec<P>, K>> { if !T::DECRYPTABLE { return None; } - Some(Decryption::all(self.into_iter().map(|item| { + Some(Decryption::all_wiping(self.into_iter().map(|item| { item.decryption_field(context.clone()) .unwrap_or_else(|| Decryption::failed(Error::NotOpened)) }))) diff --git a/packages/stack-encrypt/tests/collections.rs b/packages/stack-encrypt/tests/collections.rs index c3bce3ba9..103ab4292 100644 --- a/packages/stack-encrypt/tests/collections.rs +++ b/packages/stack-encrypt/tests/collections.rs @@ -478,3 +478,55 @@ fn a_panicking_element_conversion_wipes_the_plaintext_recovered_before_it() { "the prefix recovered before the panic is wiped during the unwind" ); } + +// --- The structural `Vec` declarations wipe what they recovered too -------- + +/// The same column, declared the structural way (`DecryptInto`), so the +/// legacy `Vec<T>: DecryptInto<Vec<P>>` path is the one under test. +impl stack_encrypt::DecryptInto<Tracked> for TrackedCiphertext { + type Context = stack_encrypt::target::AeadContext; + fn decryption<K: 'static>(self, context: Self::Context) -> Decryption<Tracked, K> { + let wiped = self.wiped; + self.ciphertext + .decryption(OptionalContext::<ScalarContext>::supplied(context.into())) + .map(move |value: String| Tracked { value, wiped }) + } +} + +#[tokio::test] +async fn a_late_element_failure_wipes_the_recovered_prefix_on_the_structural_path_too() { + let (cipher, _, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let context = stack_encrypt::nonempty!("rows"); + let first = keyset + .encrypt_with_context("recovered first".to_owned(), context) + .await + .unwrap(); + // Sealed under another context: fails authentication only once its key + // has arrived, by which time the first element has been recovered. + let second = keyset + .encrypt_with_context("never recovered".to_owned(), "elsewhere") + .await + .unwrap(); + let column = vec![ + TrackedCiphertext::new(first), + TrackedCiphertext::new(second), + ]; + let wiped: Vec<_> = column.iter().map(|t| t.wiped.clone()).collect(); + // `Tracked::drop` fails the test if the recovered prefix is dropped + // unwiped, which is what the structural path did before it opened + // through the wiping collection. + let result = cipher + .decrypt_as::<Vec<Tracked>, _>(column, stack_encrypt::target::AeadContext::from(context)) + .await; + assert!(matches!(result, Err(Error::Aead)), "{:?}", result.err()); + assert!( + wiped[0].load(Ordering::SeqCst), + "the recovered prefix is wiped" + ); + assert!( + !wiped[1].load(Ordering::SeqCst), + "nothing was recovered to wipe" + ); + assert_eq!(retrieves.load(Ordering::SeqCst), 1, "both keys in one call"); +} diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index b208600fa..6e278e402 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -485,7 +485,9 @@ async fn a_failed_field_fails_the_derived_record_before_any_io() { // --- Structs: each field from one field of the plaintext, under its own context -#[derive(Debug, Clone, PartialEq, Eq)] +// `Zeroize`: a column of these opens through the wiping collection path, +// which wipes the rows recovered before a late failure. +#[derive(Debug, Clone, PartialEq, Eq, zeroize::Zeroize)] struct User { age: u32, email: String, diff --git a/packages/stack-encrypt/tests/owned_equivalence.rs b/packages/stack-encrypt/tests/owned_equivalence.rs new file mode 100644 index 000000000..5a73821fe --- /dev/null +++ b/packages/stack-encrypt/tests/owned_equivalence.rs @@ -0,0 +1,329 @@ +//! One record, three authors, one engine: a hand-written `EncryptTarget` +//! (typed), `#[derive(EncryptTarget, DecryptSource)]` (generated) and the +//! data-plan lowering a binding sends (dynamic) each seal the same mixed, +//! searchable record against the same fake provider. The descriptors they +//! send to the key provider agree, their equality terms agree byte for byte, +//! and each opens what the others sealed, which is the AAD agreeing too: +//! ciphertext bytes are randomized and are not compared. +#![cfg(feature = "dynamic")] + +mod common; + +use common::frames::{frame, object, payload}; +use serde::Deserialize; +use stack_encrypt::dynamic::record::{self, Plan as DataPlan}; +use stack_encrypt::dynamic::{Scope, ValueFrame}; +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{ + passthrough, Borrowed, ContextExtension, DeclaredContext, DecryptSource, Decryption, + EncryptTarget, Encryption, +}; +use stack_encrypt::{ + nonempty, CipherText, Ciphertext, DecryptFrom, EncryptInto, PassthroughFrame, SealedValue, + StackCipher, StackCipherText, +}; +use vitaminc_aead_frame::serde::from_slice; +use zeroize::Zeroize; + +#[derive(Debug, Clone, PartialEq, Zeroize)] +struct User { + id: u32, + name: String, + email: String, +} +fn alice() -> User { + User { + id: 42, + name: "Alice".to_owned(), + email: "alice@example.com".to_owned(), + } +} + +// --- Typed: the operations composed by hand ------------------------------- + +struct TypedUser { + id: u32, + name: Ciphertext, + email: Ciphertext, + email_eq: EqualityTerm, +} +impl EncryptTarget<User> for TypedUser { + type ContextInput = ContextExtension<DeclaredContext>; + fn encryption<'s, K: 'static>() -> Encryption<'s, User, Self, K, Self::ContextInput> { + passthrough::<u32, K, Borrowed, DeclaredContext>() + .project(|user: &User| &user.id) + .zip( + <Ciphertext as EncryptTarget<String>>::encryption::<K>() + .project(|user: &User| &user.name) + .under(nonempty!("users").with("name")), + ) + .zip( + <Ciphertext as EncryptTarget<String>>::encryption::<K>() + .project(|user: &User| &user.email) + .under(nonempty!("users").with("email")), + ) + .zip( + <EqualityTerm as EncryptTarget<String>>::encryption::<K>() + .project(|user: &User| &user.email) + .under(nonempty!("users").with("email")), + ) + .map(|(((id, name), email), email_eq)| Self { + id, + name, + email, + email_eq, + }) + .accepting() + } +} +impl DecryptSource<User> for TypedUser { + type ContextInput = ContextExtension<DeclaredContext>; + fn decryption<K: 'static>(self, context: Self::ContextInput) -> Decryption<User, K> { + let extension: DeclaredContext = context.into(); + let name = <Ciphertext as DecryptSource<String>>::decryption::<K>( + self.name, + extension + .clone() + .under(nonempty!("users").with("name")) + .into(), + ); + let email = <Ciphertext as DecryptSource<String>>::decryption::<K>( + self.email, + extension.under(nonempty!("users").with("email")).into(), + ); + let id = self.id; + name.zip(email) + .map(move |(name, email)| User { id, name, email }) + } +} + +// --- Generated: the same declaration, derived ----------------------------- + +#[derive(EncryptTarget, DecryptSource)] +#[stash(plaintext = String)] +struct SearchableEmail { + ciphertext: Ciphertext, + equality: EqualityTerm, +} +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = User, context = "users")] +struct GeneratedUser { + #[stash(passthrough)] + id: u32, + name: Ciphertext, + email: SearchableEmail, +} + +// --- Dynamic: the declaration as a binding sends it ----------------------- + +fn data_plan() -> DataPlan { + let json = serde_json::json!({ + "id": { "context": ["users", "id"], "outputs": ["passthrough"], "type": "uint32" }, + "name": { "context": ["users", "name"], "outputs": ["c"], "type": "string" }, + "email": { "context": ["users", "email"], "outputs": ["c", "eq"], "type": "string" }, + }); + record::plan(&vitaminc_aead_frame::serde::to_vec(&json).expect("frames")).expect("a plan") +} +fn dynamic_source(user: &User) -> ValueFrame { + object(vec![ + ("id", frame(user.id)), + ("name", frame(user.name.as_str())), + ("email", frame(user.email.as_str())), + ]) +} + +/// The three outputs in one shape: the stored tree the dynamic path writes +/// and reads, so one comparison and one opener serve all three. +struct Stored { + id: u32, + name: SealedValue, + email: SealedValue, + email_eq: Vec<u8>, +} +impl Stored { + fn of_typed(typed: TypedUser) -> Self { + Self { + id: typed.id, + name: leaf(typed.name), + email: leaf(typed.email), + email_eq: typed.email_eq.into_bytes().to_vec(), + } + } + fn of_generated(generated: GeneratedUser) -> Self { + Self { + id: generated.id, + name: leaf(generated.name), + email: leaf(generated.email.ciphertext), + email_eq: generated.email.equality.into_bytes().to_vec(), + } + } + fn of_tree(tree: StackCipherText) -> Self { + let CipherText::Map(fields) = tree else { + panic!("a record is a map of fields"); + }; + let (mut id, mut name, mut email, mut email_eq) = (None, None, None, None); + for (field, node) in fields { + let CipherText::Map(outputs) = node else { + panic!("a field is a map of outputs"); + }; + for (key, node) in outputs { + match (field.as_str(), key.as_str(), node) { + ("id", "passthrough", CipherText::Passthrough(payload)) => { + id = Some(from_slice::<u32>(&payload.0).expect("a u32")); + } + ("name", "c", CipherText::Single(leaf)) => name = Some(leaf), + ("email", "c", CipherText::Single(leaf)) => email = Some(leaf), + ("email", "eq", CipherText::Passthrough(payload)) => { + email_eq = Some(from_slice::<&[u8]>(&payload.0).expect("bytes").to_vec()); + } + (field, key, _) => panic!("unexpected output {field}/{key}"), + } + } + } + Self { + id: id.expect("id"), + name: name.expect("name"), + email: email.expect("email"), + email_eq: email_eq.expect("email/eq"), + } + } + fn tree(&self) -> StackCipherText { + let passthrough = + |value: &dyn Fn() -> Vec<u8>| CipherText::Passthrough(PassthroughFrame(value())); + CipherText::Map(vec![ + ( + "id".into(), + CipherText::Map(vec![( + "passthrough".into(), + passthrough(&|| payload(self.id)), + )]), + ), + ( + "name".into(), + CipherText::Map(vec![("c".into(), CipherText::Single(self.name.clone()))]), + ), + ( + "email".into(), + CipherText::Map(vec![ + ("c".into(), CipherText::Single(self.email.clone())), + ( + "eq".into(), + passthrough(&|| payload(self.email_eq.as_slice())), + ), + ]), + ), + ]) + } + fn typed(&self) -> TypedUser { + TypedUser { + id: self.id, + name: ciphertext(&self.name), + email: ciphertext(&self.email), + email_eq: EqualityTerm::from_bytes(self.email_eq.as_slice().try_into().expect("32")), + } + } + fn generated(&self) -> GeneratedUser { + GeneratedUser { + id: self.id, + name: ciphertext(&self.name), + email: SearchableEmail { + ciphertext: ciphertext(&self.email), + equality: EqualityTerm::from_bytes( + self.email_eq.as_slice().try_into().expect("32"), + ), + }, + } + } +} +fn leaf(ciphertext: Ciphertext) -> SealedValue { + SealedValue::from_bytes(&ciphertext.to_bytes()).expect("a leaf") +} +fn ciphertext(leaf: &SealedValue) -> Ciphertext { + Ciphertext::from_bytes(&leaf.to_bytes()).expect("a ciphertext") +} + +#[derive(Deserialize, PartialEq, Debug)] +struct Opened { + id: u32, + name: String, + email: String, +} +async fn open_dynamic(cipher: &StackCipher<common::RecordingSource>, stored: &Stored) -> User { + let opened = record::decrypt(Scope::Client(cipher), stored.tree(), &data_plan(), None) + .expect("the record fits the plan") + .await + .expect("the lowering opens it"); + let opened: Opened = from_slice(opened.risky_ref()).expect("an object of the three fields"); + User { + id: opened.id, + name: opened.name, + email: opened.email, + } +} + +#[tokio::test] +async fn typed_generated_and_dynamic_declarations_are_one_engine() { + let user = alice(); + let (cipher, sent) = common::recording_cipher().await; + let keyset = cipher.default_keyset(); + let plan = data_plan(); + + // Each author seals the record. The passthrough field requests no key; + // the two sealed fields request one each, under the field's pair, and + // the equality term derives locally. + let typed: TypedUser = user.clone().encrypt_into(&keyset).await.unwrap(); + let generated: GeneratedUser = user.clone().encrypt_into(&keyset).await.unwrap(); + let dynamic = record::encrypt(&keyset, dynamic_source(&user), &plan) + .expect("the source fits the plan") + .await + .unwrap(); + // Field order within a call is the declaration's (the data plan's is + // the frame's key order), so each call is compared as a set. + let sorted = |call: &Vec<String>| { + let mut call = call.clone(); + call.sort(); + call + }; + let generated_descriptors: Vec<_> = sent.lock().unwrap().generate.iter().map(sorted).collect(); + assert_eq!( + generated_descriptors, + vec![ + vec!["users/email", "users/name"], + vec!["users/email", "users/name"], + vec!["users/email", "users/name"], + ], + "one batched request per author, the same descriptors" + ); + + let typed = Stored::of_typed(typed); + let generated = Stored::of_generated(generated); + let dynamic = Stored::of_tree(dynamic); + // The one-way terms are the same bytes, so a query term from any author + // finds the rows every author stored. + assert_eq!(typed.email_eq, generated.email_eq); + assert_eq!(generated.email_eq, dynamic.email_eq); + assert_eq!(typed.id, 42); + assert_eq!(generated.id, 42); + assert_eq!(dynamic.id, 42); + + // Every author opens every author's record: same context, same AAD. + for stored in [&typed, &generated, &dynamic] { + assert_eq!( + User::decrypt_from(stored.typed(), &cipher).await.unwrap(), + user + ); + assert_eq!( + User::decrypt_from(stored.generated(), &cipher) + .await + .unwrap(), + user + ); + assert_eq!(open_dynamic(&cipher, stored).await, user); + } + // And every open retrieved under the same descriptors. + let retrieved: Vec<_> = sent.lock().unwrap().retrieve.iter().map(sorted).collect(); + assert_eq!(retrieved.len(), 9); + assert!(retrieved + .iter() + .all(|call| call == &["users/email", "users/name"])); +} diff --git a/packages/stack-encrypt/tests/owned_records.rs b/packages/stack-encrypt/tests/owned_records.rs new file mode 100644 index 000000000..e4ae2b477 --- /dev/null +++ b/packages/stack-encrypt/tests/owned_records.rs @@ -0,0 +1,698 @@ +//! Owned record declarations: `#[derive(EncryptTarget, DecryptSource)]` over +//! a plaintext struct, with passthrough fields, nested records, renamed +//! source fields, a stored context, and one batch per collection. The +//! public mixed-record and searchable-record programs run here against the +//! fake key source. No service credentials are needed. +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use stack_encrypt::sem::{EqualityTerm, MatchTerms, OreTerm}; +use stack_encrypt::target::{ + AcceptsContext, AeadContext, ContextExtension, DecryptField, DecryptSource, Decryptable, + Decryption, EncryptTarget, Encryption, NoCallerContext, OptionalContext, RequiredContext, + ScalarContext, +}; +use stack_encrypt::{ + nonempty, Ciphertext, DecryptFrom, EncryptInto, Encrypted, Error, NonEmpty, StackCipherText, +}; +use zeroize::Zeroize; +mod common; + +// --- The public mixed-record program --------------------------------------- + +#[derive(Debug, PartialEq, Clone, Zeroize)] +struct User { + id: u64, + name: String, + email: String, + created_at: i64, +} + +/// Plaintext `id` and `created_at` carried through; `name` and `email` +/// sealed on their own, under `("users", "name")` and `("users", "email")`. +#[derive(EncryptTarget, DecryptSource, Serialize, Deserialize)] +#[stash(struct = User, context = "users")] +struct EncryptedUser { + #[stash(passthrough)] + id: u64, + name: Ciphertext, + email: Ciphertext, + #[stash(passthrough)] + created_at: i64, +} + +fn alice() -> User { + User { + id: 42, + name: "Alice".to_owned(), + email: "alice@example.com".to_owned(), + created_at: 1_700_000_000, + } +} +fn bob() -> User { + User { + id: 43, + name: "Bob".to_owned(), + email: "bob@example.com".to_owned(), + created_at: 1_700_000_100, + } +} + +#[tokio::test] +async fn a_mixed_record_round_trips_through_serde_with_its_passthrough_fields() { + let (cipher, sent) = common::recording_cipher().await; + let keyset = cipher.default_keyset(); + let user = alice(); + + let encrypted: EncryptedUser = user.clone().encrypt_into(&keyset).await.unwrap(); + assert_eq!(encrypted.id, user.id); + assert_eq!(encrypted.created_at, user.created_at); + // One conversion, one batched key request, one data key per sealed field. + let generated = sent.lock().unwrap().generate.clone(); + assert_eq!(generated, vec![vec!["users/name", "users/email"]]); + + // Persist the typed output and read it back: plaintext fields as + // themselves, ciphertext fields as Base64 envelopes. + let json = serde_json::to_value(&encrypted).unwrap(); + assert_eq!(json["id"], 42); + assert_eq!(json["created_at"], 1_700_000_000); + assert!(json["name"].is_string() && json["email"].is_string()); + let loaded: EncryptedUser = serde_json::from_value(json).unwrap(); + + let restored = User::decrypt_from(loaded, &cipher).await.unwrap(); + assert_eq!(restored, user); + let retrieved = sent.lock().unwrap().retrieve.clone(); + assert_eq!(retrieved, vec![vec!["users/name", "users/email"]]); +} + +#[tokio::test] +async fn each_sealed_field_is_an_ordinary_ciphertext_under_its_pair() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let encrypted: EncryptedUser = alice().encrypt_into(&keyset).await.unwrap(); + assert_eq!( + keyset + .decrypt_with_context::<String>(encrypted.name.clone(), ("users", "name")) + .await + .unwrap(), + "Alice" + ); + assert_eq!( + String::decrypt_from_with_context(encrypted.email.clone(), &keyset, ("users", "email")) + .await + .unwrap(), + "alice@example.com" + ); + // The pair is the context: a field moved to the other column is refused. + assert!(matches!( + keyset + .decrypt_with_context::<String>(encrypted.name, ("users", "email")) + .await, + Err(Error::Aead) + )); +} + +#[tokio::test] +async fn a_caller_extension_binds_every_sealed_field_to_the_row() { + let (cipher, sent) = common::recording_cipher().await; + let keyset = cipher.default_keyset(); + let user = alice(); + let encrypted: EncryptedUser = user + .clone() + .encrypt_into_with_context(&keyset, 42u64) + .await + .unwrap(); + assert_eq!( + sent.lock().unwrap().generated(), + vec!["(users/name)/42u64", "(users/email)/42u64"] + ); + // The same extension opens it; another row's id, or none, does not. + assert!(matches!( + User::decrypt_from( + EncryptedUser { + id: encrypted.id, + name: encrypted.name.clone(), + email: encrypted.email.clone(), + created_at: encrypted.created_at + }, + &keyset + ) + .await, + Err(Error::Aead) + )); + assert!(matches!( + User::decrypt_from_with_context( + EncryptedUser { + id: encrypted.id, + name: encrypted.name.clone(), + email: encrypted.email.clone(), + created_at: encrypted.created_at + }, + &keyset, + 43u64 + ) + .await, + Err(Error::Aead) + )); + assert_eq!( + User::decrypt_from_with_context(encrypted, &keyset, 42u64) + .await + .unwrap(), + user + ); +} + +// --- Ownership: sealed fields wiped, passthrough fields moved ------------- + +/// A passthrough field type whose `Clone` fails the test: the borrowed +/// form of the record would copy it, and the owned conversion must move it +/// into the output and back instead. +#[derive(Debug, PartialEq, Zeroize)] +struct Handle(u64); +impl Clone for Handle { + fn clone(&self) -> Self { + panic!("a passthrough field is moved by the owned conversion, never cloned") + } +} + +/// A sealed field type that records being wiped and refuses to be dropped +/// otherwise. +struct Secret { + value: String, + wiped: Arc<AtomicBool>, +} +impl Zeroize for Secret { + fn zeroize(&mut self) { + self.value.zeroize(); + self.wiped.store(true, Ordering::SeqCst); + } +} +impl Drop for Secret { + fn drop(&mut self) { + assert!(self.value.is_empty(), "sealed plaintext dropped unwiped"); + } +} +struct SecretCiphertext(Ciphertext); +impl EncryptTarget<Secret> for SecretCiphertext { + type ContextInput = OptionalContext<ScalarContext>; + fn encryption<'s, K: 'static>() -> Encryption<'s, Secret, Self, K, Self::ContextInput> { + stack_encrypt::target::scalar_ciphertext::<String, K>() + .project(|secret: &Secret| &secret.value) + .map(Self) + } +} +impl DecryptSource<Secret> for SecretCiphertext { + type ContextInput = OptionalContext<ScalarContext>; + fn decryption<K: 'static>(self, context: Self::ContextInput) -> Decryption<Secret, K> { + self.0.decryption(context).map(|value: String| Secret { + value, + wiped: Arc::default(), + }) + } +} +// As a field of a record, a hand-written target says it holds recoverable +// ciphertext and how it opens under the context the record hands it, as a +// hand-written structural target does. +impl Decryptable for SecretCiphertext { + const DECRYPTABLE: bool = true; +} +impl<Ctx: Into<OptionalContext<ScalarContext>>> DecryptField<Secret, Ctx> for SecretCiphertext { + fn decryption_field<K: 'static>(self, context: Ctx) -> Option<Decryption<Secret, K>> { + Some(self.decryption(context.into())) + } +} + +#[derive(Zeroize)] +struct Tracked { + handle: Handle, + secret: Secret, +} +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = Tracked, context = "tracked")] +struct EncryptedTracked { + #[stash(passthrough)] + handle: Handle, + secret: SecretCiphertext, +} + +#[tokio::test] +async fn the_owned_record_wipes_sealed_fields_and_moves_passthrough_fields() { + let (cipher, generates, _) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let wiped = Arc::new(AtomicBool::new(false)); + let tracked = Tracked { + handle: Handle(7), + secret: Secret { + value: "secret".to_owned(), + wiped: wiped.clone(), + }, + }; + let pending = tracked.encrypt_into::<EncryptedTracked, _>(&keyset); + assert!( + wiped.load(Ordering::SeqCst), + "the sealed field is wiped before any await" + ); + assert_eq!(generates.load(Ordering::SeqCst), 0); + let encrypted = pending.await.unwrap(); + assert_eq!( + encrypted.handle, + Handle(7), + "the passthrough field is moved" + ); + let opened = Tracked::decrypt_from(encrypted, &keyset).await.unwrap(); + assert_eq!(opened.handle, Handle(7)); + assert_eq!(opened.secret.value, "secret"); + // The caller owns the recovered plaintext; wipe it to satisfy `Secret::drop`. + let mut opened = opened; + opened.secret.zeroize(); +} + +#[tokio::test] +async fn the_owned_record_wipes_sealed_fields_when_the_context_is_refused() { + let (cipher, generates, _) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let wiped = Arc::new(AtomicBool::new(false)); + let tracked = Tracked { + handle: Handle(7), + secret: Secret { + value: "secret".to_owned(), + wiped: wiped.clone(), + }, + }; + let pending = tracked.encrypt_into_with_context::<EncryptedTracked, _>( + &keyset, + NonEmpty::new("x".repeat(65536)).unwrap(), + ); + assert!(wiped.load(Ordering::SeqCst)); + assert!(pending.await.is_err()); + assert_eq!(generates.load(Ordering::SeqCst), 0); +} + +// --- The public searchable-record program, as one batch -------------------- + +/// An application-defined scalar target: the ciphertext plus an equality +/// term, under the one context the record (or the caller) supplies. +#[derive(EncryptTarget, DecryptSource)] +#[stash(plaintext = String)] +struct SearchableEmail { + ciphertext: Ciphertext, + equality: EqualityTerm, +} + +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = User, context = "users")] +struct SearchableUser { + #[stash(passthrough)] + id: u64, + name: Ciphertext, + email: SearchableEmail, + #[stash(passthrough)] + created_at: i64, +} + +#[tokio::test] +async fn a_searchable_column_is_one_batch_and_its_terms_match_a_query_term() { + let (cipher, generates, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let users = vec![alice(), bob()]; + + // One collection conversion batches key generation across both users: + // two name keys and two email keys in one logical call. + let encrypted: Vec<SearchableUser> = users.clone().encrypt_into(&keyset).await.unwrap(); + assert_eq!(generates.load(Ordering::SeqCst), 1); + assert_eq!(encrypted.len(), 2); + + // Later: a query under the same keyset, field context and operation, + // from another cipher, as a query path in another process derives it. + let reader = common::stack_cipher().await; + let query: EqualityTerm = "alice@example.com" + .to_owned() + .encrypt_into_with_context(&reader.default_keyset(), nonempty!("users").with("email")) + .await + .unwrap(); + // An in-memory scan over the stored rows stands in for the index. + let matches: Vec<SearchableUser> = encrypted + .into_iter() + .filter(|row| row.email.equality == query) + .collect(); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].id, 42); + + // The matched rows open in one batch. + let restored = Vec::<User>::decrypt_from(matches, &cipher).await.unwrap(); + assert_eq!(restored, &users[..1]); + assert_eq!(retrieves.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn a_searchable_email_standalone_requires_its_context() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let sealed: SearchableEmail = "alice@example.com" + .to_owned() + .encrypt_into_with_context(&keyset, nonempty!("users/email")) + .await + .unwrap(); + // The record's indexes are its terms'. + assert_eq!( + <SearchableEmail as EncryptTarget<String>>::indexes(), + vec![stack_encrypt::IndexSpec::Equality] + ); + assert_eq!( + String::decrypt_from_with_context(sealed, &keyset, nonempty!("users/email")) + .await + .unwrap(), + "alice@example.com" + ); +} + +// --- Nested records, renamed sources, pinned identities -------------------- + +#[derive(Debug, PartialEq, Clone, Zeroize)] +struct Address { + street: String, + city: String, +} +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = Address, context = "addresses")] +struct EncryptedAddress { + street: Ciphertext, + city: Ciphertext, +} + +#[derive(Debug, PartialEq, Clone, Zeroize)] +struct Account { + account_id: u64, + holder: String, + address: Address, + opened: i64, +} +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = Account, context = "accounts")] +struct EncryptedAccount { + /// A passthrough under another name. + #[stash(passthrough, from = account_id)] + id: u64, + /// A sealed field under another name, keyed under the plaintext field. + #[stash(from = holder)] + owner: Ciphertext, + /// A sealed field keyed under a pinned segment. + #[stash(from = opened, identity = "opened_at")] + opened: Ciphertext, + /// A nested record: its fields sit under their own pairs, extended by + /// this one. + address: EncryptedAddress, +} + +#[tokio::test] +async fn nested_renamed_and_pinned_fields_compose_their_contexts() { + let (cipher, sent) = common::recording_cipher().await; + let keyset = cipher.default_keyset(); + let account = Account { + account_id: 9, + holder: "Carol".to_owned(), + address: Address { + street: "1 Main St".to_owned(), + city: "Springfield".to_owned(), + }, + opened: 1_600_000_000, + }; + let encrypted: EncryptedAccount = account.clone().encrypt_into(&keyset).await.unwrap(); + assert_eq!(encrypted.id, 9); + assert_eq!( + sent.lock().unwrap().generated(), + vec![ + "accounts/holder", + "accounts/opened_at", + "(addresses/street)/(accounts/address)", + "(addresses/city)/(accounts/address)", + ] + ); + let restored = Account::decrypt_from(encrypted, &keyset).await.unwrap(); + assert_eq!(restored, account); +} + +// --- A record that stores its context ------------------------------------- + +#[derive(EncryptTarget, DecryptSource)] +#[stash(plaintext = String)] +struct TextEq { + #[stash(context_field)] + identifier: &'static str, + c: Ciphertext, + hm: EqualityTerm, +} + +#[tokio::test] +async fn a_stored_context_opens_as_stored_and_checks_an_expectation() { + let (cipher, _, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let value = "alice@example.com".to_owned(); + let encrypted: TextEq = value + .clone() + .encrypt_into_with_context(&keyset, nonempty!("users/email")) + .await + .unwrap(); + assert_eq!(encrypted.identifier, "users/email"); + let generator = common::stack_cipher().await; + let hm: EqualityTerm = value + .clone() + .encrypt_into_with_context(&generator.default_keyset(), nonempty!("users/email")) + .await + .unwrap(); + assert_eq!(encrypted.hm, hm); + + // Omitted: opens under the stored identifier. + let again = TextEq { + identifier: encrypted.identifier, + c: encrypted.c.clone(), + hm: encrypted.hm.clone(), + }; + assert_eq!(String::decrypt_from(again, &keyset).await.unwrap(), value); + // Supplied: the stored identifier must equal it, before any key I/O. + let moved = TextEq { + identifier: "users/name", + c: encrypted.c.clone(), + hm: encrypted.hm.clone(), + }; + let before = retrieves.load(Ordering::SeqCst); + assert!(matches!( + String::decrypt_from_with_context(moved, &keyset, nonempty!("users/email")).await, + Err(Error::ContextMismatch { .. }) + )); + assert_eq!(retrieves.load(Ordering::SeqCst), before); + assert_eq!( + String::decrypt_from_with_context(encrypted, &keyset, nonempty!("users/email")) + .await + .unwrap(), + value + ); +} + +// --- Plaintext records over the structural leaves and a fixed context ----- + +/// The hand-written composite in `target.rs`, as an owned record: its +/// fields are the structural leaves, which take the same required context. +#[derive(EncryptTarget, DecryptSource)] +#[stash(plaintext = u32)] +struct EncryptedAge { + c: StackCipherText, + hm: EqualityTerm, + ob: OreTerm<u32>, +} + +/// A record made only of ciphertexts accepts an AEAD-only context; a term +/// beside them would be refused at its field (`tests/ui`). +#[derive(EncryptTarget, DecryptSource)] +#[stash(plaintext = String, context_type = AeadContext)] +struct SealedName { + #[stash(decrypt)] + c: StackCipherText, + #[allow(dead_code)] + shadow: Ciphertext, +} + +/// The ciphertext with its terms, as one structural leaf of an owned record. +#[derive(EncryptTarget, DecryptSource)] +#[stash(plaintext = String)] +struct Indexed { + email: Encrypted<(EqualityTerm, MatchTerms)>, +} + +/// Generic over the plaintext: whatever every field accepts. +#[derive(EncryptTarget, DecryptSource)] +struct SearchableText { + c: Ciphertext, + hm: EqualityTerm, + m: MatchTerms, +} + +#[tokio::test] +async fn plaintext_records_compose_structural_and_opaque_leaves() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let generator = common::stack_cipher().await; + let generator = generator.default_keyset(); + + let age: EncryptedAge = 42u32 + .encrypt_into_with_context(&keyset, nonempty!("users/age")) + .await + .unwrap(); + let hm: EqualityTerm = 42u32 + .encrypt_into_with_context(&generator, nonempty!("users/age")) + .await + .unwrap(); + assert_eq!(age.hm, hm); + assert_eq!( + <EncryptedAge as EncryptTarget<u32>>::indexes(), + vec![ + stack_encrypt::IndexSpec::Equality, + stack_encrypt::IndexSpec::Ore + ] + ); + assert_eq!( + u32::decrypt_from_with_context(age, &keyset, nonempty!("users/age")) + .await + .unwrap(), + 42 + ); + + let name: SealedName = "Dana" + .to_owned() + .encrypt_into_with_context(&keyset, nonempty!("users/name")) + .await + .unwrap(); + assert!(matches!( + String::decrypt_from_with_context(name, &keyset, nonempty!("users/name")).await, + Ok(opened) if opened == "Dana" + )); + + let indexed: Indexed = "alice@example.com" + .to_owned() + .encrypt_into_with_context(&keyset, nonempty!("users/email")) + .await + .unwrap(); + assert_eq!( + <Indexed as EncryptTarget<String>>::indexes().len(), + 2, + "equality and match" + ); + assert_eq!( + String::decrypt_from_with_context(indexed, &keyset, nonempty!("users/email")) + .await + .unwrap(), + "alice@example.com" + ); + + let text: SearchableText = "alice" + .to_owned() + .encrypt_into_with_context(&keyset, nonempty!("users/name")) + .await + .unwrap(); + let m: MatchTerms = "alice" + .to_owned() + .encrypt_into_with_context(&generator, nonempty!("users/name")) + .await + .unwrap(); + assert_eq!(text.m, m); + assert_eq!( + String::decrypt_from_with_context(text, &keyset, nonempty!("users/name")) + .await + .unwrap(), + "alice" + ); +} + +// --- A field whose target declares its whole context ---------------------- + +/// A target that supplies every context itself is a field of a `plaintext` +/// record only through a record that likewise takes none; here it is used +/// standalone and in a column. +struct FixedInteger(Ciphertext); +impl EncryptTarget<i32> for FixedInteger { + type ContextInput = NoCallerContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, i32, Self, K, Self::ContextInput> { + stack_encrypt::target::scalar_ciphertext() + .map(Self) + .under(nonempty!("users/fixed-age")) + .accepting() + } +} +impl DecryptSource<i32> for FixedInteger { + type ContextInput = NoCallerContext; + fn decryption<K: 'static>(self, _: Self::ContextInput) -> Decryption<i32, K> { + self.0 + .decryption(OptionalContext::<ScalarContext>::supplied( + "users/fixed-age".into(), + )) + } +} + +#[tokio::test] +async fn an_owned_record_is_a_field_of_a_structural_record_and_a_plan() { + use stack_encrypt::plan::pick; + use stack_encrypt::target::legacy::EncryptInto as _; + use stack_encrypt::{EncryptFrom, Plan}; + + #[derive(Clone)] + struct Profile { + user: User, + age: u32, + } + /// A structural record holding an owned record as a field: the bridge + /// makes the owned record an `EncryptFrom` with a `DeclaredContext`. + #[derive(EncryptFrom)] + #[stash(struct = Profile, context = "profiles")] + struct EncryptedProfile { + user: EncryptedUser, + #[allow(dead_code)] + age: EncryptedAge, + } + + let (cipher, sent) = common::recording_cipher().await; + let keyset = cipher.default_keyset(); + let profile = Profile { + user: alice(), + age: 34, + }; + let encrypted: EncryptedProfile = profile.encrypt_into(&keyset).await.unwrap(); + assert_eq!(encrypted.user.id, 42); + assert_eq!( + sent.lock().unwrap().generated(), + vec![ + "(users/name)/(profiles/user)", + "(users/email)/(profiles/user)", + "profiles/age" + ] + ); + + // And as a typed field of a plan, through the same bridge. + let plan = Plan::context("profiles") + .fields::<Profile, _>() + .encrypt_into::<EncryptedUser, _>(pick("user", |p: &Profile| &p.user)) + .target::<Ciphertext, _>(pick("age", |p: &Profile| &p.age)) + .build() + .unwrap(); + let profile = Profile { + user: bob(), + age: 51, + }; + let mut values = cipher.encrypt(&profile).using(&plan).await.unwrap(); + let user: EncryptedUser = values.take("user").unwrap(); + assert_eq!(user.id, 43); + let age: Ciphertext = values.take("age").unwrap(); + assert_eq!( + keyset + .decrypt_with_context::<u32>(age, ("profiles", "age")) + .await + .unwrap(), + 51 + ); + let _ = ( + FixedInteger::encryption::<common::CountingSource>, + RequiredContext::<u64>::into_inner, + ); + let _: ContextExtension<u64> = + <ContextExtension<u64> as stack_encrypt::target::AllowsNoContext>::omitted(); +} diff --git a/packages/stack-encrypt/tests/ui/aead_context_with_term.stderr b/packages/stack-encrypt/tests/ui/aead_context_with_term.stderr index 1087c647c..056d7150f 100644 --- a/packages/stack-encrypt/tests/ui/aead_context_with_term.stderr +++ b/packages/stack-encrypt/tests/ui/aead_context_with_term.stderr @@ -6,13 +6,13 @@ error[E0277]: the trait bound `CallerContext: From<AeadContext>` is not satisfie | = help: the following other types implement trait `From<T>`: `CallerContext` implements `From<NonEmpty<T>>` + `CallerContext` implements `From<RequiredContext<C>>` `CallerContext` implements `From<i128>` `CallerContext` implements `From<i16>` `CallerContext` implements `From<i32>` `CallerContext` implements `From<i64>` `CallerContext` implements `From<i8>` `CallerContext` implements `From<u128>` - `CallerContext` implements `From<u16>` and $N others = note: required for `AeadContext` to implement `Into<CallerContext>` = help: see issue #48214 diff --git a/packages/stack-encrypt/tests/ui/bare_context.stderr b/packages/stack-encrypt/tests/ui/bare_context.stderr index 327a95d9f..3e6b206f2 100644 --- a/packages/stack-encrypt/tests/ui/bare_context.stderr +++ b/packages/stack-encrypt/tests/ui/bare_context.stderr @@ -8,13 +8,13 @@ error[E0277]: the trait bound `CallerContext: From<&str>` is not satisfied | = help: the following other types implement trait `From<T>`: `CallerContext` implements `From<NonEmpty<T>>` + `CallerContext` implements `From<RequiredContext<C>>` `CallerContext` implements `From<i128>` `CallerContext` implements `From<i16>` `CallerContext` implements `From<i32>` `CallerContext` implements `From<i64>` `CallerContext` implements `From<i8>` `CallerContext` implements `From<u128>` - `CallerContext` implements `From<u16>` and $N others = note: required for `&str` to implement `Into<CallerContext>` note: required by a bound in `stack_encrypt::target::legacy::EncryptInto::encrypt_into_with_context` @@ -37,12 +37,12 @@ error[E0277]: the trait bound `DeclaredContext: From<&str>` is not satisfied = help: the following other types implement trait `From<T>`: `DeclaredContext` implements `From<()>` `DeclaredContext` implements `From<CallerContext>` + `DeclaredContext` implements `From<ContextExtension<C>>` `DeclaredContext` implements `From<NoCallerContext>` `DeclaredContext` implements `From<NonEmpty<T>>` `DeclaredContext` implements `From<i128>` `DeclaredContext` implements `From<i16>` `DeclaredContext` implements `From<i32>` - `DeclaredContext` implements `From<i64>` and $N others = note: required for `&str` to implement `Into<DeclaredContext>` note: required by a bound in `stack_encrypt::target::legacy::EncryptInto::encrypt_into_with_context` @@ -64,13 +64,13 @@ error[E0277]: the trait bound `CallerContext: From<&str>` is not satisfied | = help: the following other types implement trait `From<T>`: `CallerContext` implements `From<NonEmpty<T>>` + `CallerContext` implements `From<RequiredContext<C>>` `CallerContext` implements `From<i128>` `CallerContext` implements `From<i16>` `CallerContext` implements `From<i32>` `CallerContext` implements `From<i64>` `CallerContext` implements `From<i8>` `CallerContext` implements `From<u128>` - `CallerContext` implements `From<u16>` and $N others = note: required for `&str` to implement `Into<CallerContext>` note: required by a bound in `stack_encrypt::target::legacy::EncryptInto::encrypt_into_with_context` @@ -93,12 +93,12 @@ error[E0277]: the trait bound `DeclaredContext: From<&str>` is not satisfied = help: the following other types implement trait `From<T>`: `DeclaredContext` implements `From<()>` `DeclaredContext` implements `From<CallerContext>` + `DeclaredContext` implements `From<ContextExtension<C>>` `DeclaredContext` implements `From<NoCallerContext>` `DeclaredContext` implements `From<NonEmpty<T>>` `DeclaredContext` implements `From<i128>` `DeclaredContext` implements `From<i16>` `DeclaredContext` implements `From<i32>` - `DeclaredContext` implements `From<i64>` and $N others = note: required for `&str` to implement `Into<DeclaredContext>` note: required by a bound in `stack_encrypt::target::legacy::DecryptFrom::decrypt_from_with_context` @@ -121,12 +121,12 @@ error[E0277]: the trait bound `AeadContext: From<&str>` is not satisfied = help: the following other types implement trait `From<T>`: `AeadContext` implements `From<CallerContext>` `AeadContext` implements `From<NonEmpty<T>>` + `AeadContext` implements `From<RequiredContext<C>>` `AeadContext` implements `From<i128>` `AeadContext` implements `From<i16>` `AeadContext` implements `From<i32>` `AeadContext` implements `From<i64>` `AeadContext` implements `From<i8>` - `AeadContext` implements `From<u128>` and $N others = note: required for `&str` to implement `Into<AeadContext>` note: required by a bound in `decrypt_into` diff --git a/packages/stack-encrypt/tests/ui/context_field_conflicts.stderr b/packages/stack-encrypt/tests/ui/context_field_conflicts.stderr index 09e47367d..bcb6f092c 100644 --- a/packages/stack-encrypt/tests/ui/context_field_conflicts.stderr +++ b/packages/stack-encrypt/tests/ui/context_field_conflicts.stderr @@ -4,7 +4,7 @@ error: a record has exactly one `context_field` 3 | struct Twice { | ^^^^^ -error: `context_field` is metadata and cannot also be derived or defaulted +error: `context_field` is metadata and cannot also be derived, defaulted or passed through --> tests/ui/context_field_conflicts.rs:13:8 | 13 | i: String, diff --git a/packages/stack-encrypt/tests/ui/leaf_without_context.stderr b/packages/stack-encrypt/tests/ui/leaf_without_context.stderr index b408e8c03..fc886ada1 100644 --- a/packages/stack-encrypt/tests/ui/leaf_without_context.stderr +++ b/packages/stack-encrypt/tests/ui/leaf_without_context.stderr @@ -72,13 +72,13 @@ error[E0277]: the trait bound `CallerContext: From<()>` is not satisfied | = help: the following other types implement trait `From<T>`: `CallerContext` implements `From<NonEmpty<T>>` + `CallerContext` implements `From<RequiredContext<C>>` `CallerContext` implements `From<i128>` `CallerContext` implements `From<i16>` `CallerContext` implements `From<i32>` `CallerContext` implements `From<i64>` `CallerContext` implements `From<i8>` `CallerContext` implements `From<u128>` - `CallerContext` implements `From<u16>` and $N others = note: required for `()` to implement `Into<CallerContext>` note: required by a bound in `decrypt_into` diff --git a/packages/stack-encrypt/tests/ui/owned_aead_record_with_term.rs b/packages/stack-encrypt/tests/ui/owned_aead_record_with_term.rs new file mode 100644 index 000000000..5bf9d707c --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_aead_record_with_term.rs @@ -0,0 +1,15 @@ +//! A `plaintext` owned record declared `context_type = AeadContext` requires +//! an AEAD-only context, which a term cannot derive under: the term field is +//! refused where it is declared. +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{AeadContext, EncryptTarget}; +use stack_encrypt::Ciphertext; + +#[derive(EncryptTarget)] +#[stash(plaintext = String, context_type = AeadContext)] +struct SealedAndIndexed { + c: Ciphertext, + hm: EqualityTerm, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/owned_aead_record_with_term.stderr b/packages/stack-encrypt/tests/ui/owned_aead_record_with_term.stderr new file mode 100644 index 000000000..5e130aaf9 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_aead_record_with_term.stderr @@ -0,0 +1,25 @@ +error[E0277]: the trait bound `RequiredContext<CallerContext>: From<RequiredContext<AeadContext>>` is not satisfied + --> tests/ui/owned_aead_record_with_term.rs:8:10 + | + 8 | #[derive(EncryptTarget)] + | ^^^^^^^^^^^^^ the trait `From<RequiredContext<AeadContext>>` is not implemented for `RequiredContext<CallerContext>` +... +12 | hm: EqualityTerm, + | ------------ required by a bound introduced by this call + | +help: the trait `From<RequiredContext<AeadContext>>` is not implemented for `RequiredContext<CallerContext>` + but trait `From<RequiredContext<NonEmpty<_>>>` is implemented for it + --> src/target/input.rs + | + | impl<'c, T: IntoContext<'c>> From<RequiredContext<NonEmpty<T>>> for RequiredContext<CallerContext> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: for that trait implementation, expected `NonEmpty<_>`, found `AeadContext` + = note: required for `RequiredContext<AeadContext>` to implement `Into<RequiredContext<CallerContext>>` +note: required by a bound in `Encryption::<'s, S, T, K, Ctx, M>::accepting` + --> src/target/operations.rs + | + | pub fn accepting<C2>(self) -> Encryption<'s, S, T, K, C2, M> + | --------- required by a bound in this associated function + | where + | C2: Into<Ctx> + 's, + | ^^^^^^^^^ required by this bound in `Encryption::<'s, S, T, K, Ctx, M>::accepting` diff --git a/packages/stack-encrypt/tests/ui/owned_collection_context.stderr b/packages/stack-encrypt/tests/ui/owned_collection_context.stderr index 286be41fa..29ff466b4 100644 --- a/packages/stack-encrypt/tests/ui/owned_collection_context.stderr +++ b/packages/stack-encrypt/tests/ui/owned_collection_context.stderr @@ -14,6 +14,12 @@ help: the following other types implement trait `AllowsNoContext` ... | impl<C> AllowsNoContext for OptionalContext<C> { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext<C>` +... + | impl<C> AllowsNoContext for ContextExtension<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ContextExtension<C>` +... + | impl<T> AllowsNoContext for ExpectedContext<T> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ExpectedContext<T>` note: required by a bound in `stack_encrypt::EncryptInto::encrypt_into` --> src/target/conversion.rs | @@ -39,6 +45,12 @@ help: the following other types implement trait `AllowsNoContext` ... | impl<C> AllowsNoContext for OptionalContext<C> { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext<C>` +... + | impl<C> AllowsNoContext for ContextExtension<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ContextExtension<C>` +... + | impl<T> AllowsNoContext for ExpectedContext<T> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ExpectedContext<T>` note: required by a bound in `stack_encrypt::DecryptFrom::decrypt_from` --> src/target/conversion.rs | @@ -62,6 +74,12 @@ help: the following other types implement trait `AcceptsContext` ... | impl<C> AcceptsContext for OptionalContext<C> { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext<C>` +... + | impl<C> AcceptsContext for ContextExtension<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ContextExtension<C>` +... + | impl<T> AcceptsContext for ExpectedContext<T> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ExpectedContext<T>` error[E0277]: the trait bound `NoCallerContext: AcceptsContext` is not satisfied --> tests/ui/owned_collection_context.rs:44:51 @@ -79,6 +97,12 @@ help: the following other types implement trait `AcceptsContext` ... | impl<C> AcceptsContext for OptionalContext<C> { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext<C>` +... + | impl<C> AcceptsContext for ContextExtension<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ContextExtension<C>` +... + | impl<T> AcceptsContext for ExpectedContext<T> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ExpectedContext<T>` note: required by a bound in `stack_encrypt::DecryptFrom::decrypt_from_with_context` --> src/target/conversion.rs | diff --git a/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr b/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr index 1130a9e9c..cbeb50b2d 100644 --- a/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr +++ b/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr @@ -65,7 +65,7 @@ note: required by a bound in `stack_encrypt::EncryptInto::encrypt_into` | ^^^^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into` = note: this error originates in the macro `opaque` (in Nightly builds, run with -Z macro-backtrace for more info) -error[E0277]: the trait bound `&str: zeroize::Zeroize` is not satisfied +error[E0277]: the trait bound `&str: Zeroize` is not satisfied --> tests/ui/owned_collection_elements.rs:12:13 | 12 | let _ = EncryptInto::encrypt_into::<Vec<Ciphertext>, _>(vec!["borrowed", "plaintext"], keyset); @@ -81,7 +81,7 @@ error[E0277]: the trait bound `&str: zeroize::Zeroize` is not satisfied i128 i16 and $N others - = note: required for `&str` to implement `zeroize::Zeroize` + = note: required for `&str` to implement `Zeroize` = note: required for `Vec<stack_encrypt::Ciphertext>` to implement `EncryptTarget<Vec<&str>>` error[E0277]: the trait bound `Vec<u8>: OpaqueEncode` is not satisfied diff --git a/packages/stack-encrypt/tests/ui/owned_record_field_context.rs b/packages/stack-encrypt/tests/ui/owned_record_field_context.rs new file mode 100644 index 000000000..ed2b7b199 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_record_field_context.rs @@ -0,0 +1,58 @@ +//! A field of a `struct = ..` owned record is handed the pair the record +//! infers, extended by the caller's context. A target that accepts no +//! caller context, or requires one of another type, cannot take it, and is +//! refused at that field. +use stack_encrypt::target::{ + AcceptsContext, DecryptSource, Decryptable, Decryption, EncryptTarget, Encryption, + NoCallerContext, OptionalContext, RequiredContext, ScalarContext, +}; +use stack_encrypt::{nonempty, Ciphertext}; + +/// Declares its whole context itself. +struct Fixed(Ciphertext); +impl EncryptTarget<i32> for Fixed { + type ContextInput = NoCallerContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, i32, Self, K, Self::ContextInput> { + stack_encrypt::target::scalar_ciphertext() + .map(Self) + .under(nonempty!("users/fixed")) + .accepting() + } +} +impl DecryptSource<i32> for Fixed { + type ContextInput = NoCallerContext; + fn decryption<K: 'static>(self, _: Self::ContextInput) -> Decryption<i32, K> { + self.0.decryption(OptionalContext::<ScalarContext>::supplied("users/fixed".into())) + } +} + +impl Decryptable for Fixed { + const DECRYPTABLE: bool = true; +} + +/// Requires a row id from the caller, not a column pair. +struct Bound(Ciphertext); +impl Decryptable for Bound { + const DECRYPTABLE: bool = true; +} +impl EncryptTarget<i32> for Bound { + type ContextInput = RequiredContext<u64>; + fn encryption<'s, K: 'static>() -> Encryption<'s, i32, Self, K, Self::ContextInput> { + stack_encrypt::target::scalar_ciphertext().map(Self).accepting() + } +} + +#[derive(zeroize::Zeroize)] +struct Reading { + fixed: i32, + bound: i32, +} + +#[derive(EncryptTarget)] +#[stash(struct = Reading, context = "readings")] +struct EncryptedReading { + fixed: Fixed, + bound: Bound, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/owned_record_field_context.stderr b/packages/stack-encrypt/tests/ui/owned_record_field_context.stderr new file mode 100644 index 000000000..82fca205a --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_record_field_context.stderr @@ -0,0 +1,40 @@ +error[E0277]: the trait bound `NoCallerContext: From<CallerContext>` is not satisfied + --> tests/ui/owned_record_field_context.rs:54:12 + | +54 | fixed: Fixed, + | ^^^^^ the trait `From<CallerContext>` is not implemented for `NoCallerContext` + | +note: required by a bound in `Encryption::<'s, S, T, K, Ctx, M>::under` + --> src/target/operations.rs + | + | pub fn under( + | ----- required by a bound in this associated function +... + | Ctx: From<CallerContext>, + | ^^^^^^^^^^^^^^^^^^^ required by this bound in `Encryption::<'s, S, T, K, Ctx, M>::under` + +error[E0277]: the trait bound `u64: From<CallerContext>` is not satisfied + --> tests/ui/owned_record_field_context.rs:55:12 + | +55 | bound: Bound, + | ^^^^^ the trait `From<CallerContext>` is not implemented for `u64` + | + = help: the following other types implement trait `From<T>`: + `u64` implements `From<aws_lc_rs::endian::BigEndian<u64>>` + `u64` implements `From<aws_lc_rs::endian::LittleEndian<u64>>` + `u64` implements `From<bool>` + `u64` implements `From<char>` + `u64` implements `From<std::ascii::Char>` + `u64` implements `From<u16>` + `u64` implements `From<u32>` + `u64` implements `From<u8>` + and $N others + = note: required for `RequiredContext<u64>` to implement `From<CallerContext>` +note: required by a bound in `Encryption::<'s, S, T, K, Ctx, M>::under` + --> src/target/operations.rs + | + | pub fn under( + | ----- required by a bound in this associated function +... + | Ctx: From<CallerContext>, + | ^^^^^^^^^^^^^^^^^^^ required by this bound in `Encryption::<'s, S, T, K, Ctx, M>::under` diff --git a/packages/stack-encrypt/tests/ui/owned_record_required_context.rs b/packages/stack-encrypt/tests/ui/owned_record_required_context.rs new file mode 100644 index 000000000..83b0b68d3 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_record_required_context.rs @@ -0,0 +1,19 @@ +//! A `plaintext` owned record requires the caller's context: omitting it is +//! refused at the call, on both sides, however `Default` the context type is. +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{DecryptSource, EncryptTarget}; +use stack_encrypt::{Ciphertext, DecryptFrom, EncryptInto}; + +#[derive(EncryptTarget, DecryptSource)] +#[stash(plaintext = String)] +struct SearchableEmail { + ciphertext: Ciphertext, + equality: EqualityTerm, +} + +fn omitted<K: 'static>(keyset: &stack_encrypt::KeysetCipher<'_, K>, stored: SearchableEmail) { + let _ = String::from("alice@example.com").encrypt_into::<SearchableEmail, _>(keyset); + let _ = String::decrypt_from(stored, keyset); +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/owned_record_required_context.stderr b/packages/stack-encrypt/tests/ui/owned_record_required_context.stderr new file mode 100644 index 000000000..18660210b --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_record_required_context.stderr @@ -0,0 +1,59 @@ +error[E0277]: the trait bound `RequiredContext<CallerContext>: AllowsNoContext` is not satisfied + --> tests/ui/owned_record_required_context.rs:15:47 + | +15 | let _ = String::from("alice@example.com").encrypt_into::<SearchableEmail, _>(keyset); + | ^^^^^^^^^^^^ the trait `AllowsNoContext` is not implemented for `RequiredContext<CallerContext>` + | +help: the following other types implement trait `AllowsNoContext` + --> src/target/input.rs + | + | impl AllowsNoContext for NoCallerContext { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `NoCallerContext` +... + | impl<C> AllowsNoContext for OptionalContext<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext<C>` +... + | impl<C> AllowsNoContext for ContextExtension<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ContextExtension<C>` +... + | impl<T> AllowsNoContext for ExpectedContext<T> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ExpectedContext<T>` +note: required by a bound in `stack_encrypt::EncryptInto::encrypt_into` + --> src/target/conversion.rs + | + | fn encrypt_into<'a, T, K: 'static>(self, cipher: &'a KeysetCipher<'_, K>) -> Pending<'a, T, K> + | ------------ required by a bound in this associated function +... + | T::ContextInput: AllowsNoContext, + | ^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into` + +error[E0277]: the trait bound `RequiredContext<CallerContext>: AllowsNoContext` is not satisfied + --> tests/ui/owned_record_required_context.rs:16:34 + | +16 | let _ = String::decrypt_from(stored, keyset); + | -------------------- ^^^^^^ the trait `AllowsNoContext` is not implemented for `RequiredContext<CallerContext>` + | | + | required by a bound introduced by this call + | +help: the following other types implement trait `AllowsNoContext` + --> src/target/input.rs + | + | impl AllowsNoContext for NoCallerContext { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `NoCallerContext` +... + | impl<C> AllowsNoContext for OptionalContext<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext<C>` +... + | impl<C> AllowsNoContext for ContextExtension<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ContextExtension<C>` +... + | impl<T> AllowsNoContext for ExpectedContext<T> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ExpectedContext<T>` +note: required by a bound in `stack_encrypt::DecryptFrom::decrypt_from` + --> src/target/conversion.rs + | + | fn decrypt_from<'a, S, K: 'static>( + | ------------ required by a bound in this associated function +... + | S::ContextInput: AllowsNoContext, + | ^^^^^^^^^^^^^^^ required by this bound in `DecryptFrom::decrypt_from` diff --git a/packages/stack-encrypt/tests/ui/owned_struct_plaintext_not_zeroize.rs b/packages/stack-encrypt/tests/ui/owned_struct_plaintext_not_zeroize.rs new file mode 100644 index 000000000..ef07051ca --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_struct_plaintext_not_zeroize.rs @@ -0,0 +1,17 @@ +//! The plaintext of an owned record is a `Zeroize` type: the conversion +//! takes responsibility for wiping what it seals, and the bound is where +//! that responsibility is declared. +use stack_encrypt::target::EncryptTarget; +use stack_encrypt::Ciphertext; + +struct User { + email: String, +} + +#[derive(EncryptTarget)] +#[stash(struct = User, context = "users")] +struct EncryptedUser { + email: Ciphertext, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/owned_struct_plaintext_not_zeroize.stderr b/packages/stack-encrypt/tests/ui/owned_struct_plaintext_not_zeroize.stderr new file mode 100644 index 000000000..2b2f807f1 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_struct_plaintext_not_zeroize.stderr @@ -0,0 +1,119 @@ +error[E0277]: the trait bound `User: zeroize::DefaultIsZeroes` is not satisfied + --> tests/ui/owned_struct_plaintext_not_zeroize.rs:11:10 + | +11 | #[derive(EncryptTarget)] + | ^^^^^^^^^^^^^ unsatisfied trait bound + | +help: the trait `zeroize::DefaultIsZeroes` is not implemented for `User` + --> tests/ui/owned_struct_plaintext_not_zeroize.rs:7:1 + | + 7 | struct User { + | ^^^^^^^^^^^ + = help: the following other types implement trait `zeroize::DefaultIsZeroes`: + () + PhantomPinned + bool + char + f32 + f64 + i128 + i16 + and $N others + = note: required for `User` to implement `Zeroize` +note: required by a bound in `EncryptTarget` + --> src/target/conversion.rs + | + | pub trait EncryptTarget<S: Zeroize>: Sized + 'static { + | ^^^^^^^ required by this bound in `EncryptTarget` + = note: this error originates in the derive macro `EncryptTarget` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0277]: the trait bound `User: Zeroize` is not satisfied + --> tests/ui/owned_struct_plaintext_not_zeroize.rs:11:10 + | +11 | #[derive(EncryptTarget)] + | ^^^^^^^^^^^^^ unsatisfied trait bound + | +help: the trait `zeroize::DefaultIsZeroes` is not implemented for `User` + --> tests/ui/owned_struct_plaintext_not_zeroize.rs:7:1 + | + 7 | struct User { + | ^^^^^^^^^^^ + = help: the following other types implement trait `zeroize::DefaultIsZeroes`: + () + PhantomPinned + bool + char + f32 + f64 + i128 + i16 + and $N others + = note: required for `User` to implement `Zeroize` +note: required by a bound in `EncryptTarget` + --> src/target/conversion.rs + | + | pub trait EncryptTarget<S: Zeroize>: Sized + 'static { + | ^^^^^^^ required by this bound in `EncryptTarget` + = note: this error originates in the derive macro `EncryptTarget` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0277]: the trait bound `User: Zeroize` is not satisfied + --> tests/ui/owned_struct_plaintext_not_zeroize.rs:12:18 + | +12 | #[stash(struct = User, context = "users")] + | ^^^^ unsatisfied trait bound + | +help: the trait `zeroize::DefaultIsZeroes` is not implemented for `User` + --> tests/ui/owned_struct_plaintext_not_zeroize.rs:7:1 + | + 7 | struct User { + | ^^^^^^^^^^^ + = help: the following other types implement trait `zeroize::DefaultIsZeroes`: + () + PhantomPinned + bool + char + f32 + f64 + i128 + i16 + and $N others + = note: required for `User` to implement `Zeroize` +note: required by a bound in `stack_encrypt::EncryptTarget::encryption` + --> src/target/conversion.rs + | + | pub trait EncryptTarget<S: Zeroize>: Sized + 'static { + | ^^^^^^^ required by this bound in `EncryptTarget::encryption` +... + | fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::ContextInput> + | ---------- required by a bound in this associated function + +error[E0277]: the trait bound `User: Zeroize` is not satisfied + --> tests/ui/owned_struct_plaintext_not_zeroize.rs:12:18 + | +12 | #[stash(struct = User, context = "users")] + | ^^^^ unsatisfied trait bound + | +help: the trait `zeroize::DefaultIsZeroes` is not implemented for `User` + --> tests/ui/owned_struct_plaintext_not_zeroize.rs:7:1 + | + 7 | struct User { + | ^^^^^^^^^^^ + = help: the following other types implement trait `zeroize::DefaultIsZeroes`: + () + PhantomPinned + bool + char + f32 + f64 + i128 + i16 + and $N others + = note: required for `User` to implement `Zeroize` +note: required by a bound in `stack_encrypt::EncryptTarget::indexes` + --> src/target/conversion.rs + | + | pub trait EncryptTarget<S: Zeroize>: Sized + 'static { + | ^^^^^^^ required by this bound in `EncryptTarget::indexes` +... + | fn indexes() -> Vec<IndexSpec> { + | ------- required by a bound in this associated function diff --git a/packages/stack-encrypt/tests/ui/pass/owned_records.rs b/packages/stack-encrypt/tests/ui/pass/owned_records.rs new file mode 100644 index 000000000..00c26c3d0 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/pass/owned_records.rs @@ -0,0 +1,160 @@ +//! The owned record forms that must compile: a `struct = ..` record with +//! passthrough, renamed, pinned and nested fields; `plaintext` records over +//! opaque and structural leaves, generic, and with a stored context; a column +//! of records; and the structural bridge into a legacy record and a plan. +//! `tests/ui/owned_record_*.rs` and `tests/ui/passthrough_misuse.rs` pin the +//! forms that must not. +use stack_encrypt::plan::pick; +use stack_encrypt::sem::{EqualityTerm, MatchTerms, OreTerm}; +use stack_encrypt::target::{legacy, AeadContext, DecryptSource, EncryptTarget}; +use stack_encrypt::{ + nonempty, Ciphertext, DecryptFrom, EncryptFrom, EncryptInto, Encrypted, KeysetCipher, Plan, + StackCipher, StackCipherText, +}; +use stack_kms::FakeDataKeySource; +use zeroize::Zeroize; + +#[derive(Clone, Zeroize)] +struct Address { + street: String, +} +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = Address, context = "addresses")] +struct EncryptedAddress { + street: Ciphertext, +} + +#[derive(Clone, Zeroize)] +struct User { + id: u64, + name: String, + email: String, + address: Address, + created: i64, +} +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = User, context = "users")] +struct EncryptedUser { + #[stash(passthrough)] + id: u64, + #[stash(from = name, identity = "full_name")] + full_name: Ciphertext, + email: SearchableEmail, + address: EncryptedAddress, + #[stash(passthrough, from = created)] + created_at: i64, + #[stash(default)] + version: u8, +} + +#[derive(EncryptTarget, DecryptSource)] +#[stash(plaintext = String)] +struct SearchableEmail { + ciphertext: Ciphertext, + equality: EqualityTerm, +} + +#[derive(EncryptTarget, DecryptSource)] +#[stash(plaintext = u32)] +struct EncryptedAge { + c: StackCipherText, + hm: EqualityTerm, + ob: OreTerm<u32>, +} + +#[derive(EncryptTarget, DecryptSource)] +#[stash(plaintext = String, context_type = AeadContext)] +struct SealedName { + c: StackCipherText, +} + +#[derive(EncryptTarget, DecryptSource)] +struct Searchable { + c: Encrypted<(EqualityTerm, MatchTerms)>, +} + +#[derive(EncryptTarget, DecryptSource)] +#[stash(plaintext = String)] +struct TextEq { + #[stash(context_field)] + identifier: &'static str, + c: Ciphertext, + hm: EqualityTerm, +} + +/// The structural bridge: an owned record as a field of a structural one. +#[derive(Clone)] +struct Profile { + user: User, + age: u32, +} +#[derive(EncryptFrom)] +#[stash(struct = Profile, context = "profiles")] +struct EncryptedProfile { + user: EncryptedUser, + age: EncryptedAge, +} + +async fn records( + cipher: &StackCipher<FakeDataKeySource>, + keyset: &KeysetCipher<'_, FakeDataKeySource>, + user: User, + profile: Profile, +) -> Result<(), stack_encrypt::Error> { + // A struct record, with and without an extension, and as a column. + let encrypted: EncryptedUser = user.clone().encrypt_into(keyset).await?; + let _: User = User::decrypt_from(encrypted, cipher).await?; + let encrypted: EncryptedUser = user.clone().encrypt_into_with_context(keyset, 7u64).await?; + let _: User = User::decrypt_from_with_context(encrypted, keyset, 7u64).await?; + let column: Vec<EncryptedUser> = vec![user.clone()].encrypt_into(keyset).await?; + let _: Vec<User> = Vec::decrypt_from(column, cipher).await?; + + // Plaintext records require their context. + let email: SearchableEmail = user + .email + .clone() + .encrypt_into_with_context(keyset, nonempty!("users/email")) + .await?; + let _: String = String::decrypt_from_with_context(email, keyset, nonempty!("users/email")).await?; + let age: EncryptedAge = 34u32.encrypt_into_with_context(keyset, nonempty!("users/age")).await?; + let _: u32 = u32::decrypt_from_with_context(age, keyset, nonempty!("users/age")).await?; + let name: SealedName = user + .name + .clone() + .encrypt_into_with_context(keyset, nonempty!("users/name")) + .await?; + let _: String = String::decrypt_from_with_context(name, keyset, nonempty!("users/name")).await?; + let text: Searchable = user + .name + .clone() + .encrypt_into_with_context(keyset, nonempty!("users/name")) + .await?; + let _: String = String::decrypt_from_with_context(text, keyset, nonempty!("users/name")).await?; + + // A stored context: omitted opens as stored, supplied also checks it. + let stored: TextEq = user + .email + .clone() + .encrypt_into_with_context(keyset, nonempty!("users/email")) + .await?; + let _: String = String::decrypt_from_with_context(stored, keyset, nonempty!("users/email")).await?; + let stored: TextEq = user + .email + .encrypt_into_with_context(keyset, nonempty!("users/email")) + .await?; + let _: String = String::decrypt_from(stored, keyset).await?; + + // The bridge: in a structural record, and as a typed plan field. + let _: EncryptedProfile = legacy::EncryptInto::encrypt_into(&profile, keyset).await?; + let plan = Plan::context("profiles") + .fields::<Profile, _>() + .encrypt_into::<EncryptedUser, _>(pick("user", |p: &Profile| &p.user)) + .target::<Ciphertext, _>(pick("age", |p: &Profile| &p.age)) + .build()?; + let _ = cipher.encrypt(&profile).using(&plan).await?; + Ok(()) +} + +fn main() { + let _ = records; +} diff --git a/packages/stack-encrypt/tests/ui/passthrough_misuse.rs b/packages/stack-encrypt/tests/ui/passthrough_misuse.rs new file mode 100644 index 000000000..4be64a514 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/passthrough_misuse.rs @@ -0,0 +1,49 @@ +//! `passthrough` carries a field of the plaintext struct into an owned +//! record in the clear. It has no form on the structural derives, on a +//! `plaintext` record, or beside the attributes of a derived field. +use stack_encrypt::target::{DecryptSource, EncryptTarget}; +use stack_encrypt::{Ciphertext, DecryptInto, EncryptFrom, StackCipherText}; + +#[derive(zeroize::Zeroize)] +struct User { + id: u64, + email: String, +} + +/// The structural derive moves nothing: it runs over a borrow. +#[derive(EncryptFrom, DecryptInto)] +#[stash(struct = User, context = "users")] +struct Structural { + #[stash(passthrough)] + id: u64, + email: StackCipherText, +} + +/// A `plaintext` record derives every field from the whole value. +#[derive(EncryptTarget)] +#[stash(plaintext = String)] +struct WholeValue { + #[stash(passthrough)] + id: u64, + email: Ciphertext, +} + +/// A passthrough field is moved through as it is. +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = User, context = "users")] +struct Marked { + #[stash(passthrough, decrypt)] + id: u64, + email: Ciphertext, +} + +/// One plaintext field has one output. +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = User, context = "users")] +struct Twice { + #[stash(passthrough, from = email)] + plain_email: String, + email: Ciphertext, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/passthrough_misuse.stderr b/packages/stack-encrypt/tests/ui/passthrough_misuse.stderr new file mode 100644 index 000000000..c27b94d9a --- /dev/null +++ b/packages/stack-encrypt/tests/ui/passthrough_misuse.stderr @@ -0,0 +1,23 @@ +error: `passthrough` is a field of an owned record: derive `EncryptTarget` and `DecryptSource`, which move the plaintext field into the record and back. The structural `EncryptFrom` / `DecryptInto` derives have no form for it + --> tests/ui/passthrough_misuse.rs:18:9 + | +18 | id: u64, + | ^^^ + +error: `passthrough` carries one field of the plaintext struct into the record in the clear, which is what `#[stash(struct = ..)]` reaches: a `plaintext` record derives every field from the whole value + --> tests/ui/passthrough_misuse.rs:27:9 + | +27 | id: u64, + | ^^^ + +error: a `passthrough` field is moved through as it is, so `identity`, `default` and `decrypt` do not apply to it: it is keyed under nothing, filled from the plaintext and moved back on decryption + --> tests/ui/passthrough_misuse.rs:36:9 + | +36 | id: u64, + | ^^^ + +error: `plain_email` and `email` are both derived from the plaintext field `email`: a plaintext field has one output. For a ciphertext with search terms beside it, make it one field of type `Encrypted<Terms>`, e.g. `plain_email: Encrypted<(EqualityTerm, MatchTerms)>` + --> tests/ui/passthrough_misuse.rs:46:5 + | +46 | email: Ciphertext, + | ^^^^^ diff --git a/packages/stack-encrypt/tests/ui/scalar_no_caller_context.stderr b/packages/stack-encrypt/tests/ui/scalar_no_caller_context.stderr index 4bd377c0c..c09db851a 100644 --- a/packages/stack-encrypt/tests/ui/scalar_no_caller_context.stderr +++ b/packages/stack-encrypt/tests/ui/scalar_no_caller_context.stderr @@ -12,6 +12,12 @@ help: the following other types implement trait `AcceptsContext` ... | impl<C> AcceptsContext for OptionalContext<C> { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext<C>` +... + | impl<C> AcceptsContext for ContextExtension<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ContextExtension<C>` +... + | impl<T> AcceptsContext for ExpectedContext<T> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ExpectedContext<T>` error[E0277]: the trait bound `NoCallerContext: AcceptsContext` is not satisfied --> tests/ui/scalar_no_caller_context.rs:18:44 @@ -29,6 +35,12 @@ help: the following other types implement trait `AcceptsContext` ... | impl<C> AcceptsContext for OptionalContext<C> { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext<C>` +... + | impl<C> AcceptsContext for ContextExtension<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ContextExtension<C>` +... + | impl<T> AcceptsContext for ExpectedContext<T> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ExpectedContext<T>` note: required by a bound in `stack_encrypt::DecryptFrom::decrypt_from_with_context` --> src/target/conversion.rs | diff --git a/packages/stack-encrypt/tests/ui/scalar_required_context.stderr b/packages/stack-encrypt/tests/ui/scalar_required_context.stderr index 0705bdec4..ebbe069fa 100644 --- a/packages/stack-encrypt/tests/ui/scalar_required_context.stderr +++ b/packages/stack-encrypt/tests/ui/scalar_required_context.stderr @@ -12,6 +12,12 @@ help: the following other types implement trait `AllowsNoContext` ... | impl<C> AllowsNoContext for OptionalContext<C> { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext<C>` +... + | impl<C> AllowsNoContext for ContextExtension<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ContextExtension<C>` +... + | impl<T> AllowsNoContext for ExpectedContext<T> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ExpectedContext<T>` note: required by a bound in `stack_encrypt::EncryptInto::encrypt_into` --> src/target/conversion.rs | @@ -37,6 +43,12 @@ help: the following other types implement trait `AllowsNoContext` ... | impl<C> AllowsNoContext for OptionalContext<C> { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext<C>` +... + | impl<C> AllowsNoContext for ContextExtension<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ContextExtension<C>` +... + | impl<T> AllowsNoContext for ExpectedContext<T> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ExpectedContext<T>` note: required by a bound in `stack_encrypt::DecryptFrom::decrypt_from` --> src/target/conversion.rs | From c089ab664e37f49776869c0698558f11a71f4945 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 3 Nov 2026 00:10:14 +1100 Subject: [PATCH 02/10] fix(stack-encrypt): wipe an owned column's unvisited elements when a declaration panics The owned `Vec<S>` conversion hands each element to its own declaration by value. A declaration that panicked unwound through `vec::IntoIter`'s drop, which freed every element not yet handed over without wiping it. The whole vector's guard did wipe them before this branch moved the column to per-element owned declarations. The elements not yet handed over now stay in a guard that wipes them. An element is taken out only once its description and context exist. This also pins the structural `Vec<T>: DecryptField<Vec<P>, _>` impl. Swapping its `all_wiping` back to `all` failed no test, because the existing test reaches only the `DecryptInto` impl. A structural record with a column field now opens a recovered prefix and a late refusal through it. --- .../stack-encrypt/src/target/collections.rs | 31 +++++- packages/stack-encrypt/tests/collections.rs | 103 ++++++++++++++++++ 2 files changed, 130 insertions(+), 4 deletions(-) diff --git a/packages/stack-encrypt/src/target/collections.rs b/packages/stack-encrypt/src/target/collections.rs index 2316f9545..5a8464e47 100644 --- a/packages/stack-encrypt/src/target/collections.rs +++ b/packages/stack-encrypt/src/target/collections.rs @@ -67,15 +67,38 @@ where // element left unvisited after an earlier failure would be // dropped as it is. The column still reports a refused // context once, since `collect` keeps the first failure. - let elements: Vec<_> = source - .into_iter() - .map(|element| cipher.run(T::owned_encryption(), element, context.clone())) - .collect(); + // + // The elements not yet handed over stay in a guard that wipes + // them if a declaration panics, as the whole vector's guard + // did before each element was handed to its own. An element + // is taken out only once its description and context exist, + // so nothing that can panic runs while it is held unguarded. + let mut unvisited = Unvisited(source.into_iter()); + let mut elements = Vec::with_capacity(unvisited.0.len()); + while !unvisited.0.as_slice().is_empty() { + let description = T::owned_encryption(); + let context = context.clone(); + let Some(element) = unvisited.0.next() else { + break; + }; + elements.push(cipher.run(description, element, context)); + } Pending::collect(cipher, elements) }) } } +/// The elements of an owned column not yet handed to their declaration. +/// Dropped, by an unwind out of a declaration or once every element has +/// been handed over, it wipes whatever it still holds. +struct Unvisited<S: Zeroize>(std::vec::IntoIter<S>); + +impl<S: Zeroize> Drop for Unvisited<S> { + fn drop(&mut self) { + self.0.as_mut_slice().iter_mut().for_each(Zeroize::zeroize); + } +} + impl<P: Zeroize + 'static, T: DecryptSource<P>> DecryptSource<Vec<P>> for Vec<T> where T::ContextInput: Clone, diff --git a/packages/stack-encrypt/tests/collections.rs b/packages/stack-encrypt/tests/collections.rs index 103ab4292..ddd051502 100644 --- a/packages/stack-encrypt/tests/collections.rs +++ b/packages/stack-encrypt/tests/collections.rs @@ -530,3 +530,106 @@ async fn a_late_element_failure_wipes_the_recovered_prefix_on_the_structural_pat ); assert_eq!(retrieves.load(Ordering::SeqCst), 1, "both keys in one call"); } + +/// Seals a `Flagged`, and panics while declaring the element whose +/// plaintext is `"panics"`. +// The ciphertext is never read: the declaration panics before one exists. +struct PanickingTarget(#[allow(dead_code)] Ciphertext); +impl EncryptTarget<Flagged> for PanickingTarget { + type ContextInput = OptionalContext<ScalarContext>; + fn encryption<'s, K: 'static>() -> Encryption<'s, Flagged, Self, K, Self::ContextInput> { + stack_encrypt::target::scalar_ciphertext::<String, K>() + .project(|source: &Flagged| { + assert!(source.value != "panics", "element declaration failed"); + &source.value + }) + .map(Self) + } +} + +#[tokio::test] +async fn a_panicking_element_declaration_wipes_the_elements_after_it() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let flags: Vec<Arc<AtomicBool>> = (0..3).map(|_| Arc::default()).collect(); + let column: Vec<Flagged> = ["panics", "second", "third"] + .iter() + .zip(&flags) + .map(|(value, wiped)| Flagged { + value: (*value).to_owned(), + wiped: wiped.clone(), + }) + .collect(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + drop(column.encrypt_into::<Vec<PanickingTarget>, _>(&keyset)); + })); + assert!(result.is_err(), "the first element's declaration panicked"); + assert!( + all_wiped(&flags), + "the element that panicked and every element not yet handed over are wiped during the unwind" + ); +} + +// --- A structural record with a column field wipes its recovered prefix ---- + +impl stack_encrypt::target::Decryptable for TrackedCiphertext { + const DECRYPTABLE: bool = true; +} +impl<Ctx: Into<stack_encrypt::target::AeadContext>> stack_encrypt::target::DecryptField<Tracked, Ctx> + for TrackedCiphertext +{ + fn decryption_field<K: 'static>(self, context: Ctx) -> Option<Decryption<Tracked, K>> { + Some(stack_encrypt::DecryptInto::decryption(self, context.into())) + } +} + +struct Rows { + // Never read: the column fails, so no `Rows` is built. + #[allow(dead_code)] + items: Vec<Tracked>, +} +/// A column as a field: the structural `Vec<T>: DecryptField<Vec<P>, _>` +/// impl is the one that opens `items`. +#[derive(stack_encrypt::DecryptInto)] +#[stash(struct = Rows, context = "rows")] +struct StoredRows { + items: Vec<TrackedCiphertext>, +} + +#[tokio::test] +async fn a_late_element_failure_in_a_column_field_wipes_the_recovered_prefix() { + let (cipher, _, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let first = keyset + .encrypt_with_context("recovered first".to_owned(), ("rows", "items")) + .await + .unwrap(); + // Sealed under another context: fails authentication only once its key + // has arrived, by which time the first element has been recovered. + let second = keyset + .encrypt_with_context("never recovered".to_owned(), "elsewhere") + .await + .unwrap(); + let stored = StoredRows { + items: vec![ + TrackedCiphertext::new(first), + TrackedCiphertext::new(second), + ], + }; + let wiped: Vec<_> = stored.items.iter().map(|t| t.wiped.clone()).collect(); + // `Tracked::drop` fails the test if the recovered prefix is dropped + // unwiped. + let result = cipher + .decrypt_as::<Rows, _>(stored, stack_encrypt::target::DeclaredContext::default()) + .await; + assert!(matches!(result, Err(Error::Aead)), "{:?}", result.err()); + assert!( + wiped[0].load(Ordering::SeqCst), + "the recovered prefix is wiped" + ); + assert!( + !wiped[1].load(Ordering::SeqCst), + "nothing was recovered to wipe" + ); + assert_eq!(retrieves.load(Ordering::SeqCst), 1, "both keys in one call"); +} From 9084b01c69f7b87dffc4565c23040823c2717c5c Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 3 Nov 2026 00:10:15 +1100 Subject: [PATCH 03/10] fix(stack-encrypt-derive): refuse an owned struct record that leaves a plaintext field unaccounted for The owned form of a `struct = ..` record replaces the default guard over the whole plaintext. It destructures the struct with `..`, so any field that the record neither sealed nor carried through was freed unwiped. The destructure is now exhaustive. Such a field is a compile error at the `struct = ..` path ("pattern does not mention field"), just as the decrypt side already refuses a plaintext it cannot rebuild. --- .../stack-encrypt-derive/src/owned_encrypt.rs | 9 +++++++-- .../tests/ui/owned_struct_unmapped_field.rs | 20 +++++++++++++++++++ .../ui/owned_struct_unmapped_field.stderr | 5 +++++ 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 packages/stack-encrypt/tests/ui/owned_struct_unmapped_field.rs create mode 100644 packages/stack-encrypt/tests/ui/owned_struct_unmapped_field.stderr diff --git a/packages/stack-encrypt-derive/src/owned_encrypt.rs b/packages/stack-encrypt-derive/src/owned_encrypt.rs index 749c2b75a..5a0806183 100644 --- a/packages/stack-encrypt-derive/src/owned_encrypt.rs +++ b/packages/stack-encrypt-derive/src/owned_encrypt.rs @@ -183,15 +183,20 @@ fn struct_derive(input: &DeriveInput, record: &Record) -> TokenStream { }) .collect(); // The plaintext destructured into one local per participating field, - // every other field dropped as it is (`..`). + // exhaustively: the owned form replaces the guard over the whole value, + // so a plaintext field the record neither seals nor carries would be + // dropped unwiped. Without `..`, such a field is refused at compile + // time ("pattern does not mention field"), at the `struct = ..` path, as + // the decrypt side already refuses a plaintext it cannot rebuild. let bindings = fields.iter().map(|field| { let from = field.from().unwrap_or_else(|| unreachable!("a `from`")); let local = &field.local; quote!(#from: #local) }); let nested = nested_tuple(&locals); + let pattern = quote_spanned!(source.span()=> #source { #(#bindings),* }); let divide = quote!(|__source: #source| { - let #source { #(#bindings),*, .. } = __source; + let #pattern = __source; #nested }); let split = match locals.as_slice() { diff --git a/packages/stack-encrypt/tests/ui/owned_struct_unmapped_field.rs b/packages/stack-encrypt/tests/ui/owned_struct_unmapped_field.rs new file mode 100644 index 000000000..b760d2041 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_struct_unmapped_field.rs @@ -0,0 +1,20 @@ +//! An owned `struct = ..` record accounts for every field of its plaintext: +//! the owned conversion wipes what it seals and moves what it carries, so a +//! field it does neither would be dropped unwiped. It is refused at the +//! `struct = ..` path, naming the field. +use stack_encrypt::target::EncryptTarget; +use stack_encrypt::Ciphertext; + +#[derive(zeroize::Zeroize)] +struct User { + email: String, + password: String, +} + +#[derive(EncryptTarget)] +#[stash(struct = User, context = "users")] +struct EncryptedUser { + email: Ciphertext, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/owned_struct_unmapped_field.stderr b/packages/stack-encrypt/tests/ui/owned_struct_unmapped_field.stderr new file mode 100644 index 000000000..6035aa8db --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_struct_unmapped_field.stderr @@ -0,0 +1,5 @@ +error[E0027]: pattern does not mention field `password` + --> tests/ui/owned_struct_unmapped_field.rs:15:18 + | +15 | #[stash(struct = User, context = "users")] + | ^^^^ missing field `password` From 06a6264ce471725aa12d3c935c121d905988d12e Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 3 Nov 2026 00:10:15 +1100 Subject: [PATCH 04/10] fix(stack-encrypt): wipe the fields a record opened before a later field is refused A `struct = ..` record deriving `DecryptSource` zipped its field openings with `Pending::zip`. When a later field failed authentication after its key arrived, `zip` dropped the plaintext of the fields already opened without wiping it. `Decryption::zip_wiping` (backed by `Pending::zip_wiping`) holds the left side in a guard, which wipes it when the right side fails or panics. The owned derive opens its fields through it. The structural `DecryptInto` derive is unchanged. Its field plaintexts carry no `Zeroize` bound today. --- .../stack-encrypt-derive/src/owned_decrypt.rs | 11 ++-- packages/stack-encrypt-derive/src/shape.rs | 18 +++++- .../stack-encrypt/src/target/operations.rs | 17 ++++++ packages/stack-encrypt/src/target/pending.rs | 60 ++++++++++++++++++- packages/stack-encrypt/tests/owned_records.rs | 39 ++++++++++++ 5 files changed, 137 insertions(+), 8 deletions(-) diff --git a/packages/stack-encrypt-derive/src/owned_decrypt.rs b/packages/stack-encrypt-derive/src/owned_decrypt.rs index ee8b0359e..4c7d817f1 100644 --- a/packages/stack-encrypt-derive/src/owned_decrypt.rs +++ b/packages/stack-encrypt-derive/src/owned_decrypt.rs @@ -10,7 +10,7 @@ //! passed the caller's expectation. use crate::decrypt::check; use crate::owned_encrypt::{encrypt_input, spanned_predicate, threaded_input}; -use crate::shape::{trait_impl, zip, Field, Flavor, Record}; +use crate::shape::{trait_impl, zip_wiping, Field, Flavor, Record}; use proc_macro2::{Span, TokenStream}; use quote::{quote, quote_spanned}; use syn::spanned::Spanned; @@ -133,8 +133,9 @@ fn decrypt_input(record: &Record) -> Type { } /// A `struct = ..` record: each field opened under its pair extended by the -/// caller's extension, zipped, and the plaintext rebuilt with the -/// passthrough fields moved back. +/// caller's extension, zipped so that a field recovered before a later one +/// fails is wiped, and the plaintext rebuilt with the passthrough fields +/// moved back. fn struct_body( record: &Record, plaintext: &Type, @@ -177,7 +178,9 @@ fn struct_body( quote!(#from: self.#member) }); let output = quote!(#literal { #(#opened,)* #(#carried),* }); - let zipped = zip(operations, output); + // Wiping: a field opened before a later field is refused is wiped, not + // dropped as it is. + let zipped = zip_wiping(operations, output); Ok(quote! { let __context: #krate::target::DeclaredContext = ::core::convert::Into::into(__context); #zipped diff --git a/packages/stack-encrypt-derive/src/shape.rs b/packages/stack-encrypt-derive/src/shape.rs index 50c9e9fe8..fd6fe4d52 100644 --- a/packages/stack-encrypt-derive/src/shape.rs +++ b/packages/stack-encrypt-derive/src/shape.rs @@ -660,7 +660,8 @@ pub(crate) fn outputs(fields: &[&Field]) -> (TokenStream, TokenStream) { /// The chain zipping `operations` into one description, and the nested /// tuple pattern that binds each operation's output to its local in the /// closure that maps the chain's output. -fn zip_chain(operations: Vec<(TokenStream, Ident)>) -> (TokenStream, TokenStream) { +fn zip_chain(operations: Vec<(TokenStream, Ident)>, method: &str) -> (TokenStream, TokenStream) { + let method = Ident::new(method, proc_macro2::Span::call_site()); let mut chain = TokenStream::new(); let mut pattern = TokenStream::new(); for (index, (operation, local)) in operations.into_iter().enumerate() { @@ -668,7 +669,7 @@ fn zip_chain(operations: Vec<(TokenStream, Ident)>) -> (TokenStream, TokenStream chain = operation; pattern = quote!(#local); } else { - chain = quote!(#chain.zip(#operation)); + chain = quote!(#chain.#method(#operation)); pattern = quote!((#pattern, #local)); } } @@ -677,7 +678,18 @@ fn zip_chain(operations: Vec<(TokenStream, Ident)>) -> (TokenStream, TokenStream /// The zipped `operations`, mapped to `result`. pub(crate) fn zip(operations: Vec<(TokenStream, Ident)>, result: TokenStream) -> TokenStream { - let (chain, pattern) = zip_chain(operations); + let (chain, pattern) = zip_chain(operations, "zip"); + quote!(#chain.map(move |#pattern| #result)) +} + +/// [`zip`] over recovered plaintext: each field opened before a later one +/// fails is wiped rather than dropped as it is (`Decryption::zip_wiping`). +/// What an owned record's fields open through. +pub(crate) fn zip_wiping( + operations: Vec<(TokenStream, Ident)>, + result: TokenStream, +) -> TokenStream { + let (chain, pattern) = zip_chain(operations, "zip_wiping"); quote!(#chain.map(move |#pattern| #result)) } diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index c5f28c65e..41f9b97bc 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -856,6 +856,23 @@ impl<T: 'static, K: 'static> Decryption<T, K> { } } } + /// [`zip`](Self::zip) for recovered plaintext: when the right side + /// fails after its key arrived, or panics in its conversion, the + /// plaintext the left side recovered is wiped rather than dropped as it + /// is. A record deriving `DecryptSource` opens its fields through here, + /// so a field opened before a later field is refused does not outlive + /// the refusal unwiped. + pub fn zip_wiping<U: 'static>(self, other: Decryption<U, K>) -> Decryption<(T, U), K> + where + T: zeroize::Zeroize, + { + match (self.inner, other.inner) { + (Opening::Failed(error), _) | (_, Opening::Failed(error)) => Decryption::failed(error), + (Opening::Open(left), Opening::Open(right)) => { + Decryption::open(Box::new(move |cipher| left(cipher).zip_wiping(right(cipher)))) + } + } + } /// Open every description, retrieving all their keys in one batch, and /// collect the results in order. /// diff --git a/packages/stack-encrypt/src/target/pending.rs b/packages/stack-encrypt/src/target/pending.rs index df6da3735..f9a33658d 100644 --- a/packages/stack-encrypt/src/target/pending.rs +++ b/packages/stack-encrypt/src/target/pending.rs @@ -298,6 +298,29 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { /// is *not* authorised for the keyset is refused at ZeroKMS /// ([`Error::Kms`]), exactly as it would be on its own. pub fn zip<U: 'a>(self, other: Pending<'a, U, K>) -> Pending<'a, (T, U), K> { + self.zip_releasing(other, drop) + } + + /// [`zip`](Self::zip) for recovered plaintext: when the right side + /// fails at fulfilment (an authentication or type failure after its key + /// arrived, or a panic in its conversion), the left side's recovered + /// value is wiped before the error is returned or the panic unwinds, + /// not dropped as it is. What a record's fields open through. + pub(crate) fn zip_wiping<U: 'a>(self, other: Pending<'a, U, K>) -> Pending<'a, (T, U), K> + where + T: zeroize::Zeroize, + { + self.zip_releasing(other, |mut first: T| zeroize::Zeroize::zeroize(&mut first)) + } + + /// The one pair builder. `release` is handed the left side's output + /// when the right side fails or panics after it; it is not called when + /// both succeed. + fn zip_releasing<U: 'a>( + self, + other: Pending<'a, U, K>, + release: impl FnOnce(T) + MaybeSend + 'a, + ) -> Pending<'a, (T, U), K> { let keyset = match merge_scopes(self.keyset, other.keyset) { Ok(keyset) => keyset, Err(error) => return Pending::failed(self.cipher, error), @@ -317,7 +340,16 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { keyset, requests, failed: None, - fulfil: Box::new(move |responses| Ok((first(responses)?, second(responses)?))), + fulfil: Box::new(move |responses| { + // Held in a guard so that a right side that fails or panics + // releases the left side's output, as a collection does. + let held = Held { + item: Some(first(responses)?), + release: Some(release), + }; + let second = second(responses)?; + Ok((held.keep(), second)) + }), } } @@ -433,6 +465,32 @@ impl<T, R: FnOnce(Vec<T>)> Drop for Recovered<T, R> { } } +/// The left side of a pair, recovered before the right side fulfils. +/// Dropped before [`keep`](Self::keep), by an early return or an unwind, it +/// hands the value to `release`. +struct Held<T, R: FnOnce(T)> { + item: Option<T>, + release: Option<R>, +} + +impl<T, R: FnOnce(T)> Held<T, R> { + /// The right side succeeded: the value is the output, not released. + fn keep(mut self) -> T { + self.release = None; + self.item + .take() + .unwrap_or_else(|| unreachable!("a held value is kept once")) + } +} + +impl<T, R: FnOnce(T)> Drop for Held<T, R> { + fn drop(&mut self) { + if let (Some(release), Some(item)) = (self.release.take(), self.item.take()) { + release(item); + } + } +} + impl<'a, T: 'a, K> Pending<'a, T, K> where K: DataKeySource + IndexKeySource, diff --git a/packages/stack-encrypt/tests/owned_records.rs b/packages/stack-encrypt/tests/owned_records.rs index e4ae2b477..b98309bf1 100644 --- a/packages/stack-encrypt/tests/owned_records.rs +++ b/packages/stack-encrypt/tests/owned_records.rs @@ -289,6 +289,45 @@ async fn the_owned_record_wipes_sealed_fields_when_the_context_is_refused() { assert_eq!(generates.load(Ordering::SeqCst), 0); } +#[derive(Zeroize)] +struct Pair { + first: Secret, + second: Secret, +} +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = Pair, context = "pairs")] +struct EncryptedPair { + first: SecretCiphertext, + second: SecretCiphertext, +} + +#[tokio::test] +async fn a_field_opened_before_a_later_field_is_refused_is_wiped() { + let (cipher, _, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let secret = |value: &str| Secret { + value: value.to_owned(), + wiped: Arc::default(), + }; + let encrypted: EncryptedPair = Pair { + first: secret("opened first"), + second: secret("never opened"), + } + .encrypt_into(&keyset) + .await + .unwrap(); + // `second` holds the ciphertext sealed under `first`'s pair, so it is + // refused only once its key has arrived, after `first` has opened. + // `Secret::drop` fails the test if `first` is then dropped unwiped. + let moved = EncryptedPair { + second: SecretCiphertext(encrypted.first.0.clone()), + first: encrypted.first, + }; + let result = Pair::decrypt_from(moved, &keyset).await; + assert!(matches!(result, Err(Error::Aead))); + assert_eq!(retrieves.load(Ordering::SeqCst), 1, "both keys in one call"); +} + // --- The public searchable-record program, as one batch -------------------- /// An application-defined scalar target: the ciphertext plus an equality From 322dab1ec7a2d12692ab5c95f0ffed049823787c Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 3 Nov 2026 00:10:50 +1100 Subject: [PATCH 05/10] docs(stack-encrypt): renumber the owned-record ADR to 0012 and repair the changelog sections ADR-0011 is taken by the opaque-blob ADR on a sibling branch, so the owned-record ADR becomes 0012. Nothing in the tree linked to it by number. The `[Unreleased]` `Deprecated` heading sat in the middle of `Added`, which filed the owned-collections bullet under deprecations. It now follows the full `Added` list. The owned-record bullet, the derive's "Owned records" docs and the ADR now cover what the fixes on this branch made true: a `struct` record names every plaintext field, its plaintext cannot implement `Drop`, and a field opened before a later refusal is wiped. A new bullet adds `Decryption::zip_wiping`. The owned-collections bullet now describes the per-element owned form. --- .../stack-encrypt-derive/docs/attributes.md | 9 +++++ packages/stack-encrypt/CHANGELOG.md | 40 +++++++++++-------- ...s.md => 0012-owned-record-declarations.md} | 9 +++++ 3 files changed, 42 insertions(+), 16 deletions(-) rename packages/stack-encrypt/docs/adr/{0011-owned-record-declarations.md => 0012-owned-record-declarations.md} (92%) diff --git a/packages/stack-encrypt-derive/docs/attributes.md b/packages/stack-encrypt-derive/docs/attributes.md index f0cf92873..e2769894c 100644 --- a/packages/stack-encrypt-derive/docs/attributes.md +++ b/packages/stack-encrypt-derive/docs/attributes.md @@ -97,6 +97,15 @@ field is moved into the record by the owned conversion; its type is `Clone` too, since the record's borrowed form, which a plan or a structural record runs, copies it. +A `struct` record accounts for every field of its plaintext, sealing it or +carrying it through: the owned conversion destructures the struct, and a +field it does neither would be dropped unwiped, so it is refused at the +`struct = ..` path ("pattern does not mention field"). For the same reason +the plaintext struct cannot implement `Drop` (so not `ZeroizeOnDrop` +either): a type with a destructor cannot be destructured. On decryption a +field opened before a later field is refused is wiped, not dropped as it +is. + A `plaintext` record's fields are all handed the one context the record requires, so a term beside an `AeadContext`-only record is refused at the term. Omitting a required context is refused at the call diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 5eded5638..3144fb2f1 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -51,7 +51,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 record or a typed plan field without a second derive; derive one pair or the other, not both. The crate docs' "Records" section and the derive crate's "Owned records" section show the forms; `tests/ui` pins the ones - that are refused, at the field. + that are refused, at the field. A `struct` record names every field of + its plaintext, sealed or passthrough: the owned conversion destructures + the struct, so an unaccounted field is a compile error rather than a + field dropped unwiped, and the plaintext cannot implement `Drop`. A field + opened before a later field is refused is wiped. +- **`Decryption::zip_wiping`**: `zip` for recovered plaintext. When the + right side fails after its key arrived, or panics in its conversion, the + left side's plaintext is wiped rather than dropped as it is. The owned + record derive opens its fields through it. - **`EncryptTarget::owned_encryption` and `indexes`**: the description over the plaintext by value, in `Owned` source mode, defaulting to guard the value with `Protected`, run `encryption` over a borrow and wipe it before @@ -77,18 +85,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`FieldsBuilder::target::<T, _>()`**: a plan field laid out by an owned target that has no structural declaration, such as a `Ciphertext`. Lowers to `<T as EncryptTarget<F>>::encryption().accepting::<CallerContext>()`. - -### Deprecated - -- The structural derives `#[derive(EncryptFrom, DecryptInto)]` and the - `target::legacy` conversion traits remain for the plans and the dynamic - engine that run them, and are the migration path's starting point, not its - end: new records derive `EncryptTarget` / `DecryptSource`, which emit the - structural declarations too. Their removal is tracked separately (#1488) - and follows once every consumer has an owned equivalent. No - `#[deprecated]` attribute is applied yet, since the crate's own plan and - dynamic modules still implement them. - - **Owned collections: `Vec<S>` encrypts into `Vec<T>` and back through `EncryptInto` and `DecryptFrom`** (#1482). `Vec<T>` implements `EncryptTarget<Vec<S>>` and `DecryptSource<Vec<P>>` for any owned element @@ -101,9 +97,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 on its own, in one logical batch per column (one `generate_keys` call, one `retrieve_keys` call per keyset, element order kept, no call for an empty column; the key source may still split a logical batch into several - provider requests). The vector is held in one guard and wiped before the - pending request is returned, including when the context is refused, with - no `Clone` bound; a column that fails after some elements were recovered + provider requests). Each element is wiped by its own declaration before + the pending request is returned, including when the context is refused, + with no `Clone` bound, and the elements not yet handed over are wiped if a + declaration panics; a column that fails after some elements were recovered wipes those before returning the error, and before unwinding when an element's conversion panics. `Vec<u8>` is not a column of scalars: `u8` is not `OpaqueEncode`, because Vitamin C keeps `Vec<u8>` a @@ -111,6 +108,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Direct `keyset.encrypt` still takes one scalar; `encrypt_tree` remains the structural path. +### Deprecated + +- The structural derives `#[derive(EncryptFrom, DecryptInto)]` and the + `target::legacy` conversion traits remain for the plans and the dynamic + engine that run them, and are the migration path's starting point, not its + end: new records derive `EncryptTarget` / `DecryptSource`, which emit the + structural declarations too. Their removal is tracked separately (#1488) + and follows once every consumer has an owned equivalent. No + `#[deprecated]` attribute is applied yet, since the crate's own plan and + dynamic modules still implement them. + ## [0.3.0] - 2026-10-27 ### Breaking diff --git a/packages/stack-encrypt/docs/adr/0011-owned-record-declarations.md b/packages/stack-encrypt/docs/adr/0012-owned-record-declarations.md similarity index 92% rename from packages/stack-encrypt/docs/adr/0011-owned-record-declarations.md rename to packages/stack-encrypt/docs/adr/0012-owned-record-declarations.md index 63a7e40fe..2766b2d56 100644 --- a/packages/stack-encrypt/docs/adr/0011-owned-record-declarations.md +++ b/packages/stack-encrypt/docs/adr/0012-owned-record-declarations.md @@ -54,6 +54,15 @@ function pointer that destructures the struct: it captures nothing, so like the accepted plaintext-ownership rule made mechanical: the conversion wipes what it seals and transfers what the caller chose to keep in the clear. +The destructure is exhaustive. The owned form replaces the guard over the +whole value, so a plaintext field the record neither seals nor carries +would be dropped unwiped; it is a compile error at the `struct = ..` path +instead, as the decrypt side already refuses a plaintext it cannot rebuild. +A plaintext with a destructor (`ZeroizeOnDrop`) cannot be destructured, so +it is not an owned `struct` record source. On the way back, the fields open +through `Decryption::zip_wiping`: a field recovered before a later field is +refused is wiped, as the column's recovered prefix is. + A passthrough field is not encrypted and not authenticated (ADR-0010). It is `Clone` because the borrowed form, which a plan or a structural record runs, has to copy it; the owned form never does, and a test with a `Clone` From fcca6c6204d0f6998106065cca9ec49320e66555 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 3 Nov 2026 00:12:32 +1100 Subject: [PATCH 06/10] test(stack-encrypt): enroll the owned-record drop witnesses in the lifecycle profile The owned-column slice enrolled its drop witnesses. The owned-record witnesses are the same evidence for records and now go in the same place. A new `owned-records` suite (`tests/owned_records.rs`) adds three witnesses: - under buffer cleanup, the record wipes its sealed fields before any await and on a refused context, and moves its passthrough fields; - under partial output, a field opened before a later field is refused is wiped. Two new collection witnesses join the existing `collections` suite. One checks the elements after a panicking declaration. The other checks a structural record's column field. --- .../stack-encrypt/lifecycle-contract.md | 7 ++++--- .../v1/lifecycle-v1.enrollment.json | 21 +++++++++++++++++-- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/conformance/stack-encrypt/lifecycle-contract.md b/conformance/stack-encrypt/lifecycle-contract.md index 68f913e6c..338ef9300 100644 --- a/conformance/stack-encrypt/lifecycle-contract.md +++ b/conformance/stack-encrypt/lifecycle-contract.md @@ -12,8 +12,8 @@ process. Python and TypeScript are explicitly not enrolled. |---|---|---| | `lifecycle-ownership-v1` | Actual record operations and owned `Vec<S>` to `Vec<T>` conversions release fulfilment captures exactly once on success, provider failure and unpolled drop; an unpolled encrypt or decrypt makes no key request. Zipped fulfilments do not share key material. | Client keys are consumed/redacted; explicit close and unreachable-client cleanup release guest memory; calls on a closed client fail. GC cleanup is eventual, so callers should close explicitly. | | `lifecycle-cancellation-v1` | Dropping an encrypt future paused in key generation (for a record operation or an owned column), or a decrypt future paused in key retrieval, drops the provider future and operation captures; subsequent work on the cipher succeeds. Guest ABI abort spends the handle and drops the operation. | Cancellation before a call sends no request; cancellation between steps or during token fetch leaves unrelated operations usable. A guest step that overruns cancellation closes the client. Discarded late output is wiped. | -| `lifecycle-partial-output-v1` | A batch with a valid first row and a later authenticated type mismatch releases its decoded prefix without marked heap residue, through the dynamic record path and the owned `Vec<Ciphertext>` to `Vec<String>` conversion alike; owned-column drop witnesses show that prefix is wiped, not leaked, after a late failure or a panicking element conversion. Existing context cases reject late authentication failures without returning partial output. | Generated and raw batch APIs return no partial rows on failure; interrupted HTTP bodies fail; cancelled output is wiped. SDK conversion diagnostics contain no decrypted values. This does not assert erasure of partially populated caller-owned Go objects. | -| `lifecycle-buffer-cleanup-v1` | The existing allocator observer checks selected write/read/JSON parse paths, including an owned `Vec<String>` column sealed through its guard, and has a negative control. Drop witnesses show an owned column's source is wiped before its pending is returned, including on a refused or panicking context. Guest ABI tests check buffer-registry cleanup, error-buffer release and protected text drop. | Request bodies survive until their owner closes them, then wipe; refused requests wipe; heap growth wipes abandoned backing arrays; guest runtime close releases memory. Post-encryption memory scans are supplementary observations. | +| `lifecycle-partial-output-v1` | A batch with a valid first row and a later authenticated type mismatch releases its decoded prefix without marked heap residue, through the dynamic record path and the owned `Vec<Ciphertext>` to `Vec<String>` conversion alike; owned-column drop witnesses show that prefix is wiped, not leaked, after a late failure or a panicking element conversion, through a structural record's column field too; an owned record's drop witness shows a field opened before a later field is refused is wiped. Existing context cases reject late authentication failures without returning partial output. | Generated and raw batch APIs return no partial rows on failure; interrupted HTTP bodies fail; cancelled output is wiped. SDK conversion diagnostics contain no decrypted values. This does not assert erasure of partially populated caller-owned Go objects. | +| `lifecycle-buffer-cleanup-v1` | The existing allocator observer checks selected write/read/JSON parse paths, including an owned `Vec<String>` column sealed through its guard, and has a negative control. Drop witnesses show an owned column's source is wiped before its pending is returned, including on a refused or panicking context and for the elements after one whose declaration panics; and that an owned record wipes the fields it seals, on a refused context too, and moves its passthrough fields without copying them. Guest ABI tests check buffer-registry cleanup, error-buffer release and protected text drop. | Request bodies survive until their owner closes them, then wipe; refused requests wipe; heap growth wipes abandoned backing arrays; guest runtime close releases memory. Post-encryption memory scans are supplementary observations. | | `lifecycle-diagnostics-v1` | Guest errors discard untrusted foreign cause messages; failed/successful exports do not hand over stale diagnostics. | Structured errors remain available after failures; SDK errors and redacted display/logging expose types or fields rather than plaintext values. | | `lifecycle-validation-before-io-v1` | Existing runtime/read/context cases count provider calls. Wrong shape, independently detectable context mismatch and oversized descriptors fail before key I/O; lazy assembly stops after local failure. | Host refusals happen before entering the guest, and shared runtime/read cases exercise the uninitialised guest's preflight boundary. Go does not yet expose native provider counters. | | `lifecycle-logical-batching-v1` | Instrumented `DataKeySource` calls establish one logical generation batch, retrieval grouping by keyset, descriptor ordering and no work for empty batches, for record operations and for an owned `Vec<S>` column of any length including zero. | Go uses the same engine, but this profile does not claim independently instrumented Go key-request counts. | @@ -61,7 +61,8 @@ These entries in the index are limitations, not successful requirements: paths use borrowed/static path text because owned path copies have separate cleanup limitations. It sees only blocks that are freed: plaintext leaked and never freed is not observed. For the owned `Vec<S>` conversion, drop - witnesses in `packages/stack-encrypt/tests/collections.rs` observe the wipe + witnesses in `packages/stack-encrypt/tests/collections.rs` (and, for owned + records, `packages/stack-encrypt/tests/owned_records.rs`) observe the wipe itself, so a leaked source or recovered prefix fails there. The lifecycle runner preserves the observer's output. - **`caller-owned-host-values` — outside the guarantee.** Caller-owned strings, diff --git a/conformance/stack-encrypt/v1/lifecycle-v1.enrollment.json b/conformance/stack-encrypt/v1/lifecycle-v1.enrollment.json index b9a0e39d6..bce75d4c7 100644 --- a/conformance/stack-encrypt/v1/lifecycle-v1.enrollment.json +++ b/conformance/stack-encrypt/v1/lifecycle-v1.enrollment.json @@ -35,6 +35,14 @@ "features": ["--all-features"], "binary": "collections" }, + "owned-records": { + "language": "rust", + "lane": "fast", + "workspace": ".", + "package": "stack-encrypt", + "features": ["--all-features"], + "binary": "owned_records" + }, "runtime": { "language": "rust", "lane": "fast", @@ -144,7 +152,11 @@ ], "collections": [ "a_late_element_failure_wipes_the_plaintext_recovered_before_it", - "a_panicking_element_conversion_wipes_the_plaintext_recovered_before_it" + "a_panicking_element_conversion_wipes_the_plaintext_recovered_before_it", + "a_late_element_failure_in_a_column_field_wipes_the_recovered_prefix" + ], + "owned-records": [ + "a_field_opened_before_a_later_field_is_refused_is_wiped" ], "contexts": ["shared_context_boundaries"], "go": [ @@ -168,7 +180,12 @@ "collections": [ "owned_elements_are_wiped_before_the_pending_is_returned", "owned_elements_are_wiped_when_the_context_is_refused", - "owned_elements_are_guarded_during_context_conversion" + "owned_elements_are_guarded_during_context_conversion", + "a_panicking_element_declaration_wipes_the_elements_after_it" + ], + "owned-records": [ + "the_owned_record_wipes_sealed_fields_and_moves_passthrough_fields", + "the_owned_record_wipes_sealed_fields_when_the_context_is_refused" ], "guest-abi": [ "buffers::tests::wipe_all_leaves_no_live_buffer", From d2c231beca04e82eea3262f6337c2c9d0151e642 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 3 Nov 2026 00:12:49 +1100 Subject: [PATCH 07/10] style(stack-encrypt): apply rustfmt to the wiping zip and the column-field test --- packages/stack-encrypt/src/target/operations.rs | 4 +++- packages/stack-encrypt/tests/collections.rs | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index 41f9b97bc..44203a980 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -869,7 +869,9 @@ impl<T: 'static, K: 'static> Decryption<T, K> { match (self.inner, other.inner) { (Opening::Failed(error), _) | (_, Opening::Failed(error)) => Decryption::failed(error), (Opening::Open(left), Opening::Open(right)) => { - Decryption::open(Box::new(move |cipher| left(cipher).zip_wiping(right(cipher)))) + Decryption::open(Box::new(move |cipher| { + left(cipher).zip_wiping(right(cipher)) + })) } } } diff --git a/packages/stack-encrypt/tests/collections.rs b/packages/stack-encrypt/tests/collections.rs index ddd051502..7d2d3a336 100644 --- a/packages/stack-encrypt/tests/collections.rs +++ b/packages/stack-encrypt/tests/collections.rs @@ -575,8 +575,8 @@ async fn a_panicking_element_declaration_wipes_the_elements_after_it() { impl stack_encrypt::target::Decryptable for TrackedCiphertext { const DECRYPTABLE: bool = true; } -impl<Ctx: Into<stack_encrypt::target::AeadContext>> stack_encrypt::target::DecryptField<Tracked, Ctx> - for TrackedCiphertext +impl<Ctx: Into<stack_encrypt::target::AeadContext>> + stack_encrypt::target::DecryptField<Tracked, Ctx> for TrackedCiphertext { fn decryption_field<K: 'static>(self, context: Ctx) -> Option<Decryption<Tracked, K>> { Some(stack_encrypt::DecryptInto::decryption(self, context.into())) From 30cb5926afddba8cdad826d457d441d5b36c2c3b Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 3 Nov 2026 00:28:11 +1100 Subject: [PATCH 08/10] fix(stack-encrypt): guard the plaintext while encrypt_into constructs its declaration The context-omitting EncryptInto::encrypt_into handed self to the declaration only after T::owned_encryption() had been constructed, with nothing guarding it meanwhile, so a declaration that panicked while constructed (a record building its fields' declarations) dropped the plaintext unwiped. It now holds the plaintext in Protected until the declaration and the omitted context exist, as encrypt_into_with_context already did for its context conversion; that variant now also constructs the declaration under the guard rather than as an argument. --- .../stack-encrypt/src/target/conversion.rs | 20 +++-- packages/stack-encrypt/tests/owned_records.rs | 88 +++++++++++++++++++ 2 files changed, 103 insertions(+), 5 deletions(-) diff --git a/packages/stack-encrypt/src/target/conversion.rs b/packages/stack-encrypt/src/target/conversion.rs index dd8eac2e8..daa150c32 100644 --- a/packages/stack-encrypt/src/target/conversion.rs +++ b/packages/stack-encrypt/src/target/conversion.rs @@ -77,7 +77,15 @@ pub trait EncryptInto: Zeroize + Sized { T: EncryptTarget<Self>, T::ContextInput: AllowsNoContext, { - cipher.run(T::owned_encryption(), self, T::ContextInput::omitted()) + // Guarded while the declaration is constructed and the omitted + // context initialised: either may panic (a record's declaration + // constructs its fields' declarations here), and the plaintext must + // not then drop unwiped. The declaration takes over the guard once + // both exist. + let source = Protected::new(self); + let description = T::owned_encryption(); + let context = T::ContextInput::omitted(); + cipher.run(description, source.risky_unwrap(), context) } /// Encrypt with a supplied context, without manually wrapping its presence. @@ -90,12 +98,14 @@ pub trait EncryptInto: Zeroize + Sized { T: EncryptTarget<Self>, T::ContextInput: AcceptsContext, { - // Guarded while the caller's context converts: a conversion that - // panics must not drop the plaintext unwiped. The declaration takes - // over the guard from here. + // Guarded while the caller's context converts and the declaration + // is constructed: either may panic, and the plaintext must not then + // drop unwiped. The declaration takes over the guard once both + // exist. let source = Protected::new(self); let context = T::ContextInput::supplied(context.into()); - cipher.run(T::owned_encryption(), source.risky_unwrap(), context) + let description = T::owned_encryption(); + cipher.run(description, source.risky_unwrap(), context) } } impl<S: Zeroize> EncryptInto for S {} diff --git a/packages/stack-encrypt/tests/owned_records.rs b/packages/stack-encrypt/tests/owned_records.rs index b98309bf1..1a60510b9 100644 --- a/packages/stack-encrypt/tests/owned_records.rs +++ b/packages/stack-encrypt/tests/owned_records.rs @@ -328,6 +328,94 @@ async fn a_field_opened_before_a_later_field_is_refused_is_wiped() { assert_eq!(retrieves.load(Ordering::SeqCst), 1, "both keys in one call"); } +// --- A panic while declaring wipes what has not been handed over ----------- + +// A sealed field type with no drop check of its own, so a value dropped +// unwiped during an unwind is seen through the flag rather than as an abort. +struct Flagged { + value: String, + wiped: Arc<AtomicBool>, +} +impl Zeroize for Flagged { + fn zeroize(&mut self) { + self.value.zeroize(); + self.wiped.store(true, Ordering::SeqCst); + } +} +fn flagged(value: &str) -> (Flagged, Arc<AtomicBool>) { + let wiped = Arc::new(AtomicBool::new(false)); + let source = Flagged { + value: value.to_owned(), + wiped: wiped.clone(), + }; + (source, wiped) +} + +/// Seals a `Flagged`, and panics while declaring the one whose plaintext is +/// `"panics"`: inside the default owned guard, as the field's description +/// runs. +// The ciphertext is never read: these tests only encrypt. +struct FlaggedCiphertext(#[allow(dead_code)] Ciphertext); +impl EncryptTarget<Flagged> for FlaggedCiphertext { + type ContextInput = OptionalContext<ScalarContext>; + fn encryption<'s, K: 'static>() -> Encryption<'s, Flagged, Self, K, Self::ContextInput> { + stack_encrypt::target::scalar_ciphertext::<String, K>() + .project(|source: &Flagged| { + assert!(source.value != "panics", "field declaration failed"); + &source.value + }) + .map(Self) + } +} +impl Decryptable for FlaggedCiphertext { + const DECRYPTABLE: bool = false; +} + +/// Seals a `Flagged` through a declaration whose construction panics: what +/// `encrypt_into` is holding then is the plaintext alone. +struct UnconstructedCiphertext(#[allow(dead_code)] Ciphertext); +impl EncryptTarget<Flagged> for UnconstructedCiphertext { + type ContextInput = OptionalContext<ScalarContext>; + fn encryption<'s, K: 'static>() -> Encryption<'s, Flagged, Self, K, Self::ContextInput> { + FlaggedCiphertext::encryption().map(|FlaggedCiphertext(c)| Self(c)) + } + fn owned_encryption<'s, K: 'static>( + ) -> Encryption<'s, Flagged, Self, K, Self::ContextInput, stack_encrypt::target::Owned> { + panic!("declaration construction failed") + } +} + +#[tokio::test] +async fn a_declaration_that_panics_while_constructed_wipes_the_plaintext() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let (source, wiped) = flagged("secret"); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + drop(source.encrypt_into::<UnconstructedCiphertext, _>(&keyset)); + })); + assert!( + result.is_err(), + "the declaration panicked while constructed" + ); + assert!( + wiped.load(Ordering::SeqCst), + "the plaintext is wiped during the unwind, with the context omitted" + ); + + let (source, wiped) = flagged("secret"); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + drop(source.encrypt_into_with_context::<UnconstructedCiphertext, _>(&keyset, "rows")); + })); + assert!( + result.is_err(), + "the declaration panicked while constructed" + ); + assert!( + wiped.load(Ordering::SeqCst), + "the plaintext is wiped during the unwind, with a context supplied" + ); +} + // --- The public searchable-record program, as one batch -------------------- /// An application-defined scalar target: the ciphertext plus an equality From 710b15ebd396733116852f16b8105702b22a1bdd Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 3 Nov 2026 00:28:11 +1100 Subject: [PATCH 09/10] fix(stack-encrypt)!: wipe the parts an owned split has not yet handed over Encryption::split ran the first part's description while the other part sat in a local, so a panic in that description (a field declaration's selector, say) dropped the later sealed fields of an owned struct record unwiped. split now takes a wipe for the waiting part and holds it in a guard that calls it during the unwind; the part leaves the guard only once the first description has returned. The derive writes each wipe to zeroize the sealed fields still waiting and leave the passthrough ones. A sealed field's plaintext is already Zeroize, as every EncryptTarget source is, so no bound is added, and passthrough field types take none. BREAKING CHANGE: Encryption::split takes a fourth argument, wipe: fn(&mut U). --- .../v1/lifecycle-v1.enrollment.json | 4 +- .../stack-encrypt-derive/docs/attributes.md | 8 ++- .../stack-encrypt-derive/src/owned_encrypt.rs | 44 +++++++++++++-- packages/stack-encrypt/CHANGELOG.md | 6 +- .../adr/0012-owned-record-declarations.md | 14 +++++ .../stack-encrypt/src/target/operations.rs | 46 +++++++++++++++- packages/stack-encrypt/tests/owned_records.rs | 55 +++++++++++++++++++ 7 files changed, 166 insertions(+), 11 deletions(-) diff --git a/conformance/stack-encrypt/v1/lifecycle-v1.enrollment.json b/conformance/stack-encrypt/v1/lifecycle-v1.enrollment.json index bce75d4c7..b31ad8b88 100644 --- a/conformance/stack-encrypt/v1/lifecycle-v1.enrollment.json +++ b/conformance/stack-encrypt/v1/lifecycle-v1.enrollment.json @@ -185,7 +185,9 @@ ], "owned-records": [ "the_owned_record_wipes_sealed_fields_and_moves_passthrough_fields", - "the_owned_record_wipes_sealed_fields_when_the_context_is_refused" + "the_owned_record_wipes_sealed_fields_when_the_context_is_refused", + "a_panicking_field_declaration_wipes_the_sealed_fields_after_it", + "a_declaration_that_panics_while_constructed_wipes_the_plaintext" ], "guest-abi": [ "buffers::tests::wipe_all_leaves_no_live_buffer", diff --git a/packages/stack-encrypt-derive/docs/attributes.md b/packages/stack-encrypt-derive/docs/attributes.md index e2769894c..fab54ff20 100644 --- a/packages/stack-encrypt-derive/docs/attributes.md +++ b/packages/stack-encrypt-derive/docs/attributes.md @@ -102,9 +102,11 @@ carrying it through: the owned conversion destructures the struct, and a field it does neither would be dropped unwiped, so it is refused at the `struct = ..` path ("pattern does not mention field"). For the same reason the plaintext struct cannot implement `Drop` (so not `ZeroizeOnDrop` -either): a type with a destructor cannot be destructured. On decryption a -field opened before a later field is refused is wiped, not dropped as it -is. +either): a type with a destructor cannot be destructured. A field's +declaration that panics wipes the sealed fields not yet handed to their +own declarations, and a record's declaration that panics while it is +constructed wipes the whole plaintext. On decryption a field opened before +a later field is refused is wiped, not dropped as it is. A `plaintext` record's fields are all handed the one context the record requires, so a term beside an `AeadContext`-only record is refused at the diff --git a/packages/stack-encrypt-derive/src/owned_encrypt.rs b/packages/stack-encrypt-derive/src/owned_encrypt.rs index 5a0806183..43f2c3749 100644 --- a/packages/stack-encrypt-derive/src/owned_encrypt.rs +++ b/packages/stack-encrypt-derive/src/owned_encrypt.rs @@ -204,15 +204,20 @@ fn struct_derive(input: &DeriveInput, record: &Record) -> TokenStream { [first, rest @ ..] => { // `rest` as one description over the nested tuple of its parts, // divided by identity; then the struct divided into `first` and - // that tuple. + // that tuple. Each `split` is told how to wipe the parts still + // waiting for their descriptions, should an earlier field's + // description panic. let mut tail = { let last = rest[rest.len() - 1]; quote!(#last) }; - for local in rest[..rest.len() - 1].iter().rev() { - tail = quote!(#local.split(#tail, |__parts| __parts)); + for index in (1..locals.len() - 1).rev() { + let local = locals[index]; + let wipe = wipe_waiting(krate, &fields[index + 1..]); + tail = quote!(#local.split(#tail, |__parts| __parts, #wipe)); } - quote!(#first.split(#tail, #divide)) + let wipe = wipe_waiting(krate, &fields[1..]); + quote!(#first.split(#tail, #divide, #wipe)) } [] => unreachable!("a record has a derived field"), }; @@ -251,6 +256,37 @@ fn struct_derive(input: &DeriveInput, record: &Record) -> TokenStream { quote!(#encrypt_target #bridge) } +/// The `wipe` a `split` is handed for the parts still waiting, the nested +/// tuple of `waiting`: every sealed field in it wiped, every passthrough +/// field left as it is. A sealed field's plaintext is `Zeroize` already, as +/// every `EncryptTarget` source is, so this adds no bound; a passthrough +/// field is stored in the clear and takes none. +fn wipe_waiting(krate: &syn::Path, waiting: &[&Field]) -> TokenStream { + let mut wipes = Vec::new(); + let mut base = quote!((*__parts)); + for (index, field) in waiting.iter().enumerate() { + let last = index == waiting.len() - 1; + let place = if last { + base.clone() + } else { + let first = syn::Index::from(0); + quote!(#base.#first) + }; + if field.is_derived() { + wipes.push(quote!(#krate::target::Zeroize::zeroize(&mut #place);)); + } + if !last { + let rest = syn::Index::from(1); + base = quote!(#base.#rest); + } + } + if wipes.is_empty() { + quote!(|_| {}) + } else { + quote!(|__parts| { #(#wipes)* }) + } +} + /// The nested tuple `(a, (b, (c, d)))` of the locals: how `split` divides /// a struct, one part per step. fn nested_tuple(locals: &[&Ident]) -> TokenStream { diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 3144fb2f1..bfef388a7 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -66,7 +66,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the pending is returned; `EncryptInto` and the owned column run it. A record overrides it to divide the struct between its fields with the new `Encryption::split` (two descriptions over two parts of one value, one - context) and `Encryption::project_owned`. `indexes` is what a plan field + context, and a `wipe` for the part that waits while the first part's + description runs, called if that description panics) and + `Encryption::project_owned`. `EncryptInto` guards the plaintext until the + declaration and its context have been constructed, so a declaration that + panics while it is constructed wipes it. `indexes` is what a plan field typed with the target answers queries by; the terms, `Encrypted<Terms>` and `plaintext` records report theirs. - **The core leaves implement the owned declarations**: `StackCipherText` diff --git a/packages/stack-encrypt/docs/adr/0012-owned-record-declarations.md b/packages/stack-encrypt/docs/adr/0012-owned-record-declarations.md index 2766b2d56..bd28c14b3 100644 --- a/packages/stack-encrypt/docs/adr/0012-owned-record-declarations.md +++ b/packages/stack-encrypt/docs/adr/0012-owned-record-declarations.md @@ -54,6 +54,20 @@ function pointer that destructures the struct: it captures nothing, so like the accepted plaintext-ownership rule made mechanical: the conversion wipes what it seals and transfers what the caller chose to keep in the clear. +Each `split` runs its first part's description while the other part waits, +and it is handed a `wipe` for that waiting part. If the first description +panics (a declaration's selector, say), the waiting part goes to `wipe` +during the unwind rather than being dropped as it is, as an owned column's +unvisited elements are. The derive writes each `wipe` to zeroize the sealed +fields still waiting and to leave the passthrough ones. A sealed field's +plaintext is `Zeroize` already, as every `EncryptTarget` source is, so this +adds no bound, and a passthrough field, stored in the clear, takes none. A +`Zeroize` bound on `split`'s waiting part would have done the same work, +but it would have fallen on passthrough field types too. `EncryptInto` +holds the whole plaintext in a `Protected` guard until the declaration and +its context exist, so a declaration that panics while it is constructed +wipes the plaintext too. + The destructure is exhaustive. The owned form replaces the guard over the whole value, so a plaintext field the record neither seals nor carries would be dropped unwiped; it is a compile error at the `struct = ..` path diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index 44203a980..2cd919b2f 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -416,23 +416,65 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx, Ow /// nesting a `split` per field, so a passthrough field is moved into /// the output and never copied, and a sealed field is guarded by its own /// target's description. Nothing is cloned, so `P` need not be `Clone`. + /// + /// `self` runs first while `other`'s part waits, and `wipe` is what + /// keeps that part safe meanwhile: if `self`'s description panics + /// before `other` has received its part, the part is handed to `wipe` + /// during the unwind instead of being dropped as it is. `wipe` wipes + /// what `other` seals and leaves what it stores in the clear, so a part + /// that is all passthrough takes `|_| {}`, and no `Zeroize` bound falls + /// on a part that is not sealed. Once `other` has its part, wiping it is + /// `other`'s responsibility, as it is for any owned source. pub fn split<P: 's, U: 's, V: 'static>( self, other: Encryption<'s, U, V, K, Ctx, Owned>, divide: fn(P) -> (S, U), + wipe: fn(&mut U), ) -> Encryption<'s, P, (T, V), K, Ctx, Owned> where Ctx: Clone, { Encryption { - build: Box::new(move |source, cipher, cx| { + build: Box::new(move |source, cipher, cx: Ctx| { + let first_cx = cx.clone(); let (mine, theirs) = divide(source); - (self.build)(mine, cipher, cx.clone()).zip((other.build)(theirs, cipher, cx)) + let waiting = Waiting { + part: Some(theirs), + wipe, + }; + let first = (self.build)(mine, cipher, first_cx); + first.zip((other.build)(waiting.hand_over(), cipher, cx)) }), } } } +/// The part of a [`split`](Encryption::split) value that waits while the +/// other part's description runs. Dropped before +/// [`hand_over`](Self::hand_over), by an unwind out of that description, it +/// hands the part to `wipe`. +struct Waiting<U> { + part: Option<U>, + wipe: fn(&mut U), +} + +impl<U> Waiting<U> { + /// The description before it returned: the part goes to its own. + fn hand_over(mut self) -> U { + self.part + .take() + .unwrap_or_else(|| unreachable!("a waiting part is handed over once")) + } +} + +impl<U> Drop for Waiting<U> { + fn drop(&mut self) { + if let Some(part) = self.part.as_mut() { + (self.wipe)(part); + } + } +} + /// A description whose operation is chosen by the value it is handed, at /// the moment it runs: `choose` sees the source, the keyset cipher and the /// context, and returns the pending of whichever typed operation applies. diff --git a/packages/stack-encrypt/tests/owned_records.rs b/packages/stack-encrypt/tests/owned_records.rs index 1a60510b9..974ec7e3c 100644 --- a/packages/stack-encrypt/tests/owned_records.rs +++ b/packages/stack-encrypt/tests/owned_records.rs @@ -371,6 +371,61 @@ impl Decryptable for FlaggedCiphertext { const DECRYPTABLE: bool = false; } +#[derive(Zeroize)] +struct Trio { + first: Flagged, + #[zeroize(skip)] + handle: u64, + third: Flagged, + fourth: Flagged, +} +// Only encrypted, and never completed: its fields are never read. +#[allow(dead_code)] +#[derive(EncryptTarget)] +#[stash(struct = Trio, context = "trios")] +struct EncryptedTrio { + first: FlaggedCiphertext, + #[stash(passthrough)] + handle: u64, + third: FlaggedCiphertext, + fourth: FlaggedCiphertext, +} + +#[tokio::test] +async fn a_panicking_field_declaration_wipes_the_sealed_fields_after_it() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + // The outer `split` (the first field panics, the rest wait as one part) + // and a nested one (the third panics, the fourth waits on its own). + for panicking in ["first", "third"] { + let value = |field: &'static str| if field == panicking { "panics" } else { field }; + let (first, first_wiped) = flagged(value("first")); + let (third, third_wiped) = flagged(value("third")); + let (fourth, fourth_wiped) = flagged(value("fourth")); + let trio = Trio { + first, + handle: 7, + third, + fourth, + }; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + drop(trio.encrypt_into::<EncryptedTrio, _>(&keyset)); + })); + assert!( + result.is_err(), + "the {panicking} field's declaration panicked" + ); + assert!( + first_wiped.load(Ordering::SeqCst) && third_wiped.load(Ordering::SeqCst), + "the fields handed over are wiped by their own guards ({panicking} panicked)" + ); + assert!( + fourth_wiped.load(Ordering::SeqCst), + "the sealed fields not yet handed over are wiped during the unwind ({panicking} panicked)" + ); + } +} + /// Seals a `Flagged` through a declaration whose construction panics: what /// `encrypt_into` is holding then is the plaintext alone. struct UnconstructedCiphertext(#[allow(dead_code)] Ciphertext); From 50f7b55d7438f8acd767f5c6a311747ca287c145 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 3 Nov 2026 00:29:48 +1100 Subject: [PATCH 10/10] test(stack-encrypt): pin the owned MatchTerms index and ContextExtension inspection Covers two mutants that survived the in-diff gate: MatchTerms' EncryptTarget::indexes replaced with vec![] (each_owned_term_target_names_its_index, which also pins the other owned terms) and ContextExtension::into_inner replaced with None (a_context_extension_is_inspected_as_supplied_or_omitted). The third survivor, ExpectedContext's omitted replaced with Default::default(), is equivalent: the body is Self::default(). It is excluded in .cargo/mutants.toml beside the matching From<()> entry, with the test that pins the omitted behaviour named. --- .cargo/mutants.toml | 5 +++ packages/stack-encrypt/tests/owned_records.rs | 39 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 602ecd6da..119cd5c7a 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -81,6 +81,11 @@ exclude_re = [ 'stack-encrypt/src/sem/mod\.rs:\d+:\d+: replace <impl MatchConfig for DefaultMatch>::options -> MatchOptions with Default::default\(\)$', # Both unit-context conversions explicitly return Self::default(). 'stack-encrypt/src/target/context\.rs:\d+:\d+: replace <impl From<\(\)> for (DeclaredContext|ExpectedContext<T>)>::from -> Self with Default::default\(\)$', + # Omitting an expectation is expecting nothing: `ExpectedContext::omitted` + # returns Self::default(). That an omitted expectation opens under the + # stored context without checking it is pinned by + # `a_stored_context_opens_as_stored_and_checks_an_expectation`. + 'stack-encrypt/src/target/input\.rs:\d+:\d+: replace <impl AllowsNoContext for ExpectedContext<T>>::omitted -> Self with Default::default\(\)$', # A caller-free declaration adds nothing to the declared context: the # conversion returns Self::default(). 'stack-encrypt/src/target/input\.rs:\d+:\d+: replace <impl From<NoCallerContext> for super::DeclaredContext>::from -> Self with Default::default\(\)$', diff --git a/packages/stack-encrypt/tests/owned_records.rs b/packages/stack-encrypt/tests/owned_records.rs index 974ec7e3c..8c6b236ea 100644 --- a/packages/stack-encrypt/tests/owned_records.rs +++ b/packages/stack-encrypt/tests/owned_records.rs @@ -527,6 +527,45 @@ async fn a_searchable_column_is_one_batch_and_its_terms_match_a_query_term() { assert_eq!(retrieves.load(Ordering::SeqCst), 1); } +#[test] +fn each_owned_term_target_names_its_index() { + use stack_encrypt::sem::{DefaultMatch, MatchConfig, OpeTerm, OreBlockTerm}; + use stack_encrypt::IndexSpec; + assert_eq!( + <EqualityTerm as EncryptTarget<String>>::indexes(), + [IndexSpec::Equality] + ); + assert_eq!( + <MatchTerms<DefaultMatch> as EncryptTarget<String>>::indexes(), + [IndexSpec::Match(DefaultMatch::options())] + ); + assert_eq!( + <OreTerm<String> as EncryptTarget<String>>::indexes(), + [IndexSpec::Ore] + ); + assert_eq!( + <OpeTerm<String> as EncryptTarget<String>>::indexes(), + [IndexSpec::Ope] + ); + assert_eq!( + <OreBlockTerm<u32> as EncryptTarget<u32>>::indexes(), + [IndexSpec::OreBlock] + ); +} + +#[test] +fn a_context_extension_is_inspected_as_supplied_or_omitted() { + use stack_encrypt::target::AllowsNoContext; + assert_eq!( + <ContextExtension<u64> as AcceptsContext>::supplied(7).into_inner(), + Some(7) + ); + assert_eq!( + <ContextExtension<u64> as AllowsNoContext>::omitted().into_inner(), + None + ); +} + #[tokio::test] async fn a_searchable_email_standalone_requires_its_context() { let cipher = common::stack_cipher().await;