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 ::options -> MatchOptions with Default::default\(\)$', # Both unit-context conversions explicitly return Self::default(). 'stack-encrypt/src/target/context\.rs:\d+:\d+: replace for (DeclaredContext|ExpectedContext)>::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 >::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 for super::DeclaredContext>::from -> Self with Default::default\(\)$', diff --git a/Cargo.lock b/Cargo.lock index e14762064..a3c9190c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3456,6 +3456,7 @@ dependencies = [ "stack-encrypt", "syn 3.0.9", "tokio", + "zeroize", ] [[package]] diff --git a/conformance/stack-encrypt/coverage.md b/conformance/stack-encrypt/coverage.md index 6080d4e74..15bccd68a 100644 --- a/conformance/stack-encrypt/coverage.md +++ b/conformance/stack-encrypt/coverage.md @@ -7,8 +7,8 @@ Paths are relative to the repository root. New profile cases run from `v1/`. | Requirement | Existing evidence | Remaining work | |---|---|---| | Scalar value widths and lossless transport | Initial scalar cases and [value-kind requirements](fixture-contract.md#value-kind-requirements), executed by the existing Rust and generated Go runners | Complete per-kind boundary matrices, bytes and broader floats; Python/TS mappings | -| Owned opaque ciphertext and storage envelope | `packages/stack-encrypt/tests/scalar.rs`, `frozen_bytes.rs`; initial cross-opening cases | Blob profile; encoded targets; supported version matrix | -| Record declaration and lowering | [Declaration grammar](declaration-grammar.md); `v1/record-declarations-v1.cases.json` shared by native Rust and Go; native normalization observations; existing record lowering suites | EQL-resolver profile; SDK-to-declaration mappings; runtime source/read shapes | +| Owned opaque ciphertext and storage envelope | `packages/stack-encrypt/tests/scalar.rs`, `frozen_bytes.rs`; initial cross-opening cases; `owned_records.rs` (owned record derives: passthrough ownership transfer, sealed-field wiping, nested and renamed fields, stored context, one batch per column) | Blob profile; encoded targets; supported version matrix | +| Record declaration and lowering | [Declaration grammar](declaration-grammar.md); `v1/record-declarations-v1.cases.json` shared by native Rust and Go; native normalization observations; existing record lowering suites; `packages/stack-encrypt/tests/owned_equivalence.rs` seals one mixed, searchable record through a hand-written owned target, the owned derive and the data plan against one recording provider and compares descriptors, term bytes and cross-opening | EQL-resolver profile; SDK-to-declaration mappings; runtime source/read shapes | | Context binding and composition | `packages/stack-encrypt/tests/target.rs`, `descriptor.rs`, `record_lowering.rs`; `languages/golang/encrypt/context_field_test.go`; initial wrong-context cases | Shared composition vectors; expected-context and required-input matrix | | Query terms and diagnostics | [Query-operator requirements](fixture-contract.md#query-operator-requirements), existing Rust/Go runners and initial equality/query-error cases | Common rejection corpus for every operator, capability and input shape; wider kind/index combinations | | Missing, SQL NULL and sealed null | `packages/stack-encrypt/src/dynamic/record.rs` null tests; `languages/golang/encrypt/null_test.go` | Explicit language mappings and common missing/null fixtures | diff --git a/conformance/stack-encrypt/lifecycle-contract.md b/conformance/stack-encrypt/lifecycle-contract.md index 68f913e6c..338ef9300 100644 --- a/conformance/stack-encrypt/lifecycle-contract.md +++ b/conformance/stack-encrypt/lifecycle-contract.md @@ -12,8 +12,8 @@ process. Python and TypeScript are explicitly not enrolled. |---|---|---| | `lifecycle-ownership-v1` | Actual record operations and owned `Vec` to `Vec` conversions release fulfilment captures exactly once on success, provider failure and unpolled drop; an unpolled encrypt or decrypt makes no key request. Zipped fulfilments do not share key material. | Client keys are consumed/redacted; explicit close and unreachable-client cleanup release guest memory; calls on a closed client fail. GC cleanup is eventual, so callers should close explicitly. | | `lifecycle-cancellation-v1` | Dropping an encrypt future paused in key generation (for a record operation or an owned column), or a decrypt future paused in key retrieval, drops the provider future and operation captures; subsequent work on the cipher succeeds. Guest ABI abort spends the handle and drops the operation. | Cancellation before a call sends no request; cancellation between steps or during token fetch leaves unrelated operations usable. A guest step that overruns cancellation closes the client. Discarded late output is wiped. | -| `lifecycle-partial-output-v1` | A batch with a valid first row and a later authenticated type mismatch releases its decoded prefix without marked heap residue, through the dynamic record path and the owned `Vec` to `Vec` conversion alike; owned-column drop witnesses show that prefix is wiped, not leaked, after a late failure or a panicking element conversion. Existing context cases reject late authentication failures without returning partial output. | Generated and raw batch APIs return no partial rows on failure; interrupted HTTP bodies fail; cancelled output is wiped. SDK conversion diagnostics contain no decrypted values. This does not assert erasure of partially populated caller-owned Go objects. | -| `lifecycle-buffer-cleanup-v1` | The existing allocator observer checks selected write/read/JSON parse paths, including an owned `Vec` column sealed through its guard, and has a negative control. Drop witnesses show an owned column's source is wiped before its pending is returned, including on a refused or panicking context. Guest ABI tests check buffer-registry cleanup, error-buffer release and protected text drop. | Request bodies survive until their owner closes them, then wipe; refused requests wipe; heap growth wipes abandoned backing arrays; guest runtime close releases memory. Post-encryption memory scans are supplementary observations. | +| `lifecycle-partial-output-v1` | A batch with a valid first row and a later authenticated type mismatch releases its decoded prefix without marked heap residue, through the dynamic record path and the owned `Vec` to `Vec` conversion alike; owned-column drop witnesses show that prefix is wiped, not leaked, after a late failure or a panicking element conversion, through a structural record's column field too; an owned record's drop witness shows a field opened before a later field is refused is wiped. Existing context cases reject late authentication failures without returning partial output. | Generated and raw batch APIs return no partial rows on failure; interrupted HTTP bodies fail; cancelled output is wiped. SDK conversion diagnostics contain no decrypted values. This does not assert erasure of partially populated caller-owned Go objects. | +| `lifecycle-buffer-cleanup-v1` | The existing allocator observer checks selected write/read/JSON parse paths, including an owned `Vec` column sealed through its guard, and has a negative control. Drop witnesses show an owned column's source is wiped before its pending is returned, including on a refused or panicking context and for the elements after one whose declaration panics; and that an owned record wipes the fields it seals, on a refused context too, and moves its passthrough fields without copying them. Guest ABI tests check buffer-registry cleanup, error-buffer release and protected text drop. | Request bodies survive until their owner closes them, then wipe; refused requests wipe; heap growth wipes abandoned backing arrays; guest runtime close releases memory. Post-encryption memory scans are supplementary observations. | | `lifecycle-diagnostics-v1` | Guest errors discard untrusted foreign cause messages; failed/successful exports do not hand over stale diagnostics. | Structured errors remain available after failures; SDK errors and redacted display/logging expose types or fields rather than plaintext values. | | `lifecycle-validation-before-io-v1` | Existing runtime/read/context cases count provider calls. Wrong shape, independently detectable context mismatch and oversized descriptors fail before key I/O; lazy assembly stops after local failure. | Host refusals happen before entering the guest, and shared runtime/read cases exercise the uninitialised guest's preflight boundary. Go does not yet expose native provider counters. | | `lifecycle-logical-batching-v1` | Instrumented `DataKeySource` calls establish one logical generation batch, retrieval grouping by keyset, descriptor ordering and no work for empty batches, for record operations and for an owned `Vec` column of any length including zero. | Go uses the same engine, but this profile does not claim independently instrumented Go key-request counts. | @@ -61,7 +61,8 @@ These entries in the index are limitations, not successful requirements: paths use borrowed/static path text because owned path copies have separate cleanup limitations. It sees only blocks that are freed: plaintext leaked and never freed is not observed. For the owned `Vec` conversion, drop - witnesses in `packages/stack-encrypt/tests/collections.rs` observe the wipe + witnesses in `packages/stack-encrypt/tests/collections.rs` (and, for owned + records, `packages/stack-encrypt/tests/owned_records.rs`) observe the wipe itself, so a leaked source or recovered prefix fails there. The lifecycle runner preserves the observer's output. - **`caller-owned-host-values` — outside the guarantee.** Caller-owned strings, diff --git a/conformance/stack-encrypt/v1/lifecycle-v1.enrollment.json b/conformance/stack-encrypt/v1/lifecycle-v1.enrollment.json index b9a0e39d6..b31ad8b88 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,14 @@ "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", + "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/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..fab54ff20 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,103 @@ 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 `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` — 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..4c7d817f1 --- /dev/null +++ b/packages/stack-encrypt-derive/src/owned_decrypt.rs @@ -0,0 +1,289 @@ +//! 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_wiping, 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 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, + 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),* }); + // 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 + }) +} + +/// 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..43f2c3749 --- /dev/null +++ b/packages/stack-encrypt-derive/src/owned_encrypt.rs @@ -0,0 +1,441 @@ +//! 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, + // 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 #pattern = __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. 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 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)); + } + let wipe = wipe_waiting(krate, &fields[1..]); + quote!(#first.split(#tail, #divide, #wipe)) + } + [] => 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 `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 { + 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..fd6fe4d52 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) => { @@ -589,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() { @@ -597,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)); } } @@ -606,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/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index cc3ac27e6..bfef388a7 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking +- **The structural `Vec<T>: DecryptInto<Vec<P>>` and `DecryptField<Vec<P>, _>` + impls require `P: Zeroize`** (#1483). They opened through `Decryption::all`, + which dropped the elements recovered before a late element failure as they + were; they now open through the wiping collection path the owned + `Vec<T>: DecryptSource<Vec<P>>` uses, so the recovered prefix is wiped + before the error is returned. A struct plaintext a structural record + opens a column into derives `Zeroize`; every scalar the engine opens + into already carries it. - Repeated plan-chain `.extend(..)` calls now append parts in order, matching Go's `Cipher.Extend`, instead of replacing earlier extensions. `.extend(())` adds nothing, and grouped `NonEmpty` parts retain their grouping. @@ -20,6 +28,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Owned records: `#[derive(EncryptTarget, DecryptSource)]`** (#1483). A + struct encrypts field by field into a record type that states its own + layout: `#[stash(struct = User, context = "users")]` hands each field's + target the pair `("users", "<field>")`, `#[stash(passthrough)]` carries a + plaintext field into the record in the clear — moved, never copied, and + neither encrypted nor authenticated — and `#[stash(from = ..)]` and + `identity = ".."` rename the source field and pin the segment as on the + structural derive. `user.encrypt_into(&keyset)` consumes the plaintext, + wipes every field it seals and moves the passthrough fields; + `User::decrypt_from(record, &cipher)` opens each field under its pair and + moves them back; `encrypt_into_with_context(&keyset, 7u64)` extends every + pair by the row id and the same extension opens it. Records nest (a + record-typed field's own pairs are extended by the outer pair), a + `#[stash(plaintext = String)]` record is a searchable scalar (a + `Ciphertext` beside an `EqualityTerm`) and a field of a `struct` record, + a `context_field` record stores its context and opens under an + `ExpectedContext`, and a `Vec<User>` encrypts into a `Vec<Record>` as + one batch. The plaintext type is `Zeroize`. Both derives also emit the + structural `EncryptFrom` / `DecryptInto` / `DecryptField` declarations as a + bridge, so an owned record is a field of a `#[derive(EncryptFrom)]` + record or a typed plan field without a second derive; derive one pair or + the other, not both. The crate docs' "Records" section and the derive + crate's "Owned records" section show the forms; `tests/ui` pins the ones + that are refused, at the field. 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>()`. - **Owned collections: `Vec<S>` encrypts into `Vec<T>` and back through `EncryptInto` and `DecryptFrom`** (#1482). `Vec<T>` implements `EncryptTarget<Vec<S>>` and `DecryptSource<Vec<P>>` for any owned element @@ -32,9 +101,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 on its own, in one logical batch per column (one `generate_keys` call, one `retrieve_keys` call per keyset, element order kept, no call for an empty column; the key source may still split a logical batch into several - provider requests). The vector is held in one guard and wiped before the - pending request is returned, including when the context is refused, with - no `Clone` bound; a column that fails after some elements were recovered + provider requests). Each element is wiped by its own declaration before + the pending request is returned, including when the context is refused, + with no `Clone` bound, and the elements not yet handed over are wiped if a + declaration panics; a column that fails after some elements were recovered wipes those before returning the error, and before unwinding when an element's conversion panics. `Vec<u8>` is not a column of scalars: `u8` is not `OpaqueEncode`, because Vitamin C keeps `Vec<u8>` a @@ -42,6 +112,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Direct `keyset.encrypt` still takes one scalar; `encrypt_tree` remains the structural path. +### Deprecated + +- The structural derives `#[derive(EncryptFrom, DecryptInto)]` and the + `target::legacy` conversion traits remain for the plans and the dynamic + engine that run them, and are the migration path's starting point, not its + end: new records derive `EncryptTarget` / `DecryptSource`, which emit the + structural declarations too. Their removal is tracked separately (#1488) + and follows once every consumer has an owned equivalent. No + `#[deprecated]` attribute is applied yet, since the crate's own plan and + dynamic modules still implement them. + ## [0.3.0] - 2026-10-27 ### Breaking diff --git a/packages/stack-encrypt/docs/adr/0012-owned-record-declarations.md b/packages/stack-encrypt/docs/adr/0012-owned-record-declarations.md new file mode 100644 index 000000000..bd28c14b3 --- /dev/null +++ b/packages/stack-encrypt/docs/adr/0012-owned-record-declarations.md @@ -0,0 +1,150 @@ +--- +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. + +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. + +### 3. Caller context is a capability, composed through the tree + +Two inputs join `NoCallerContext`, `RequiredContext<C>` and +`OptionalContext<C>`: + +- `ContextExtension<C>`: omission keeps the declared contexts; a supplied + part extends each of them. What a `struct` record takes, both ways. +- `ExpectedContext<T>` (the existing type, now a `ContextInput`): omission + opens under the stored context; a supplied `NonEmpty<T>` also checks it. + What a `context_field` record takes to open. + +A `plaintext = T` record takes `RequiredContext<CallerContext>` (or of +`AeadContext` with `context_type`), and `RequiredContext<NonEmpty<T>>` when +it stores its context. The core leaves take the same wrappers around the +context their operation needs, so a term is an `EncryptTarget` alone, and +nothing is inferred from `Default`. + +A record field is handed its pair extended by the caller's part through +`Encryption::under` (ADR-0004), which asks the field's input to be +`From<CallerContext>`. That one bound is the field-specific diagnostic: an +`OptionalContext`, a `RequiredContext` of a core context and a nested +record's `ContextExtension` satisfy it; a `NoCallerContext` target or a +`RequiredContext<u64>` target does not, and the error is reported at the +field. A `plaintext` record's fields are handed the one context the record +requires, so a term beside an `AeadContext`-only record is refused at the +term, and omitting a required context is refused at the call. + +### 4. The structural pair is a bridge, not a second declaration + +Both owned derives also emit the structural declarations: `EncryptTarget` +emits `EncryptFrom` (the record's legacy context converted into its owned +input at the root) and `Decryptable`; `DecryptSource` emits `DecryptInto` +and `DecryptField`. The core leaves implement both pairs by hand. So an +owned record is a field of a structural record and a typed plan field +without a second derive, and the plan builder gains `target::<T, _>()` for +a target that declares itself the owned way alone (`Ciphertext`). The +dynamic lowering is untouched: the equivalence test seals one record +through a hand-written `EncryptTarget`, the derive and the data plan, +against one recording provider, and finds the same descriptors, the same +term bytes and every author opening every author's record. The structural +derives stay, for the plans and the dynamic engine that still speak them; +their removal is a later decision, once every consumer has an owned +equivalent. + +### 5. The structural `Vec` opens through the wiping path + +`Vec<T>: DecryptInto<Vec<P>>` and `DecryptField<Vec<P>, _>` opened through +`Decryption::all`, which dropped the elements recovered before a late +failure as they were. They now open through `all_wiping`, as the owned +column does, which puts `P: Zeroize` on those public impls: every plaintext +the engine opens into carries it, and a structural caller with a struct +plaintext derives it. + +## Consequences + +- `user.encrypt_into(&keyset)` on a `struct` record wipes `name` and + `email` and moves `id`; `Vec<User>` into `Vec<SearchableUser>` is one + key request; `User::decrypt_from(record, &cipher)` moves `id` back. +- A record's plaintext is `Zeroize`. The bound is where the wiping + obligation is declared, not a promise about caller-retained copies or + compiler temporaries (ADR-0010's limits stand). +- Configured targets (a JSON index with options) are not yet owned-record + fields: the one configurable target's plaintext, `serde_json::Value`, is + not `Zeroize`, so it cannot be an owned source. The attribute for a + typed options factory waits on a zeroizing document plaintext. +- `Option<T>` and tuple targets remain structural-only in this slice. diff --git a/packages/stack-encrypt/examples/encrypted_record.rs b/packages/stack-encrypt/examples/encrypted_record.rs index 3e0c8a978..d6bf06a25 100644 --- a/packages/stack-encrypt/examples/encrypted_record.rs +++ b/packages/stack-encrypt/examples/encrypted_record.rs @@ -48,7 +48,9 @@ struct EncryptedAge { } /// The plaintext. -#[derive(Debug, Clone, PartialEq)] +// `Zeroize`: a column of users opens through the wiping collection path, +// which wipes the rows recovered before a late failure. +#[derive(Debug, Clone, PartialEq, zeroize::Zeroize)] struct User { age: u32, email: String, diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 497e2b024..c0a1b5a26 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -287,11 +287,79 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! own, so `Vec<u8>` neither encrypts byte by byte nor as one `Ciphertext` //! here. //! -//! Existing structural plans and derives still use [`StackCipherText`], a tree -//! of sealed leaves. Their low-level direct entry points are +//! # Records +//! +//! A struct encrypts field by field into a record type that states its own +//! layout: `#[derive(EncryptTarget, DecryptSource)]` with +//! `#[stash(struct = User, context = "users")]`. A field whose type is a +//! target (a [`Ciphertext`], a record, a term) is derived from the plaintext +//! field of its name under the pair `("users", "<field>")`; a +//! `#[stash(passthrough)]` field is carried into the record in the clear, +//! **moved** rather than copied and neither encrypted nor authenticated; a +//! `#[stash(from = ..)]` renames the plaintext field a field is taken from. +//! The conversion wipes the plaintext fields it seals and moves the ones it +//! passes through, so the plaintext type is `Zeroize` and nothing is cloned. +//! A caller's context extends every field's pair +//! ([`ContextExtension`]); omitted, the pairs stand as declared. +//! +//! ``` +//! # async fn example() -> Result<(), stack_encrypt::Error> { +//! use stack_encrypt::target::{DecryptSource, EncryptTarget}; +//! use stack_encrypt::{Ciphertext, DecryptFrom, EncryptInto, StackCipher}; +//! +//! #[derive(Debug, PartialEq, zeroize::Zeroize)] +//! struct User { id: u64, name: String, email: String } +//! +//! #[derive(EncryptTarget, DecryptSource, serde::Serialize, serde::Deserialize)] +//! #[stash(struct = User, context = "users")] +//! struct EncryptedUser { +//! #[stash(passthrough)] +//! id: u64, +//! name: Ciphertext, // under ("users", "name") +//! email: Ciphertext, // under ("users", "email") +//! } +//! +//! let cipher = StackCipher::builder() +//! .kms(stack_encrypt::kms::FakeDataKeySource::new()).init().await?; +//! let keyset = cipher.default_keyset(); +//! let user = User { id: 42, name: "Alice".into(), email: "alice@example.com".into() }; +//! +//! // One conversion, one batched key request for both sealed fields. +//! let encrypted: EncryptedUser = user.encrypt_into(&keyset).await?; +//! assert_eq!(encrypted.id, 42); +//! let stored = serde_json::to_vec(&encrypted).unwrap(); +//! +//! let loaded: EncryptedUser = serde_json::from_slice(&stored).unwrap(); +//! let restored = User::decrypt_from(loaded, &cipher).await?; +//! assert_eq!(restored.email, "alice@example.com"); +//! +//! // Bound to a row: the same extension is needed to open it. +//! let user = User { id: 43, name: "Bob".into(), email: "bob@example.com".into() }; +//! let encrypted: EncryptedUser = user.encrypt_into_with_context(&keyset, 43u64).await?; +//! let restored = User::decrypt_from_with_context(encrypted, &cipher, 43u64).await?; +//! assert_eq!(restored.id, 43); +//! # Ok(()) +//! # } +//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(example()).unwrap(); +//! ``` +//! +//! A `#[stash(plaintext = String)]` record is the other shape: every field +//! is derived from the whole value under the one context the caller must +//! supply ([`target::RequiredContext`]) — a [`Ciphertext`] beside an +//! [`sem::EqualityTerm`] is a searchable scalar — and it is a field of a +//! `struct` record like any target. A `Vec` of plaintext structs encrypts +//! into a `Vec` of records as one batch. The derive crate's documentation +//! lists every attribute; [`target`] describes the declaration traits a +//! hand-written target implements, and how a record field's context is +//! composed. +//! +//! Existing structural plans and derives (`#[derive(EncryptFrom, DecryptInto)]`) +//! still use [`StackCipherText`], a tree +//! of sealed leaves, over a borrowed plaintext. Their low-level direct entry points are //! [`KeysetCipher::encrypt_tree`] and [`KeysetCipher::decrypt_tree`]; their -//! borrowed conversion helpers live in [`target::legacy`]. They are separate -//! from the owned scalar API. There is no generic `Serialize` fallback and +//! borrowed conversion helpers live in [`target::legacy`]. An owned record +//! is also a structural declaration (its derive emits the bridge), so it is +//! a field of a structural record or a typed plan field too. There is no generic `Serialize` fallback and //! no implicit `Vec<u8>` blob. //! //! # What is authenticated @@ -375,10 +443,10 @@ pub use stack_kms as kms; /// crate, so a binding encodes an error from any of them the same way. pub use stack_kms::{diagnostic, ErrorPayload}; pub use target::{ - CallerContext, CipherScope, DecryptField, DecryptFrom, DecryptInto, DecryptSource, Decryptable, - Decryption, EncryptFrom, EncryptInto, EncryptTarget, Encrypted, Encryption, Equality, Index, - IndexSpec, Indexes, Json, Match, Ope, Ore, OreBlock, Pending, PendingFuture, Request, - Responses, TermSet, + CallerContext, CipherScope, ContextExtension, DecryptField, DecryptFrom, DecryptInto, + DecryptSource, Decryptable, Decryption, EncryptFrom, EncryptInto, EncryptTarget, Encrypted, + Encryption, Equality, Index, IndexSpec, Indexes, Json, Match, Ope, Ore, OreBlock, Pending, + PendingFuture, Request, Responses, TermSet, }; // Re-export the vitaminc AEAD surface callers need to drive the cipher, so they diff --git a/packages/stack-encrypt/src/plan/build.rs b/packages/stack-encrypt/src/plan/build.rs index 3cbf85ca1..85754a6e6 100644 --- a/packages/stack-encrypt/src/plan/build.rs +++ b/packages/stack-encrypt/src/plan/build.rs @@ -11,7 +11,7 @@ use super::values::Slot; use super::{FieldValues, PlanError}; use crate::target::{ ciphertext, indexed, inspect, open, passthrough, AeadContext, Borrowed, CallerContext, - DeclaredContext, DecryptField, Decryptable, Decryption, EncryptFrom, Encryption, + DeclaredContext, DecryptField, Decryptable, Decryption, EncryptFrom, EncryptTarget, Encryption, ExpectedContext, IndexSpec, Indexes, Pending, }; use crate::{Error, KeysetCipher, Label, LabelError, NonEmpty, StackCipherText}; @@ -973,6 +973,86 @@ impl<S: 'static, K: 'static> FieldsBuilder<S, K> { ) } + /// Lay the field `field` out as the owned target `T` does: `T`'s own + /// [`EncryptTarget<F>`] decides what is sealed and which terms sit + /// beside it, all under `<context>/<identity>`, and the record holds a + /// `T`. The owned sibling of [`encrypt_into`](Self::encrypt_into), for + /// a target that declares itself through `EncryptTarget` alone — an + /// opaque [`Ciphertext`](crate::Ciphertext), or a record deriving + /// `EncryptTarget` (which also answers through `encrypt_into`, by its + /// bridge). Lowers to + /// `<T as EncryptTarget<F>>::encryption().accepting::<CallerContext>()`, + /// so the field's pair reaches the target as its caller context: the + /// target's `ContextInput` must take a `CallerContext`, which a target + /// that accepts no caller context ([`NoCallerContext`](crate::target::NoCallerContext)) or requires + /// something else does not, and that is refused at the call. + /// + /// The plan runs the target over a borrow of the value, as it does every + /// field, so the value stays the caller's and nothing is wiped here; + /// the owned conversions are [`EncryptInto`](crate::EncryptInto)'s. + /// + /// ``` + /// # use stack_encrypt::plan::pick; + /// # struct User { age: u32 } + /// # async fn example() -> Result<(), stack_encrypt::Error> { + /// use stack_encrypt::kms::FakeDataKeySource; + /// use stack_encrypt::{Ciphertext, Plan, StackCipher}; + /// + /// let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; + /// let users_plan = Plan::context("users") + /// .fields() + /// .target::<Ciphertext, _>(pick("age", |u: &User| &u.age)) + /// .build()?; + /// + /// let user = User { age: 34 }; + /// let mut record = cipher.encrypt(&user).using(&users_plan).await?; + /// let age: Ciphertext = record.take("age")?; + /// // An ordinary opaque ciphertext under the field's pair. + /// assert_eq!(cipher.default_keyset().decrypt_with_context::<u32>(age, ("users", "age")).await?, 34); + /// # Ok(()) + /// # } + /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(example()).unwrap(); + /// ``` + pub fn target<T, F>(self, field: impl FieldRef<S, F>) -> Self + where + F: Send + zeroize::Zeroize + 'static, + T: EncryptTarget<F> + Decryptable + DecryptField<F, CallerContext> + Send, + T::ContextInput: From<CallerContext>, + { + let (name, access) = resolve(field); + let read = Arc::clone(&access.read); + let field = Arc::clone(&name); + let open: Option<FieldOpener<K>> = if T::DECRYPTABLE { + Some(FieldOpener { + holds: holds::<T>, + open: Arc::new(|field, slot, context| { + Ok(stored::<T>(field, slot)? + .decryption_field::<K>(context) + .map_or_else( + || Decryption::failed(Error::NotOpened), + |d| d.map(Slot::new), + )) + }), + }) + } else { + None + }; + self.declare::<F>( + name, + &access, + FieldKind::EncryptInto, + <T as EncryptTarget<F>>::indexes(), + Arc::new(move |_| { + lifted( + <T as EncryptTarget<F>>::encryption::<K>().accepting::<CallerContext>(), + &field, + &read, + ) + }), + open, + ) + } + /// Key the field just declared under `identity` rather than its name. /// /// A field's identity is the label segment its data is keyed under, and diff --git a/packages/stack-encrypt/src/scalar.rs b/packages/stack-encrypt/src/scalar.rs index a31430311..7228fe06e 100644 --- a/packages/stack-encrypt/src/scalar.rs +++ b/packages/stack-encrypt/src/scalar.rs @@ -166,6 +166,18 @@ impl<P: OpaqueDecode> DecryptSource<P> for Ciphertext { }) } } +/// A ciphertext is what a record opens: as a field of a derived record it is +/// the recoverable one among the terms beside it. +impl crate::target::Decryptable for Ciphertext { + const DECRYPTABLE: bool = true; +} +impl<P: OpaqueDecode, Ctx: Into<OptionalContext<ScalarContext>>> crate::target::DecryptField<P, Ctx> + for Ciphertext +{ + fn decryption_field<K: 'static>(self, context: Ctx) -> Option<Decryption<P, K>> { + Some(self.decryption(context.into())) + } +} impl<K: 'static> KeysetCipher<'_, K> { /// Consume and encrypt a scalar as one opaque ciphertext with empty context. /// The owned source is wiped before this returns the batched pending request. diff --git a/packages/stack-encrypt/src/target/collections.rs b/packages/stack-encrypt/src/target/collections.rs index ea1e8056a..5a8464e47 100644 --- a/packages/stack-encrypt/src/target/collections.rs +++ b/packages/stack-encrypt/src/target/collections.rs @@ -51,6 +51,52 @@ 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. + // + // 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> diff --git a/packages/stack-encrypt/src/target/conversion.rs b/packages/stack-encrypt/src/target/conversion.rs index 223eb4afb..daa150c32 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,15 @@ 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. let source = Protected::new(self); - cipher.run( - T::encryption(), - source.risky_ref(), - T::ContextInput::omitted(), - ) + 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. @@ -58,9 +98,14 @@ 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. let source = Protected::new(self); let context = T::ContextInput::supplied(context.into()); - cipher.run(T::encryption(), source.risky_ref(), 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/src/target/index.rs b/packages/stack-encrypt/src/target/index.rs index ac86f45fc..b33cf1850 100644 --- a/packages/stack-encrypt/src/target/index.rs +++ b/packages/stack-encrypt/src/target/index.rs @@ -58,7 +58,10 @@ use std::marker::PhantomData; use super::context::{AeadContext, CallerContext}; use super::operations::{ciphertext, equality, json_terms, matching, ope, open, ore, ore_block}; use super::source::{ConsumeSource, ShareSource}; -use super::{DecryptField, DecryptInto, Decryptable, Decryption, EncryptFrom, Encryption}; +use super::{ + DecryptField, DecryptInto, DecryptSource, Decryptable, Decryption, EncryptFrom, EncryptTarget, + Encryption, RequiredContext, +}; use crate::json::{JsonOptions, JsonTerms}; use crate::sem::{ DefaultMatch, EqualityTerm, MatchConfig, MatchOptions, MatchTerms, OpeTerm, OreBlockTerm, @@ -614,6 +617,33 @@ where } } +/// The owned declaration of the same layout: the ciphertext with its terms, +/// under the one caller context the record field or the caller supplies. +impl<S, Terms> EncryptTarget<S> for Encrypted<Terms> +where + S: crate::Encrypt + Clone + zeroize::Zeroize, + Terms: TermSet<S>, +{ + type ContextInput = RequiredContext<CallerContext>; + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::ContextInput> + where + S: 's, + { + indexed(Terms::INDEXES).accepting() + } + fn indexes() -> Vec<IndexSpec> { + Terms::INDEXES.specs() + } +} +impl<P: crate::Decrypt + zeroize::Zeroize + Send + 'static, Terms> DecryptSource<P> + for Encrypted<Terms> +{ + type ContextInput = RequiredContext<AeadContext>; + fn decryption<K: 'static>(self, context: Self::ContextInput) -> Decryption<P, K> { + open(self.ciphertext, context.into_inner()) + } +} + /// The ciphertext is what a record opens; the terms are one-way. impl<Terms> Decryptable for Encrypted<Terms> { const DECRYPTABLE: bool = true; @@ -624,7 +654,7 @@ where Ctx: Into<<Self as DecryptInto<P>>::Context>, { fn decryption_field<K: 'static>(self, context: Ctx) -> Option<Decryption<P, K>> { - Some(self.decryption(context.into())) + Some(<Self as DecryptInto<P>>::decryption(self, context.into())) } } diff --git a/packages/stack-encrypt/src/target/input.rs b/packages/stack-encrypt/src/target/input.rs index 81abebd5e..ab3a9614d 100644 --- a/packages/stack-encrypt/src/target/input.rs +++ b/packages/stack-encrypt/src/target/input.rs @@ -1,6 +1,19 @@ //! Explicit caller-context capabilities. `Default` never grants permission to omit context. +//! +//! | Input | Omitted | Supplied | Meaning | +//! |---|---|---|---| +//! | [`NoCallerContext`] | allowed | refused | the declaration derives every context itself | +//! | [`RequiredContext<C>`] | refused | `C` | the caller supplies the context, or the part the declaration selects | +//! | [`OptionalContext<C>`] | allowed | `C` | for an opaque scalar: omission is the empty context | +//! | [`ContextExtension<C>`] | allowed | `C` | omission keeps the declared contexts; supply extends each of them | +//! | [`ExpectedContext<T>`](super::ExpectedContext) | allowed | `NonEmpty<T>` | omission opens under the stored context; supply also checks it | +//! +//! Whether a method is available is a trait bound ([`AllowsNoContext`], +//! [`AcceptsContext`]), never inferred from `C: Default`: a +//! `RequiredContext<u64>` stays required although `u64` defaults to zero. -use crate::{ContextPiece, IntoContext}; +use super::context::{CallerContext, DeclaredContext, ExpectedContext}; +use crate::{ContextPiece, IntoContext, NonEmpty}; mod sealed { pub trait Sealed {} @@ -75,6 +88,140 @@ impl<C> AcceptsContext for OptionalContext<C> { } } +/// Caller context may extend the contexts a declaration carries itself. +/// +/// What a derived record whose fields each sit under their own pair +/// (`("users", "name")`) accepts: omitted, the declared contexts stand as +/// they are; supplied, every one of them is extended by the value — `7u64` +/// makes the field `(("users", "name"), 7u64)`. Omission appends nothing, not +/// an empty part. Decrypting a record sealed under an extension needs the +/// same extension; the omitted method's availability is not a promise that +/// it opens every stored record of the type. +#[derive(Clone, Debug)] +pub struct ContextExtension<C>(Option<C>); +impl<C> ContextExtension<C> { + /// Inspect presence when interpreting a declaration. + pub fn into_inner(self) -> Option<C> { + self.0 + } +} +impl<C> sealed::Sealed for ContextExtension<C> {} +impl<C> ContextInput for ContextExtension<C> {} +impl<C> AllowsNoContext for ContextExtension<C> { + fn omitted() -> Self { + Self(None) + } +} +impl<C> AcceptsContext for ContextExtension<C> { + type Value = C; + fn supplied(value: C) -> Self { + Self(Some(value)) + } +} +// What a record's declaration tree runs under: its fields' own contexts, +// extended by the caller's part if one was given. +impl<C: Into<DeclaredContext>> From<ContextExtension<C>> for DeclaredContext { + fn from(extension: ContextExtension<C>) -> Self { + extension.0.map_or_else(Self::default, Into::into) + } +} +// A legacy `DeclaredContext` caller (a plan, a `struct = ..` derive) hands +// its extension to an owned record field through here. +impl From<DeclaredContext> for ContextExtension<DeclaredContext> { + fn from(extension: DeclaredContext) -> Self { + if extension.is_extended() { + Self::supplied(extension) + } else { + Self::omitted() + } + } +} +// A record field is handed its own pair extended by the caller's context +// (`Encryption::under`): a nested record takes that as its extension. +impl<C: From<CallerContext>> From<CallerContext> for ContextExtension<C> { + fn from(context: CallerContext) -> Self { + Self::supplied(C::from(context)) + } +} +// A field whose target requires a caller context is given the record's pair, +// extended by the caller's part: a `RequiredContext<CallerContext>` or +// `RequiredContext<AeadContext>` field sits under a `struct = ..` record. +impl<C: From<CallerContext>> From<CallerContext> for RequiredContext<C> { + fn from(context: CallerContext) -> Self { + Self::supplied(C::from(context)) + } +} +// A record that derives terms hands its ciphertext-only fields the AEAD +// half of the context it requires. +impl From<RequiredContext<CallerContext>> for RequiredContext<super::AeadContext> { + fn from(context: RequiredContext<CallerContext>) -> Self { + Self::supplied(context.into_inner().into()) + } +} +// A record that stores its context (`context_field`) requires the +// `NonEmpty<T>` it stores and runs its fields under the caller context +// built from it. +impl<'c, T: IntoContext<'c>> From<RequiredContext<NonEmpty<T>>> for RequiredContext<CallerContext> { + fn from(context: RequiredContext<NonEmpty<T>>) -> Self { + Self::supplied(context.into_inner().into()) + } +} +impl<'c, T: IntoContext<'c>> From<RequiredContext<NonEmpty<T>>> + for RequiredContext<super::AeadContext> +{ + fn from(context: RequiredContext<NonEmpty<T>>) -> Self { + Self::supplied(context.into_inner().into()) + } +} +// A record that stores its context takes the `NonEmpty<T>` it stores from +// its structural bridge as the required input. +impl<T> From<NonEmpty<T>> for RequiredContext<NonEmpty<T>> { + fn from(context: NonEmpty<T>) -> Self { + Self::supplied(context) + } +} +// The legacy declarations' contexts, wrapped as the required input of the +// owned leaf that replaces each: what a plan or a legacy record hands an +// owned field. +impl From<super::AeadContext> for RequiredContext<super::AeadContext> { + fn from(context: super::AeadContext) -> Self { + Self::supplied(context) + } +} +// The core operations take the context the input carries, with no wrapper +// of their own: `ciphertext()` under a `RequiredContext<AeadContext>`, +// a term under a `RequiredContext<CallerContext>`. +impl<C: Into<super::AeadContext>> From<RequiredContext<C>> for super::AeadContext { + fn from(context: RequiredContext<C>) -> Self { + context.into_inner().into() + } +} +impl<C: Into<CallerContext>> From<RequiredContext<C>> for CallerContext { + fn from(context: RequiredContext<C>) -> Self { + context.into_inner().into() + } +} + +/// A record that stores its context accepts an expectation of it, or none. +/// +/// Omitted, the record opens under whatever context it stores, after +/// checking that it is nonempty; supplied, the stored context must also +/// equal the `NonEmpty<T>` the caller names, and a mismatch is refused +/// before any key is retrieved ([`ExpectedContext::validate`]). +impl<T> sealed::Sealed for ExpectedContext<T> {} +impl<T> ContextInput for ExpectedContext<T> {} +impl<T> AllowsNoContext for ExpectedContext<T> { + fn omitted() -> Self { + Self::default() + } +} +impl<T> AcceptsContext for ExpectedContext<T> { + type Value = NonEmpty<T>; + fn supplied(value: NonEmpty<T>) -> Self { + Self::from(value) + } +} + /// Owned AEAD context for an opaque scalar. Empty context is valid. /// /// Contexts use the existing canonical encoding. No extra tuple, prefix or diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 430db8f36..e7418634e 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -8,9 +8,55 @@ //! target, under the element's `ContextInput`, as one logical batch of //! independently authenticated elements; `Vec<T>` decrypts back the same way. //! +//! # The owned declarations +//! +//! | Trait | Implemented on | Methods | Source | +//! |---|---|---|---| +//! | [`EncryptTarget<S>`] | an encrypted output or term | `encryption` (borrowed `&S`), `owned_encryption` (`S` by value, defaulting to guard-and-wipe), `indexes` | `S: Zeroize` | +//! | [`DecryptSource<P>`] | a recoverable encrypted output | `decryption(self, ContextInput)` | opens to `P: Zeroize` | +//! | [`EncryptInto`] | every `Zeroize` plaintext | `encrypt_into`, `encrypt_into_with_context` | consumes and wipes | +//! | [`DecryptFrom`] | every `Zeroize` plaintext | `decrypt_from`, `decrypt_from_with_context` | returns the owned value | +//! +//! The core leaves implement both: [`Ciphertext`](crate::Ciphertext) takes +//! an [`OptionalContext`] (empty is a context); [`StackCipherText`](crate::StackCipherText) +//! and [`Encrypted<Terms>`] require an [`AeadContext`] to open and the +//! terms ([`EqualityTerm`](crate::sem::EqualityTerm), [`MatchTerms`](crate::sem::MatchTerms), +//! the order terms) require a [`CallerContext`] to derive, each as a +//! [`RequiredContext`]. A term implements [`EncryptTarget`] alone. +//! +//! # Records +//! +//! `#[derive(EncryptTarget, DecryptSource)]` composes the field declarations. +//! A `struct = User, context = "users"` record hands each field its pair +//! `("users", "<field>")`, extended by the caller's [`ContextExtension`] if +//! one is given, through [`Encryption::under`]: the field target's +//! `ContextInput` must therefore be `From<CallerContext>`, which an +//! [`OptionalContext`], a [`RequiredContext`] of a core context and a +//! nested record's [`ContextExtension`] are, and a [`NoCallerContext`] or a +//! `RequiredContext<u64>` is not — refused at that field. Its borrowed +//! `encryption` picks each field out of a borrow ([`Encryption::project`], +//! cloning a passthrough field); its `owned_encryption` divides the struct +//! between the fields ([`Encryption::split`]), so a `#[stash(passthrough)]` +//! field is moved into the record and each sealed field is guarded by its +//! own target's owned form. Decryption opens each field under the same pair +//! and moves the passthrough fields back. +//! +//! A `plaintext = T` record runs every field over the whole value under the +//! one [`RequiredContext<CallerContext>`] (or `RequiredContext<AeadContext>` +//! with `context_type = AeadContext`) the caller supplies; a +//! `#[stash(context_field)]` record requires the `NonEmpty<T>` it stores and +//! opens under an [`ExpectedContext`]. Both derives also emit the structural +//! declarations ([`EncryptFrom`], [`DecryptInto`], [`DecryptField`]) as a +//! bridge, so an owned record is a field of a structural record or a typed +//! plan field ([`FieldsBuilder::encrypt_into`](crate::plan::FieldsBuilder::encrypt_into), +//! or [`target`](crate::plan::FieldsBuilder::target) for an owned-only target +//! such as a `Ciphertext`). A hand-written owned target used as a record +//! field implements [`Decryptable`] and [`DecryptField`] beside +//! [`DecryptSource`], as a hand-written structural one does. +//! //! # Structural API during migration //! -//! Existing derives, plans and dynamic targets still use the declarations +//! Existing structural derives, plans and dynamic targets still use the declarations //! below. Their borrowed convenience methods are isolated in [`legacy`]. //! //! [`EncryptFrom<S>`] describes ciphertext and/or term operations and has an @@ -144,6 +190,10 @@ pub mod legacy { } pub use conversion::{DecryptFrom, DecryptSource, EncryptInto, EncryptTarget}; pub use input::{ - AcceptsContext, AllowsNoContext, ContextInput, NoCallerContext, OptionalContext, - RequiredContext, ScalarContext, + AcceptsContext, AllowsNoContext, ContextExtension, ContextInput, NoCallerContext, + OptionalContext, RequiredContext, ScalarContext, }; +pub use stack_encrypt_derive::{DecryptSource, EncryptTarget}; +/// The cleanup bound every owned plaintext carries, named here so the +/// derives can spell it without a `zeroize` dependency of the caller's. +pub use zeroize::Zeroize; diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index 3bfce0e98..2cd919b2f 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -10,9 +10,11 @@ //! no argument to route. What a subtree may do is take a context of its own, //! by name, with [`Encryption::under`] or [`Encryption::extend`]. use super::context::{AeadContext, CallerContext, DeclaredContext, Extends}; +use super::conversion::{DecryptSource, EncryptTarget}; use super::core::{encrypt_native, open_native, Term}; use super::index::{Index, IndexSpec, Json}; -use super::source::{Borrowed, ConsumeSource, ShareSource, SourceMode}; +use super::input::RequiredContext; +use super::source::{Borrowed, ConsumeSource, Owned, ShareSource, SourceMode}; use super::{CipherScope, Pending}; use crate::json::{JsonDocument, JsonOptions, JsonTerms}; use crate::{Error, IntoContext, KeysetCipher, NonEmpty, StackCipher, StackCipherText}; @@ -124,7 +126,7 @@ type Open<T, K> = Box<dyn for<'a> FnOnce(&'a StackCipher<K>) -> Pending<'a, T, K /// `M` is how the description is handed its plaintext ([`SourceMode`]): /// by reference in the default [`Borrowed`] mode, which every /// [`EncryptFrom`] declaration runs in, or by value in -/// [`Owned`](super::Owned) mode, run with [`KeysetCipher::run`]. In owned mode +/// [`Owned`] mode, run with [`KeysetCipher::run`]. In owned mode /// a single operation consumes the plaintext without copying it, so `S` need /// not be `Clone`; [`zip`](Self::zip) is the one place that asks for it. #[must_use = "an encryption description does nothing until a keyset cipher executes it"] @@ -239,7 +241,7 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's, M: SourceMode<'s, S>> /// /// Both sides also receive the same plaintext. In the default /// [`Borrowed`] mode that is the same reference, and costs nothing. In - /// [`Owned`](super::Owned) mode this side gets a clone and `other` takes + /// [`Owned`] mode this side gets a clone and `other` takes /// ownership, so a chain of `n` operations makes `n - 1` copies rather /// than `n`, and only here does an owned plaintext need to be `Clone`. /// In Owned mode, a match term next to an operation that consumes the @@ -389,6 +391,90 @@ 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`. + /// + /// `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: Ctx| { + let first_cx = cx.clone(); + let (mine, theirs) = divide(source); + 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. @@ -460,7 +546,7 @@ where /// [`accepting`](Encryption::accepting) lets it take the term's context — /// the AEAD half of the same value — so the two zip under one context. /// -/// `Encrypt` consumes the plaintext. In [`Owned`](super::Owned) mode it is +/// `Encrypt` consumes the plaintext. In [`Owned`] mode it is /// handed over, so `S` need not be `Clone`; in the default [`Borrowed`] mode /// it is cloned once, which is what `M: ConsumeSource<'s, S>` asks. pub fn ciphertext<'s, S: crate::Encrypt + 's, K: 'static, M: ConsumeSource<'s, S>>( @@ -511,7 +597,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. @@ -812,6 +898,25 @@ 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. /// @@ -912,7 +1017,7 @@ impl<K: 'static> KeysetCipher<'_, K> { /// `source` is what the description's mode hands its operations: `&S` /// for a [`Borrowed`]-mode description (the default, and what /// [`encrypt_as`](Self::encrypt_as) runs for a type), or `S` itself for - /// an [`Owned`](super::Owned)-mode one. Owned mode is how a plaintext + /// an [`Owned`]-mode one. Owned mode is how a plaintext /// that is not `Clone` reaches an operation: a single operation consumes /// it without a copy. /// @@ -1204,6 +1309,75 @@ where } } +// The owned declarations of the same leaves. Each requires the context its +// operation needs, wrapped as the caller's required input: a ciphertext +// an `AeadContext`, a term a `CallerContext`. Omission is not available — +// a structural leaf takes no empty context — and `Default` grants nothing. +// The borrowed form clones a consumed plaintext, as the legacy declaration +// does; the owned form's default guards and wipes the value instead. +impl<S: crate::Encrypt + Clone + zeroize::Zeroize> EncryptTarget<S> for StackCipherText { + type ContextInput = RequiredContext<AeadContext>; + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::ContextInput> + where + S: 's, + { + ciphertext().accepting() + } +} +impl<P: crate::Decrypt + zeroize::Zeroize + Send + 'static> DecryptSource<P> for StackCipherText { + type ContextInput = RequiredContext<AeadContext>; + fn decryption<K: 'static>(self, context: Self::ContextInput) -> Decryption<P, K> { + open(self, context.into_inner()) + } +} +impl<S: crate::Encrypt + Clone + zeroize::Zeroize> EncryptTarget<S> for crate::sem::EqualityTerm { + type ContextInput = RequiredContext<CallerContext>; + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::ContextInput> + where + S: 's, + { + equality().accepting() + } + fn indexes() -> Vec<super::IndexSpec> { + <Self as EncryptFrom<S>>::indexes() + } +} +impl<S: AsRef<str> + zeroize::Zeroize, O: crate::sem::MatchConfig + 'static> EncryptTarget<S> + for crate::sem::MatchTerms<O> +{ + type ContextInput = RequiredContext<CallerContext>; + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::ContextInput> + where + S: 's, + { + matching().accepting() + } + fn indexes() -> Vec<super::IndexSpec> { + <Self as EncryptFrom<S>>::indexes() + } +} +macro_rules! owned_order_term { + ($($ty:ident, $operation:ident;)+) => {$( + impl<S: crate::sem::Orderable + Clone + zeroize::Zeroize> EncryptTarget<S> for crate::sem::$ty<S> { + type ContextInput = RequiredContext<CallerContext>; + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::ContextInput> + where + S: 's, + { + $operation().accepting() + } + fn indexes() -> Vec<super::IndexSpec> { + <Self as EncryptFrom<S>>::indexes() + } + } + )+}; +} +owned_order_term! { + OreTerm, ore; + OpeTerm, ope; + OreBlockTerm, ore_block; +} + // A `Vec<Target>` is a row per item, each encrypted independently under the // same context and settled in one batch; an `Option<Target>` is one row or // nothing, without I/O. (A plaintext `Vec`/`Option` sealed into a @@ -1247,13 +1421,17 @@ where } } } -impl<P: 'static, T: DecryptInto<P> + 'static> DecryptInto<Vec<P>> for Vec<T> +// Opened through `all_wiping`, as the owned `Vec<T>` is: an element that +// fails after its key arrived fails the column, and the elements recovered +// before it are wiped rather than dropped as they are. That is what the +// `P: Zeroize` bound buys; every plaintext the engine opens into carries it. +impl<P: zeroize::Zeroize + 'static, T: DecryptInto<P> + 'static> DecryptInto<Vec<P>> for Vec<T> where T::Context: Clone + 'static, { type Context = T::Context; fn decryption<K: 'static>(self, context: Self::Context) -> Decryption<Vec<P>, K> { - Decryption::all( + Decryption::all_wiping( self.into_iter() .map(|item| item.decryption(context.clone())), ) @@ -1282,17 +1460,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/src/target/pending.rs b/packages/stack-encrypt/src/target/pending.rs index df6da3735..f9a33658d 100644 --- a/packages/stack-encrypt/src/target/pending.rs +++ b/packages/stack-encrypt/src/target/pending.rs @@ -298,6 +298,29 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { /// is *not* authorised for the keyset is refused at ZeroKMS /// ([`Error::Kms`]), exactly as it would be on its own. pub fn zip<U: 'a>(self, other: Pending<'a, U, K>) -> Pending<'a, (T, U), K> { + self.zip_releasing(other, drop) + } + + /// [`zip`](Self::zip) for recovered plaintext: when the right side + /// fails at fulfilment (an authentication or type failure after its key + /// arrived, or a panic in its conversion), the left side's recovered + /// value is wiped before the error is returned or the panic unwinds, + /// not dropped as it is. What a record's fields open through. + pub(crate) fn zip_wiping<U: 'a>(self, other: Pending<'a, U, K>) -> Pending<'a, (T, U), K> + where + T: zeroize::Zeroize, + { + self.zip_releasing(other, |mut first: T| zeroize::Zeroize::zeroize(&mut first)) + } + + /// The one pair builder. `release` is handed the left side's output + /// when the right side fails or panics after it; it is not called when + /// both succeed. + fn zip_releasing<U: 'a>( + self, + other: Pending<'a, U, K>, + release: impl FnOnce(T) + MaybeSend + 'a, + ) -> Pending<'a, (T, U), K> { let keyset = match merge_scopes(self.keyset, other.keyset) { Ok(keyset) => keyset, Err(error) => return Pending::failed(self.cipher, error), @@ -317,7 +340,16 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { keyset, requests, failed: None, - fulfil: Box::new(move |responses| Ok((first(responses)?, second(responses)?))), + fulfil: Box::new(move |responses| { + // Held in a guard so that a right side that fails or panics + // releases the left side's output, as a collection does. + let held = Held { + item: Some(first(responses)?), + release: Some(release), + }; + let second = second(responses)?; + Ok((held.keep(), second)) + }), } } @@ -433,6 +465,32 @@ impl<T, R: FnOnce(Vec<T>)> Drop for Recovered<T, R> { } } +/// The left side of a pair, recovered before the right side fulfils. +/// Dropped before [`keep`](Self::keep), by an early return or an unwind, it +/// hands the value to `release`. +struct Held<T, R: FnOnce(T)> { + item: Option<T>, + release: Option<R>, +} + +impl<T, R: FnOnce(T)> Held<T, R> { + /// The right side succeeded: the value is the output, not released. + fn keep(mut self) -> T { + self.release = None; + self.item + .take() + .unwrap_or_else(|| unreachable!("a held value is kept once")) + } +} + +impl<T, R: FnOnce(T)> Drop for Held<T, R> { + fn drop(&mut self) { + if let (Some(release), Some(item)) = (self.release.take(), self.item.take()) { + release(item); + } + } +} + impl<'a, T: 'a, K> Pending<'a, T, K> where K: DataKeySource + IndexKeySource, diff --git a/packages/stack-encrypt/tests/collections.rs b/packages/stack-encrypt/tests/collections.rs index c3bce3ba9..7d2d3a336 100644 --- a/packages/stack-encrypt/tests/collections.rs +++ b/packages/stack-encrypt/tests/collections.rs @@ -478,3 +478,158 @@ 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"); +} + +/// 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"); +} diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index b208600fa..6e278e402 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -485,7 +485,9 @@ async fn a_failed_field_fails_the_derived_record_before_any_io() { // --- Structs: each field from one field of the plaintext, under its own context -#[derive(Debug, Clone, PartialEq, Eq)] +// `Zeroize`: a column of these opens through the wiping collection path, +// which wipes the rows recovered before a late failure. +#[derive(Debug, Clone, PartialEq, Eq, zeroize::Zeroize)] struct User { age: u32, email: String, diff --git a/packages/stack-encrypt/tests/owned_equivalence.rs b/packages/stack-encrypt/tests/owned_equivalence.rs new file mode 100644 index 000000000..5a73821fe --- /dev/null +++ b/packages/stack-encrypt/tests/owned_equivalence.rs @@ -0,0 +1,329 @@ +//! One record, three authors, one engine: a hand-written `EncryptTarget` +//! (typed), `#[derive(EncryptTarget, DecryptSource)]` (generated) and the +//! data-plan lowering a binding sends (dynamic) each seal the same mixed, +//! searchable record against the same fake provider. The descriptors they +//! send to the key provider agree, their equality terms agree byte for byte, +//! and each opens what the others sealed, which is the AAD agreeing too: +//! ciphertext bytes are randomized and are not compared. +#![cfg(feature = "dynamic")] + +mod common; + +use common::frames::{frame, object, payload}; +use serde::Deserialize; +use stack_encrypt::dynamic::record::{self, Plan as DataPlan}; +use stack_encrypt::dynamic::{Scope, ValueFrame}; +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{ + passthrough, Borrowed, ContextExtension, DeclaredContext, DecryptSource, Decryption, + EncryptTarget, Encryption, +}; +use stack_encrypt::{ + nonempty, CipherText, Ciphertext, DecryptFrom, EncryptInto, PassthroughFrame, SealedValue, + StackCipher, StackCipherText, +}; +use vitaminc_aead_frame::serde::from_slice; +use zeroize::Zeroize; + +#[derive(Debug, Clone, PartialEq, Zeroize)] +struct User { + id: u32, + name: String, + email: String, +} +fn alice() -> User { + User { + id: 42, + name: "Alice".to_owned(), + email: "alice@example.com".to_owned(), + } +} + +// --- Typed: the operations composed by hand ------------------------------- + +struct TypedUser { + id: u32, + name: Ciphertext, + email: Ciphertext, + email_eq: EqualityTerm, +} +impl EncryptTarget<User> for TypedUser { + type ContextInput = ContextExtension<DeclaredContext>; + fn encryption<'s, K: 'static>() -> Encryption<'s, User, Self, K, Self::ContextInput> { + passthrough::<u32, K, Borrowed, DeclaredContext>() + .project(|user: &User| &user.id) + .zip( + <Ciphertext as EncryptTarget<String>>::encryption::<K>() + .project(|user: &User| &user.name) + .under(nonempty!("users").with("name")), + ) + .zip( + <Ciphertext as EncryptTarget<String>>::encryption::<K>() + .project(|user: &User| &user.email) + .under(nonempty!("users").with("email")), + ) + .zip( + <EqualityTerm as EncryptTarget<String>>::encryption::<K>() + .project(|user: &User| &user.email) + .under(nonempty!("users").with("email")), + ) + .map(|(((id, name), email), email_eq)| Self { + id, + name, + email, + email_eq, + }) + .accepting() + } +} +impl DecryptSource<User> for TypedUser { + type ContextInput = ContextExtension<DeclaredContext>; + fn decryption<K: 'static>(self, context: Self::ContextInput) -> Decryption<User, K> { + let extension: DeclaredContext = context.into(); + let name = <Ciphertext as DecryptSource<String>>::decryption::<K>( + self.name, + extension + .clone() + .under(nonempty!("users").with("name")) + .into(), + ); + let email = <Ciphertext as DecryptSource<String>>::decryption::<K>( + self.email, + extension.under(nonempty!("users").with("email")).into(), + ); + let id = self.id; + name.zip(email) + .map(move |(name, email)| User { id, name, email }) + } +} + +// --- Generated: the same declaration, derived ----------------------------- + +#[derive(EncryptTarget, DecryptSource)] +#[stash(plaintext = String)] +struct SearchableEmail { + ciphertext: Ciphertext, + equality: EqualityTerm, +} +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = User, context = "users")] +struct GeneratedUser { + #[stash(passthrough)] + id: u32, + name: Ciphertext, + email: SearchableEmail, +} + +// --- Dynamic: the declaration as a binding sends it ----------------------- + +fn data_plan() -> DataPlan { + let json = serde_json::json!({ + "id": { "context": ["users", "id"], "outputs": ["passthrough"], "type": "uint32" }, + "name": { "context": ["users", "name"], "outputs": ["c"], "type": "string" }, + "email": { "context": ["users", "email"], "outputs": ["c", "eq"], "type": "string" }, + }); + record::plan(&vitaminc_aead_frame::serde::to_vec(&json).expect("frames")).expect("a plan") +} +fn dynamic_source(user: &User) -> ValueFrame { + object(vec![ + ("id", frame(user.id)), + ("name", frame(user.name.as_str())), + ("email", frame(user.email.as_str())), + ]) +} + +/// The three outputs in one shape: the stored tree the dynamic path writes +/// and reads, so one comparison and one opener serve all three. +struct Stored { + id: u32, + name: SealedValue, + email: SealedValue, + email_eq: Vec<u8>, +} +impl Stored { + fn of_typed(typed: TypedUser) -> Self { + Self { + id: typed.id, + name: leaf(typed.name), + email: leaf(typed.email), + email_eq: typed.email_eq.into_bytes().to_vec(), + } + } + fn of_generated(generated: GeneratedUser) -> Self { + Self { + id: generated.id, + name: leaf(generated.name), + email: leaf(generated.email.ciphertext), + email_eq: generated.email.equality.into_bytes().to_vec(), + } + } + fn of_tree(tree: StackCipherText) -> Self { + let CipherText::Map(fields) = tree else { + panic!("a record is a map of fields"); + }; + let (mut id, mut name, mut email, mut email_eq) = (None, None, None, None); + for (field, node) in fields { + let CipherText::Map(outputs) = node else { + panic!("a field is a map of outputs"); + }; + for (key, node) in outputs { + match (field.as_str(), key.as_str(), node) { + ("id", "passthrough", CipherText::Passthrough(payload)) => { + id = Some(from_slice::<u32>(&payload.0).expect("a u32")); + } + ("name", "c", CipherText::Single(leaf)) => name = Some(leaf), + ("email", "c", CipherText::Single(leaf)) => email = Some(leaf), + ("email", "eq", CipherText::Passthrough(payload)) => { + email_eq = Some(from_slice::<&[u8]>(&payload.0).expect("bytes").to_vec()); + } + (field, key, _) => panic!("unexpected output {field}/{key}"), + } + } + } + Self { + id: id.expect("id"), + name: name.expect("name"), + email: email.expect("email"), + email_eq: email_eq.expect("email/eq"), + } + } + fn tree(&self) -> StackCipherText { + let passthrough = + |value: &dyn Fn() -> Vec<u8>| CipherText::Passthrough(PassthroughFrame(value())); + CipherText::Map(vec![ + ( + "id".into(), + CipherText::Map(vec![( + "passthrough".into(), + passthrough(&|| payload(self.id)), + )]), + ), + ( + "name".into(), + CipherText::Map(vec![("c".into(), CipherText::Single(self.name.clone()))]), + ), + ( + "email".into(), + CipherText::Map(vec![ + ("c".into(), CipherText::Single(self.email.clone())), + ( + "eq".into(), + passthrough(&|| payload(self.email_eq.as_slice())), + ), + ]), + ), + ]) + } + fn typed(&self) -> TypedUser { + TypedUser { + id: self.id, + name: ciphertext(&self.name), + email: ciphertext(&self.email), + email_eq: EqualityTerm::from_bytes(self.email_eq.as_slice().try_into().expect("32")), + } + } + fn generated(&self) -> GeneratedUser { + GeneratedUser { + id: self.id, + name: ciphertext(&self.name), + email: SearchableEmail { + ciphertext: ciphertext(&self.email), + equality: EqualityTerm::from_bytes( + self.email_eq.as_slice().try_into().expect("32"), + ), + }, + } + } +} +fn leaf(ciphertext: Ciphertext) -> SealedValue { + SealedValue::from_bytes(&ciphertext.to_bytes()).expect("a leaf") +} +fn ciphertext(leaf: &SealedValue) -> Ciphertext { + Ciphertext::from_bytes(&leaf.to_bytes()).expect("a ciphertext") +} + +#[derive(Deserialize, PartialEq, Debug)] +struct Opened { + id: u32, + name: String, + email: String, +} +async fn open_dynamic(cipher: &StackCipher<common::RecordingSource>, stored: &Stored) -> User { + let opened = record::decrypt(Scope::Client(cipher), stored.tree(), &data_plan(), None) + .expect("the record fits the plan") + .await + .expect("the lowering opens it"); + let opened: Opened = from_slice(opened.risky_ref()).expect("an object of the three fields"); + User { + id: opened.id, + name: opened.name, + email: opened.email, + } +} + +#[tokio::test] +async fn typed_generated_and_dynamic_declarations_are_one_engine() { + let user = alice(); + let (cipher, sent) = common::recording_cipher().await; + let keyset = cipher.default_keyset(); + let plan = data_plan(); + + // Each author seals the record. The passthrough field requests no key; + // the two sealed fields request one each, under the field's pair, and + // the equality term derives locally. + let typed: TypedUser = user.clone().encrypt_into(&keyset).await.unwrap(); + let generated: GeneratedUser = user.clone().encrypt_into(&keyset).await.unwrap(); + let dynamic = record::encrypt(&keyset, dynamic_source(&user), &plan) + .expect("the source fits the plan") + .await + .unwrap(); + // Field order within a call is the declaration's (the data plan's is + // the frame's key order), so each call is compared as a set. + let sorted = |call: &Vec<String>| { + let mut call = call.clone(); + call.sort(); + call + }; + let generated_descriptors: Vec<_> = sent.lock().unwrap().generate.iter().map(sorted).collect(); + assert_eq!( + generated_descriptors, + vec![ + vec!["users/email", "users/name"], + vec!["users/email", "users/name"], + vec!["users/email", "users/name"], + ], + "one batched request per author, the same descriptors" + ); + + let typed = Stored::of_typed(typed); + let generated = Stored::of_generated(generated); + let dynamic = Stored::of_tree(dynamic); + // The one-way terms are the same bytes, so a query term from any author + // finds the rows every author stored. + assert_eq!(typed.email_eq, generated.email_eq); + assert_eq!(generated.email_eq, dynamic.email_eq); + assert_eq!(typed.id, 42); + assert_eq!(generated.id, 42); + assert_eq!(dynamic.id, 42); + + // Every author opens every author's record: same context, same AAD. + for stored in [&typed, &generated, &dynamic] { + assert_eq!( + User::decrypt_from(stored.typed(), &cipher).await.unwrap(), + user + ); + assert_eq!( + User::decrypt_from(stored.generated(), &cipher) + .await + .unwrap(), + user + ); + assert_eq!(open_dynamic(&cipher, stored).await, user); + } + // And every open retrieved under the same descriptors. + let retrieved: Vec<_> = sent.lock().unwrap().retrieve.iter().map(sorted).collect(); + assert_eq!(retrieved.len(), 9); + assert!(retrieved + .iter() + .all(|call| call == &["users/email", "users/name"])); +} diff --git a/packages/stack-encrypt/tests/owned_records.rs b/packages/stack-encrypt/tests/owned_records.rs new file mode 100644 index 000000000..8c6b236ea --- /dev/null +++ b/packages/stack-encrypt/tests/owned_records.rs @@ -0,0 +1,919 @@ +//! 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); +} + +#[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"); +} + +// --- 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; +} + +#[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); +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 +/// 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); +} + +#[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; + let keyset = cipher.default_keyset(); + let sealed: SearchableEmail = "alice@example.com" + .to_owned() + .encrypt_into_with_context(&keyset, nonempty!("users/email")) + .await + .unwrap(); + // The record's indexes are its terms'. + assert_eq!( + <SearchableEmail as EncryptTarget<String>>::indexes(), + vec![stack_encrypt::IndexSpec::Equality] + ); + assert_eq!( + String::decrypt_from_with_context(sealed, &keyset, nonempty!("users/email")) + .await + .unwrap(), + "alice@example.com" + ); +} + +// --- Nested records, renamed sources, pinned identities -------------------- + +#[derive(Debug, PartialEq, Clone, Zeroize)] +struct Address { + street: String, + city: String, +} +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = Address, context = "addresses")] +struct EncryptedAddress { + street: Ciphertext, + city: Ciphertext, +} + +#[derive(Debug, PartialEq, Clone, Zeroize)] +struct Account { + account_id: u64, + holder: String, + address: Address, + opened: i64, +} +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = Account, context = "accounts")] +struct EncryptedAccount { + /// A passthrough under another name. + #[stash(passthrough, from = account_id)] + id: u64, + /// A sealed field under another name, keyed under the plaintext field. + #[stash(from = holder)] + owner: Ciphertext, + /// A sealed field keyed under a pinned segment. + #[stash(from = opened, identity = "opened_at")] + opened: Ciphertext, + /// A nested record: its fields sit under their own pairs, extended by + /// this one. + address: EncryptedAddress, +} + +#[tokio::test] +async fn nested_renamed_and_pinned_fields_compose_their_contexts() { + let (cipher, sent) = common::recording_cipher().await; + let keyset = cipher.default_keyset(); + let account = Account { + account_id: 9, + holder: "Carol".to_owned(), + address: Address { + street: "1 Main St".to_owned(), + city: "Springfield".to_owned(), + }, + opened: 1_600_000_000, + }; + let encrypted: EncryptedAccount = account.clone().encrypt_into(&keyset).await.unwrap(); + assert_eq!(encrypted.id, 9); + assert_eq!( + sent.lock().unwrap().generated(), + vec![ + "accounts/holder", + "accounts/opened_at", + "(addresses/street)/(accounts/address)", + "(addresses/city)/(accounts/address)", + ] + ); + let restored = Account::decrypt_from(encrypted, &keyset).await.unwrap(); + assert_eq!(restored, account); +} + +// --- A record that stores its context ------------------------------------- + +#[derive(EncryptTarget, DecryptSource)] +#[stash(plaintext = String)] +struct TextEq { + #[stash(context_field)] + identifier: &'static str, + c: Ciphertext, + hm: EqualityTerm, +} + +#[tokio::test] +async fn a_stored_context_opens_as_stored_and_checks_an_expectation() { + let (cipher, _, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let value = "alice@example.com".to_owned(); + let encrypted: TextEq = value + .clone() + .encrypt_into_with_context(&keyset, nonempty!("users/email")) + .await + .unwrap(); + assert_eq!(encrypted.identifier, "users/email"); + let generator = common::stack_cipher().await; + let hm: EqualityTerm = value + .clone() + .encrypt_into_with_context(&generator.default_keyset(), nonempty!("users/email")) + .await + .unwrap(); + assert_eq!(encrypted.hm, hm); + + // Omitted: opens under the stored identifier. + let again = TextEq { + identifier: encrypted.identifier, + c: encrypted.c.clone(), + hm: encrypted.hm.clone(), + }; + assert_eq!(String::decrypt_from(again, &keyset).await.unwrap(), value); + // Supplied: the stored identifier must equal it, before any key I/O. + let moved = TextEq { + identifier: "users/name", + c: encrypted.c.clone(), + hm: encrypted.hm.clone(), + }; + let before = retrieves.load(Ordering::SeqCst); + assert!(matches!( + String::decrypt_from_with_context(moved, &keyset, nonempty!("users/email")).await, + Err(Error::ContextMismatch { .. }) + )); + assert_eq!(retrieves.load(Ordering::SeqCst), before); + assert_eq!( + String::decrypt_from_with_context(encrypted, &keyset, nonempty!("users/email")) + .await + .unwrap(), + value + ); +} + +// --- Plaintext records over the structural leaves and a fixed context ----- + +/// The hand-written composite in `target.rs`, as an owned record: its +/// fields are the structural leaves, which take the same required context. +#[derive(EncryptTarget, DecryptSource)] +#[stash(plaintext = u32)] +struct EncryptedAge { + c: StackCipherText, + hm: EqualityTerm, + ob: OreTerm<u32>, +} + +/// A record made only of ciphertexts accepts an AEAD-only context; a term +/// beside them would be refused at its field (`tests/ui`). +#[derive(EncryptTarget, DecryptSource)] +#[stash(plaintext = String, context_type = AeadContext)] +struct SealedName { + #[stash(decrypt)] + c: StackCipherText, + #[allow(dead_code)] + shadow: Ciphertext, +} + +/// The ciphertext with its terms, as one structural leaf of an owned record. +#[derive(EncryptTarget, DecryptSource)] +#[stash(plaintext = String)] +struct Indexed { + email: Encrypted<(EqualityTerm, MatchTerms)>, +} + +/// Generic over the plaintext: whatever every field accepts. +#[derive(EncryptTarget, DecryptSource)] +struct SearchableText { + c: Ciphertext, + hm: EqualityTerm, + m: MatchTerms, +} + +#[tokio::test] +async fn plaintext_records_compose_structural_and_opaque_leaves() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let generator = common::stack_cipher().await; + let generator = generator.default_keyset(); + + let age: EncryptedAge = 42u32 + .encrypt_into_with_context(&keyset, nonempty!("users/age")) + .await + .unwrap(); + let hm: EqualityTerm = 42u32 + .encrypt_into_with_context(&generator, nonempty!("users/age")) + .await + .unwrap(); + assert_eq!(age.hm, hm); + assert_eq!( + <EncryptedAge as EncryptTarget<u32>>::indexes(), + vec![ + stack_encrypt::IndexSpec::Equality, + stack_encrypt::IndexSpec::Ore + ] + ); + assert_eq!( + u32::decrypt_from_with_context(age, &keyset, nonempty!("users/age")) + .await + .unwrap(), + 42 + ); + + let name: SealedName = "Dana" + .to_owned() + .encrypt_into_with_context(&keyset, nonempty!("users/name")) + .await + .unwrap(); + assert!(matches!( + String::decrypt_from_with_context(name, &keyset, nonempty!("users/name")).await, + Ok(opened) if opened == "Dana" + )); + + let indexed: Indexed = "alice@example.com" + .to_owned() + .encrypt_into_with_context(&keyset, nonempty!("users/email")) + .await + .unwrap(); + assert_eq!( + <Indexed as EncryptTarget<String>>::indexes().len(), + 2, + "equality and match" + ); + assert_eq!( + String::decrypt_from_with_context(indexed, &keyset, nonempty!("users/email")) + .await + .unwrap(), + "alice@example.com" + ); + + let text: SearchableText = "alice" + .to_owned() + .encrypt_into_with_context(&keyset, nonempty!("users/name")) + .await + .unwrap(); + let m: MatchTerms = "alice" + .to_owned() + .encrypt_into_with_context(&generator, nonempty!("users/name")) + .await + .unwrap(); + assert_eq!(text.m, m); + assert_eq!( + String::decrypt_from_with_context(text, &keyset, nonempty!("users/name")) + .await + .unwrap(), + "alice" + ); +} + +// --- A field whose target declares its whole context ---------------------- + +/// A target that supplies every context itself is a field of a `plaintext` +/// record only through a record that likewise takes none; here it is used +/// standalone and in a column. +struct FixedInteger(Ciphertext); +impl EncryptTarget<i32> for FixedInteger { + type ContextInput = NoCallerContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, i32, Self, K, Self::ContextInput> { + stack_encrypt::target::scalar_ciphertext() + .map(Self) + .under(nonempty!("users/fixed-age")) + .accepting() + } +} +impl DecryptSource<i32> for FixedInteger { + type ContextInput = NoCallerContext; + fn decryption<K: 'static>(self, _: Self::ContextInput) -> Decryption<i32, K> { + self.0 + .decryption(OptionalContext::<ScalarContext>::supplied( + "users/fixed-age".into(), + )) + } +} + +#[tokio::test] +async fn an_owned_record_is_a_field_of_a_structural_record_and_a_plan() { + use stack_encrypt::plan::pick; + use stack_encrypt::target::legacy::EncryptInto as _; + use stack_encrypt::{EncryptFrom, Plan}; + + #[derive(Clone)] + struct Profile { + user: User, + age: u32, + } + /// A structural record holding an owned record as a field: the bridge + /// makes the owned record an `EncryptFrom` with a `DeclaredContext`. + #[derive(EncryptFrom)] + #[stash(struct = Profile, context = "profiles")] + struct EncryptedProfile { + user: EncryptedUser, + #[allow(dead_code)] + age: EncryptedAge, + } + + let (cipher, sent) = common::recording_cipher().await; + let keyset = cipher.default_keyset(); + let profile = Profile { + user: alice(), + age: 34, + }; + let encrypted: EncryptedProfile = profile.encrypt_into(&keyset).await.unwrap(); + assert_eq!(encrypted.user.id, 42); + assert_eq!( + sent.lock().unwrap().generated(), + vec![ + "(users/name)/(profiles/user)", + "(users/email)/(profiles/user)", + "profiles/age" + ] + ); + + // And as a typed field of a plan, through the same bridge. + let plan = Plan::context("profiles") + .fields::<Profile, _>() + .encrypt_into::<EncryptedUser, _>(pick("user", |p: &Profile| &p.user)) + .target::<Ciphertext, _>(pick("age", |p: &Profile| &p.age)) + .build() + .unwrap(); + let profile = Profile { + user: bob(), + age: 51, + }; + let mut values = cipher.encrypt(&profile).using(&plan).await.unwrap(); + let user: EncryptedUser = values.take("user").unwrap(); + assert_eq!(user.id, 43); + let age: Ciphertext = values.take("age").unwrap(); + assert_eq!( + keyset + .decrypt_with_context::<u32>(age, ("profiles", "age")) + .await + .unwrap(), + 51 + ); + let _ = ( + FixedInteger::encryption::<common::CountingSource>, + RequiredContext::<u64>::into_inner, + ); + let _: ContextExtension<u64> = + <ContextExtension<u64> as stack_encrypt::target::AllowsNoContext>::omitted(); +} diff --git a/packages/stack-encrypt/tests/ui/aead_context_with_term.stderr b/packages/stack-encrypt/tests/ui/aead_context_with_term.stderr index 1087c647c..056d7150f 100644 --- a/packages/stack-encrypt/tests/ui/aead_context_with_term.stderr +++ b/packages/stack-encrypt/tests/ui/aead_context_with_term.stderr @@ -6,13 +6,13 @@ error[E0277]: the trait bound `CallerContext: From<AeadContext>` is not satisfie | = help: the following other types implement trait `From<T>`: `CallerContext` implements `From<NonEmpty<T>>` + `CallerContext` implements `From<RequiredContext<C>>` `CallerContext` implements `From<i128>` `CallerContext` implements `From<i16>` `CallerContext` implements `From<i32>` `CallerContext` implements `From<i64>` `CallerContext` implements `From<i8>` `CallerContext` implements `From<u128>` - `CallerContext` implements `From<u16>` and $N others = note: required for `AeadContext` to implement `Into<CallerContext>` = help: see issue #48214 diff --git a/packages/stack-encrypt/tests/ui/bare_context.stderr b/packages/stack-encrypt/tests/ui/bare_context.stderr index 327a95d9f..3e6b206f2 100644 --- a/packages/stack-encrypt/tests/ui/bare_context.stderr +++ b/packages/stack-encrypt/tests/ui/bare_context.stderr @@ -8,13 +8,13 @@ error[E0277]: the trait bound `CallerContext: From<&str>` is not satisfied | = help: the following other types implement trait `From<T>`: `CallerContext` implements `From<NonEmpty<T>>` + `CallerContext` implements `From<RequiredContext<C>>` `CallerContext` implements `From<i128>` `CallerContext` implements `From<i16>` `CallerContext` implements `From<i32>` `CallerContext` implements `From<i64>` `CallerContext` implements `From<i8>` `CallerContext` implements `From<u128>` - `CallerContext` implements `From<u16>` and $N others = note: required for `&str` to implement `Into<CallerContext>` note: required by a bound in `stack_encrypt::target::legacy::EncryptInto::encrypt_into_with_context` @@ -37,12 +37,12 @@ error[E0277]: the trait bound `DeclaredContext: From<&str>` is not satisfied = help: the following other types implement trait `From<T>`: `DeclaredContext` implements `From<()>` `DeclaredContext` implements `From<CallerContext>` + `DeclaredContext` implements `From<ContextExtension<C>>` `DeclaredContext` implements `From<NoCallerContext>` `DeclaredContext` implements `From<NonEmpty<T>>` `DeclaredContext` implements `From<i128>` `DeclaredContext` implements `From<i16>` `DeclaredContext` implements `From<i32>` - `DeclaredContext` implements `From<i64>` and $N others = note: required for `&str` to implement `Into<DeclaredContext>` note: required by a bound in `stack_encrypt::target::legacy::EncryptInto::encrypt_into_with_context` @@ -64,13 +64,13 @@ error[E0277]: the trait bound `CallerContext: From<&str>` is not satisfied | = help: the following other types implement trait `From<T>`: `CallerContext` implements `From<NonEmpty<T>>` + `CallerContext` implements `From<RequiredContext<C>>` `CallerContext` implements `From<i128>` `CallerContext` implements `From<i16>` `CallerContext` implements `From<i32>` `CallerContext` implements `From<i64>` `CallerContext` implements `From<i8>` `CallerContext` implements `From<u128>` - `CallerContext` implements `From<u16>` and $N others = note: required for `&str` to implement `Into<CallerContext>` note: required by a bound in `stack_encrypt::target::legacy::EncryptInto::encrypt_into_with_context` @@ -93,12 +93,12 @@ error[E0277]: the trait bound `DeclaredContext: From<&str>` is not satisfied = help: the following other types implement trait `From<T>`: `DeclaredContext` implements `From<()>` `DeclaredContext` implements `From<CallerContext>` + `DeclaredContext` implements `From<ContextExtension<C>>` `DeclaredContext` implements `From<NoCallerContext>` `DeclaredContext` implements `From<NonEmpty<T>>` `DeclaredContext` implements `From<i128>` `DeclaredContext` implements `From<i16>` `DeclaredContext` implements `From<i32>` - `DeclaredContext` implements `From<i64>` and $N others = note: required for `&str` to implement `Into<DeclaredContext>` note: required by a bound in `stack_encrypt::target::legacy::DecryptFrom::decrypt_from_with_context` @@ -121,12 +121,12 @@ error[E0277]: the trait bound `AeadContext: From<&str>` is not satisfied = help: the following other types implement trait `From<T>`: `AeadContext` implements `From<CallerContext>` `AeadContext` implements `From<NonEmpty<T>>` + `AeadContext` implements `From<RequiredContext<C>>` `AeadContext` implements `From<i128>` `AeadContext` implements `From<i16>` `AeadContext` implements `From<i32>` `AeadContext` implements `From<i64>` `AeadContext` implements `From<i8>` - `AeadContext` implements `From<u128>` and $N others = note: required for `&str` to implement `Into<AeadContext>` note: required by a bound in `decrypt_into` diff --git a/packages/stack-encrypt/tests/ui/context_field_conflicts.stderr b/packages/stack-encrypt/tests/ui/context_field_conflicts.stderr index 09e47367d..bcb6f092c 100644 --- a/packages/stack-encrypt/tests/ui/context_field_conflicts.stderr +++ b/packages/stack-encrypt/tests/ui/context_field_conflicts.stderr @@ -4,7 +4,7 @@ error: a record has exactly one `context_field` 3 | struct Twice { | ^^^^^ -error: `context_field` is metadata and cannot also be derived or defaulted +error: `context_field` is metadata and cannot also be derived, defaulted or passed through --> tests/ui/context_field_conflicts.rs:13:8 | 13 | i: String, diff --git a/packages/stack-encrypt/tests/ui/leaf_without_context.stderr b/packages/stack-encrypt/tests/ui/leaf_without_context.stderr index b408e8c03..fc886ada1 100644 --- a/packages/stack-encrypt/tests/ui/leaf_without_context.stderr +++ b/packages/stack-encrypt/tests/ui/leaf_without_context.stderr @@ -72,13 +72,13 @@ error[E0277]: the trait bound `CallerContext: From<()>` is not satisfied | = help: the following other types implement trait `From<T>`: `CallerContext` implements `From<NonEmpty<T>>` + `CallerContext` implements `From<RequiredContext<C>>` `CallerContext` implements `From<i128>` `CallerContext` implements `From<i16>` `CallerContext` implements `From<i32>` `CallerContext` implements `From<i64>` `CallerContext` implements `From<i8>` `CallerContext` implements `From<u128>` - `CallerContext` implements `From<u16>` and $N others = note: required for `()` to implement `Into<CallerContext>` note: required by a bound in `decrypt_into` diff --git a/packages/stack-encrypt/tests/ui/owned_aead_record_with_term.rs b/packages/stack-encrypt/tests/ui/owned_aead_record_with_term.rs new file mode 100644 index 000000000..5bf9d707c --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_aead_record_with_term.rs @@ -0,0 +1,15 @@ +//! A `plaintext` owned record declared `context_type = AeadContext` requires +//! an AEAD-only context, which a term cannot derive under: the term field is +//! refused where it is declared. +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{AeadContext, EncryptTarget}; +use stack_encrypt::Ciphertext; + +#[derive(EncryptTarget)] +#[stash(plaintext = String, context_type = AeadContext)] +struct SealedAndIndexed { + c: Ciphertext, + hm: EqualityTerm, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/owned_aead_record_with_term.stderr b/packages/stack-encrypt/tests/ui/owned_aead_record_with_term.stderr new file mode 100644 index 000000000..5e130aaf9 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_aead_record_with_term.stderr @@ -0,0 +1,25 @@ +error[E0277]: the trait bound `RequiredContext<CallerContext>: From<RequiredContext<AeadContext>>` is not satisfied + --> tests/ui/owned_aead_record_with_term.rs:8:10 + | + 8 | #[derive(EncryptTarget)] + | ^^^^^^^^^^^^^ the trait `From<RequiredContext<AeadContext>>` is not implemented for `RequiredContext<CallerContext>` +... +12 | hm: EqualityTerm, + | ------------ required by a bound introduced by this call + | +help: the trait `From<RequiredContext<AeadContext>>` is not implemented for `RequiredContext<CallerContext>` + but trait `From<RequiredContext<NonEmpty<_>>>` is implemented for it + --> src/target/input.rs + | + | impl<'c, T: IntoContext<'c>> From<RequiredContext<NonEmpty<T>>> for RequiredContext<CallerContext> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: for that trait implementation, expected `NonEmpty<_>`, found `AeadContext` + = note: required for `RequiredContext<AeadContext>` to implement `Into<RequiredContext<CallerContext>>` +note: required by a bound in `Encryption::<'s, S, T, K, Ctx, M>::accepting` + --> src/target/operations.rs + | + | pub fn accepting<C2>(self) -> Encryption<'s, S, T, K, C2, M> + | --------- required by a bound in this associated function + | where + | C2: Into<Ctx> + 's, + | ^^^^^^^^^ required by this bound in `Encryption::<'s, S, T, K, Ctx, M>::accepting` diff --git a/packages/stack-encrypt/tests/ui/owned_collection_context.stderr b/packages/stack-encrypt/tests/ui/owned_collection_context.stderr index 286be41fa..29ff466b4 100644 --- a/packages/stack-encrypt/tests/ui/owned_collection_context.stderr +++ b/packages/stack-encrypt/tests/ui/owned_collection_context.stderr @@ -14,6 +14,12 @@ help: the following other types implement trait `AllowsNoContext` ... | impl<C> AllowsNoContext for OptionalContext<C> { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext<C>` +... + | impl<C> AllowsNoContext for ContextExtension<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ContextExtension<C>` +... + | impl<T> AllowsNoContext for ExpectedContext<T> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ExpectedContext<T>` note: required by a bound in `stack_encrypt::EncryptInto::encrypt_into` --> src/target/conversion.rs | @@ -39,6 +45,12 @@ help: the following other types implement trait `AllowsNoContext` ... | impl<C> AllowsNoContext for OptionalContext<C> { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext<C>` +... + | impl<C> AllowsNoContext for ContextExtension<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ContextExtension<C>` +... + | impl<T> AllowsNoContext for ExpectedContext<T> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ExpectedContext<T>` note: required by a bound in `stack_encrypt::DecryptFrom::decrypt_from` --> src/target/conversion.rs | @@ -62,6 +74,12 @@ help: the following other types implement trait `AcceptsContext` ... | impl<C> AcceptsContext for OptionalContext<C> { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext<C>` +... + | impl<C> AcceptsContext for ContextExtension<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ContextExtension<C>` +... + | impl<T> AcceptsContext for ExpectedContext<T> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ExpectedContext<T>` error[E0277]: the trait bound `NoCallerContext: AcceptsContext` is not satisfied --> tests/ui/owned_collection_context.rs:44:51 @@ -79,6 +97,12 @@ help: the following other types implement trait `AcceptsContext` ... | impl<C> AcceptsContext for OptionalContext<C> { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext<C>` +... + | impl<C> AcceptsContext for ContextExtension<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ContextExtension<C>` +... + | impl<T> AcceptsContext for ExpectedContext<T> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ExpectedContext<T>` note: required by a bound in `stack_encrypt::DecryptFrom::decrypt_from_with_context` --> src/target/conversion.rs | diff --git a/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr b/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr index 1130a9e9c..cbeb50b2d 100644 --- a/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr +++ b/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr @@ -65,7 +65,7 @@ note: required by a bound in `stack_encrypt::EncryptInto::encrypt_into` | ^^^^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into` = note: this error originates in the macro `opaque` (in Nightly builds, run with -Z macro-backtrace for more info) -error[E0277]: the trait bound `&str: zeroize::Zeroize` is not satisfied +error[E0277]: the trait bound `&str: Zeroize` is not satisfied --> tests/ui/owned_collection_elements.rs:12:13 | 12 | let _ = EncryptInto::encrypt_into::<Vec<Ciphertext>, _>(vec!["borrowed", "plaintext"], keyset); @@ -81,7 +81,7 @@ error[E0277]: the trait bound `&str: zeroize::Zeroize` is not satisfied i128 i16 and $N others - = note: required for `&str` to implement `zeroize::Zeroize` + = note: required for `&str` to implement `Zeroize` = note: required for `Vec<stack_encrypt::Ciphertext>` to implement `EncryptTarget<Vec<&str>>` error[E0277]: the trait bound `Vec<u8>: OpaqueEncode` is not satisfied diff --git a/packages/stack-encrypt/tests/ui/owned_record_field_context.rs b/packages/stack-encrypt/tests/ui/owned_record_field_context.rs new file mode 100644 index 000000000..ed2b7b199 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_record_field_context.rs @@ -0,0 +1,58 @@ +//! A field of a `struct = ..` owned record is handed the pair the record +//! infers, extended by the caller's context. A target that accepts no +//! caller context, or requires one of another type, cannot take it, and is +//! refused at that field. +use stack_encrypt::target::{ + AcceptsContext, DecryptSource, Decryptable, Decryption, EncryptTarget, Encryption, + NoCallerContext, OptionalContext, RequiredContext, ScalarContext, +}; +use stack_encrypt::{nonempty, Ciphertext}; + +/// Declares its whole context itself. +struct Fixed(Ciphertext); +impl EncryptTarget<i32> for Fixed { + type ContextInput = NoCallerContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, i32, Self, K, Self::ContextInput> { + stack_encrypt::target::scalar_ciphertext() + .map(Self) + .under(nonempty!("users/fixed")) + .accepting() + } +} +impl DecryptSource<i32> for Fixed { + type ContextInput = NoCallerContext; + fn decryption<K: 'static>(self, _: Self::ContextInput) -> Decryption<i32, K> { + self.0.decryption(OptionalContext::<ScalarContext>::supplied("users/fixed".into())) + } +} + +impl Decryptable for Fixed { + const DECRYPTABLE: bool = true; +} + +/// Requires a row id from the caller, not a column pair. +struct Bound(Ciphertext); +impl Decryptable for Bound { + const DECRYPTABLE: bool = true; +} +impl EncryptTarget<i32> for Bound { + type ContextInput = RequiredContext<u64>; + fn encryption<'s, K: 'static>() -> Encryption<'s, i32, Self, K, Self::ContextInput> { + stack_encrypt::target::scalar_ciphertext().map(Self).accepting() + } +} + +#[derive(zeroize::Zeroize)] +struct Reading { + fixed: i32, + bound: i32, +} + +#[derive(EncryptTarget)] +#[stash(struct = Reading, context = "readings")] +struct EncryptedReading { + fixed: Fixed, + bound: Bound, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/owned_record_field_context.stderr b/packages/stack-encrypt/tests/ui/owned_record_field_context.stderr new file mode 100644 index 000000000..82fca205a --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_record_field_context.stderr @@ -0,0 +1,40 @@ +error[E0277]: the trait bound `NoCallerContext: From<CallerContext>` is not satisfied + --> tests/ui/owned_record_field_context.rs:54:12 + | +54 | fixed: Fixed, + | ^^^^^ the trait `From<CallerContext>` is not implemented for `NoCallerContext` + | +note: required by a bound in `Encryption::<'s, S, T, K, Ctx, M>::under` + --> src/target/operations.rs + | + | pub fn under( + | ----- required by a bound in this associated function +... + | Ctx: From<CallerContext>, + | ^^^^^^^^^^^^^^^^^^^ required by this bound in `Encryption::<'s, S, T, K, Ctx, M>::under` + +error[E0277]: the trait bound `u64: From<CallerContext>` is not satisfied + --> tests/ui/owned_record_field_context.rs:55:12 + | +55 | bound: Bound, + | ^^^^^ the trait `From<CallerContext>` is not implemented for `u64` + | + = help: the following other types implement trait `From<T>`: + `u64` implements `From<aws_lc_rs::endian::BigEndian<u64>>` + `u64` implements `From<aws_lc_rs::endian::LittleEndian<u64>>` + `u64` implements `From<bool>` + `u64` implements `From<char>` + `u64` implements `From<std::ascii::Char>` + `u64` implements `From<u16>` + `u64` implements `From<u32>` + `u64` implements `From<u8>` + and $N others + = note: required for `RequiredContext<u64>` to implement `From<CallerContext>` +note: required by a bound in `Encryption::<'s, S, T, K, Ctx, M>::under` + --> src/target/operations.rs + | + | pub fn under( + | ----- required by a bound in this associated function +... + | Ctx: From<CallerContext>, + | ^^^^^^^^^^^^^^^^^^^ required by this bound in `Encryption::<'s, S, T, K, Ctx, M>::under` diff --git a/packages/stack-encrypt/tests/ui/owned_record_required_context.rs b/packages/stack-encrypt/tests/ui/owned_record_required_context.rs new file mode 100644 index 000000000..83b0b68d3 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_record_required_context.rs @@ -0,0 +1,19 @@ +//! A `plaintext` owned record requires the caller's context: omitting it is +//! refused at the call, on both sides, however `Default` the context type is. +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{DecryptSource, EncryptTarget}; +use stack_encrypt::{Ciphertext, DecryptFrom, EncryptInto}; + +#[derive(EncryptTarget, DecryptSource)] +#[stash(plaintext = String)] +struct SearchableEmail { + ciphertext: Ciphertext, + equality: EqualityTerm, +} + +fn omitted<K: 'static>(keyset: &stack_encrypt::KeysetCipher<'_, K>, stored: SearchableEmail) { + let _ = String::from("alice@example.com").encrypt_into::<SearchableEmail, _>(keyset); + let _ = String::decrypt_from(stored, keyset); +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/owned_record_required_context.stderr b/packages/stack-encrypt/tests/ui/owned_record_required_context.stderr new file mode 100644 index 000000000..18660210b --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_record_required_context.stderr @@ -0,0 +1,59 @@ +error[E0277]: the trait bound `RequiredContext<CallerContext>: AllowsNoContext` is not satisfied + --> tests/ui/owned_record_required_context.rs:15:47 + | +15 | let _ = String::from("alice@example.com").encrypt_into::<SearchableEmail, _>(keyset); + | ^^^^^^^^^^^^ the trait `AllowsNoContext` is not implemented for `RequiredContext<CallerContext>` + | +help: the following other types implement trait `AllowsNoContext` + --> src/target/input.rs + | + | impl AllowsNoContext for NoCallerContext { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `NoCallerContext` +... + | impl<C> AllowsNoContext for OptionalContext<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext<C>` +... + | impl<C> AllowsNoContext for ContextExtension<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ContextExtension<C>` +... + | impl<T> AllowsNoContext for ExpectedContext<T> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ExpectedContext<T>` +note: required by a bound in `stack_encrypt::EncryptInto::encrypt_into` + --> src/target/conversion.rs + | + | fn encrypt_into<'a, T, K: 'static>(self, cipher: &'a KeysetCipher<'_, K>) -> Pending<'a, T, K> + | ------------ required by a bound in this associated function +... + | T::ContextInput: AllowsNoContext, + | ^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into` + +error[E0277]: the trait bound `RequiredContext<CallerContext>: AllowsNoContext` is not satisfied + --> tests/ui/owned_record_required_context.rs:16:34 + | +16 | let _ = String::decrypt_from(stored, keyset); + | -------------------- ^^^^^^ the trait `AllowsNoContext` is not implemented for `RequiredContext<CallerContext>` + | | + | required by a bound introduced by this call + | +help: the following other types implement trait `AllowsNoContext` + --> src/target/input.rs + | + | impl AllowsNoContext for NoCallerContext { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `NoCallerContext` +... + | impl<C> AllowsNoContext for OptionalContext<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext<C>` +... + | impl<C> AllowsNoContext for ContextExtension<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ContextExtension<C>` +... + | impl<T> AllowsNoContext for ExpectedContext<T> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ExpectedContext<T>` +note: required by a bound in `stack_encrypt::DecryptFrom::decrypt_from` + --> src/target/conversion.rs + | + | fn decrypt_from<'a, S, K: 'static>( + | ------------ required by a bound in this associated function +... + | S::ContextInput: AllowsNoContext, + | ^^^^^^^^^^^^^^^ required by this bound in `DecryptFrom::decrypt_from` diff --git a/packages/stack-encrypt/tests/ui/owned_struct_plaintext_not_zeroize.rs b/packages/stack-encrypt/tests/ui/owned_struct_plaintext_not_zeroize.rs new file mode 100644 index 000000000..ef07051ca --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_struct_plaintext_not_zeroize.rs @@ -0,0 +1,17 @@ +//! The plaintext of an owned record is a `Zeroize` type: the conversion +//! takes responsibility for wiping what it seals, and the bound is where +//! that responsibility is declared. +use stack_encrypt::target::EncryptTarget; +use stack_encrypt::Ciphertext; + +struct User { + email: String, +} + +#[derive(EncryptTarget)] +#[stash(struct = User, context = "users")] +struct EncryptedUser { + email: Ciphertext, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/owned_struct_plaintext_not_zeroize.stderr b/packages/stack-encrypt/tests/ui/owned_struct_plaintext_not_zeroize.stderr new file mode 100644 index 000000000..2b2f807f1 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_struct_plaintext_not_zeroize.stderr @@ -0,0 +1,119 @@ +error[E0277]: the trait bound `User: zeroize::DefaultIsZeroes` is not satisfied + --> tests/ui/owned_struct_plaintext_not_zeroize.rs:11:10 + | +11 | #[derive(EncryptTarget)] + | ^^^^^^^^^^^^^ unsatisfied trait bound + | +help: the trait `zeroize::DefaultIsZeroes` is not implemented for `User` + --> tests/ui/owned_struct_plaintext_not_zeroize.rs:7:1 + | + 7 | struct User { + | ^^^^^^^^^^^ + = help: the following other types implement trait `zeroize::DefaultIsZeroes`: + () + PhantomPinned + bool + char + f32 + f64 + i128 + i16 + and $N others + = note: required for `User` to implement `Zeroize` +note: required by a bound in `EncryptTarget` + --> src/target/conversion.rs + | + | pub trait EncryptTarget<S: Zeroize>: Sized + 'static { + | ^^^^^^^ required by this bound in `EncryptTarget` + = note: this error originates in the derive macro `EncryptTarget` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0277]: the trait bound `User: Zeroize` is not satisfied + --> tests/ui/owned_struct_plaintext_not_zeroize.rs:11:10 + | +11 | #[derive(EncryptTarget)] + | ^^^^^^^^^^^^^ unsatisfied trait bound + | +help: the trait `zeroize::DefaultIsZeroes` is not implemented for `User` + --> tests/ui/owned_struct_plaintext_not_zeroize.rs:7:1 + | + 7 | struct User { + | ^^^^^^^^^^^ + = help: the following other types implement trait `zeroize::DefaultIsZeroes`: + () + PhantomPinned + bool + char + f32 + f64 + i128 + i16 + and $N others + = note: required for `User` to implement `Zeroize` +note: required by a bound in `EncryptTarget` + --> src/target/conversion.rs + | + | pub trait EncryptTarget<S: Zeroize>: Sized + 'static { + | ^^^^^^^ required by this bound in `EncryptTarget` + = note: this error originates in the derive macro `EncryptTarget` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0277]: the trait bound `User: Zeroize` is not satisfied + --> tests/ui/owned_struct_plaintext_not_zeroize.rs:12:18 + | +12 | #[stash(struct = User, context = "users")] + | ^^^^ unsatisfied trait bound + | +help: the trait `zeroize::DefaultIsZeroes` is not implemented for `User` + --> tests/ui/owned_struct_plaintext_not_zeroize.rs:7:1 + | + 7 | struct User { + | ^^^^^^^^^^^ + = help: the following other types implement trait `zeroize::DefaultIsZeroes`: + () + PhantomPinned + bool + char + f32 + f64 + i128 + i16 + and $N others + = note: required for `User` to implement `Zeroize` +note: required by a bound in `stack_encrypt::EncryptTarget::encryption` + --> src/target/conversion.rs + | + | pub trait EncryptTarget<S: Zeroize>: Sized + 'static { + | ^^^^^^^ required by this bound in `EncryptTarget::encryption` +... + | fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::ContextInput> + | ---------- required by a bound in this associated function + +error[E0277]: the trait bound `User: Zeroize` is not satisfied + --> tests/ui/owned_struct_plaintext_not_zeroize.rs:12:18 + | +12 | #[stash(struct = User, context = "users")] + | ^^^^ unsatisfied trait bound + | +help: the trait `zeroize::DefaultIsZeroes` is not implemented for `User` + --> tests/ui/owned_struct_plaintext_not_zeroize.rs:7:1 + | + 7 | struct User { + | ^^^^^^^^^^^ + = help: the following other types implement trait `zeroize::DefaultIsZeroes`: + () + PhantomPinned + bool + char + f32 + f64 + i128 + i16 + and $N others + = note: required for `User` to implement `Zeroize` +note: required by a bound in `stack_encrypt::EncryptTarget::indexes` + --> src/target/conversion.rs + | + | pub trait EncryptTarget<S: Zeroize>: Sized + 'static { + | ^^^^^^^ required by this bound in `EncryptTarget::indexes` +... + | fn indexes() -> Vec<IndexSpec> { + | ------- required by a bound in this associated function diff --git a/packages/stack-encrypt/tests/ui/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` diff --git a/packages/stack-encrypt/tests/ui/pass/owned_records.rs b/packages/stack-encrypt/tests/ui/pass/owned_records.rs new file mode 100644 index 000000000..00c26c3d0 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/pass/owned_records.rs @@ -0,0 +1,160 @@ +//! The owned record forms that must compile: a `struct = ..` record with +//! passthrough, renamed, pinned and nested fields; `plaintext` records over +//! opaque and structural leaves, generic, and with a stored context; a column +//! of records; and the structural bridge into a legacy record and a plan. +//! `tests/ui/owned_record_*.rs` and `tests/ui/passthrough_misuse.rs` pin the +//! forms that must not. +use stack_encrypt::plan::pick; +use stack_encrypt::sem::{EqualityTerm, MatchTerms, OreTerm}; +use stack_encrypt::target::{legacy, AeadContext, DecryptSource, EncryptTarget}; +use stack_encrypt::{ + nonempty, Ciphertext, DecryptFrom, EncryptFrom, EncryptInto, Encrypted, KeysetCipher, Plan, + StackCipher, StackCipherText, +}; +use stack_kms::FakeDataKeySource; +use zeroize::Zeroize; + +#[derive(Clone, Zeroize)] +struct Address { + street: String, +} +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = Address, context = "addresses")] +struct EncryptedAddress { + street: Ciphertext, +} + +#[derive(Clone, Zeroize)] +struct User { + id: u64, + name: String, + email: String, + address: Address, + created: i64, +} +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = User, context = "users")] +struct EncryptedUser { + #[stash(passthrough)] + id: u64, + #[stash(from = name, identity = "full_name")] + full_name: Ciphertext, + email: SearchableEmail, + address: EncryptedAddress, + #[stash(passthrough, from = created)] + created_at: i64, + #[stash(default)] + version: u8, +} + +#[derive(EncryptTarget, DecryptSource)] +#[stash(plaintext = String)] +struct SearchableEmail { + ciphertext: Ciphertext, + equality: EqualityTerm, +} + +#[derive(EncryptTarget, DecryptSource)] +#[stash(plaintext = u32)] +struct EncryptedAge { + c: StackCipherText, + hm: EqualityTerm, + ob: OreTerm<u32>, +} + +#[derive(EncryptTarget, DecryptSource)] +#[stash(plaintext = String, context_type = AeadContext)] +struct SealedName { + c: StackCipherText, +} + +#[derive(EncryptTarget, DecryptSource)] +struct Searchable { + c: Encrypted<(EqualityTerm, MatchTerms)>, +} + +#[derive(EncryptTarget, DecryptSource)] +#[stash(plaintext = String)] +struct TextEq { + #[stash(context_field)] + identifier: &'static str, + c: Ciphertext, + hm: EqualityTerm, +} + +/// The structural bridge: an owned record as a field of a structural one. +#[derive(Clone)] +struct Profile { + user: User, + age: u32, +} +#[derive(EncryptFrom)] +#[stash(struct = Profile, context = "profiles")] +struct EncryptedProfile { + user: EncryptedUser, + age: EncryptedAge, +} + +async fn records( + cipher: &StackCipher<FakeDataKeySource>, + keyset: &KeysetCipher<'_, FakeDataKeySource>, + user: User, + profile: Profile, +) -> Result<(), stack_encrypt::Error> { + // A struct record, with and without an extension, and as a column. + let encrypted: EncryptedUser = user.clone().encrypt_into(keyset).await?; + let _: User = User::decrypt_from(encrypted, cipher).await?; + let encrypted: EncryptedUser = user.clone().encrypt_into_with_context(keyset, 7u64).await?; + let _: User = User::decrypt_from_with_context(encrypted, keyset, 7u64).await?; + let column: Vec<EncryptedUser> = vec![user.clone()].encrypt_into(keyset).await?; + let _: Vec<User> = Vec::decrypt_from(column, cipher).await?; + + // Plaintext records require their context. + let email: SearchableEmail = user + .email + .clone() + .encrypt_into_with_context(keyset, nonempty!("users/email")) + .await?; + let _: String = String::decrypt_from_with_context(email, keyset, nonempty!("users/email")).await?; + let age: EncryptedAge = 34u32.encrypt_into_with_context(keyset, nonempty!("users/age")).await?; + let _: u32 = u32::decrypt_from_with_context(age, keyset, nonempty!("users/age")).await?; + let name: SealedName = user + .name + .clone() + .encrypt_into_with_context(keyset, nonempty!("users/name")) + .await?; + let _: String = String::decrypt_from_with_context(name, keyset, nonempty!("users/name")).await?; + let text: Searchable = user + .name + .clone() + .encrypt_into_with_context(keyset, nonempty!("users/name")) + .await?; + let _: String = String::decrypt_from_with_context(text, keyset, nonempty!("users/name")).await?; + + // A stored context: omitted opens as stored, supplied also checks it. + let stored: TextEq = user + .email + .clone() + .encrypt_into_with_context(keyset, nonempty!("users/email")) + .await?; + let _: String = String::decrypt_from_with_context(stored, keyset, nonempty!("users/email")).await?; + let stored: TextEq = user + .email + .encrypt_into_with_context(keyset, nonempty!("users/email")) + .await?; + let _: String = String::decrypt_from(stored, keyset).await?; + + // The bridge: in a structural record, and as a typed plan field. + let _: EncryptedProfile = legacy::EncryptInto::encrypt_into(&profile, keyset).await?; + let plan = Plan::context("profiles") + .fields::<Profile, _>() + .encrypt_into::<EncryptedUser, _>(pick("user", |p: &Profile| &p.user)) + .target::<Ciphertext, _>(pick("age", |p: &Profile| &p.age)) + .build()?; + let _ = cipher.encrypt(&profile).using(&plan).await?; + Ok(()) +} + +fn main() { + let _ = records; +} diff --git a/packages/stack-encrypt/tests/ui/passthrough_misuse.rs b/packages/stack-encrypt/tests/ui/passthrough_misuse.rs new file mode 100644 index 000000000..4be64a514 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/passthrough_misuse.rs @@ -0,0 +1,49 @@ +//! `passthrough` carries a field of the plaintext struct into an owned +//! record in the clear. It has no form on the structural derives, on a +//! `plaintext` record, or beside the attributes of a derived field. +use stack_encrypt::target::{DecryptSource, EncryptTarget}; +use stack_encrypt::{Ciphertext, DecryptInto, EncryptFrom, StackCipherText}; + +#[derive(zeroize::Zeroize)] +struct User { + id: u64, + email: String, +} + +/// The structural derive moves nothing: it runs over a borrow. +#[derive(EncryptFrom, DecryptInto)] +#[stash(struct = User, context = "users")] +struct Structural { + #[stash(passthrough)] + id: u64, + email: StackCipherText, +} + +/// A `plaintext` record derives every field from the whole value. +#[derive(EncryptTarget)] +#[stash(plaintext = String)] +struct WholeValue { + #[stash(passthrough)] + id: u64, + email: Ciphertext, +} + +/// A passthrough field is moved through as it is. +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = User, context = "users")] +struct Marked { + #[stash(passthrough, decrypt)] + id: u64, + email: Ciphertext, +} + +/// One plaintext field has one output. +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = User, context = "users")] +struct Twice { + #[stash(passthrough, from = email)] + plain_email: String, + email: Ciphertext, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/passthrough_misuse.stderr b/packages/stack-encrypt/tests/ui/passthrough_misuse.stderr new file mode 100644 index 000000000..c27b94d9a --- /dev/null +++ b/packages/stack-encrypt/tests/ui/passthrough_misuse.stderr @@ -0,0 +1,23 @@ +error: `passthrough` is a field of an owned record: derive `EncryptTarget` and `DecryptSource`, which move the plaintext field into the record and back. The structural `EncryptFrom` / `DecryptInto` derives have no form for it + --> tests/ui/passthrough_misuse.rs:18:9 + | +18 | id: u64, + | ^^^ + +error: `passthrough` carries one field of the plaintext struct into the record in the clear, which is what `#[stash(struct = ..)]` reaches: a `plaintext` record derives every field from the whole value + --> tests/ui/passthrough_misuse.rs:27:9 + | +27 | id: u64, + | ^^^ + +error: a `passthrough` field is moved through as it is, so `identity`, `default` and `decrypt` do not apply to it: it is keyed under nothing, filled from the plaintext and moved back on decryption + --> tests/ui/passthrough_misuse.rs:36:9 + | +36 | id: u64, + | ^^^ + +error: `plain_email` and `email` are both derived from the plaintext field `email`: a plaintext field has one output. For a ciphertext with search terms beside it, make it one field of type `Encrypted<Terms>`, e.g. `plain_email: Encrypted<(EqualityTerm, MatchTerms)>` + --> tests/ui/passthrough_misuse.rs:46:5 + | +46 | email: Ciphertext, + | ^^^^^ diff --git a/packages/stack-encrypt/tests/ui/scalar_no_caller_context.stderr b/packages/stack-encrypt/tests/ui/scalar_no_caller_context.stderr index 4bd377c0c..c09db851a 100644 --- a/packages/stack-encrypt/tests/ui/scalar_no_caller_context.stderr +++ b/packages/stack-encrypt/tests/ui/scalar_no_caller_context.stderr @@ -12,6 +12,12 @@ help: the following other types implement trait `AcceptsContext` ... | impl<C> AcceptsContext for OptionalContext<C> { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext<C>` +... + | impl<C> AcceptsContext for ContextExtension<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ContextExtension<C>` +... + | impl<T> AcceptsContext for ExpectedContext<T> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ExpectedContext<T>` error[E0277]: the trait bound `NoCallerContext: AcceptsContext` is not satisfied --> tests/ui/scalar_no_caller_context.rs:18:44 @@ -29,6 +35,12 @@ help: the following other types implement trait `AcceptsContext` ... | impl<C> AcceptsContext for OptionalContext<C> { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext<C>` +... + | impl<C> AcceptsContext for ContextExtension<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ContextExtension<C>` +... + | impl<T> AcceptsContext for ExpectedContext<T> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ExpectedContext<T>` note: required by a bound in `stack_encrypt::DecryptFrom::decrypt_from_with_context` --> src/target/conversion.rs | diff --git a/packages/stack-encrypt/tests/ui/scalar_required_context.stderr b/packages/stack-encrypt/tests/ui/scalar_required_context.stderr index 0705bdec4..ebbe069fa 100644 --- a/packages/stack-encrypt/tests/ui/scalar_required_context.stderr +++ b/packages/stack-encrypt/tests/ui/scalar_required_context.stderr @@ -12,6 +12,12 @@ help: the following other types implement trait `AllowsNoContext` ... | impl<C> AllowsNoContext for OptionalContext<C> { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext<C>` +... + | impl<C> AllowsNoContext for ContextExtension<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ContextExtension<C>` +... + | impl<T> AllowsNoContext for ExpectedContext<T> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ExpectedContext<T>` note: required by a bound in `stack_encrypt::EncryptInto::encrypt_into` --> src/target/conversion.rs | @@ -37,6 +43,12 @@ help: the following other types implement trait `AllowsNoContext` ... | impl<C> AllowsNoContext for OptionalContext<C> { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext<C>` +... + | impl<C> AllowsNoContext for ContextExtension<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ContextExtension<C>` +... + | impl<T> AllowsNoContext for ExpectedContext<T> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ExpectedContext<T>` note: required by a bound in `stack_encrypt::DecryptFrom::decrypt_from` --> src/target/conversion.rs |