From 0c0602d79cf08221aa549270c1741b4e8dc9b833 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 2 Nov 2026 23:51:20 +1100 Subject: [PATCH 01/12] 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 | 330 +++++++++ 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 + .../ui/scalar_collections_and_borrows.stderr | 2 +- .../tests/ui/scalar_no_caller_context.stderr | 12 + .../tests/ui/scalar_required_context.stderr | 12 + 47 files changed, 3461 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 e69dd133d..c452341fd 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 ce3f4c4f4..32cd6d307 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 863f1e763..9f69ccc46 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. @@ -48,6 +56,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: `EncryptInto` encrypts a `Vec<S>` into a `Vec<T>`, and `DecryptFrom` decrypts it back** (#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 db1af8a3b..c0499bc54 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -314,11 +314,81 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! [`OpaqueEncode`] type. So a `Vec<u8>` encrypts into one `Ciphertext`, and //! a `Vec<Vec<u8>>` is a collection of byte strings. //! -//! 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. +//! from the owned scalar API. 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. //! //! # What is authenticated //! @@ -401,10 +471,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 a5cdb7600..b0ea17319 100644 --- a/packages/stack-encrypt/src/scalar.rs +++ b/packages/stack-encrypt/src/scalar.rs @@ -227,6 +227,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 08be9547e..d39a97dd8 100644 --- a/packages/stack-encrypt/src/target/collections.rs +++ b/packages/stack-encrypt/src/target/collections.rs @@ -57,6 +57,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 b89e6f579..8581b4e1b 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -10,9 +10,55 @@ //! target's `ContextInput`. The collection makes one logical batch of key //! requests. Each element is authenticated on its own. //! +//! # 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 @@ -146,6 +192,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 5c416e382..190ff2cb7 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"] @@ -240,7 +242,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 @@ -390,6 +392,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. @@ -461,7 +505,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>>( @@ -512,7 +556,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. @@ -914,7 +958,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. /// @@ -1206,6 +1250,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 @@ -1249,13 +1362,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())), ) @@ -1284,17 +1401,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 50c6568a8..64808de75 100644 --- a/packages/stack-encrypt/tests/collections.rs +++ b/packages/stack-encrypt/tests/collections.rs @@ -525,3 +525,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..bd6c491a3 --- /dev/null +++ b/packages/stack-encrypt/tests/owned_equivalence.rs @@ -0,0 +1,330 @@ +//! 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, AsProtectedRef, 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" }, + }); + let declaration = vitaminc_aead_frame::serde::to_vec(&json).expect("frames"); + record::plan(declaration.as_protected_ref()).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 029645211..682ba9f46 100644 --- a/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr +++ b/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr @@ -40,7 +40,7 @@ error[E0277]: the trait bound `for<'a> &'a Option<i32>: Scalar` is not satisfied = note: 1 redundant requirement hidden = note: required for `Vec<stack_encrypt::Ciphertext>` to implement `EncryptTarget<Vec<Option<i32>>>` -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:11:13 | 11 | let _ = EncryptInto::encrypt_into::<Vec<Ciphertext>, _>(vec!["borrowed", "plaintext"], keyset); @@ -56,7 +56,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 `for<'a> &'a Vec<i32>: Scalar` 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_collections_and_borrows.stderr b/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.stderr index ab5de3568..6a2a157b5 100644 --- a/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.stderr +++ b/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.stderr @@ -49,7 +49,7 @@ error[E0277]: the trait bound `&str: OpaqueEncode` 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 `&str` to implement `OpaqueEncode` note: required by a bound in `stack_encrypt::scalar::<impl KeysetCipher<'_, K>>::encrypt` --> src/scalar.rs 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 0b849ce8fa5a6f98171a8ac88dd8604c0846522b 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/12] 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 d39a97dd8..4663c4b14 100644 --- a/packages/stack-encrypt/src/target/collections.rs +++ b/packages/stack-encrypt/src/target/collections.rs @@ -73,15 +73,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 64808de75..2d50b582f 100644 --- a/packages/stack-encrypt/tests/collections.rs +++ b/packages/stack-encrypt/tests/collections.rs @@ -577,3 +577,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 4fae22c3eb514d183cf26bd3427187b7c42cc40c 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/12] 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 ec86b9f6d3cc6c9211d5fa08c02a1253bb4dfccb 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/12] 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 190ff2cb7..32dd36d3a 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -857,6 +857,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 11089a99f..d9d97bc25 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)) + }), } } @@ -438,6 +470,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 c57ecfac230ea00ae7a44e6805c03b0ab5acc349 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/12] 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 | 39 ++++++++++++------- ...s.md => 0012-owned-record-declarations.md} | 9 +++++ 3 files changed, 42 insertions(+), 15 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 9f69ccc46..b4de10e99 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -79,7 +79,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 @@ -106,17 +114,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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: `EncryptInto` encrypts a `Vec<S>` into a `Vec<T>`, and `DecryptFrom` decrypts it back** (#1482). `Vec<T>` implements `EncryptTarget<Vec<S>>` and `DecryptSource<Vec<P>>` for any owned element @@ -137,9 +134,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 call for each keyset to decrypt it. An empty collection makes no call. The key source can still split a logical batch into several requests to its provider. - - The plaintext `Vec` is wiped before the pending request is returned, even - when the context is refused. The elements do not need to implement - `Clone`. + - Each element is wiped by its own declaration before the pending request + is returned, even when the context is refused. If a declaration panics, + the elements not yet handed over are wiped too. The elements do not need + to implement `Clone`. - If an element fails after earlier elements were decrypted, the earlier plaintexts are wiped before the error is returned. If an element's conversion panics, they are wiped before the panic unwinds. @@ -171,6 +169,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `ConstantTimeEq` for `Vec<u8>` (cipherstash/vitaminc#602), the bytes are compared through `TimingSafeEq`. +### 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 7ad9d3468d5db455d13fa5d161f4591839107943 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/12] 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 7186846f7..88d3c8144 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, or a decrypt future paused in key retrieval, drops the provider future and operation captures, for a record operation or an owned collection; 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. Tests check this for the dynamic record path and for the owned `Vec<Ciphertext>` to `Vec<String>` conversion. For owned collections, drop witnesses are test values that record whether they were wiped. They show that the prefix is wiped, not leaked, after a late failure or an element's panic. 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 and has a negative control. One of those paths encrypts an owned `Vec<String>` collection through its guard. Drop witnesses show that an owned collection's source is wiped before its pending request is returned. That includes a refused context, and a context conversion that panics. 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. Tests check this for the dynamic record path and for the owned `Vec<Ciphertext>` to `Vec<String>` conversion. For owned collections, drop witnesses are test values that record whether they were wiped. They show that the prefix is wiped, not leaked, after a late failure or an element's panic. That includes a column field of a structural record. An owned record's drop witness shows that 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 and has a negative control. One of those paths encrypts an owned `Vec<String>` collection through its guard. Drop witnesses show that an owned collection's source is wiped before its pending request is returned. That includes a refused context, a context conversion that panics, and the elements after one whose declaration panics. Drop witnesses also show 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>` collection of any length, including an empty one. | Go uses the same engine, but this profile does not claim independently instrumented Go key-request counts. | @@ -62,7 +62,8 @@ These entries in the index are limitations, not successful requirements: cleanup limitations. The observer only scans blocks that are freed, so it misses plaintext that is leaked and never freed. For the owned `Vec<S>` conversion, the drop witnesses in `packages/stack-encrypt/tests/collections.rs` - record the wipe itself. A test there fails if a source or a decrypted prefix + record the wipe itself. For owned records, the drop witnesses in + `packages/stack-encrypt/tests/owned_records.rs` do the same. A test there fails if a source or a decrypted prefix is leaked. 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 ddb9076eaca703e19e471e0b0b4661845fa20793 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/12] 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 32dd36d3a..d01700af8 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -870,7 +870,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 2d50b582f..065ba516f 100644 --- a/packages/stack-encrypt/tests/collections.rs +++ b/packages/stack-encrypt/tests/collections.rs @@ -622,8 +622,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 cdd5d58978af5c44b43b78375d681af0d64e7fcb 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/12] 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 44e0230a7c9285de24f96c2c9531629d1aec6921 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/12] 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 b4de10e99..6b5cff0db 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -94,7 +94,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 d01700af8..896b7bd29 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -417,23 +417,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 2ae1b95374ccd6276c5c1be99d3e65552d1319dd 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/12] 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; From 452b6045b4be9e72de48d205667dfef2e2950cf4 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Fri, 6 Nov 2026 22:43:35 +1100 Subject: [PATCH 11/12] fix(stack-encrypt): make the encrypted_record example follow the misuse-resistance rules The example's plaintext type derived Debug, Clone and PartialEq, so it printed decrypted rows, cloned a plaintext to encrypt it twice, and compared plaintexts with a non-constant-time ==. It also used the structural derives, which this branch replaces for new records. The example now uses the owned derives. User derives Zeroize and nothing else. Each decrypted value goes straight into a Protected, which wipes it on drop, and the example prints counts and match results rather than plaintext. The row-binding section builds each plaintext from a function instead of cloning one. Applies the rules in cipherstash/vitaminc#494 (misuse-resistance rules 4 and 9). --- .../examples/encrypted_record.rs | 235 ++++++++++-------- 1 file changed, 132 insertions(+), 103 deletions(-) diff --git a/packages/stack-encrypt/examples/encrypted_record.rs b/packages/stack-encrypt/examples/encrypted_record.rs index d6bf06a25..4519c2115 100644 --- a/packages/stack-encrypt/examples/encrypted_record.rs +++ b/packages/stack-encrypt/examples/encrypted_record.rs @@ -1,85 +1,109 @@ -//! A searchable encrypted struct, end to end. +//! Encrypt a table of users, query it by equality and by order, and decrypt +//! only the rows that match. //! -//! The point of target-directed encryption: define the encrypted *shape* of -//! a value — "the ciphertext plus the index terms this field needs" — derive -//! `EncryptFrom` / `DecryptInto` for it, and every insert is one -//! `encrypt_into(..).await`. A tiny in-memory "table" then answers equality -//! and range queries purely by comparing terms, decrypting only the rows -//! that match. +//! Each user is encrypted into an `EncryptedUser`. That type's fields say what +//! is stored for each plaintext field: //! -//! The async shape is the other half of the point: nothing here does I/O -//! until the `.await`. Terms derive locally; each ciphertext queues its -//! data-key request; the derive combines the field pendings with `zip` / -//! `map` — so a whole `Vec` of structs, encrypted through the `Vec` -//! implementation, settles in **one** batched ZeroKMS call, and the matching -//! rows decrypt in one more. +//! - The `age` field stores a ciphertext, an equality term, and an order term. +//! - The `email` field stores a ciphertext. //! -//! Run with: +//! One `encrypt_into(..).await` call encrypts the whole table. Queries compare +//! terms only, so they need no decryption and no ZeroKMS call. +//! +//! Nothing calls ZeroKMS until an `.await`. Encrypting the table makes one +//! batched `generate_keys` call. Decrypting the matching rows makes one +//! batched `retrieve_keys` call. +//! +//! The plaintext type, `User`, follows the rules for a type that contains +//! secrets. `User` derives `Zeroize` and nothing else: +//! +//! - No `Debug`, so the plaintext cannot be printed. +//! - No `Clone`, so there are no copies to wipe. +//! - No `PartialEq`, because a derived comparison does not run in constant +//! time. +//! +//! Encrypting a `User` consumes it and wipes it. A decrypted `User` goes +//! straight into a `Protected`. The `Protected` wipes it when it is dropped. +//! +//! Run it with: //! //! ```sh //! cargo run -p stack-encrypt --example encrypted_record //! ``` //! -//! Talks to real ZeroKMS. On a developer machine, `npx stash auth login` is -//! sufficient: the cipher finds both the access token and the client key in -//! the CLI's profile directory. In CI, set `CS_CLIENT_ACCESS_KEY` / -//! `CS_WORKSPACE_CRN` and `CS_CLIENT_ID` / `CS_CLIENT_KEY` instead (see the -//! `zerokms_auth` example for the lookup order). +//! The example calls the real ZeroKMS service. On a developer machine, run +//! `npx stash auth login` first: the cipher reads the access token and the +//! client key from the profile that command writes. In CI, set +//! `CS_CLIENT_ACCESS_KEY`, `CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, and +//! `CS_CLIENT_KEY` instead. The `zerokms_auth` example shows the order in +//! which the cipher looks for credentials. use stack_encrypt::sem::{EqualityTerm, OreTerm}; -use stack_encrypt::target::legacy::{DecryptFrom, EncryptInto}; -use stack_encrypt::{nonempty, DecryptInto, EncryptFrom, Error, StackCipher, StackCipherText}; - -// --- The shapes ---------------------------------------------------------------- - -/// "An encrypted `u32`, stored as its ciphertext plus an equality term and an -/// ORE term." The same shape as an EQL `integer_ord_ore` payload, minus the -/// EQL wire encoding. Every field is derived from the one `u32`, under one -/// context: the context authenticates the ciphertext (AAD) and -/// domain-separates both terms (PRF context). `DecryptInto` opens the -/// ciphertext field and passes over the terms — the field types say which is -/// which, so no attribute is needed. -#[derive(EncryptFrom, DecryptInto)] +use stack_encrypt::target::{DecryptSource, EncryptTarget}; +use stack_encrypt::{ + nonempty, Ciphertext, Controlled, DecryptFrom, EncryptInto, Error, Protected, StackCipher, +}; +use zeroize::Zeroize; + +// --- The types ----------------------------------------------------------------- + +/// An encrypted `u32`, stored as a ciphertext, an equality term, and an +/// order term. This is the layout of an EQL `integer_ord_ore` payload, without the +/// EQL encoding. +/// +/// All three fields are derived from the one `u32`, under one context. The +/// context is the associated data of the ciphertext, and it keeps each term +/// separate from terms derived for other fields. Decryption opens the +/// ciphertext and ignores the terms. +#[derive(EncryptTarget, DecryptSource)] #[stash(plaintext = u32)] struct EncryptedAge { - c: StackCipherText, + c: Ciphertext, eq: EqualityTerm, ord: OreTerm<u32>, } -/// The plaintext. -// `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)] +/// The plaintext. `Zeroize` is the only trait it derives. The module +/// documentation explains why. +#[derive(Zeroize)] struct User { age: u32, email: String, } -/// `User`, encrypted field by field: `age` from `user.age` under -/// `("users", "age")`, `email` from `user.email` under `("users", "email")`. The prefix -/// is named once, explicitly — it is part of the stored data's identity, so -/// it is never inferred from a Rust type name — and the field half follows -/// the plaintext field. -#[derive(EncryptFrom, DecryptInto)] +/// A `User`, encrypted field by field. +/// +/// The `age` field is derived from `user.age` under the context +/// `("users", "age")`, and the `email` field from `user.email` under +/// `("users", "email")`. The first part of each context, `"users"`, is +/// written here once. It is part of what identifies the stored data, so the +/// derive never takes it from the name of a Rust type. +#[derive(EncryptTarget, DecryptSource)] #[stash(struct = User, context = "users")] struct EncryptedUser { age: EncryptedAge, - email: StackCipherText, + email: Ciphertext, +} + +/// A user for the row-binding section. A function, not a cloned value, so +/// that each call builds its own plaintext. +fn alice() -> User { + User { + age: 34, + email: "alice@example.com".to_owned(), + } } #[tokio::main(flavor = "current_thread")] async fn main() -> Result<(), Box<dyn std::error::Error>> { - // One cipher does everything the shapes need: ZeroKMS-backed AEAD (every - // leaf sealed under its own data key) and SEM term derivation under the - // keyset's index key, which `init` loads. Data keys and terms are bound to - // the same keyset by construction — there is no way to mix them up. + // The cipher seals each value under its own ZeroKMS data key. `new` also + // loads the keyset's index key. The index key derives the terms. Data keys + // and terms always come from the same keyset. let cipher = StackCipher::new().await?; - // Sealing and term derivation bind to a keyset; this is the client's - // default one. + // The client's default keyset. let keyset = cipher.default_keyset(); - // --- Write side: encrypt a table of users --------------------------------- + // --- Encrypt a table of users ------------------------------------------- let users: Vec<User> = [ (29, "ada"), @@ -95,18 +119,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { }) .collect(); - // Every field carries its own context, so nothing is needed from the - // caller — and one await seals the whole table: the `Vec` implementation - // merges every struct's pending, so five users (two ciphertexts and two - // terms each) settle in a single batched generate_keys call. + // Each field has its own context, so the call needs no context from the + // caller. The `Vec` conversion combines every user's key requests, so + // five users make one batched `generate_keys` call. The call consumes + // `users` and wipes each plaintext. let table: Vec<EncryptedUser> = users.encrypt_into(&keyset).await?; println!("stored {} encrypted users in one ZeroKMS call", table.len()); - // --- Query side: terms only, no plaintext, no decryption ------------------ + // --- Query the table with terms ----------------------------------------- - // Term probes derive under the index key the cipher already holds, under - // the same context the field was stored under: building a query never - // calls ZeroKMS at all. + // A query term is derived under the index key that the cipher already + // loaded, and under the context the field was stored under. Building one + // never calls ZeroKMS. // WHERE age = 34: compare equality terms. let probe: EqualityTerm = 34u32 @@ -117,7 +141,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { .collect(); println!("WHERE age = 34 => rows {equal:?}"); - // WHERE age > 40: compare ORE terms. + // WHERE age > 40: compare order terms. let bound: OreTerm<u32> = 40u32 .encrypt_into_with_context(&keyset, nonempty!("users").with("age")) .await?; @@ -126,77 +150,82 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { .collect(); println!("WHERE age > 40 => rows {over_40:?}"); - // ORDER BY age: sort by ORE term. + // ORDER BY age: sort by order term. let mut by_age: Vec<usize> = (0..table.len()).collect(); by_age.sort_by(|&a, &b| table[a].age.ord.cmp(&table[b].age.ord)); println!("ORDER BY age => rows {by_age:?}"); - // --- Read side: decrypt only the rows a query matched --------------------- + // --- Decrypt only the rows a query matched ------------------------------ - // A separate client: any process holding the same ZeroKMS credentials and + // A second client. Any process with the same ZeroKMS credentials and // keyset can decrypt what this one wrote. let decryptor = StackCipher::new().await?; - // Collect the matching rows and decrypt them together: one batched - // retrieve_keys call, however many rows matched. Each field opens under - // the context it was sealed under — it is bound into the AAD, so a - // ciphertext cannot be replayed against a different field. + // Decrypting the matching rows together makes one batched + // `retrieve_keys` call, however many rows match. Each field opens under + // the context it was sealed under. That context is the ciphertext's + // associated data, so a ciphertext copied into another field does not + // open. let mut table = table; let mut matches: Vec<EncryptedUser> = Vec::new(); - // Descending index order keeps earlier indices valid across swap_remove. + // Take the rows in descending order, so that `swap_remove` does not move + // a row that is still to be taken. for i in over_40.into_iter().rev() { matches.push(table.swap_remove(i)); } - let matched: Vec<User> = Vec::<User>::decrypt_from(matches, &decryptor).await?; - for user in &matched { - println!("decrypted matching row: {user:?}"); - } - - // --- Binding a value to its record ---------------------------------------- - - // A context the caller passes *extends* every field's own: under the - // record's id, `age` is sealed under `(("users", "age"), id)` and opens only - // there — a ciphertext can no longer be moved between records of the - // same table. The price is that its terms are scoped to that record too: - // a probe built under `("users", "age")` alone never matches them, so extend - // where a value is read by id, not where it is searched across rows. + // The decrypted users are wrapped as soon as they arrive. The + // `Protected` wipes them when it is dropped, and `risky_ref` marks the + // one place the plaintext is read. + let matched = Protected::new(Vec::<User>::decrypt_from(matches, &decryptor).await?); + println!("decrypted {} matching rows", matched.risky_ref().len()); + + // --- Bind a value to its row -------------------------------------------- + + // A context that the caller passes extends the context of every field. + // Under the row id 42, `age` is sealed under `(("users", "age"), 42)`. It + // opens only under that context. So a ciphertext cannot be moved to + // another row of the table. + // + // The terms are bound to the row too. A probe built under `("users", + // "age")` alone does not match them. So extend the context for a value you + // read by its row id, not for a value you search for across rows. let id = 42u64; - let alice = User { - age: 34, - email: "alice@example.com".into(), - }; - let record: EncryptedUser = alice.clone().encrypt_into_with_context(&keyset, id).await?; - let unscoped: Vec<usize> = std::iter::once(&record) - .enumerate() - .filter(|(_, r)| r.age.eq == probe) - .map(|(i, _)| i) - .collect(); + let record: EncryptedUser = alice().encrypt_into_with_context(&keyset, id).await?; println!( - "record-scoped terms match the table probe: {}", - !unscoped.is_empty() + "row-bound terms match the table probe: {}", + record.age.eq == probe ); let scoped: EqualityTerm = 34u32 .encrypt_into_with_context(&keyset, nonempty!("users").with("age").with(id)) .await?; println!( - " ...and a probe built under the same id: {}", + "row-bound terms match a probe built under the same id: {}", record.age.eq == scoped ); - let opened = User::decrypt_from_with_context(record, &decryptor, id).await?; - assert_eq!(opened, alice); - // The record id is in every field's context, and the context is the - // ZeroKMS descriptor of every data key: opening under another id fails - // at ZeroKMS, before any key material moves. (Against a source that - // does not enforce descriptors — the fake — the AEAD refuses instead.) - let record: EncryptedUser = alice.encrypt_into_with_context(&keyset, id).await?; + let opened = Protected::new(User::decrypt_from_with_context(record, &decryptor, id).await?); + println!("opened the row under its own id"); + drop(opened); + + // The row id is part of every field's context. The context is also the + // ZeroKMS descriptor of every data key. So opening the row under another + // id fails at ZeroKMS, before any key material is sent. + // + // Some key sources do not check descriptors, such as the in-memory source + // the tests use. Such a source lets the request through, and the AEAD + // refuses the ciphertext instead. + let record: EncryptedUser = alice().encrypt_into_with_context(&keyset, id).await?; let wrong_id = User::decrypt_from_with_context(record, &decryptor, 43u64).await; println!( "opening under another id: {}", match wrong_id { Err(Error::Kms(e)) => format!("refused by ZeroKMS ({e})"), - Err(Error::Aead) => "refused (AEAD)".to_owned(), - _ => "unexpected".to_owned(), + Err(Error::Aead) => "refused by the AEAD".to_owned(), + Ok(mut user) => { + user.zeroize(); + "opened, which is a bug".to_owned() + } + Err(_) => "refused with an unexpected error".to_owned(), } ); From 9c4e31c4320e38a58d92f8ebd6b651760acfadc8 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Fri, 6 Nov 2026 22:43:36 +1100 Subject: [PATCH 12/12] docs(stack-encrypt): rewrite the owned-record docs in plain language and state what the caller must wipe Rewrites the prose this branch adds: the crate's "Records" section, the target module docs, the derive crate's "Owned records" section, ADR-0012, the CHANGELOG entries, the conformance rows, and the doc and test comments. The text follows ISO 24495-1: one idea per sentence, terms explained where they first appear, and lists in place of long chains. It also applies the misuse-resistance rules in cipherstash/vitaminc#494: - The doc examples no longer show FakeDataKeySource in visible code, and their plaintext types derive only Zeroize. - Decrypted values go into a Protected in the examples. - DecryptFrom, the derive docs and ADR-0012 say that a decrypted plaintext is not wiped on drop. The plaintext of a struct record cannot derive ZeroizeOnDrop, so the caller must wrap or wipe it. - The passthrough docs say the field can be read, changed or removed in storage, and must not decide authorisation or tenancy. - ExpectedContext and the derive docs say that a context_field record opened with no expected context is not checked. - Encryption::project_owned says it drops the other fields unwiped. - ADR-0012 gains "Options considered" and "What is left to the caller". The trybuild snapshots change only in line numbers. --- conformance/stack-encrypt/coverage.md | 4 +- .../stack-encrypt/lifecycle-contract.md | 4 +- .../stack-encrypt-derive/docs/attributes.md | 210 +++++++---- packages/stack-encrypt-derive/src/attrs.rs | 6 +- packages/stack-encrypt-derive/src/lib.rs | 45 ++- .../stack-encrypt-derive/src/owned_decrypt.rs | 63 ++-- .../stack-encrypt-derive/src/owned_encrypt.rs | 140 +++---- packages/stack-encrypt-derive/src/shape.rs | 25 +- packages/stack-encrypt/CHANGELOG.md | 180 +++++---- .../adr/0012-owned-record-declarations.md | 341 +++++++++++------- packages/stack-encrypt/src/lib.rs | 150 +++++--- packages/stack-encrypt/src/plan/build.rs | 47 ++- packages/stack-encrypt/src/scalar.rs | 4 +- .../stack-encrypt/src/target/collections.rs | 37 +- .../stack-encrypt/src/target/conversion.rs | 81 +++-- packages/stack-encrypt/src/target/index.rs | 2 +- packages/stack-encrypt/src/target/input.rs | 100 ++--- packages/stack-encrypt/src/target/mod.rs | 106 +++--- .../stack-encrypt/src/target/operations.rs | 103 +++--- packages/stack-encrypt/src/target/pending.rs | 32 +- packages/stack-encrypt/tests/collections.rs | 31 +- packages/stack-encrypt/tests/derive.rs | 4 +- .../stack-encrypt/tests/owned_equivalence.rs | 43 ++- packages/stack-encrypt/tests/owned_records.rs | 140 +++---- .../tests/ui/owned_aead_record_with_term.rs | 6 +- .../tests/ui/owned_record_field_context.rs | 10 +- .../tests/ui/owned_record_required_context.rs | 5 +- .../ui/owned_record_required_context.stderr | 8 +- .../ui/owned_struct_plaintext_not_zeroize.rs | 6 +- .../tests/ui/owned_struct_unmapped_field.rs | 9 +- .../ui/owned_struct_unmapped_field.stderr | 4 +- .../tests/ui/pass/owned_records.rs | 22 +- .../tests/ui/passthrough_misuse.rs | 13 +- .../tests/ui/passthrough_misuse.stderr | 12 +- 34 files changed, 1185 insertions(+), 808 deletions(-) diff --git a/conformance/stack-encrypt/coverage.md b/conformance/stack-encrypt/coverage.md index 32cd6d307..747b1bb61 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; `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 | +| Owned opaque ciphertext and storage envelope | `packages/stack-encrypt/tests/scalar.rs`, `frozen_bytes.rs`; initial cross-opening cases; `owned_records.rs`, which tests the owned record derives: passthrough fields are moved, sealed fields are wiped, nested and renamed fields work, a record can store its context, and a column makes one batch | 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`, which seals one mixed, searchable record three ways: through a hand-written owned target, the owned derive, and the data plan. All three use one provider that records its requests. The test compares the descriptors and the term bytes, and checks that each way decrypts the records of the others | 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/conformance/stack-encrypt/lifecycle-contract.md b/conformance/stack-encrypt/lifecycle-contract.md index 88d3c8144..bad967da3 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, or a decrypt future paused in key retrieval, drops the provider future and operation captures, for a record operation or an owned collection; 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. Tests check this for the dynamic record path and for the owned `Vec<Ciphertext>` to `Vec<String>` conversion. For owned collections, drop witnesses are test values that record whether they were wiped. They show that the prefix is wiped, not leaked, after a late failure or an element's panic. That includes a column field of a structural record. An owned record's drop witness shows that 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 and has a negative control. One of those paths encrypts an owned `Vec<String>` collection through its guard. Drop witnesses show that an owned collection's source is wiped before its pending request is returned. That includes a refused context, a context conversion that panics, and the elements after one whose declaration panics. Drop witnesses also show 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-partial-output-v1` | A batch with a valid first row and a later authenticated type mismatch releases its decoded prefix without marked heap residue. Tests check this for the dynamic record path and for the owned `Vec<Ciphertext>` to `Vec<String>` conversion. For owned collections, drop witnesses are test values that record whether they were wiped. They show that the prefix is wiped, not leaked, after a late failure or an element's panic. The same holds for a column field of a structural record. For an owned record, a drop witness shows that 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 and has a negative control. One of those paths encrypts an owned `Vec<String>` collection through its guard. Drop witnesses show that an owned collection's source is wiped before its pending request is returned. That includes a refused context, and a context conversion that panics. It also includes the elements after an element whose declaration panics. For an owned record, drop witnesses show that the record wipes the fields it seals, also when the context is refused. They also show that the record 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>` collection of any length, including an empty one. | Go uses the same engine, but this profile does not claim independently instrumented Go key-request counts. | diff --git a/packages/stack-encrypt-derive/docs/attributes.md b/packages/stack-encrypt-derive/docs/attributes.md index fab54ff20..00c0f2fb3 100644 --- a/packages/stack-encrypt-derive/docs/attributes.md +++ b/packages/stack-encrypt-derive/docs/attributes.md @@ -1,11 +1,17 @@ # Attributes -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. +All attributes are written inside `#[stash(...)]`. The four derives read the +same attributes: + +- `EncryptTarget` and `DecryptSource` declare a record the owned way. The + caller passes the plaintext by value. Encrypting it consumes it and wipes + it. + Only these two derives accept `passthrough`. Each also implements the + structural traits, so the record works where a structural one is expected. +- `EncryptFrom` and `DecryptInto` declare a record the structural way, over + a borrowed plaintext. + +Derive one pair on a record, not both. ## On the struct @@ -38,7 +44,7 @@ literal. | `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<Terms>` field). | | `default` / `default = expr` | Not derived: filled with `Default::default()` or `expr`. Never encrypted, never authenticated. | | `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`. | +| `passthrough` | Owned derives, with `struct` only: move the plaintext field of the same name (or the one `from = field` names) into the record as it is, and move it back on decryption. **Not encrypted and not authenticated**: see [Passthrough fields](#passthrough-fields). The field has no context. Cannot be combined with `identity`, `default` or `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. @@ -72,66 +78,130 @@ 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`: +`#[derive(EncryptTarget, DecryptSource)]` takes the attributes above. +`value.encrypt_into(&keyset)` consumes the plaintext and wipes each field it +seals, and `Plaintext::decrypt_from(record, &cipher)` returns the plaintext. +The plaintext type must implement `Zeroize`. + +### What the caller passes + +The record's `ContextInput` decides which context a caller can pass, and +which context the caller must pass: -| Record | Encrypt input | Decrypt input | A call | +| Record | Encrypt input | Decrypt input | Example call | |---|---|---|---| -| `struct = User, context = "users"` | `ContextExtension<DeclaredContext>` | 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<CallerContext>` (`RequiredContext<AeadContext>` with `context_type = AeadContext`) | the same | `value.encrypt_into_with_context(&keyset, nonempty!("users/email"))` | -| with a `context_field` of type `C` | `RequiredContext<NonEmpty<C>>` | `ExpectedContext<C>` | `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<Terms>` -(`RequiredContext<CallerContext>`), a `StackCipherText` -(`RequiredContext<AeadContext>`), 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<u64>`) 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 `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. 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 -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<C>` — 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::<Record, _>`) without -a second derive. A hand-written owned target that is to be a record field -implements `Decryptable` and `DecryptField` beside `DecryptSource`. +| `struct = User, context = "users"` | `ContextExtension<DeclaredContext>` | The same as for encryption | `user.encrypt_into(&keyset)`, or `user.encrypt_into_with_context(&keyset, 7u64)` to extend every field's context with the row id `7` | +| `plaintext = T` | `RequiredContext<CallerContext>`, or `RequiredContext<AeadContext>` with `context_type = AeadContext` | The same as for encryption | `value.encrypt_into_with_context(&keyset, nonempty!("users/email"))` | +| With a `context_field` of type `C` | `RequiredContext<NonEmpty<C>>` | `ExpectedContext<C>` | `T::decrypt_from_with_context(record, &cipher, nonempty!(..))`, which checks the stored context before it decrypts | + +A `context_field` record can also be opened with `T::decrypt_from(record, +&cipher)`, with no context. That call opens the record under whatever +context it stores, and checks only that the context is not empty. The +stored context is not authenticated, so a ciphertext moved together with its +stored context opens in its new place. Pass the context you expect, unless +you have checked the record's place some other way. + +### Fields of a `struct` record + +Each field of a `struct` record is given its context, `("users", "<field>")`, +extended by the caller's context if there is one. So the field's target must +accept a context from the record. These targets accept one: + +- `Ciphertext`, whose input is `OptionalContext`. +- A term, or an `Encrypted<Terms>`, whose input is + `RequiredContext<CallerContext>`. +- `StackCipherText`, whose input is `RequiredContext<AeadContext>`. +- A `plaintext` record. +- Another `struct` record. The outer field's context then extends the + inner record's own contexts. + +Two kinds of target are a compile error at the field. One kind builds its +whole context itself (`NoCallerContext`). The other requires a context of +another type, such as `RequiredContext<u64>`. + +The record must name every field of its plaintext, as a sealed field or a +passthrough field. The owned conversion moves the fields out of the +plaintext struct, and a field it did not name would be dropped without +being wiped. So a field that is left out is a compile error at the +`struct = ..` path ("pattern does not mention field"). + +### Passthrough fields + +A `#[stash(passthrough)]` field is moved from the plaintext into the record, +and back again on decryption. A passthrough field is **not encrypted and +not authenticated**. +Anyone with access to the stored record can read the value, change it or +remove it, and every sealed field beside it still decrypts. + +- Use a passthrough field only for a value that is not secret. +- Treat the value you read back as untrusted input. Never let it decide + authorisation, tenancy, or which record to trust. +- Do not use a passthrough field for a value that must be readable and + also tamper-evident. Pass that value as the caller's context instead, as + the row id is passed above. A changed context makes decryption fail. + +The owned conversion never copies a passthrough field. The field's type +must still implement `Clone`, because the structural form of the record +copies it. Plans and structural records run that form. + +### Wiping + +- The conversion wipes each sealed field before the pending request is + returned. +- If a field's declaration panics, the sealed fields that are still waiting + for their own declarations are wiped. +- If the record's declaration panics while it is being built, the whole + plaintext is wiped. +- On decryption, if a later field is refused, the fields already opened are + wiped before the error is returned. + +**A decrypted plaintext is not wiped when it is dropped.** The derive moves +the fields out of the plaintext struct, and Rust does not allow that for a +type that implements `Drop`. So the plaintext type cannot derive +`ZeroizeOnDrop`. Wrap a decrypted value in `stack_encrypt::Protected` as +soon as it is returned, or call `zeroize` on it when you no longer need it. +Do not derive these traits on the plaintext type either: + +- `Debug`, because it prints the plaintext. +- `Clone`, because each clone is another copy to wipe. +- `PartialEq`, because a derived comparison does not run in constant time. + +### Fields of a `plaintext` record + +Every field of a `plaintext` record is given the one context the record +requires. So a term field in a record with `context_type = AeadContext` is a +compile error at the term, because a term needs a `CallerContext`. A call +that leaves out a required context is a compile error at the call +(`AllowsNoContext`), even if the context type implements `Default`. + +### The structural traits + +Each owned derive also implements the structural traits: + +- `EncryptTarget` also implements `EncryptFrom` and `Decryptable`. + `EncryptFrom` converts the record's structural context into the owned + input. The structural context is a `DeclaredContext`, a `CallerContext`, + or a `NonEmpty<C>`. +- `DecryptSource` also implements `DecryptInto` and `DecryptField`. + +So an owned record can be a field of a `#[derive(EncryptFrom)]` record, or a +typed field of a plan (`encrypt_into::<Record, _>`), with no second derive. +A hand-written owned target that will be a record field must implement +`Decryptable` and `DecryptField`, as well as `DecryptSource`. + +### Example ```rust use stack_encrypt::sem::EqualityTerm; use stack_encrypt::target::{DecryptSource, EncryptTarget}; -use stack_encrypt::{Ciphertext, DecryptFrom, EncryptInto, StackCipher}; +use stack_encrypt::{Ciphertext, Controlled, DecryptFrom, EncryptInto, Protected, StackCipher}; -#[derive(Debug, PartialEq, zeroize::Zeroize)] +// The plaintext derives `Zeroize` and nothing else. +#[derive(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. +/// An email address you can search for: its ciphertext and an equality +/// term, under the one context that the record field supplies. #[derive(EncryptTarget, DecryptSource)] #[stash(plaintext = String)] struct SearchableEmail { ciphertext: Ciphertext, equality: EqualityTerm } @@ -139,30 +209,38 @@ struct SearchableEmail { ciphertext: Ciphertext, equality: EqualityTerm } #[derive(EncryptTarget, DecryptSource)] #[stash(struct = User, context = "users")] struct SearchableUser { + // Not encrypted and not authenticated. #[stash(passthrough)] id: u64, - name: Ciphertext, // ("users", "name") - email: SearchableEmail, // ("users", "email") + name: Ciphertext, // sealed under ("users", "name") + email: SearchableEmail, // derived under ("users", "email") } # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +// With no `.kms(..)`, the cipher uses ZeroKMS. It reads credentials from +// the environment, or from the profile that `npx stash auth login` writes. let cipher = StackCipher::builder() - .kms(stack_encrypt::kms::FakeDataKeySource::new()).init().await?; +# .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. +// One batched key request seals the four sealed fields of both rows. let rows: Vec<SearchableUser> = users.encrypt_into(&keyset).await?; -// A query term is the same derivation under the same context. + +// 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<SearchableUser> = rows.into_iter().filter(|r| r.email.equality == wanted).collect(); -let opened = Vec::<User>::decrypt_from(matched, &cipher).await?; -assert_eq!(opened.len(), 1); -assert_eq!(opened[0].id, 42); + +// Wrap the decrypted users at once. `Protected` wipes them when it is dropped. +let opened = Protected::new(Vec::<User>::decrypt_from(matched, &cipher).await?); +assert_eq!(opened.risky_ref().len(), 1); +assert_eq!(opened.risky_ref()[0].id, 42); # Ok::<(), stack_encrypt::Error>(()) # }).unwrap(); ``` diff --git a/packages/stack-encrypt-derive/src/attrs.rs b/packages/stack-encrypt-derive/src/attrs.rs index 5c27a7843..ab72c6ea2 100644 --- a/packages/stack-encrypt-derive/src/attrs.rs +++ b/packages/stack-encrypt-derive/src/attrs.rs @@ -223,9 +223,9 @@ pub(crate) struct FieldAttrs { pub(crate) default: Option<Option<Expr>>, /// `#[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. + /// `#[stash(passthrough)]`, on a field of a `struct = ..` record with the + /// owned derives. The plaintext field is moved into the record in the + /// clear, not encrypted, and moved back when the record is decrypted. pub(crate) passthrough: bool, } diff --git a/packages/stack-encrypt-derive/src/lib.rs b/packages/stack-encrypt-derive/src/lib.rs index ae667d03a..74b39c63e 100644 --- a/packages/stack-encrypt-derive/src/lib.rs +++ b/packages/stack-encrypt-derive/src/lib.rs @@ -1,13 +1,18 @@ -//! Derive operation declarations for encrypted records. The owned pair, -//! `EncryptTarget<P>` / `DecryptSource<P>`, 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<P>` / `DecryptInto<P>`, 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. +//! Derive the declarations of encrypted records. There are two pairs of +//! derives, and both take the same attributes: +//! +//! - `EncryptTarget<P>` and `DecryptSource<P>` declare a record for the owned +//! conversions, `value.encrypt_into(&keyset)` and +//! `P::decrypt_from(record, &cipher)`. Encrypting consumes the plaintext +//! and wipes it. [Owned records](#owned-records) lists what only this pair +//! adds. +//! - `EncryptFrom<P>` and `DecryptInto<P>` declare a record for the +//! structural calls, `keyset.encrypt_as` and `cipher.decrypt_as`, over a +//! borrowed plaintext. +//! +//! Stack Encrypt runs every declaration, through Vitamin C's plaintext +//! traits. The generated code is given a context and encrypted outputs. It is +//! never given a cipher. //! //! ``` //! use stack_encrypt::{EncryptFrom, DecryptInto, StackCipher, StackCipherText, nonempty}; @@ -238,10 +243,14 @@ pub fn derive_decrypt_into(input: TokenStream) -> TokenStream { .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). +/// Derive the owned `EncryptTarget` declaration for a record struct. The +/// derive also implements the structural `EncryptFrom`, so the record works +/// where a structural record is expected. +/// +/// The derive takes the same attributes as `EncryptFrom`. It also takes +/// `#[stash(passthrough)]` on a field of a `struct = ..` record. A +/// passthrough field is not encrypted and not authenticated. 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 { @@ -252,8 +261,12 @@ pub fn derive_encrypt_target(input: TokenStream) -> TokenStream { } /// Derive the owned `DecryptSource<Plaintext>` declaration for a record -/// struct, one impl per `plaintext` type, with the structural `DecryptInto` -/// and `DecryptField` beside it as a bridge. See +/// struct, with one impl for each `plaintext` type. The derive also +/// implements the structural `DecryptInto` and `DecryptField`, so the record +/// works where a structural record is expected. +/// +/// A decrypted plaintext is not wiped when it is dropped. Wrap it in +/// `stack_encrypt::Protected` as soon as it is returned. See /// [Owned records](crate#owned-records). #[doc = include_str!("../docs/attributes.md")] #[proc_macro_derive(DecryptSource, attributes(stash))] diff --git a/packages/stack-encrypt-derive/src/owned_decrypt.rs b/packages/stack-encrypt-derive/src/owned_decrypt.rs index 4c7d817f1..e788553b1 100644 --- a/packages/stack-encrypt-derive/src/owned_decrypt.rs +++ b/packages/stack-encrypt-derive/src/owned_decrypt.rs @@ -1,13 +1,16 @@ -//! Emit an owned `DecryptSource` declaration, with the structural -//! `DecryptInto` and `DecryptField` beside it as a bridge. +//! Emit an owned `DecryptSource` declaration. Also emit the structural +//! `DecryptInto` and `DecryptField`, so the record works where a structural +//! record is expected. //! -//! 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. +//! Decryption follows the same layout as the owned encryption: +//! +//! - A `struct = ..` record opens each field through the field type's +//! `DecryptField`. Each field opens under its context, extended by the +//! caller's context if there is one. The passthrough fields are moved back +//! into the plaintext. +//! - A `plaintext = T` record opens its one recoverable field under the +//! context the caller supplies. A `context_field` record opens it under the +//! context it stores, once that context has passed the caller's check. use crate::decrypt::check; use crate::owned_encrypt::{encrypt_input, spanned_predicate, threaded_input}; use crate::shape::{trait_impl, zip_wiping, Field, Flavor, Record}; @@ -21,7 +24,8 @@ pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { 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. + // Each group recovers one plaintext, or one plaintext field. `None` means + // 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 { @@ -99,7 +103,7 @@ pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { impls.push(bridge(&input, &record, &plaintext, generic)); } - // `DecryptField`, through the bridge, as the structural derive emits it. + // `DecryptField`, emitted the same way as the structural derive emits it. let mut generics = input.generics.clone(); generics.params.push(parse_quote!(__P)); generics.params.push(parse_quote!(__Ctx)); @@ -119,9 +123,9 @@ pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { 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. +/// The caller's input on the decrypt side. A `context_field` record takes a +/// check of the stored context. A `struct` record takes an extension. Any +/// other record takes the caller context the encrypt side required. fn decrypt_input(record: &Record) -> Type { let krate = &record.krate; if let Some(field) = record.context_field() { @@ -132,10 +136,10 @@ fn decrypt_input(record: &Record) -> Type { } } -/// A `struct = ..` record: each field opened under its pair extended by the -/// 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. +/// A `struct = ..` record. Each field opens under its context, extended by the +/// caller's context. The fields are zipped so that a field recovered before a +/// later field fails is wiped. The plaintext is then rebuilt, with the +/// passthrough fields moved back. fn struct_body( record: &Record, plaintext: &Type, @@ -178,8 +182,8 @@ fn struct_body( quote!(#from: self.#member) }); let output = quote!(#literal { #(#opened,)* #(#carried),* }); - // Wiping: a field opened before a later field is refused is wiped, not - // dropped as it is. + // 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); @@ -187,9 +191,9 @@ fn struct_body( }) } -/// 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. +/// A `plaintext = T` record. Its one recoverable field opens under the +/// required context. A `context_field` record first builds that context from +/// the context it stores, and checks it against the caller's expectation. fn value_body( record: &Record, plaintext: &Type, @@ -209,8 +213,9 @@ fn value_body( }; } }); - // The where clause states per field what the body is checked against, - // for a generic impl; a concrete one reports at the field alone. + // In a generic impl, the where clause states, for each field, what the + // body is checked against. A concrete impl reports the error at the field + // alone. for field in fields.iter().filter(|_| generic) { let ty = &field.ty; if explicit { @@ -243,8 +248,8 @@ fn value_body( 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. + // Run every inspection before chaining them. 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(); @@ -259,8 +264,8 @@ fn value_body( quote!(#stored #operation) } -/// The structural `DecryptInto` bridge: the record's legacy context, -/// converted into its owned input. +/// The structural `DecryptInto`. The record's structural context is 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); diff --git a/packages/stack-encrypt-derive/src/owned_encrypt.rs b/packages/stack-encrypt-derive/src/owned_encrypt.rs index 43f2c3749..4799849c9 100644 --- a/packages/stack-encrypt-derive/src/owned_encrypt.rs +++ b/packages/stack-encrypt-derive/src/owned_encrypt.rs @@ -1,14 +1,18 @@ -//! 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. +//! Emit an owned `EncryptTarget` declaration. Also emit the structural +//! `EncryptFrom`. Then the record can be 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. +//! The derive composes the declarations of the fields: +//! +//! - A `struct = ..` record takes each field out of the plaintext and runs the +//! field's own target under the field's context. The borrowed form uses +//! `project`. The owned form uses a chain of `split` calls. +//! - A `plaintext = T` record runs every field's target over the whole value, +//! under the one context the caller supplies. +//! +//! The field target's `ContextInput` decides which context the field can be +//! given. So a target that cannot take the record's context is a compile error +//! 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}; @@ -27,9 +31,10 @@ pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { 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`). +/// The caller's input on the encrypt side. A record whose fields have their +/// own contexts takes an extension. A record that stores its context requires +/// the `NonEmpty<T>` it stores. Any other record requires the caller context, +/// or the type `context_type` names. pub(crate) fn encrypt_input(record: &Record) -> Type { let krate = &record.krate; if let Some(field) = record.context_field() { @@ -43,16 +48,17 @@ pub(crate) fn encrypt_input(record: &Record) -> Type { } } -/// The context every field of a `plaintext` record is run under: what the -/// record is handed, or the `CallerContext` built from the stored one. +/// The context that every field of a `plaintext` record runs under. This is +/// the context the record is given, or the `CallerContext` built from the +/// stored context. 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`. +/// The context type that the fields of a `plaintext` record take. This is the +/// type `context_type` names, or `CallerContext`. fn caller_context(record: &Record) -> Type { let krate = &record.krate; record @@ -61,8 +67,8 @@ fn caller_context(record: &Record) -> Type { .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. +/// A where-predicate with the span of the field it is about. So an error about +/// what the 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| { @@ -71,7 +77,8 @@ pub(crate) fn spanned_predicate(span: Span, tokens: TokenStream) -> WherePredica } /// The fields the encryption produces, in declaration order: the derived -/// fields and the passthrough fields. Each gets an output local. +/// fields and the passthrough fields. Each field gets a local variable for its +/// output. fn participating(record: &Record) -> Vec<&Field> { record .fields @@ -80,7 +87,8 @@ fn participating(record: &Record) -> Vec<&Field> { .collect() } -/// `a.zip(b).zip(c)` over the locals, and the pattern binding each back. +/// `a.zip(b).zip(c)` over the local variables, and the pattern that binds each +/// one back. fn zip_chain(locals: &[&Ident]) -> (TokenStream, TokenStream) { let mut chain = TokenStream::new(); let mut pattern = TokenStream::new(); @@ -96,8 +104,8 @@ fn zip_chain(locals: &[&Ident]) -> (TokenStream, TokenStream) { (chain, pattern) } -/// The struct literal filling every field: participating fields from their -/// locals, `default` fields with their defaults. +/// The struct literal that fills every field. A participating field takes its +/// local variable, and a `default` field takes its default. fn literal(record: &Record, stored: Option<TokenStream>) -> TokenStream { let assignments = record.fields.iter().map(|field| { let member = &field.member; @@ -115,18 +123,21 @@ fn literal(record: &Record, stored: Option<TokenStream>) -> TokenStream { 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. +/// `#[stash(struct = User, context = "users")]`. Each field is taken out of +/// the plaintext and run by its own target, under the field's context. A +/// passthrough field is passed through. +/// +/// - The borrowed form projects each field. +/// - The owned form divides the struct between the fields with `split`. A +/// passthrough field is moved. Each sealed field is guarded by its own +/// 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. + // The borrowed form: `project` a reference to the field. let borrowed: Vec<TokenStream> = fields .iter() .map(|field| { @@ -158,8 +169,8 @@ fn struct_derive(input: &DeriveInput, record: &Record) -> TokenStream { #zipped.map(move |#zip_pattern| #value).accepting() }; - // Owned: each field's own owned description, the struct divided - // between them right to left. + // The owned form: each field's own owned description, with the struct + // divided between them from right to left. let owned: Vec<TokenStream> = fields .iter() .map(|field| { @@ -182,12 +193,14 @@ fn struct_derive(input: &DeriveInput, record: &Record) -> TokenStream { } }) .collect(); - // The plaintext destructured into one local per participating field, - // 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. + // Destructure the plaintext into one local variable for each participating + // field. The pattern has no `..`, so it must name every field. + // + // The owned form does not guard the whole value. So a plaintext field that the + // record neither seals nor moves would be dropped without being wiped. + // Such a field is a compile error at the `struct = ..` path ("pattern does + // not mention field"). The decrypt side already refuses a plaintext that + // it cannot rebuild. let bindings = fields.iter().map(|field| { let from = field.from().unwrap_or_else(|| unreachable!("a `from`")); let local = &field.local; @@ -202,11 +215,11 @@ fn struct_derive(input: &DeriveInput, record: &Record) -> TokenStream { 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. Each `split` is told how to wipe the parts still - // waiting for their descriptions, should an earlier field's - // description panic. + // Build `rest` as one description over the nested tuple of its + // parts, divided by identity. Then divide the struct into `first` + // and that tuple. Each `split` is given the function that wipes + // the parts still waiting for their descriptions, in case an + // earlier field's description panics. let mut tail = { let last = rest[rest.len() - 1]; quote!(#last) @@ -256,11 +269,13 @@ 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. +/// The `wipe` function that a `split` is given for the parts still waiting. +/// Those parts are the nested tuple `waiting`. The function wipes every sealed +/// field in the tuple, and leaves every passthrough field as it is. +/// +/// The plaintext of a sealed field already implements `Zeroize`, as the source +/// of every `EncryptTarget` does, so this adds no bound. A passthrough field +/// is stored in the clear, so it needs no bound. fn wipe_waiting(krate: &syn::Path, waiting: &[&Field]) -> TokenStream { let mut wipes = Vec::new(); let mut base = quote!((*__parts)); @@ -287,8 +302,8 @@ fn wipe_waiting(krate: &syn::Path, waiting: &[&Field]) -> TokenStream { } } -/// The nested tuple `(a, (b, (c, d)))` of the locals: how `split` divides -/// a struct, one part per step. +/// The nested tuple `(a, (b, (c, d)))` of the local variables. This is how +/// `split` divides a struct, one part at each step. fn nested_tuple(locals: &[&Ident]) -> TokenStream { match locals { [] => quote!(()), @@ -305,10 +320,10 @@ fn own_expr(krate: &syn::Path, context: &OwnContext) -> TokenStream { 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. +/// `#[stash(plaintext = T)]`, or no record attribute. Every field's target +/// runs over the whole value, under the one required context, and the results +/// are zipped. A record with a `context_field` requires the `NonEmpty<T>` it +/// stores, and returns it beside the outputs. fn value_derive(input: &DeriveInput, record: &Record, fields: &[&Field]) -> TokenStream { let krate = &record.krate; let input_type = encrypt_input(record); @@ -328,11 +343,12 @@ fn value_derive(input: &DeriveInput, record: &Record, fields: &[&Field]) -> Toke } 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. + // Check each field, with the error at the field: a target that does + // not accept the record's context is refused there. A generic impl + // states the same check for each field in its where clause, and the + // body is checked against it. A concrete impl lets the body report the + // error at the field alone. That matters because a failure in a where + // clause is reported at the derive. let descriptions: Vec<TokenStream> = fields .iter() .map(|field| { @@ -400,10 +416,10 @@ fn value_derive(input: &DeriveInput, record: &Record, fields: &[&Field]) -> Toke 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. +/// The structural `EncryptFrom`. The record's structural context is converted +/// into its owned input at the root. This impl is emitted beside every owned +/// declaration. So an owned record can be a field of a plan or of a structural +/// record, with no second derive. fn bridge( input: &DeriveInput, record: &Record, diff --git a/packages/stack-encrypt-derive/src/shape.rs b/packages/stack-encrypt-derive/src/shape.rs index fd6fe4d52..ea6d429a9 100644 --- a/packages/stack-encrypt-derive/src/shape.rs +++ b/packages/stack-encrypt-derive/src/shape.rs @@ -51,12 +51,13 @@ 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. + /// `#[stash(passthrough)]`, on a field of a `struct = ..` record with the + /// owned derives. The plaintext field `from` is moved into the record as + /// it is, and moved back on decryption. It is not encrypted and not + /// authenticated. Passthrough { - /// The plaintext field carried through: its own name, or the - /// `#[stash(from = field)]` override. + /// The plaintext field that is carried through. This is the field's + /// own name, or the field that `#[stash(from = field)]` names. from: Member, }, } @@ -154,11 +155,11 @@ pub(crate) enum FieldContext<'a> { #[derive(Clone, Copy, PartialEq, Eq)] #[cfg_attr(test, derive(Debug))] pub(crate) enum Flavor { - /// `EncryptFrom` / `DecryptInto`: the structural declarations, run over - /// a borrowed plaintext. + /// `EncryptFrom` and `DecryptInto`: the structural declarations. They run + /// over a borrowed plaintext. Structural, - /// `EncryptTarget` / `DecryptSource`: the owned declarations, which - /// also emit the structural pair as a bridge. + /// `EncryptTarget` and `DecryptSource`: the owned declarations. These also + /// emit the structural pair. Owned, } @@ -682,9 +683,9 @@ pub(crate) fn zip(operations: Vec<(TokenStream, Ident)>, result: TokenStream) -> 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. +/// [`zip`] over recovered plaintext. Each field opened before a later field +/// fails is wiped, not dropped as it is (`Decryption::zip_wiping`). An owned +/// record's fields open through this. pub(crate) fn zip_wiping( operations: Vec<(TokenStream, Ident)>, result: TokenStream, diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 6b5cff0db..99d647a83 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -10,13 +10,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. + impls require `P: Zeroize`** (#1483). These impls used to decrypt through + `Decryption::all`. When an element failed late, the elements recovered + before it were dropped without being wiped. The impls now decrypt through + the same wiping path as the owned `Vec<T>: DecryptSource<Vec<P>>`, so + those elements are wiped before the error is returned. + - If a structural record decrypts a column into a struct plaintext, derive + `Zeroize` on that struct. + - Every scalar the engine decrypts into already implements `Zeroize`. - 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. @@ -57,67 +58,93 @@ 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. 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 - 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 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` - (`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>()`. - + struct now encrypts field by field into a record type that declares its + own layout. The crate docs' "Records" section and the derive crate's + "Owned records" section show each form. The `tests/ui` cases pin the + forms that are refused, with the error at the field. + - With `#[stash(struct = User, context = "users")]`, each field's target + is given the context `("users", "<field>")`. `#[stash(from = ..)]` + names another source field, and `identity = ".."` sets the second part + of the context, as on the structural derive. + - A `#[stash(passthrough)]` field is moved from the plaintext into the + record, in the clear. It is never copied. A passthrough field is **not + encrypted and not authenticated**. Use it only for values that are not + secret, and treat the value read back as untrusted input. + - `user.encrypt_into(&keyset)` consumes the plaintext, wipes every field + it seals, and moves the passthrough fields. `User::decrypt_from(record, + &cipher)` decrypts each field under its context, and moves the + passthrough fields back. + - `encrypt_into_with_context(&keyset, 7u64)` extends every field's + context with the row id. The record then decrypts only with the same + extension. + - Records nest. The outer field's context extends the contexts of the + inner record's fields. + - A `#[stash(plaintext = String)]` record can be searched, for example a + `Ciphertext` beside an `EqualityTerm`. It can be a field of a `struct` + record. + - A `context_field` record stores its context, and decrypts under an + `ExpectedContext`. + - A `Vec<User>` encrypts into a `Vec<Record>` with one batched key + request. + - The plaintext type must implement `Zeroize`. A `struct` record must name + every field of its plaintext, as a sealed field or a passthrough field. + A field it leaves out is a compile error, so no field is dropped without + being wiped. For the same reason, the plaintext type cannot implement + `Drop`, so it cannot derive `ZeroizeOnDrop`. The caller must wipe a + decrypted plaintext, for example by wrapping it in `Protected`. + - If a later field is refused during decryption, the fields already + decrypted are wiped. + - Both derives also implement the structural `EncryptFrom`, + `DecryptInto`, and `DecryptField`. So an owned record can be a field of a + `#[derive(EncryptFrom)]` record, or a typed plan field, with no second + derive. Derive one pair on a record, not both. +- **`Decryption::zip_wiping`**, a `zip` for recovered plaintext. The right + side can fail after its key arrives, or panic in its conversion. The + plaintext that the left side recovered is then wiped, not dropped as it + is. The owned record derive decrypts its fields through this method. +- **`EncryptTarget::owned_encryption` and `EncryptTarget::indexes`.** + - `owned_encryption` describes the encryption over the plaintext by + value, in `Owned` source mode. By default, it guards the value with + `Protected`, runs `encryption` over a borrow of it, and wipes it before + the pending request is returned. `EncryptInto` and the owned column run + this method. + - A record overrides `owned_encryption`. It divides the struct between its + fields with the new `Encryption::split`. `split` runs two descriptions + over two parts of one value, under one context. `split` also takes a + `wipe` function for the part that waits while the first description + runs. If that description panics, `wipe` wipes the waiting part. + - `Encryption::project_owned` is also new. It drops the other fields of + the struct without wiping them. Use it only when those fields contain + nothing secret. + - `EncryptInto` guards the plaintext until the declaration and its context + have been built. So a declaration that panics while it is being built + wipes the plaintext. + - `indexes` returns the indexes that a plan field typed with the target + supports. The terms, `Encrypted<Terms>`, and `plaintext` records report + their indexes. +- **The core leaves implement the owned declarations.** + - `StackCipherText` takes a `RequiredContext<AeadContext>`, to encrypt + and to decrypt. + - `Encrypted<Terms>` takes a `RequiredContext<CallerContext>` to encrypt, + and a `RequiredContext<AeadContext>` to decrypt. + - `EqualityTerm`, `MatchTerms`, `OreTerm`, `OpeTerm`, and `OreBlockTerm` + implement `EncryptTarget` alone. + - `Ciphertext` implements `Decryptable` and `DecryptField`, so it can be a + record field. +- **`ContextExtension<C>`** is the caller input of a record whose fields + have their own contexts. If the caller leaves the context out, the fields' + contexts stay as they are. If the caller passes a context, it extends + each of them. +- **`ExpectedContext<T>` is a `ContextInput`.** If the caller leaves the + context out, the record decrypts under the context it stores, unchecked. + If the caller passes a `NonEmpty<T>`, the stored context must equal it. + - `ContextExtension` and `ExpectedContext` convert from the structural + contexts, `DeclaredContext` and `CallerContext`. So do the + `RequiredContext` wrappers of the core contexts. This is how a record + field is given its context. +- **`FieldsBuilder::target::<T, _>()`** adds a plan field laid out by an + owned target that has no structural declaration, such as a `Ciphertext`. + It lowers to `<T as EncryptTarget<F>>::encryption().accepting::<CallerContext>()`. - **Owned collections: `EncryptInto` encrypts a `Vec<S>` into a `Vec<T>`, and `DecryptFrom` decrypts it back** (#1482). `Vec<T>` implements `EncryptTarget<Vec<S>>` and `DecryptSource<Vec<P>>` for any owned element @@ -175,14 +202,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. +- **The structural derives `#[derive(EncryptFrom, DecryptInto)]` and the + `target::legacy` conversion traits.** New records derive `EncryptTarget` + and `DecryptSource`. These derives also implement the structural traits. The + structural API stays for now, because the plans and the dynamic engine + run it. It is where a migration starts, not where it ends. + - The structural API will be removed once every consumer has an owned + equivalent. That removal is tracked in #1488. + - No `#[deprecated]` attribute is applied yet, because the crate's own + plan and dynamic modules still implement these traits. ## [0.3.0] - 2026-10-27 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 bd28c14b3..1f772fc04 100644 --- a/packages/stack-encrypt/docs/adr/0012-owned-record-declarations.md +++ b/packages/stack-encrypt/docs/adr/0012-owned-record-declarations.md @@ -4,147 +4,238 @@ 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. +# Records are owned targets, and the structural traits become a bridge + +## Summary + +This ADR calls a struct that is encrypted field by field a record. A +record is now declared with `#[derive(EncryptTarget, DecryptSource)]`. Encrypting a +record consumes the plaintext struct. The conversion wipes each field it +seals, and moves each field it stores in the clear, so nothing is copied. +The same derives also implement the older structural traits, `EncryptFrom` +and `DecryptInto`, so an owned record works wherever a structural record +does. + +This ADR records five decisions, and the limits they leave to the caller. + +## Context + +The owned API is `EncryptInto` and `DecryptFrom`, over the declarations +`EncryptTarget` and `DecryptSource`. Each declaration has a `ContextInput` +type. That type says which context the caller can pass, and which context +the caller must pass. Before this change, the owned API covered one opaque +scalar, and a `Vec` of them. + +Records were declared only with the structural traits, `EncryptFrom` and +`DecryptInto`. The derives, the typed fields of the plan builder, and the +dynamic lowering all used them. The structural traits work over a borrowed +plaintext, and take their context from `Default`. + +The public examples show a mixed record: `id` stored in the clear, and +`name` and `email` sealed. A user writing that record had three problems: + +- No derive consumed the plaintext and wiped it. +- There was no way to say "store this field in the clear". +- Nothing made the record's fields and the scalar API agree on contexts. ## Decision -### 1. One trait, two forms of the description +### 1. One trait, with 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. +`EncryptTarget<S>` keeps `encryption()`. That method describes the +encryption over `&S`. This change adds `owned_encryption()`. That method +describes the encryption over `S` by value, in `Owned` source mode. -The signature map the plan asked for: +The default `owned_encryption` guards the value in `Protected`, runs the +borrowed description over it, and wipes it before the pending request is +returned. `EncryptInto` used to do exactly this for a scalar, by hand. So a +declaration that only composes operations does not need to implement +anything new. `EncryptInto` now runs `owned_encryption`, and the collection +declaration runs it for each element. -| Declaration | Over | Handed | Cleanup | +| Declaration | Plaintext it is given | It returns | Who wipes the plaintext | |---|---|---|---| -| `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 | +| `encryption()` | A borrowed `&S` | `Encryption<'s, S, T, K, Input>` | The caller, or the owner that guards the borrow | +| `owned_encryption()` | `S` by value | `Encryption<'s, S, T, K, Input, Owned>` | The declaration. The default guards the value and wipes it | +| `decryption(self, Input)` | The stored value | `Decryption<P, K>` | The engine until the call returns, then the caller | ### 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. - -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 -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` -that panics holds it to that. +A `struct = User` record that derives `EncryptTarget` composes the targets +of its fields directly. + +- **The borrowed form** takes each field out of the borrow, with + `Encryption::project`. +- **The owned form** divides the struct with a chain of `Encryption::split` + calls. `split` is a new combinator in `Owned` mode. It gives 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. + - 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 function that divides the struct is a plain function pointer that +destructures it. The function captures nothing, so it cannot reach a +cipher. It can only move values. The selector of `project` has the same +limits. So the code enforces the rule that a conversion wipes what it seals +and moves what the caller keeps in the clear. + +Each `split` runs the description of its first part while the other part +waits. `split` is given a `wipe` function for the waiting part. If the first +description panics, the waiting part is passed to `wipe` during the unwind, +and not dropped as it is. An owned column treats its unvisited elements the +same way. The derive writes each `wipe` to wipe the sealed fields that are +still waiting, and to leave the passthrough fields alone. + +- The plaintext of a sealed field already implements `Zeroize`, as every + `EncryptTarget` source does. So `wipe` adds no bound. +- A passthrough field is stored in the clear, so it needs no bound. + +`EncryptInto` keeps the whole plaintext in a `Protected` guard until the +declaration and its context exist. So a declaration that panics while it is +being built also wipes the plaintext. + +The destructure is exhaustive. The owned form does not guard the whole +value. So a plaintext field that the record neither seals nor moves would +be dropped without being wiped. Such a field is a compile error at the +`struct = ..` path instead. The decrypt side already refused a plaintext +that it could not rebuild. + +On decryption, the fields open through `Decryption::zip_wiping`. A field +recovered before a later field is refused is wiped, as the recovered +elements of a column are. + +A passthrough field is not encrypted and not authenticated (ADR-0010). Its +type must implement `Clone`, because the borrowed form copies it. Plans and +structural records run the borrowed form. The owned form never copies the +field, and a test with a `Clone` that panics checks that. ### 3. Caller context is a capability, composed through the tree -Two inputs join `NoCallerContext`, `RequiredContext<C>` and +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. +- **`ContextExtension<C>`.** If the caller leaves the context out, the + declared contexts stay as they are. If the caller passes a context, it + extends each of them. A `struct` record takes this input, to encrypt and + to decrypt. +- **`ExpectedContext<T>`**, an existing type that is now a `ContextInput`. + If the caller leaves the context out, the record opens under the context + it stores. If the caller passes a `NonEmpty<T>`, the stored context must + also equal it. A `context_field` record takes this input to decrypt. + +A `plaintext = T` record takes `RequiredContext<CallerContext>`, or +`RequiredContext<AeadContext>` with `context_type = AeadContext`. It takes +`RequiredContext<NonEmpty<T>>` when it stores its context. The core leaves +take the same wrappers around the context their operation needs. So a term +implements `EncryptTarget` alone, and no context is inferred from +`Default`. + +A record field is given its own context, extended by the caller's context, +through `Encryption::under` (ADR-0004). `under` requires the field's input +to implement `From<CallerContext>`. That one bound produces the error at the +field: + +- These inputs implement it: `OptionalContext`, a `RequiredContext` of a + core context, and a nested record's `ContextExtension`. +- These inputs do not: `NoCallerContext`, and `RequiredContext<u64>`. A + field whose target takes one of them is a compile error at that field. + +Every field of a `plaintext` record is given the one context the record +requires. So a term in a record with `context_type = AeadContext` is a +compile error at the term. A call that leaves out a required context is a +compile error at the call. + +### 4. The structural traits are a bridge, not a second declaration + +Each owned derive also implements the structural traits: + +- `EncryptTarget` also implements `EncryptFrom` and `Decryptable`. + `EncryptFrom` converts the record's structural context into its owned + input, at the root. +- `DecryptSource` also implements `DecryptInto` and `DecryptField`. + +The core leaves implement both pairs by hand. So an owned record can be a +field of a structural record, or a typed plan field, with no second derive. +The plan builder gains `target::<T, _>()`, for a target that implements +only the owned traits, such as `Ciphertext`. + +The dynamic lowering does not change. An equivalence test seals one record +three ways: through a hand-written `EncryptTarget`, through the derive, and +through the data plan. All three use one provider that records its +requests. The test finds the same descriptors and the same term bytes, and +each of the three decrypts the records of the other two. + +The structural derives stay, because the plans and the dynamic engine still +use them. Removing them is a later decision, once every consumer has an +owned equivalent. + +### 5. The structural `Vec` decrypts through the wiping path + +`Vec<T>: DecryptInto<Vec<P>>` and `DecryptField<Vec<P>, _>` used to decrypt +through `Decryption::all`. When an element failed late, the elements +recovered before it were dropped without being wiped. The impls now decrypt +through `all_wiping`, as the owned column does. + +That puts the bound `P: Zeroize` on those public impls. Every plaintext the +engine decrypts into already implements `Zeroize`. A structural caller with +a struct plaintext must derive it. + +## Options considered + +- **A `Zeroize` bound on the waiting part of `split`.** This would do the + same work as the `wipe` function. It was rejected because the bound would + also apply to the types of passthrough fields. Those fields are stored in + the clear, so their types have no reason to implement `Zeroize`. +- **A non-exhaustive destructure (`..`).** This would let the plaintext + type implement `Drop`, and so derive `ZeroizeOnDrop`. It was rejected + because a field that the record does not name would then be dropped + without being wiped. Nothing would report that field. +- **A second declaration for the structural traits.** This was rejected. + Two declarations of one record could disagree about contexts. The + equivalence test shows that one declaration, bridged, gives the same + bytes as the data plan. ## 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. + `email`, and moves `id`. Encrypting a `Vec<User>` into a + `Vec<SearchableUser>` makes one key request. `User::decrypt_from(record, + &cipher)` moves `id` back. +- A record's plaintext must implement `Zeroize`. The bound says who must + wipe the plaintext. It is not a promise about copies the caller keeps, or + temporaries the compiler makes. The limits in ADR-0010 still apply. + +### What is left to the caller + +These are deliberate limits. Each is documented on the type or method the +caller uses. + +- **A decrypted plaintext is not wiped when it is dropped.** The owned form + destructures the plaintext struct, and Rust does not allow that for a + type that implements `Drop`. So the plaintext of a `struct` record cannot + derive `ZeroizeOnDrop`. The caller must wrap the decrypted value in + `Protected`, or call `zeroize` on it. The derive docs and the crate + example show the wrapping. +- **A passthrough field is not encrypted and not authenticated.** Anyone + with access to the stored record can read it, change it or remove it, and + the sealed fields still decrypt. The attribute's name and its docs say so. + A value that must be readable and tamper-evident belongs in the caller's + context instead. +- **A `context_field` record decrypted with no expected context opens under + whatever context it stores.** The stored context is not authenticated, so + a ciphertext moved together with its stored context opens in its new + place. The caller must pass the context it expects to get the check. +- **`Encryption::project_owned` drops the other fields of the struct + without wiping them.** It suits a struct whose other fields contain + nothing secret. A record divides its struct with `split` instead. + +### What is not done yet + +- Configured targets, such as a JSON index with options, cannot yet be + fields of an owned record. The one configurable target's plaintext, + `serde_json::Value`, does not implement `Zeroize`, so it cannot be an + owned source. The attribute for a typed options factory waits for a + document plaintext that can be wiped. +- `Option<T>` and tuple targets stay structural-only in this change. diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index c0499bc54..b411f15fc 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -316,79 +316,123 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs 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. +//! A record is a struct that is encrypted field by field. You declare the +//! stored form of a plaintext struct as a second struct, and derive +//! `EncryptTarget` and `DecryptSource` on it. In +//! `#[stash(struct = User, context = "users")]`, `User` is the plaintext +//! type. The string `"users"` is the first part of every field's context. +//! +//! Each field of the record is one of these: +//! +//! - **A sealed field.** Its type is a target, such as a [`Ciphertext`], a +//! term or another record. It is derived from the plaintext field of the +//! same name, under the context `("users", "<field>")`. Use +//! `#[stash(from = ..)]` when the plaintext field has another name. +//! - **A passthrough field,** marked `#[stash(passthrough)]`. The plaintext +//! field is moved into the record as it is. A passthrough field is **not +//! encrypted and not authenticated**. Anyone with access to the stored +//! record can read it, change it, or remove it, and the sealed fields +//! still decrypt. Use it only for values that are not secret. Treat the +//! value you read back as untrusted input. Never let it decide +//! authorisation, tenancy, or which record to trust. A value that must be +//! readable and also tamper-evident belongs in the context instead, as the +//! row id below does. +//! +//! The record must name every field of the plaintext, as a sealed or a +//! passthrough field. A plaintext field it leaves out is a compile error. +//! +//! Encrypting a plaintext consumes it. The conversion wipes each field it +//! seals and moves each passthrough field, so no field is copied. This is +//! why the plaintext type must implement `Zeroize`. +//! +//! **A decrypted plaintext is not wiped when it is dropped.** The derive +//! moves the fields out of the plaintext struct, and Rust does not allow +//! that for a type that implements `Drop`. So the plaintext type cannot +//! derive `ZeroizeOnDrop`. +//! +//! Wrap a decrypted value in a [`Protected`] as soon as it is returned, as +//! the example does. Or call `zeroize` on it when you no longer need it. Do +//! not derive these traits on the plaintext +//! type either: +//! +//! - `Debug`, because it prints the plaintext. +//! - `Clone`, because each clone is another copy to wipe. +//! - `PartialEq`, because a derived comparison does not run in constant +//! time. +//! +//! By default, each field is sealed under its own context and nothing else. +//! Two records of the same type can then exchange a sealed field, and both +//! still decrypt. To prevent that, pass a context that names the row, such +//! as its id. That context extends every field's context +//! ([`ContextExtension`]), and the same context is needed to decrypt. //! //! ``` -//! # async fn example() -> Result<(), stack_encrypt::Error> { +//! # async fn example() -> Result<(), Box<dyn std::error::Error>> { //! use stack_encrypt::target::{DecryptSource, EncryptTarget}; -//! use stack_encrypt::{Ciphertext, DecryptFrom, EncryptInto, StackCipher}; +//! use stack_encrypt::{Ciphertext, Controlled, DecryptFrom, EncryptInto, Protected, StackCipher}; //! -//! #[derive(Debug, PartialEq, zeroize::Zeroize)] +//! // The plaintext derives `Zeroize` and nothing else. +//! #[derive(zeroize::Zeroize)] //! struct User { id: u64, name: String, email: String } //! //! #[derive(EncryptTarget, DecryptSource, serde::Serialize, serde::Deserialize)] //! #[stash(struct = User, context = "users")] //! struct EncryptedUser { +//! // Not encrypted and not authenticated. //! #[stash(passthrough)] //! id: u64, -//! name: Ciphertext, // under ("users", "name") -//! email: Ciphertext, // under ("users", "email") +//! name: Ciphertext, // sealed under ("users", "name") +//! email: Ciphertext, // sealed under ("users", "email") //! } //! +//! // With no `.kms(..)`, the cipher uses ZeroKMS. It reads credentials from +//! // the environment, or from the profile that `npx stash auth login` writes. //! let cipher = StackCipher::builder() -//! .kms(stack_encrypt::kms::FakeDataKeySource::new()).init().await?; +//! # .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); +//! let id = 42u64; +//! let user = User { id, name: "Alice".into(), email: "alice@example.com".into() }; +//! +//! // One call, and one batched key request for both sealed fields. The row +//! // id extends every field's context, so the record opens only as row 42. +//! let encrypted: EncryptedUser = user.encrypt_into_with_context(&keyset, id).await?; +//! let stored = serde_json::to_vec(&encrypted)?; +//! +//! let loaded: EncryptedUser = serde_json::from_slice(&stored)?; +//! let restored = Protected::new(User::decrypt_from_with_context(loaded, &cipher, id).await?); +//! assert_eq!(restored.risky_ref().id, id); +//! // `restored` wipes the plaintext when it is dropped. //! # 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. 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. +//! A record can also be derived from one whole value, with +//! `#[stash(plaintext = String)]`. Every field is then derived from that +//! value, under one context that the caller must supply +//! ([`target::RequiredContext`]). For example, a [`Ciphertext`] field and an +//! [`sem::EqualityTerm`] field make a value you can search for. A record of +//! this kind can be a field of a `struct` record, like any target. +//! +//! A `Vec` of plaintext structs encrypts into a `Vec` of records with one +//! batched key request. The derive crate's documentation lists every +//! attribute. The [`target`] module describes the traits a hand-written +//! target implements, and how the context of a record field is built. +//! +//! The older structural plans and derives (`#[derive(EncryptFrom, +//! DecryptInto)]`) encrypt a borrowed plaintext into a [`StackCipherText`]. +//! A `StackCipherText` is a tree of sealed leaves. The low-level entry +//! points of the structural API are [`KeysetCipher::encrypt_tree`] and +//! [`KeysetCipher::decrypt_tree`]. Its conversion helpers are in +//! [`target::legacy`]. The structural API is separate from the owned scalar +//! API. +//! +//! The owned derives also implement the +//! structural traits. So an owned record can be a field of a structural +//! record, or a typed field of a plan. There is no generic `Serialize` +//! fallback. //! //! # What is authenticated //! diff --git a/packages/stack-encrypt/src/plan/build.rs b/packages/stack-encrypt/src/plan/build.rs index 85754a6e6..fd41e01d7 100644 --- a/packages/stack-encrypt/src/plan/build.rs +++ b/packages/stack-encrypt/src/plan/build.rs @@ -973,32 +973,41 @@ 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. + /// Lay out the field `field` as the owned target `T` lays it out. `T`'s + /// own [`EncryptTarget<F>`] decides what is sealed and which terms are + /// stored beside it, all under `<context>/<identity>`. The record + /// contains a `T`. /// - /// 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. + /// This is the owned form of [`encrypt_into`](Self::encrypt_into), for a + /// target that implements only `EncryptTarget`. Examples are an opaque + /// [`Ciphertext`](crate::Ciphertext), or a record that derives + /// `EncryptTarget`. A derived record also works with `encrypt_into`, + /// because the derive implements the structural traits too. + /// + /// The field lowers to + /// `<T as EncryptTarget<F>>::encryption().accepting::<CallerContext>()`. + /// So the field's context reaches the target as its caller context, and + /// the target's `ContextInput` must take a `CallerContext`. A target + /// that accepts no caller context + /// ([`NoCallerContext`](crate::target::NoCallerContext)), or that + /// requires a context of another type, is a compile error at the call. + /// + /// The plan runs the target over a borrow of the value, as it does for + /// every field. So the value stays the caller's, and this method wipes + /// nothing. The owned conversions consume the value and wipe it. They are + /// in [`EncryptInto`](crate::EncryptInto). /// /// ``` /// # 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?; + /// // With no `.kms(..)`, the cipher uses ZeroKMS. + /// let cipher = StackCipher::builder() + /// # .kms(stack_encrypt::kms::FakeDataKeySource::new()) + /// .init() + /// .await?; /// let users_plan = Plan::context("users") /// .fields() /// .target::<Ciphertext, _>(pick("age", |u: &User| &u.age)) @@ -1007,7 +1016,7 @@ impl<S: 'static, K: 'static> FieldsBuilder<S, K> { /// 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. + /// // An ordinary opaque ciphertext, under the field's context. /// assert_eq!(cipher.default_keyset().decrypt_with_context::<u32>(age, ("users", "age")).await?, 34); /// # Ok(()) /// # } diff --git a/packages/stack-encrypt/src/scalar.rs b/packages/stack-encrypt/src/scalar.rs index b0ea17319..3e6116955 100644 --- a/packages/stack-encrypt/src/scalar.rs +++ b/packages/stack-encrypt/src/scalar.rs @@ -227,8 +227,8 @@ 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. +/// A ciphertext is what a record decrypts. As a field of a derived record, it +/// is the field that can be decrypted, beside any terms. impl crate::target::Decryptable for Ciphertext { const DECRYPTABLE: bool = true; } diff --git a/packages/stack-encrypt/src/target/collections.rs b/packages/stack-encrypt/src/target/collections.rs index 4663c4b14..a3c512caa 100644 --- a/packages/stack-encrypt/src/target/collections.rs +++ b/packages/stack-encrypt/src/target/collections.rs @@ -57,28 +57,28 @@ 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. + /// Each element is moved into the owned form of the element's own + /// declaration. So an element that moves part of itself into its output, + /// such as a record's passthrough fields, does so here too. Each element's + /// own declaration guards it and wipes it before the column's pending + /// request 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. + // Run every element, whatever happened to the elements before it. + // Each 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, because + // `collect` keeps the first failure. // - // 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. + // The elements not yet handed over stay in a guard. The guard + // wipes them if a declaration panics, as the guard over the whole + // vector did before. An element is taken out only once its + // description and context exist, so nothing that can panic runs + // while an element is unguarded. let mut unvisited = Unvisited(source.into_iter()); let mut elements = Vec::with_capacity(unvisited.0.len()); while !unvisited.0.as_slice().is_empty() { @@ -94,9 +94,10 @@ where } } -/// 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. +/// The elements of an owned column that are not yet handed to their +/// declarations. The guard is dropped when a declaration unwinds, or when +/// every element has been handed over. When it is dropped, it wipes the +/// elements that are left in it. struct Unvisited<S: Zeroize>(std::vec::IntoIter<S>); impl<S: Zeroize> Drop for Unvisited<S> { diff --git a/packages/stack-encrypt/src/target/conversion.rs b/packages/stack-encrypt/src/target/conversion.rs index daa150c32..861c8ba5e 100644 --- a/packages/stack-encrypt/src/target/conversion.rs +++ b/packages/stack-encrypt/src/target/conversion.rs @@ -8,39 +8,38 @@ use crate::KeysetCipher; use vitaminc_protected::{Controlled, Protected}; use zeroize::Zeroize; -/// Declare how to encrypt an owned plaintext into this target. +/// A declaration has two forms of one description. The library runs the form +/// that matches how the call passes the plaintext: /// -/// A declaration has two forms of one description, and the library runs -/// whichever the call supplies the plaintext in: +/// - [`encryption`](Self::encryption) is given the plaintext **by reference**. +/// A declaration composes this form when it takes a field out of a borrowed +/// struct ([`Encryption::project`]). A collection or a plan runs it for each +/// element. +/// - [`owned_encryption`](Self::owned_encryption) is given the plaintext **by +/// value**. [`EncryptInto`] runs this form. By default, it guards the value +/// with [`Protected`], runs the borrowed description over it, and wipes it +/// before the pending request is returned. The value is wiped even when +/// building or running the description fails. A record overrides this form, +/// field by field ([`Encryption::split`]). It moves the fields it stores in +/// the clear into its output, and guards the fields it seals. /// -/// - [`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. +/// Neither form is given the cipher and the plaintext together. The source +/// does not need to implement `Clone`. pub trait EncryptTarget<S: Zeroize>: Sized + 'static { /// What the caller must or may supply, independently of declared context. type ContextInput: ContextInput; - /// Describe the encryption over a borrowed plaintext; key I/O is deferred - /// to the returned pending. + /// Describe the encryption over a borrowed plaintext. Key requests wait + /// until the returned pending request runs. 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. + /// Describe the encryption over the plaintext by value. This form is + /// responsible for wiping what it does not move 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. + /// 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 does not need to implement + /// this method. fn owned_encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::ContextInput, Owned> where S: 's, @@ -52,10 +51,10 @@ pub trait EncryptTarget<S: Zeroize>: Sized + 'static { 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. + /// The indexes that this target's terms support, as data. A plan field + /// typed with this target lets a query use these indexes. The default is + /// none. That is correct for a ciphertext, and for a record whose fields + /// have contexts of their own. fn indexes() -> Vec<IndexSpec> { vec![] } @@ -77,11 +76,11 @@ pub trait EncryptInto: Zeroize + Sized { T: EncryptTarget<Self>, T::ContextInput: AllowsNoContext, { - // 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. + // The plaintext stays guarded while the declaration is built. It also + // stays guarded while the omitted context is initialised. Either step + // can panic, because a record's declaration builds its fields' + // declarations here. The plaintext must not then be dropped without + // being wiped. The declaration takes over the guard once both exist. let source = Protected::new(self); let description = T::owned_encryption(); let context = T::ContextInput::omitted(); @@ -98,10 +97,10 @@ pub trait EncryptInto: Zeroize + Sized { T: EncryptTarget<Self>, T::ContextInput: AcceptsContext, { - // 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. + // The plaintext stays guarded while the caller's context is converted. + // It also stays guarded while the declaration is built. Either step + // can panic. If one does, the plaintext must not be dropped without being + // wiped. The declaration takes over the guard once both exist. let source = Protected::new(self); let context = T::ContextInput::supplied(context.into()); let description = T::owned_encryption(); @@ -112,8 +111,12 @@ impl<S: Zeroize> EncryptInto for S {} /// Construct owned plaintext from a declared encrypted source. /// -/// Successful return transfers cleanup responsibility to the caller: a -/// `Zeroize` bound does not make ordinary returned values wipe themselves on drop. +/// **The caller must wipe the returned plaintext.** A `Zeroize` bound does not +/// make a returned value wipe itself when it is dropped. The plaintext of a +/// derived `struct = ..` record cannot implement `ZeroizeOnDrop` at all. +/// Wrap the value in +/// [`Protected`] as soon as it is returned, or call `zeroize` on it when you +/// no longer need it. pub trait DecryptFrom: Zeroize + Sized + 'static { /// Decrypt with explicitly omitted caller context. fn decrypt_from<'a, S, K: 'static>( diff --git a/packages/stack-encrypt/src/target/index.rs b/packages/stack-encrypt/src/target/index.rs index b33cf1850..a03beab5b 100644 --- a/packages/stack-encrypt/src/target/index.rs +++ b/packages/stack-encrypt/src/target/index.rs @@ -618,7 +618,7 @@ 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. +/// under the one caller context that the record field or the caller supplies. impl<S, Terms> EncryptTarget<S> for Encrypted<Terms> where S: crate::Encrypt + Clone + zeroize::Zeroize, diff --git a/packages/stack-encrypt/src/target/input.rs b/packages/stack-encrypt/src/target/input.rs index ab3a9614d..6dcb869e3 100644 --- a/packages/stack-encrypt/src/target/input.rs +++ b/packages/stack-encrypt/src/target/input.rs @@ -1,16 +1,17 @@ //! Explicit caller-context capabilities. `Default` never grants permission to omit context. //! -//! | Input | Omitted | Supplied | Meaning | +//! | Input | Context left out | Context passed | What the input means | //! |---|---|---|---| -//! | [`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 | +//! | [`NoCallerContext`] | Allowed | Refused | The declaration builds every context itself. | +//! | [`RequiredContext<C>`] | Refused | `C` | The caller supplies the context, or the part of it that the declaration selects. | +//! | [`OptionalContext<C>`] | Allowed | `C` | For an opaque scalar. A context left out is the empty context. | +//! | [`ContextExtension<C>`] | Allowed | `C` | A context left out keeps the declared contexts. A context passed extends each of them. | +//! | [`ExpectedContext<T>`](super::ExpectedContext) | Allowed | `NonEmpty<T>` | A context left out opens under the stored context, unchecked. A context passed is checked against the stored one. | //! -//! 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. +//! A trait bound decides whether a method is available ([`AllowsNoContext`] +//! or [`AcceptsContext`]). It is never inferred from `C: Default`. For +//! example, `RequiredContext<u64>` stays required, although `u64` has a +//! default of zero. use super::context::{CallerContext, DeclaredContext, ExpectedContext}; use crate::{ContextPiece, IntoContext, NonEmpty}; @@ -88,15 +89,19 @@ impl<C> AcceptsContext for OptionalContext<C> { } } -/// Caller context may extend the contexts a declaration carries itself. +/// A caller context that can extend the contexts a declaration builds 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. +/// A derived record whose fields each have their own context, such as +/// `("users", "name")`, takes this input. +/// +/// - If the caller leaves the context out, the declared contexts stay as they +/// are. Nothing is appended, not even an empty part. +/// - If the caller passes a context, it extends every declared context. For +/// example, `7u64` makes the field's context `(("users", "name"), 7u64)`. +/// +/// A record sealed under an extension needs the same extension to decrypt. The +/// method that leaves the context out is available, but that does not mean it +/// opens every stored record of the type. #[derive(Clone, Debug)] pub struct ContextExtension<C>(Option<C>); impl<C> ContextExtension<C> { @@ -118,15 +123,16 @@ impl<C> AcceptsContext for ContextExtension<C> { 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. +// The context a record's declaration tree runs under: each field's own +// context, extended by the caller's context if there is one. 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. +// A structural caller with a `DeclaredContext`, such as a plan or a `struct = +// ..` derive, passes its extension to an owned record field through this +// conversion. impl From<DeclaredContext> for ContextExtension<DeclaredContext> { fn from(extension: DeclaredContext) -> Self { if extension.is_extended() { @@ -136,31 +142,32 @@ impl From<DeclaredContext> for ContextExtension<DeclaredContext> { } } } -// A record field is handed its own pair extended by the caller's context -// (`Encryption::under`): a nested record takes that as its extension. +// A record field is given its own context, 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. +// A field whose target requires a caller context is given the record's context +// for that field, extended by the caller's context. So a +// `RequiredContext<CallerContext>` field or a `RequiredContext<AeadContext>` +// field can be a field of 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. +// A record that derives terms gives its ciphertext fields the AEAD part 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. +// `NonEmpty<T>` it stores. It runs its fields under the caller context built +// from that value. 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()) @@ -173,24 +180,25 @@ impl<'c, T: IntoContext<'c>> From<RequiredContext<NonEmpty<T>>> 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. +// A record that stores its context takes the `NonEmpty<T>` it stores from its +// structural trait 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. +// The contexts of the structural declarations, wrapped as the required input +// of the owned leaf that replaces each one. A plan or a structural record +// gives an owned field its context this way. 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>`. +// The core operations take the context the input contains, with no wrapper of +// their own. For example, `ciphertext()` takes a +// `RequiredContext<AeadContext>`, and a term takes a +// `RequiredContext<CallerContext>`. impl<C: Into<super::AeadContext>> From<RequiredContext<C>> for super::AeadContext { fn from(context: RequiredContext<C>) -> Self { context.into_inner().into() @@ -202,12 +210,18 @@ impl<C: Into<CallerContext>> From<RequiredContext<C>> for CallerContext { } } -/// A record that stores its context accepts an expectation of it, or none. +/// The input of a record that stores its context: the context the caller +/// expects, or none. +/// +/// - If the caller passes a `NonEmpty<T>`, the stored context must equal it. A +/// different stored context is refused before any key is retrieved +/// ([`ExpectedContext::validate`]). +/// - If the caller leaves the context out, the record opens under whatever +/// context it stores, after a check that the context is not empty. /// -/// 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`]). +/// The stored context is not authenticated. A ciphertext moved together with +/// its stored context opens in its new place, unless the caller passes the +/// context it expects. impl<T> sealed::Sealed for ExpectedContext<T> {} impl<T> ContextInput for ExpectedContext<T> {} impl<T> AllowsNoContext for ExpectedContext<T> { diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 8581b4e1b..3589f2008 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -12,54 +12,73 @@ //! //! # The owned declarations //! -//! | Trait | Implemented on | Methods | Source | +//! | Trait | Implemented on | Methods | Plaintext | //! |---|---|---|---| -//! | [`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. +//! | [`EncryptTarget<S>`] | An encrypted output or a term | `encryption` (over `&S`), `owned_encryption` (over `S` by value, which by default guards the value and wipes it), and `indexes` | `S: Zeroize` | +//! | [`DecryptSource<P>`] | An encrypted output that can be decrypted | `decryption(self, ContextInput)` | Decrypts to `P: Zeroize` | +//! | [`EncryptInto`] | Every `Zeroize` plaintext | `encrypt_into` and `encrypt_into_with_context` | Consumed and wiped | +//! | [`DecryptFrom`] | Every `Zeroize` plaintext | `decrypt_from` and `decrypt_from_with_context` | Returned to the caller, who must wipe it | +//! +//! The core leaves implement both owned traits, with these context inputs: +//! +//! - [`Ciphertext`](crate::Ciphertext) takes an [`OptionalContext`]. The +//! empty context is a valid context. +//! - [`StackCipherText`](crate::StackCipherText) and [`Encrypted<Terms>`] +//! require an [`AeadContext`] to decrypt. +//! - The terms require a [`CallerContext`] to derive. The terms are +//! [`EqualityTerm`](crate::sem::EqualityTerm), +//! [`MatchTerms`](crate::sem::MatchTerms), and the order terms. +//! +//! Each required context is a [`RequiredContext`]. A term implements +//! [`EncryptTarget`] alone, because a term cannot be decrypted. //! //! # 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. +//! `#[derive(EncryptTarget, DecryptSource)]` composes the declarations of +//! the fields. +//! +//! A `struct = User, context = "users"` record gives each field its context, +//! `("users", "<field>")`, through [`Encryption::under`]. If the caller +//! passes a [`ContextExtension`], it extends each field's context. So the +//! field target's `ContextInput` must implement `From<CallerContext>`: +//! +//! - These inputs implement it: [`OptionalContext`], a [`RequiredContext`] +//! of a core context, and the [`ContextExtension`] of a nested record. +//! - These inputs do not: [`NoCallerContext`] and `RequiredContext<u64>`. A +//! field whose target takes one of them is a compile error at that field. +//! +//! The borrowed `encryption` of a `struct` record takes each field out of a +//! borrow ([`Encryption::project`]), and clones each passthrough field. Its +//! `owned_encryption` divides the struct between the fields +//! ([`Encryption::split`]). So each `#[stash(passthrough)]` field is moved +//! into the record. Each sealed field is guarded by the owned form of its +//! own target. +//! +//! Decryption opens each field under the same context, and moves the +//! passthrough fields back. A passthrough field is not encrypted and not +//! authenticated. +//! +//! A `plaintext = T` record runs every field over the whole value, under +//! the one context the caller supplies. That context is a +//! [`RequiredContext<CallerContext>`], or a `RequiredContext<AeadContext>` +//! with `context_type = AeadContext`. A `#[stash(context_field)]` record +//! requires the `NonEmpty<T>` it stores, and opens under an +//! [`ExpectedContext`]. +//! +//! Both derives also implement the structural traits ([`EncryptFrom`], +//! [`DecryptInto`] and [`DecryptField`]). So an owned record can be a field +//! of a structural record, or a typed field of a plan. A plan field uses +//! [`FieldsBuilder::encrypt_into`](crate::plan::FieldsBuilder::encrypt_into), +//! or [`target`](crate::plan::FieldsBuilder::target) for a target that is +//! only owned, such as a `Ciphertext`. A hand-written owned target that will +//! be a record field must implement [`Decryptable`] and [`DecryptField`], as +//! well as [`DecryptSource`]. A hand-written structural target does the +//! same. //! //! # Structural API during migration //! -//! Existing structural derives, plans and dynamic targets still use the declarations -//! below. Their borrowed convenience methods are isolated in [`legacy`]. +//! The 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 //! associated context type. It does not receive plaintext or a cipher. The @@ -196,6 +215,7 @@ pub use input::{ 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. +/// The cleanup trait that every owned plaintext must implement. It is +/// re-exported here so that the derives can name it, even when the caller's +/// crate does not depend on `zeroize`. pub use zeroize::Zeroize; diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index 896b7bd29..751107eab 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -393,39 +393,49 @@ 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`. + /// [`project`](Self::project) for a plaintext passed by value. It turns a + /// description of one field into a description of the struct that contains + /// the field. `select` moves that field out of the struct. /// - /// 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. + /// **The other fields of the struct are dropped without being wiped.** Use + /// `project_owned` only for a struct whose other fields contain nothing + /// secret. To encrypt a struct with more than one field, divide it with + /// [`split`](Self::split). `split` passes every field on to a description. + /// + /// `select` is a plain function pointer, as the selector of `project` is. + /// The function captures nothing, so it cannot reach a cipher. It can only + /// move values. 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 a plaintext passed by value between two descriptions, under the + /// one context this description is given. The key requests of both + /// descriptions are made in one batch. This is the owned form of + /// [`zip`](Self::zip), for two descriptions over two *different* parts of + /// one value. + /// + /// `divide` is a plain function pointer. It destructures `P` into the part + /// that `self` takes and the part that `other` takes. A record's + /// `owned_encryption` divides the struct into its fields this way, with + /// one `split` for each field. + /// + /// So a passthrough field is moved into the output and never copied. + /// Each sealed field is guarded by its own + /// target's description. Nothing is cloned, so `P` does not need to + /// implement `Clone`. /// - /// `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`. + /// `self` runs first, while the part for `other` waits. If the description + /// of `self` panics before `other` receives its part, that part is passed + /// to `wipe` during the unwind. Without `wipe`, the part would be dropped + /// as it is. /// - /// `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. + /// `wipe` must wipe what `other` seals, and leave what `other` stores in + /// the clear. So a part with only passthrough fields takes `|_| {}`. A + /// part that is not sealed needs no `Zeroize` bound. Once `other` has its + /// part, `other` is responsible for wiping it, 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>, @@ -451,16 +461,17 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx, Ow } /// 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`. +/// description of the other part runs. If it is dropped before +/// [`hand_over`](Self::hand_over), because that description unwound, it passes +/// 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. + /// The first description returned, so the part goes to its own + /// description. fn hand_over(mut self) -> U { self.part .take() @@ -899,12 +910,12 @@ 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. + /// [`zip`](Self::zip) for recovered plaintext. The right side can fail + /// after its key arrives, or panic in its conversion. The plaintext that + /// the left side recovered is then wiped, not dropped as it is. A record + /// that derives `DecryptSource` opens its fields through this method. So a + /// field opened before a later field is refused is wiped when the refusal + /// is returned. pub fn zip_wiping<U: 'static>(self, other: Decryption<U, K>) -> Decryption<(T, U), K> where T: zeroize::Zeroize, @@ -1311,12 +1322,14 @@ 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. +// The owned declarations of the same leaves. Each one requires the context its +// operation needs, as the caller's required input: a ciphertext needs an +// `AeadContext`, and a term needs a `CallerContext`. A call cannot leave the +// context out, because a structural leaf takes no empty context. `Default` +// does not change that. +// +// The borrowed form clones a consumed plaintext, as the structural declaration +// does. The owned form's default guards the value and wipes it 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> @@ -1423,10 +1436,10 @@ where } } } -// 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. +// Opened through `all_wiping`, as the owned `Vec<T>` is. If an element fails +// after its key arrives, the column fails, and the elements recovered before +// it are wiped. That is why `P` must implement `Zeroize`. Every plaintext the +// engine decrypts into already implements it. impl<P: zeroize::Zeroize + 'static, T: DecryptInto<P> + 'static> DecryptInto<Vec<P>> for Vec<T> where T::Context: Clone + 'static, diff --git a/packages/stack-encrypt/src/target/pending.rs b/packages/stack-encrypt/src/target/pending.rs index d9d97bc25..6b2867b45 100644 --- a/packages/stack-encrypt/src/target/pending.rs +++ b/packages/stack-encrypt/src/target/pending.rs @@ -301,11 +301,13 @@ impl<'a, T: 'a, K> Pending<'a, T, 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. + /// [`zip`](Self::zip) for recovered plaintext. The right side can fail at + /// fulfilment, with an authentication or type failure after its key + /// arrives. It can also panic in its conversion. + /// + /// In both cases, the left side's recovered value is wiped before the + /// error is returned or the panic unwinds. It is not dropped as it is. A + /// record's fields open through this method. pub(crate) fn zip_wiping<U: 'a>(self, other: Pending<'a, U, K>) -> Pending<'a, (T, U), K> where T: zeroize::Zeroize, @@ -313,9 +315,9 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { 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. + /// The builder behind every pair. `release` is given the left side's + /// output when the right side fails or panics after it. It is not called + /// when both sides succeed. fn zip_releasing<U: 'a>( self, other: Pending<'a, U, K>, @@ -341,8 +343,9 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { requests, failed: None, 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. + // The left side's output is held in a guard. So if the right + // side fails or panics, the guard releases the left side's + // output, as a collection does. let held = Held { item: Some(first(responses)?), release: Some(release), @@ -470,16 +473,17 @@ 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`. +/// The left side of a pair, recovered before the right side is fulfilled. If +/// it is dropped before [`keep`](Self::keep), by an early return or an unwind, +/// it passes 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. + /// The right side succeeded. So the value is the output. It is not + /// released. fn keep(mut self) -> T { self.release = None; self.item diff --git a/packages/stack-encrypt/tests/collections.rs b/packages/stack-encrypt/tests/collections.rs index 065ba516f..d798eba2c 100644 --- a/packages/stack-encrypt/tests/collections.rs +++ b/packages/stack-encrypt/tests/collections.rs @@ -528,8 +528,8 @@ fn a_panicking_element_conversion_wipes_the_plaintext_recovered_before_it() { // --- 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. +/// The same column, declared the structural way (`DecryptInto`). So the test +/// covers the structural `Vec<T>: DecryptInto<Vec<P>>` path. impl stack_encrypt::DecryptInto<Tracked> for TrackedCiphertext { type Context = stack_encrypt::target::AeadContext; fn decryption<K: 'static>(self, context: Self::Context) -> Decryption<Tracked, K> { @@ -549,8 +549,9 @@ async fn a_late_element_failure_wipes_the_recovered_prefix_on_the_structural_pat .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. + // This element is sealed under another context. It fails + // authentication only once its key arrives, and by then the first + // element has been recovered. let second = keyset .encrypt_with_context("never recovered".to_owned(), "elsewhere") .await @@ -560,8 +561,8 @@ async fn a_late_element_failure_wipes_the_recovered_prefix_on_the_structural_pat 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 + // `Tracked::drop` fails the test if the recovered elements are dropped + // without being wiped. The structural path did that before it decrypted // through the wiping collection. let result = cipher .decrypt_as::<Vec<Tracked>, _>(column, stack_encrypt::target::AeadContext::from(context)) @@ -580,7 +581,8 @@ async fn a_late_element_failure_wipes_the_recovered_prefix_on_the_structural_pat /// Seals a `Flagged`, and panics while declaring the element whose /// plaintext is `"panics"`. -// The ciphertext is never read: the declaration panics before one exists. +// The declaration panics before a ciphertext exists, so the ciphertext is +// never read. struct PanickingTarget(#[allow(dead_code)] Ciphertext); impl EncryptTarget<Flagged> for PanickingTarget { type ContextInput = OptionalContext<ScalarContext>; @@ -631,12 +633,12 @@ impl<Ctx: Into<stack_encrypt::target::AeadContext>> } struct Rows { - // Never read: the column fails, so no `Rows` is built. + // The column fails, so no `Rows` is built. This field is never read. #[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`. +/// A column as a field. The structural `Vec<T>: DecryptField<Vec<P>, _>` impl +/// decrypts `items`. #[derive(stack_encrypt::DecryptInto)] #[stash(struct = Rows, context = "rows")] struct StoredRows { @@ -651,8 +653,9 @@ async fn a_late_element_failure_in_a_column_field_wipes_the_recovered_prefix() { .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. + // This element is sealed under another context. It fails + // authentication only once its key arrives, and by then the first + // element has been recovered. let second = keyset .encrypt_with_context("never recovered".to_owned(), "elsewhere") .await @@ -664,8 +667,8 @@ async fn a_late_element_failure_in_a_column_field_wipes_the_recovered_prefix() { ], }; let wiped: Vec<_> = stored.items.iter().map(|t| t.wiped.clone()).collect(); - // `Tracked::drop` fails the test if the recovered prefix is dropped - // unwiped. + // `Tracked::drop` fails the test if the recovered elements are dropped + // without being wiped. let result = cipher .decrypt_as::<Rows, _>(stored, stack_encrypt::target::DeclaredContext::default()) .await; diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index 6e278e402..6c1bfbb2b 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -485,8 +485,8 @@ 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 -// `Zeroize`: a column of these opens through the wiping collection path, -// which wipes the rows recovered before a late failure. +// `Zeroize` is required: a column of these decrypts through the wiping +// collection path. That path wipes the rows recovered before a late failure. #[derive(Debug, Clone, PartialEq, Eq, zeroize::Zeroize)] struct User { age: u32, diff --git a/packages/stack-encrypt/tests/owned_equivalence.rs b/packages/stack-encrypt/tests/owned_equivalence.rs index bd6c491a3..b1bd88ec3 100644 --- a/packages/stack-encrypt/tests/owned_equivalence.rs +++ b/packages/stack-encrypt/tests/owned_equivalence.rs @@ -1,10 +1,15 @@ -//! 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. +//! One record, sealed three ways, by one engine. A hand-written +//! `EncryptTarget` (typed), `#[derive(EncryptTarget, DecryptSource)]` +//! (generated), and the data-plan lowering that a binding sends (dynamic) each +//! seal the same mixed, searchable record. All three use the same in-memory +//! provider. The tests check three things: +//! +//! - The descriptors sent to the key provider are the same. +//! - The equality terms are the same, byte for byte. +//! - Each way decrypts what the others sealed, so the associated data is the +//! same too. +//! +//! Ciphertext bytes are random, so the tests do not compare them. #![cfg(feature = "dynamic")] mod common; @@ -133,8 +138,8 @@ fn dynamic_source(user: &User) -> ValueFrame { ]) } -/// The three outputs in one shape: the stored tree the dynamic path writes -/// and reads, so one comparison and one opener serve all three. +/// The three outputs, in one shape: the stored tree that the dynamic path +/// writes and reads. So one comparison and one decryption serve all three. struct Stored { id: u32, name: SealedValue, @@ -269,17 +274,18 @@ async fn typed_generated_and_dynamic_declarations_are_one_engine() { 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. + // Each way seals the record. The passthrough field requests no key. The + // two sealed fields request one key each, under the field's context. The + // equality term is derived 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. + // Within a call, the fields are in the declaration's order. The data plan + // uses the key order of the frame instead. So the test compares each call + // as a set. let sorted = |call: &Vec<String>| { let mut call = call.clone(); call.sort(); @@ -299,15 +305,16 @@ async fn typed_generated_and_dynamic_declarations_are_one_engine() { 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. + // The terms are the same bytes. So a query term built any of the three + // ways finds the rows that each way 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. + // Each way decrypts the records of every way: the same context gives the + // same associated data. for stored in [&typed, &generated, &dynamic] { assert_eq!( User::decrypt_from(stored.typed(), &cipher).await.unwrap(), @@ -321,7 +328,7 @@ async fn typed_generated_and_dynamic_declarations_are_one_engine() { ); assert_eq!(open_dynamic(&cipher, stored).await, user); } - // And every open retrieved under the same descriptors. + // Every decryption also retrieved its keys under the same descriptors. let retrieved: Vec<_> = sent.lock().unwrap().retrieve.iter().map(sorted).collect(); assert_eq!(retrieved.len(), 9); assert!(retrieved diff --git a/packages/stack-encrypt/tests/owned_records.rs b/packages/stack-encrypt/tests/owned_records.rs index 8c6b236ea..968d8633c 100644 --- a/packages/stack-encrypt/tests/owned_records.rs +++ b/packages/stack-encrypt/tests/owned_records.rs @@ -1,8 +1,13 @@ -//! 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. +//! Tests of owned record declarations: `#[derive(EncryptTarget, +//! DecryptSource)]` over a plaintext struct. The tests cover passthrough +//! fields, nested records, renamed source fields, a stored context, and one +//! batch for each collection. The mixed-record and searchable-record programs +//! from the public docs run here against the in-memory key source, so no +//! service credentials are needed. +//! +//! The plaintext types in these tests derive `Debug`, `PartialEq`, and `Clone` +//! so that the tests can compare results. The public examples do not, because +//! those traits print, compare, and copy plaintext. use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -29,7 +34,7 @@ struct User { created_at: i64, } -/// Plaintext `id` and `created_at` carried through; `name` and `email` +/// `id` and `created_at` are stored in the clear. `name` and `email` are each /// sealed on their own, under `("users", "name")` and `("users", "email")`. #[derive(EncryptTarget, DecryptSource, Serialize, Deserialize)] #[stash(struct = User, context = "users")] @@ -68,12 +73,13 @@ async fn a_mixed_record_round_trips_through_serde_with_its_passthrough_fields() 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. + // One conversion makes one batched key request, with one data key for each + // 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. + // Store the typed output and read it back. Plaintext fields are stored as + // themselves, and 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); @@ -104,7 +110,8 @@ async fn each_sealed_field_is_an_ordinary_ciphertext_under_its_pair() { .unwrap(), "alice@example.com" ); - // The pair is the context: a field moved to the other column is refused. + // The field's context is part of the ciphertext's associated data, so a + // field moved to the other column is refused. assert!(matches!( keyset .decrypt_with_context::<String>(encrypted.name, ("users", "email")) @@ -127,7 +134,8 @@ async fn a_caller_extension_binds_every_sealed_field_to_the_row() { sent.lock().unwrap().generated(), vec!["(users/name)/42u64", "(users/email)/42u64"] ); - // The same extension opens it; another row's id, or none, does not. + // The same extension decrypts the record. Another row's id does not. No + // extension does not either. assert!(matches!( User::decrypt_from( EncryptedUser { @@ -165,9 +173,9 @@ async fn a_caller_extension_binds_every_sealed_field_to_the_row() { // --- 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. +/// A passthrough field type whose `Clone` fails the test. The borrowed form of +/// the record would copy it. The owned conversion must move it into the output +/// and back instead. #[derive(Debug, PartialEq, Zeroize)] struct Handle(u64); impl Clone for Handle { @@ -176,8 +184,8 @@ impl Clone for Handle { } } -/// A sealed field type that records being wiped and refuses to be dropped -/// otherwise. +/// A sealed field type that records when it is wiped, and fails the test if it +/// is dropped without being wiped. struct Secret { value: String, wiped: Arc<AtomicBool>, @@ -211,9 +219,10 @@ impl DecryptSource<Secret> for SecretCiphertext { }) } } -// 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. +// A hand-written target used as a record field must declare two things. It +// must declare that it contains ciphertext that can be decrypted. It must also +// declare how it decrypts under the context the record gives it. A +// hand-written structural target declares the same. impl Decryptable for SecretCiphertext { const DECRYPTABLE: bool = true; } @@ -263,7 +272,8 @@ async fn the_owned_record_wipes_sealed_fields_and_moves_passthrough_fields() { 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`. + // The caller owns the recovered plaintext. Wipe it, or `Secret::drop` + // fails the test. let mut opened = opened; opened.secret.zeroize(); } @@ -316,9 +326,10 @@ async fn a_field_opened_before_a_later_field_is_refused_is_wiped() { .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. + // `second` contains the ciphertext sealed under the context of `first`. So + // `second` is refused only once its key arrives, after `first` has been + // decrypted. `Secret::drop` fails the test if `first` is then dropped + // without being wiped. let moved = EncryptedPair { second: SecretCiphertext(encrypted.first.0.clone()), first: encrypted.first, @@ -330,8 +341,8 @@ async fn a_field_opened_before_a_later_field_is_refused_is_wiped() { // --- 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. +// A sealed field type with no drop check of its own. A value dropped without +// being wiped during an unwind is detected through the flag, not as an abort. struct Flagged { value: String, wiped: Arc<AtomicBool>, @@ -351,10 +362,10 @@ fn flagged(value: &str) -> (Flagged, Arc<AtomicBool>) { (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. +/// Seals a `Flagged`, and panics while declaring the `Flagged` whose plaintext +/// is `"panics"`. The panic happens inside the default owned guard, while the +/// field's description runs. +// These tests only encrypt, so the ciphertext is never read. struct FlaggedCiphertext(#[allow(dead_code)] Ciphertext); impl EncryptTarget<Flagged> for FlaggedCiphertext { type ContextInput = OptionalContext<ScalarContext>; @@ -379,7 +390,8 @@ struct Trio { third: Flagged, fourth: Flagged, } -// Only encrypted, and never completed: its fields are never read. +// This record is only encrypted, and the encryption never completes, so its +// fields are never read. #[allow(dead_code)] #[derive(EncryptTarget)] #[stash(struct = Trio, context = "trios")] @@ -395,8 +407,9 @@ struct EncryptedTrio { 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). + // This covers the outer `split`, where the first field panics and the + // other fields wait as one part. It also covers a nested `split`, where + // the third field panics and 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")); @@ -426,8 +439,8 @@ async fn a_panicking_field_declaration_wipes_the_sealed_fields_after_it() { } } -/// Seals a `Flagged` through a declaration whose construction panics: what -/// `encrypt_into` is holding then is the plaintext alone. +/// Seals a `Flagged` through a declaration that panics while it is built. At +/// that point, `encrypt_into` is holding only the plaintext. struct UnconstructedCiphertext(#[allow(dead_code)] Ciphertext); impl EncryptTarget<Flagged> for UnconstructedCiphertext { type ContextInput = OptionalContext<ScalarContext>; @@ -473,8 +486,8 @@ async fn a_declaration_that_panics_while_constructed_wipes_the_plaintext() { // --- 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. +/// A scalar target that the application defines: the ciphertext and an +/// equality term, under the one context the record or the caller supplies. #[derive(EncryptTarget, DecryptSource)] #[stash(plaintext = String)] struct SearchableEmail { @@ -499,21 +512,22 @@ async fn a_searchable_column_is_one_batch_and_its_terms_match_a_query_term() { 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. + // One collection conversion makes one batched key request for both users: + // two keys for the names and two keys for the email addresses. 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. + // Later, another cipher builds a query under the same keyset, field + // context, and operation. This is how a query 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. + // A scan of the stored rows in memory takes the place of a database index. let matches: Vec<SearchableUser> = encrypted .into_iter() .filter(|row| row.email.equality == query) @@ -521,7 +535,7 @@ async fn a_searchable_column_is_one_batch_and_its_terms_match_a_query_term() { assert_eq!(matches.len(), 1); assert_eq!(matches[0].id, 42); - // The matched rows open in one batch. + // The matching rows are decrypted 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); @@ -575,7 +589,7 @@ async fn a_searchable_email_standalone_requires_its_context() { .encrypt_into_with_context(&keyset, nonempty!("users/email")) .await .unwrap(); - // The record's indexes are its terms'. + // The record's indexes are the indexes of its terms. assert_eq!( <SearchableEmail as EncryptTarget<String>>::indexes(), vec![stack_encrypt::IndexSpec::Equality] @@ -612,17 +626,18 @@ struct Account { #[derive(EncryptTarget, DecryptSource)] #[stash(struct = Account, context = "accounts")] struct EncryptedAccount { - /// A passthrough under another name. + /// A passthrough field with another name. #[stash(passthrough, from = account_id)] id: u64, - /// A sealed field under another name, keyed under the plaintext field. + /// A sealed field with another name. Its context uses the name of the + /// plaintext field. #[stash(from = holder)] owner: Ciphertext, - /// A sealed field keyed under a pinned segment. + /// A sealed field whose context uses a fixed segment. #[stash(from = opened, identity = "opened_at")] opened: Ciphertext, - /// A nested record: its fields sit under their own pairs, extended by - /// this one. + /// A nested record. Its fields have their own contexts, extended by the + /// context of this field. address: EncryptedAddress, } @@ -684,14 +699,16 @@ async fn a_stored_context_opens_as_stored_and_checks_an_expectation() { .unwrap(); assert_eq!(encrypted.hm, hm); - // Omitted: opens under the stored identifier. + // With no expected context, the record decrypts 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. + // With an expected context, the stored identifier must equal it. The check + // runs before any key request. let moved = TextEq { identifier: "users/name", c: encrypted.c.clone(), @@ -713,8 +730,8 @@ async fn a_stored_context_opens_as_stored_and_checks_an_expectation() { // --- 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. +/// The hand-written composite in `target.rs`, as an owned record. Its fields +/// are the structural leaves. They take the same required context. #[derive(EncryptTarget, DecryptSource)] #[stash(plaintext = u32)] struct EncryptedAge { @@ -723,8 +740,8 @@ struct EncryptedAge { 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`). +/// A record made only of ciphertexts accepts an AEAD-only context. A term +/// beside them would be a compile error at its field (`tests/ui`). #[derive(EncryptTarget, DecryptSource)] #[stash(plaintext = String, context_type = AeadContext)] struct SealedName { @@ -741,7 +758,7 @@ struct Indexed { email: Encrypted<(EqualityTerm, MatchTerms)>, } -/// Generic over the plaintext: whatever every field accepts. +/// Generic over the plaintext: any plaintext that every field accepts. #[derive(EncryptTarget, DecryptSource)] struct SearchableText { c: Ciphertext, @@ -827,9 +844,9 @@ async fn plaintext_records_compose_structural_and_opaque_leaves() { // --- 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. +/// A target that supplies every context itself can be a field of a `plaintext` +/// record only if that record also takes no context. Here it is used on its +/// own, and in a column. struct FixedInteger(Ciphertext); impl EncryptTarget<i32> for FixedInteger { type ContextInput = NoCallerContext; @@ -861,8 +878,8 @@ async fn an_owned_record_is_a_field_of_a_structural_record_and_a_plan() { 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`. + /// A structural record with an owned record as a field. The owned derive + /// also implements `EncryptFrom` with a `DeclaredContext`, so this works. #[derive(EncryptFrom)] #[stash(struct = Profile, context = "profiles")] struct EncryptedProfile { @@ -888,7 +905,8 @@ async fn an_owned_record_is_a_field_of_a_structural_record_and_a_plan() { ] ); - // And as a typed field of a plan, through the same bridge. + // The owned record also works as a typed field of a plan, for the same + // reason. let plan = Plan::context("profiles") .fields::<Profile, _>() .encrypt_into::<EncryptedUser, _>(pick("user", |p: &Profile| &p.user)) 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 index 5bf9d707c..81ee1c2d2 100644 --- a/packages/stack-encrypt/tests/ui/owned_aead_record_with_term.rs +++ b/packages/stack-encrypt/tests/ui/owned_aead_record_with_term.rs @@ -1,6 +1,6 @@ -//! 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. +//! An owned record with `plaintext` and `context_type = AeadContext` requires +//! an AEAD-only context. A term cannot be derived under that context, so the +//! term field is a compile error where it is declared. use stack_encrypt::sem::EqualityTerm; use stack_encrypt::target::{AeadContext, EncryptTarget}; use stack_encrypt::Ciphertext; diff --git a/packages/stack-encrypt/tests/ui/owned_record_field_context.rs b/packages/stack-encrypt/tests/ui/owned_record_field_context.rs index ed2b7b199..b61657128 100644 --- a/packages/stack-encrypt/tests/ui/owned_record_field_context.rs +++ b/packages/stack-encrypt/tests/ui/owned_record_field_context.rs @@ -1,7 +1,7 @@ -//! 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. +//! A field of an owned `struct = ..` record is given the field context that +//! the record builds, extended by the caller's context. A target that accepts +//! no caller context, or that requires a context of another type, cannot take +//! it. That field is a compile error. use stack_encrypt::target::{ AcceptsContext, DecryptSource, Decryptable, Decryption, EncryptTarget, Encryption, NoCallerContext, OptionalContext, RequiredContext, ScalarContext, @@ -30,7 +30,7 @@ impl Decryptable for Fixed { const DECRYPTABLE: bool = true; } -/// Requires a row id from the caller, not a column pair. +/// Requires a row id from the caller, not a field context. struct Bound(Ciphertext); impl Decryptable for Bound { const DECRYPTABLE: bool = true; diff --git a/packages/stack-encrypt/tests/ui/owned_record_required_context.rs b/packages/stack-encrypt/tests/ui/owned_record_required_context.rs index 83b0b68d3..64a215d84 100644 --- a/packages/stack-encrypt/tests/ui/owned_record_required_context.rs +++ b/packages/stack-encrypt/tests/ui/owned_record_required_context.rs @@ -1,5 +1,6 @@ -//! 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. +//! An owned record with `plaintext` requires the caller's context. A call that +//! leaves it out is a compile error, to encrypt and to decrypt, even if the +//! context type implements `Default`. use stack_encrypt::sem::EqualityTerm; use stack_encrypt::target::{DecryptSource, EncryptTarget}; use stack_encrypt::{Ciphertext, DecryptFrom, EncryptInto}; diff --git a/packages/stack-encrypt/tests/ui/owned_record_required_context.stderr b/packages/stack-encrypt/tests/ui/owned_record_required_context.stderr index 18660210b..b4c52582b 100644 --- a/packages/stack-encrypt/tests/ui/owned_record_required_context.stderr +++ b/packages/stack-encrypt/tests/ui/owned_record_required_context.stderr @@ -1,7 +1,7 @@ error[E0277]: the trait bound `RequiredContext<CallerContext>: AllowsNoContext` is not satisfied - --> tests/ui/owned_record_required_context.rs:15:47 + --> tests/ui/owned_record_required_context.rs:16:47 | -15 | let _ = String::from("alice@example.com").encrypt_into::<SearchableEmail, _>(keyset); +16 | 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` @@ -28,9 +28,9 @@ note: required by a bound in `stack_encrypt::EncryptInto::encrypt_into` | ^^^^^^^^^^^^^^^ 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 + --> tests/ui/owned_record_required_context.rs:17:34 | -16 | let _ = String::decrypt_from(stored, keyset); +17 | let _ = String::decrypt_from(stored, keyset); | -------------------- ^^^^^^ the trait `AllowsNoContext` is not implemented for `RequiredContext<CallerContext>` | | | required by a bound introduced by this call 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 index ef07051ca..0b8ae45dd 100644 --- a/packages/stack-encrypt/tests/ui/owned_struct_plaintext_not_zeroize.rs +++ b/packages/stack-encrypt/tests/ui/owned_struct_plaintext_not_zeroize.rs @@ -1,6 +1,6 @@ -//! 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. +//! The plaintext of an owned record must implement `Zeroize`. The conversion +//! is responsible for wiping what it seals, and this bound declares that +//! responsibility. use stack_encrypt::target::EncryptTarget; use stack_encrypt::Ciphertext; diff --git a/packages/stack-encrypt/tests/ui/owned_struct_unmapped_field.rs b/packages/stack-encrypt/tests/ui/owned_struct_unmapped_field.rs index b760d2041..8d438484f 100644 --- a/packages/stack-encrypt/tests/ui/owned_struct_unmapped_field.rs +++ b/packages/stack-encrypt/tests/ui/owned_struct_unmapped_field.rs @@ -1,7 +1,8 @@ -//! 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. +//! An owned `struct = ..` record must name every field of its plaintext. The +//! owned conversion wipes the fields it seals, and moves the fields it passes +//! through. A field it does neither would be dropped without being wiped. So +//! that field is a compile error at the `struct = ..` path, and the error +//! names the field. use stack_encrypt::target::EncryptTarget; use stack_encrypt::Ciphertext; diff --git a/packages/stack-encrypt/tests/ui/owned_struct_unmapped_field.stderr b/packages/stack-encrypt/tests/ui/owned_struct_unmapped_field.stderr index 6035aa8db..d7fd263ca 100644 --- a/packages/stack-encrypt/tests/ui/owned_struct_unmapped_field.stderr +++ b/packages/stack-encrypt/tests/ui/owned_struct_unmapped_field.stderr @@ -1,5 +1,5 @@ error[E0027]: pattern does not mention field `password` - --> tests/ui/owned_struct_unmapped_field.rs:15:18 + --> tests/ui/owned_struct_unmapped_field.rs:16:18 | -15 | #[stash(struct = User, context = "users")] +16 | #[stash(struct = User, context = "users")] | ^^^^ missing field `password` diff --git a/packages/stack-encrypt/tests/ui/pass/owned_records.rs b/packages/stack-encrypt/tests/ui/pass/owned_records.rs index 00c26c3d0..7990039a6 100644 --- a/packages/stack-encrypt/tests/ui/pass/owned_records.rs +++ b/packages/stack-encrypt/tests/ui/pass/owned_records.rs @@ -1,9 +1,14 @@ -//! 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. +//! The owned record forms that must compile: +//! +//! - a `struct = ..` record with passthrough, renamed, fixed-segment and +//! nested fields; +//! - `plaintext` records over opaque and structural leaves, including a +//! generic record and a record that stores its context; +//! - a column of records; +//! - an owned record used as a field of a structural record, and of a plan. +//! //! `tests/ui/owned_record_*.rs` and `tests/ui/passthrough_misuse.rs` pin the -//! forms that must not. +//! forms that must not compile. use stack_encrypt::plan::pick; use stack_encrypt::sem::{EqualityTerm, MatchTerms, OreTerm}; use stack_encrypt::target::{legacy, AeadContext, DecryptSource, EncryptTarget}; @@ -82,7 +87,7 @@ struct TextEq { hm: EqualityTerm, } -/// The structural bridge: an owned record as a field of a structural one. +/// An owned record as a field of a structural record. #[derive(Clone)] struct Profile { user: User, @@ -131,7 +136,8 @@ async fn records( .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. + // A stored context. With no expected context, the record decrypts as + // stored. With an expected context, the stored context is also checked. let stored: TextEq = user .email .clone() @@ -144,7 +150,7 @@ async fn records( .await?; let _: String = String::decrypt_from(stored, keyset).await?; - // The bridge: in a structural record, and as a typed plan field. + // An owned record 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, _>() diff --git a/packages/stack-encrypt/tests/ui/passthrough_misuse.rs b/packages/stack-encrypt/tests/ui/passthrough_misuse.rs index 4be64a514..eebcb2bb4 100644 --- a/packages/stack-encrypt/tests/ui/passthrough_misuse.rs +++ b/packages/stack-encrypt/tests/ui/passthrough_misuse.rs @@ -1,6 +1,6 @@ -//! `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. +//! `passthrough` moves a field of the plaintext struct into an owned record, +//! in the clear. The attribute is refused on the structural derives, on a +//! `plaintext` record, and beside the attributes of a derived field. use stack_encrypt::target::{DecryptSource, EncryptTarget}; use stack_encrypt::{Ciphertext, DecryptInto, EncryptFrom, StackCipherText}; @@ -10,7 +10,7 @@ struct User { email: String, } -/// The structural derive moves nothing: it runs over a borrow. +/// The structural derive cannot move a field, because it runs over a borrow. #[derive(EncryptFrom, DecryptInto)] #[stash(struct = User, context = "users")] struct Structural { @@ -19,7 +19,8 @@ struct Structural { email: StackCipherText, } -/// A `plaintext` record derives every field from the whole value. +/// A `plaintext` record derives every field from the whole value, so it has no +/// field to pass through. #[derive(EncryptTarget)] #[stash(plaintext = String)] struct WholeValue { @@ -28,7 +29,7 @@ struct WholeValue { email: Ciphertext, } -/// A passthrough field is moved through as it is. +/// A passthrough field is moved as it is, so it cannot also be derived. #[derive(EncryptTarget, DecryptSource)] #[stash(struct = User, context = "users")] struct Marked { diff --git a/packages/stack-encrypt/tests/ui/passthrough_misuse.stderr b/packages/stack-encrypt/tests/ui/passthrough_misuse.stderr index c27b94d9a..a974b9d52 100644 --- a/packages/stack-encrypt/tests/ui/passthrough_misuse.stderr +++ b/packages/stack-encrypt/tests/ui/passthrough_misuse.stderr @@ -5,19 +5,19 @@ error: `passthrough` is a field of an owned record: derive `EncryptTarget` and ` | ^^^ 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 + --> tests/ui/passthrough_misuse.rs:28:9 | -27 | id: u64, +28 | 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 + --> tests/ui/passthrough_misuse.rs:37:9 | -36 | id: u64, +37 | 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 + --> tests/ui/passthrough_misuse.rs:47:5 | -46 | email: Ciphertext, +47 | email: Ciphertext, | ^^^^^