diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 602ecd6daf..4c1b46461d 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -81,6 +81,14 @@ 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\(\)$', + # Omitting a struct record's caller context declares no extra scope: + # `DeclaredScopes::omitted` returns Self::default(). + '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 e69dd133d8..c452341fd1 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 e9d023559a..73ffa44b88 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, [value-kind requirements](fixture-contract.md#value-kind-requirements) and the [value boundary profile](values-contract.md) (`v1/record-values-v1.cases.json`: every kind's edges, float specials, bytes, decimal and timestamp precision, sealed/SQL nulls per kind, cross-kind write refusals, Go host conversions); every positive case written fresh by each runner and opened by the other ([exchange profile](exchange-contract.md)) | Python/TS mappings; dates before year 1 and the timestamp range ends | -| Owned opaque ciphertext and storage envelope | `packages/stack-encrypt/tests/scalar.rs`, `frozen_bytes.rs`; initial cross-opening cases. Whole-value blobs: `packages/stack-encrypt/tests/opaque_blob.rs` (round trips, the bytes leaf shared with the structural writer, a pinned storage blob and a pinned sealed ciphertext, refusals by class after authentication, source wipe) and `languages/golang/encrypt/gensupport/gensupport_internal_test.go::TestOpaqueReadsARustWrittenBlob`; the storage profile itself is pinned upstream by Vitamin C's golden corpus; fresh sealed blobs exchanged in both directions by the [exchange profile](exchange-contract.md) | 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`, which tests the owned record derives: passthrough fields are moved, sealed fields are wiped, nested and renamed fields work, a record can store its context, and a column makes one batch. Whole-value blobs: `packages/stack-encrypt/tests/opaque_blob.rs` (round trips, the bytes leaf shared with the structural writer, a pinned storage blob and a pinned sealed ciphertext, refusals by class after authentication, source wipe) and `languages/golang/encrypt/gensupport/gensupport_internal_test.go::TestOpaqueReadsARustWrittenBlob`; the storage profile itself is pinned upstream by Vitamin C's golden corpus; fresh sealed blobs exchanged in both directions by the [exchange profile](exchange-contract.md) | Encoded targets; supported version matrix | +| Record declaration and lowering | [Declaration grammar](declaration-grammar.md); `v1/record-declarations-v1.cases.json` shared by native Rust and Go; native normalization observations; existing record lowering suites; `packages/stack-encrypt/tests/owned_equivalence.rs`, which seals one mixed, searchable record three ways: through a hand-written owned target, the owned derive, and the data plan. All three use one provider that records its requests. The test compares the descriptors and the term bytes, and checks that each way decrypts the records of the others | EQL-resolver profile; SDK-to-declaration mappings; runtime source/read shapes | | Context binding and composition | `packages/stack-encrypt/tests/target.rs`, `descriptor.rs`, `record_lowering.rs`; `languages/golang/encrypt/context_field_test.go`; initial wrong-context cases | Shared composition vectors; expected-context and required-input matrix | | Query terms and diagnostics | [Query-operator requirements](fixture-contract.md#query-operator-requirements), existing Rust/Go runners, initial equality/query-error cases and the [query-refusal profile](query-refusals-contract.md) (`v1/record-query-refusals-v1.cases.json`: every scalar operator against every field capability that does not answer it, the listed range shapes, other-kind values and nulls, by class, with the typed chain's refusal stage) | JSON operator and EQL target refusals; moving the typed chain's value refusals before the keyset | | 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 c28fe4e794..8016314731 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. An owned `OpaqueBlob` source is wiped before its pending is returned, on success and on an encoding refusal. A decrypted `OpaqueBlob` is wiped once on drop, and the `Zeroizing` its `into_inner` returns is wiped once when it is dropped, not before. | Client keys are consumed/redacted; explicit close and unreachable-client cleanup release guest memory; calls on a closed client fail. GC cleanup is eventual, so callers should close explicitly. | | `lifecycle-cancellation-v1` | Dropping an encrypt future paused in key generation, or a decrypt future paused in key retrieval, drops the provider future and operation captures, for a record operation or an owned collection; subsequent work on the cipher succeeds. Guest ABI abort spends the handle and drops the operation. | Cancellation before a call sends no request; cancellation between steps or during token fetch leaves unrelated operations usable. A guest step that overruns cancellation closes the client. Discarded late output is wiped. | -| `lifecycle-partial-output-v1` | A batch with a valid first row and a later authenticated type mismatch releases its decoded prefix without marked heap residue. Tests check this for the dynamic record path and for the owned `Vec` to `Vec` conversion. For owned collections, drop witnesses are test values that record whether they were wiped. They show that the prefix is wiped, not leaked, after a late failure or an element's panic. Existing context cases reject late authentication failures without returning partial output. An `OpaqueBlob` refused before its first field is built leaves no marked residue; a refusal after some fields are built is the `native-partial-decode-fields` limit below. | Generated and raw batch APIs return no partial rows on failure; interrupted HTTP bodies fail; cancelled output is wiped. SDK conversion diagnostics contain no decrypted values. This does not assert erasure of partially populated caller-owned Go objects. | -| `lifecycle-buffer-cleanup-v1` | The existing allocator observer checks selected write/read/JSON parse paths and has a negative control. It also checks an `OpaqueBlob`'s encoded buffer (which regrows while it holds plaintext), its planned leaf and its opened buffer, and for those blob paths that no marked block allocated during the operation is still live at its end (a leak the free-time scan cannot see), with its own negative control. The partial-decode limit is pinned separately and is not enrolled here (see below). One of those paths encrypts an owned `Vec` collection through its guard. Drop witnesses show that an owned collection's source is wiped before its pending request is returned. That includes a refused context, and a context conversion that panics. Guest ABI tests check buffer-registry cleanup, error-buffer release and protected text drop. | Request bodies survive until their owner closes them, then wipe; refused requests wipe; heap growth wipes abandoned backing arrays; guest runtime close releases memory. Post-encryption memory scans are supplementary observations. | +| `lifecycle-partial-output-v1` | A batch with a valid first row and a later authenticated type mismatch releases its decoded prefix without marked heap residue. Tests check this for the dynamic record path and for the owned `Vec` to `Vec` conversion. For owned collections, drop witnesses are test values that record whether they were wiped. They show that the prefix is wiped, not leaked, after a late failure or an element's panic. The same holds for a structural `Vec` that decrypts through `DecryptInto`, and for a column field of a structural record. For an owned record, a drop witness shows that a field opened before a later field is refused is wiped. Existing context cases reject late authentication failures without returning partial output. An `OpaqueBlob` refused before its first field is built leaves no marked residue; a refusal after some fields are built is the `native-partial-decode-fields` limit below. | Generated and raw batch APIs return no partial rows on failure; interrupted HTTP bodies fail; cancelled output is wiped. SDK conversion diagnostics contain no decrypted values. This does not assert erasure of partially populated caller-owned Go objects. | +| `lifecycle-buffer-cleanup-v1` | The existing allocator observer checks selected write/read/JSON parse paths and has a negative control. It also checks an `OpaqueBlob`'s encoded buffer (which regrows while it holds plaintext), its planned leaf and its opened buffer, and for those blob paths that no marked block allocated during the operation is still live at its end (a leak the free-time scan cannot see), with its own negative control. The partial-decode limit is pinned separately and is not enrolled here (see below). One of those paths encrypts an owned `Vec` collection through its guard. Drop witnesses show that an owned collection's source is wiped before its pending request is returned. That includes a refused context, and a context conversion that panics. It also includes the elements after an element whose declaration panics. For an owned record, drop witnesses show that the record wipes the fields it seals, also when the context is refused. When a context is refused, no copy of a sealed plaintext is dropped unwiped. Witnesses check this for a `struct` record, a nested record, a `plaintext` record, a record with a stored context, a column, a `StackCipherText`, a `Vec`, and an `Encrypted`. They also show that the record moves its passthrough fields without copying them, and that a record decrypted into a `Protected` is wiped when the `Protected` is dropped. 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. An `OpaqueBlob` whose `Serialize` refuses requests no key, and an oversized context is refused before the value is encoded; on decrypt the leaf authenticates (one retrieval) before any blob byte is read, and tampering is `Aead` however the bytes would decode. | 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` collection of any length, including an empty one. | Go uses the same engine, but this profile does not claim independently instrumented Go key-request counts. | @@ -72,7 +72,8 @@ These entries in the index are limitations, not successful requirements: cleanup limitations. The observer only scans blocks that are freed, so it misses plaintext that is leaked and never freed. For the owned `Vec` conversion, the drop witnesses in `packages/stack-encrypt/tests/collections.rs` - record the wipe itself. A test there fails if a source or a decrypted prefix + record the wipe itself. For owned records, the drop witnesses in + `packages/stack-encrypt/tests/owned_records.rs` do the same. A test there fails if a source or a decrypted prefix is leaked. The lifecycle runner preserves the observer's output. - **`caller-owned-host-values` — outside the guarantee.** Caller-owned strings, diff --git a/conformance/stack-encrypt/v1/lifecycle-v1.enrollment.json b/conformance/stack-encrypt/v1/lifecycle-v1.enrollment.json index a775455694..f4451cbba0 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", @@ -159,7 +167,12 @@ ], "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", + "a_late_element_failure_wipes_the_recovered_prefix_on_the_structural_path_too" + ], + "owned-records": [ + "a_field_opened_before_a_later_field_is_refused_is_wiped" ], "contexts": ["shared_context_boundaries"], "go": [ @@ -186,7 +199,23 @@ "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", + "a_record_decrypts_straight_into_a_protected_that_wipes_it_on_drop", + "a_sealed_field_is_not_leaked_when_the_scope_is_refused", + "a_nested_sealed_field_is_not_leaked_when_the_outer_scope_is_refused", + "a_plaintext_record_is_not_leaked_when_the_caller_context_is_refused", + "a_plaintext_record_is_not_leaked_when_the_stored_context_is_refused", + "a_sealed_column_is_not_leaked_when_the_scope_is_refused", + "a_stack_ciphertext_is_not_leaked_when_the_context_is_refused", + "a_vec_of_stack_ciphertext_is_not_leaked_when_the_context_is_refused", + "a_searchable_ciphertext_is_not_leaked_when_the_context_is_refused" ], "guest-abi": [ "buffers::tests::wipe_all_leaves_no_live_buffer", diff --git a/packages/eql/crates/eql-bindings/src/encryption.rs b/packages/eql/crates/eql-bindings/src/encryption.rs index 9680438663..e8f7c2d837 100644 --- a/packages/eql/crates/eql-bindings/src/encryption.rs +++ b/packages/eql/crates/eql-bindings/src/encryption.rs @@ -163,7 +163,7 @@ impl MaybeEmpty for Identifier { /// under ([`IntoContext`] below returns [`Describe::to_context`]), so the /// descriptor ZeroKMS binds and the AAD never disagree. It is the same /// context as the two-segment [`stack_encrypt::Label`] and as the pair a -/// `#[stash(struct = .., context = "")]` derive binds. +/// `#[stash(struct = .., scope = "
")]` derive binds. impl Describe for Identifier { fn describe(&self) -> Description { Description::text(self.t.as_str()).then_text(self.c.as_str()) diff --git a/packages/stack-encrypt-derive/Cargo.toml b/packages/stack-encrypt-derive/Cargo.toml index 0e90b04d01..98b4954713 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 ed09cfa976..1227a8ebfc 100644 --- a/packages/stack-encrypt-derive/docs/attributes.md +++ b/packages/stack-encrypt-derive/docs/attributes.md @@ -1,85 +1,350 @@ # Attributes -All attributes live under `#[stash(...)]`. +All attributes are written inside `#[stash(...)]`. There are two pairs of +derives, and they read the same attributes. + +**Use `EncryptTarget` and `DecryptSource`.** With these, encryption takes +the plaintext by value. The call `user.encrypt_into(&keyset)` moves `user` +into the encryption, so the caller has no copy left. The encryption then +wipes the plaintext: it overwrites the memory that held each encrypted +field. Only this pair accepts `passthrough`. + +`EncryptFrom` and `DecryptInto` are the older pair. They encrypt a borrowed +plaintext, `&user`. The caller keeps the original, and must wipe it. Plans +and the dynamic engine still use them. The API plan removes them once those +have moved to the new pair. + +Derive one pair or the other on a record. `EncryptTarget` and +`DecryptSource` already implement `EncryptFrom` and `DecryptInto` for the +record, so an owned record also works where the older pair is expected. +If you derive both pairs, those traits are implemented twice. That does not +compile. ## On the struct | Attribute | Effect | |---|---| -| `plaintext = Type` | The record is an encrypted form of `Type`, every field derived from the whole value. Repeatable: one impl per listed type. Omit it for an impl generic over the plaintext (see below). | -| `struct = Type` | The record encrypts the struct `Type` field by field: every derived field is derived from the plaintext field of its own name, under the pair `("", "")` (see [Structs, field by field](#structs-field-by-field)). Exclusive with `plaintext`; requires `context`. | -| `context = "..."` | With `struct` only: the first half of every field's inferred context — the stored data's name. Required, never inferred from the type's name, and must not be empty. | -| `context_type = Type` | The record's associated `Context`, for a record whose fields take the caller's context: what the caller passes. Defaults to `CallerContext`; `AeadContext` for a record made only of ciphertexts, so an `IntoAad`-only context type is accepted (see [Which context a record takes](#which-context-a-record-takes)). Not with `context_field` or `struct`. | -| `crate = "path"` | Where to find `stack_encrypt` in the generated code (default `::stack_encrypt`), for use through a re-export. | - -`plaintext` must be an owned type: the generated impl has no lifetime to give -a reference. Without `plaintext`, each derive emits one impl generic over the plaintext, -bounded by what the fields accept: `EncryptedAge` below is `EncryptFrom

` -for any `P` that both `StackCipherText` and `EqualityTerm` accept, and -`DecryptInto

` for any `P` its `decrypt` field opens to. With -`plaintext`, the record accepts only the listed types (a field that holds -integers should not accept a `String`). Whether `Type` is a struct makes no -difference to `plaintext`: the derive sees a name, not a definition, and -derives every field from the whole value. Encrypting a struct field by -field is `struct = Type`, which the plaintext is rebuilt from with a struct -literal. +| `plaintext = Type` | Every field is derived from one whole value of `Type`. You can repeat it: the derive writes one impl for each type you list. Leave it out for one impl that is generic over the plaintext. | +| `struct = Type` | The record encrypts the struct `Type` field by field. Each field is derived from the plaintext field of the same name, under the context `("", "")`. See [Structs, field by field](#structs-field-by-field). Needs `scope`. Not with `plaintext`. | +| `scope = "..."` | With `struct` only. The record's name, which is the first scope of every field's context. The field's name is the next scope. Required. It must not be empty, and the derive never takes it from the type's name. | +| `context_type = Type` | The context type that the caller passes, for a record whose fields all take the caller's context. The default is `CallerContext`. Use `AeadContext` for a record made only of ciphertexts. See [Which context a record takes](#which-context-a-record-takes). Not with `context_field` or `struct`. | +| `crate = "path"` | The path to `stack_encrypt` in the generated code. The default is `::stack_encrypt`. Set it when you use the derives through a re-export. | + +A `plaintext` type must be an owned type, not a reference. The generated +impl has no lifetime to give a reference. + +Without `plaintext`, each derive writes one impl that is generic over the +plaintext. The impl accepts any plaintext that every field accepts. For +example, `EncryptedAge` below accepts any `P` that both `StackCipherText` +and `EqualityTerm` accept. With `plaintext`, the record accepts only the +types you list. A record that stores an integer should not accept a +`String`, for example. + +`plaintext = User` treats a struct `User` as one value. It derives every +field from the whole `User`. To encrypt a struct field by field, use +`struct = User`. ## On a field | Attribute | Effect | |---|---| -| `context_field` | Store the caller’s typed context here and recover it on decryption. Exactly one per record; excludes the other field attributes. | -| `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. | - -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. -All outputs of a `plaintext = T` record share the caller's context; every -field of a `struct` derive sits under the record's (`identity` keys it under -another segment of it). Writing one value under two different contexts, a -dual write, is a fields plan that picks the same source twice, not a derive. - -Upgrading from 0.2.0, which accepted a field `context = ".."` and -`#[stash(nested)]`: removing the field literal (on a `plaintext = T` record), -writing `identity = ".."` in its place (on a `struct` derive field), or -removing `nested` each keys the field under a **different** context from the -one 0.2.0 used. Data written by 0.2.0 under the old form does not decrypt -under the new one, and its terms do not match new query terms, so re-encrypt -that data. - -Each derive emits one declaration per plaintext, with an associated `Context`. -A record with `#[stash(context_field)]` on a field of type `T` requires -`NonEmpty` for encryption and stores its inner value in that field. Decryption -takes `ExpectedContext`. Its default checks only that the stored value is nonempty and then opens the record under whatever context it stores — so a ciphertext moved together with its stored context opens as if it belonged where it now sits. `NonEmpty.into()` names the destination the caller believes it is opening; a stored context that differs is refused with `Error::ContextMismatch` before any key is retrieved. Either way the stored context is data the record arrived with, not something the cipher has authenticated. -`T` supplies the Vitamin C context encodings and implements `Clone`, -`MaybeEmpty`, and `PartialEq`. This metadata is not a separate encrypted field. - -Otherwise, a `struct` derive, whose fields all carry their own contexts, uses -`DeclaredContext`. `().into()` or its default selects the -declared contexts unchanged; a nonempty caller context extends each base context. -A `plaintext` record, whose fields all take the caller's context, uses `CallerContext`, constructed -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. +| `context_field` | This field stores the record's context, and decryption reads it back. At most one per record. Not with any other field attribute. | +| `identity = "..."` | With `struct` only. The field is derived under `("", "")`, in place of the plaintext field's name. One plain segment. The plan builder's `.identity(segment)` does the same. | +| `from = field` or `from = 0` | With `struct` only. Derive this field from `plaintext.field`, or `plaintext.0` for a tuple struct, when the names differ. Each plaintext field has one output, so two fields `from` the same plaintext field are refused. A ciphertext with search terms is one `Encrypted` field. | +| `default` or `default = expr` | The field is not derived. It is filled with `Default::default()` or `expr`. It is not encrypted and not authenticated. | +| `decrypt` | Decryption opens this field. Use it only when the field types do not decide it. See [Which field decryption opens](#which-field-decryption-opens). | +| `passthrough` | Owned derives, with `struct` only. The plaintext field is moved into the record as it is, and moved back on decryption. It is **not encrypted and not authenticated**. See [Passthrough fields](#passthrough-fields). Not with `identity`, `default` or `decrypt`. | + +A field cannot have a `context = ".."` of its own, on any derive. Every +field of a `plaintext` record shares the caller's context. Every field of a +`struct` record sits under the record's scope, and `identity` changes only +its second part. To write one value under two contexts, use a plan that +picks the same source twice. + +**Upgrading from 0.2.0.** Version 0.2.0 accepted a field `context = ".."` +and `#[stash(nested)]`. Each change below derives the field under a +different context from the one 0.2.0 used: + +- removing the field's `context = ".."` from a `plaintext` record; +- writing `identity = ".."` in its place on a `struct` record; +- removing `nested`. + +Data that 0.2.0 wrote does not decrypt under the new context. Its search +terms do not match new query terms either. Re-encrypt that data. + +## The context of each kind of record + +| Record | Encryption needs | Decryption needs | +|---|---|---| +| With a `context_field` of type `T` | A `NonEmpty`, which the record stores in that field | An `ExpectedContext`. See [Check where a record with a `context_field` came from](#check-where-a-record-with-a-context_field-came-from) | +| `struct = ..` | No context from the caller. Each field has its own | The same | +| `plaintext = ..` | A `CallerContext`, built from a `NonEmpty` or an integer | The same | + +The type `T` of a `context_field` must implement these traits: Vitamin C's +context traits, `Clone`, `MaybeEmpty`, and `PartialEq`. The stored context is not +encrypted. + +No context is ever taken from a Rust type's name. + +## Owned records + +An owned record is a struct that you derive `EncryptTarget` and +`DecryptSource` on. It takes the attributes above. + +- `user.encrypt_into(&keyset)` takes the plaintext by value, encrypts each + field, and wipes each field it encrypts. +- `Protected::::decrypt_from(record, &cipher)` decrypts the record + into a `Protected`. The `Protected` wipes the plaintext when it is + dropped. + +The plaintext type must implement `Zeroize`. + +### The context a call takes + +Each kind of record decides whether the caller must pass a context, may +pass one, or must not pass one. Its `ContextInput` type says which: + +| Record | Encrypt input | Decrypt input | Example call | +|---|---|---|---| +| `struct = User, scope = "users"` | `DeclaredScopes`, which takes no context from the caller | The same as for encryption | `user.encrypt_into(&keyset)` | +| `plaintext = T` | `RequiredContext`, or `RequiredContext` with `context_type = AeadContext` | The same as for encryption | `value.encrypt_into_with_context(&keyset, nonempty!("users/email"))` | +| With a `context_field` of type `C` | `RequiredContext>` | `ExpectedContext` | `T::decrypt_from_with_context(record, &cipher, nonempty!(..))`, which checks the stored context before it decrypts | + +#### Check where a record with a `context_field` came from + +A `context_field` record stores its own context beside its ciphertext. An +EQL value does this. Its `i` field names the table and column that the value +was written to, such as `users.email`. Decryption reads the context from that +field. + +The ciphertext is tied to the context it stores. If someone changes only +the stored context, decryption fails. But the context does not say where +the record should be. If someone copies the whole record, ciphertext and +stored context together, from `users.email` into the `admins.email` column, +it still decrypts there. + +So when you know where a record should have come from, say so: + +```rust,ignore +// The record stores its column in `#[stash(context_field)] identifier: +// &'static str`. This value was read from the `admins.email` column. +let email = Protected::::decrypt_from_with_context( + stored, + &cipher, + nonempty!("admins.email"), +) +.await?; +``` + +This refuses a record that stores any other context, with +`Error::ContextMismatch`, before any key is requested. + +`decrypt_from(stored, &cipher)`, with no context, does not make this check. +It decrypts under whatever context the record stores. Use it only when you +do not know the expected context, or have checked it another way. + +### Fields of a `struct` record + +Each field of a `struct` record has its own context, `("users", "")`. +The record's name, `users`, is the first scope. The field's name is the +next. + +A caller cannot add to it: `encrypt_into_with_context` does not compile for +a `struct` record. A part that the caller supplies, such as a tenant scope +or a row binding, must be declared on the record. The attributes that +declare one are not built yet. + +When the record is a field of another record, the outer field's context +extends each of its fields' contexts. So the field's target must accept a +context from the record. These targets accept one: + +- `Ciphertext`, whose input is `OptionalContext`. +- A term, or an `Encrypted`, whose input is + `RequiredContext`. +- `StackCipherText`, whose input is `RequiredContext`. +- A `plaintext` record. +- Another `struct` record. The outer field's context then extends the + inner record's own contexts. + +Two kinds of target are a compile error at the field. One kind builds its +whole context itself (`NoCallerContext`). The other requires a context of +another type, such as `RequiredContext`. + +The record must name every field of its plaintext, as a sealed field or a +passthrough field. The owned conversion moves the fields out of the +plaintext struct, and a field it did not name would be dropped without +being wiped. So a field that is left out is a compile error at the +`struct = ..` path ("pattern does not mention field"). + +### Passthrough fields + +Most database rows encrypt only some of their columns. A row's `id` and +`created_at` usually stay as they are, so that the database can index, +join, and sort on them. Mark those fields `#[stash(passthrough)]`: + +```rust,ignore +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = User, scope = "users")] +struct EncryptedUser { + #[stash(passthrough)] + id: u64, + name: Ciphertext, + email: SearchableEmail, + #[stash(passthrough)] + created_at: i64, +} +``` + +Encryption moves each passthrough field from the plaintext into the record +as it is, and decryption moves it back. It is never copied. + +A passthrough field is stored in the clear, like any unencrypted column. It +is not authenticated either. Someone who can write to the database can +change it, and the encrypted fields beside it still decrypt. So: + +- Do not decide who may read a row from a passthrough field alone. +- If a value must be readable and also detect changes, it belongs in the + record's context. A `struct` record cannot take a caller's context yet. + +The field's type must implement `Clone`. The owned conversion never clones +it, but the older `EncryptFrom` form of the record does, and plans run that +form. + +### Wiping + +- The conversion wipes each sealed field before the pending request is + returned. +- If a field's declaration panics, the sealed fields that are still waiting + for their own declarations are wiped. +- If the record's declaration panics while it is being built, the whole + plaintext is wiped. +- On decryption, if a later field is refused, the fields already opened are + wiped before the error is returned. + +#### Decrypt into a `Protected` + +The plaintext type itself cannot wipe on drop. The derive moves the fields +out of the plaintext struct. Rust does not allow that for a type that +implements `Drop`, so the type cannot derive `ZeroizeOnDrop`. + +So decrypt straight into a `Protected`. It wipes the plaintext when it is +dropped: + +```rust,ignore +let user = Protected::::decrypt_from(record, &cipher).await?; +let rows = Vec::>::decrypt_from(records, &cipher).await?; +``` + +The derive implements `DecryptSource>` for every plaintext +type the record names. Keep the value in its `Protected`, and take the +plaintext out only at the one place that needs it. `User::decrypt_from` still works, but it returns a bare +`User` that the caller must wipe. + +#### Traits on the plaintext type + +| Instead of | Derive | Why | +|---|---|---| +| `Debug` | `OpaqueDebug`, from `vitaminc::protected` | It prints the field names, with each value hidden | +| `PartialEq` | `TimingSafeEq`, from `vitaminc::protected` | It compares in constant time | +| `Clone` | Nothing | Each clone is another copy to wipe | + +### Fields of a `plaintext` record + +Every field of a `plaintext` record is given the one context the record +requires. So a term field in a record with `context_type = AeadContext` is a +compile error at the term, because a term needs a `CallerContext`. A call +that leaves out a required context is a compile error at the call +(`AllowsNoContext`), even if the context type implements `Default`. + +### The structural traits + +Each owned derive also implements the structural traits: + +- `EncryptTarget` also implements `EncryptFrom` and `Decryptable`. + `EncryptFrom` converts the record's structural context into the owned + input. The structural context is a `DeclaredContext`, a `CallerContext`, + or a `NonEmpty`. +- `DecryptSource` also implements `DecryptInto` and `DecryptField`. + +So an owned record can be a field of a `#[derive(EncryptFrom)]` record, or a +typed field of a plan (`encrypt_into::`), with no second derive. +A hand-written owned target that will be a record field must implement +`Decryptable` and `DecryptField`, as well as `DecryptSource`. + +### Example + +```rust +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{DecryptSource, EncryptTarget}; +use stack_encrypt::{Ciphertext, Controlled, DecryptFrom, EncryptInto, Protected, StackCipher}; + +// The plaintext derives `Zeroize`. It derives no `Debug`, `Clone` or +// `PartialEq`. +#[derive(zeroize::Zeroize)] +struct User { id: u64, name: String, email: String } + +/// An email address you can search for: its ciphertext and an equality +/// term, under the one context that the record field supplies. +#[derive(EncryptTarget, DecryptSource)] +#[stash(plaintext = String)] +struct SearchableEmail { ciphertext: Ciphertext, equality: EqualityTerm } + +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = User, scope = "users")] +struct SearchableUser { + // Not encrypted and not authenticated. + #[stash(passthrough)] + id: u64, + name: Ciphertext, // sealed under ("users", "name") + email: SearchableEmail, // derived under ("users", "email") +} + +# tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +// With no `.kms(..)`, the cipher uses ZeroKMS. It reads credentials from +// the environment, or from the profile that `npx stash auth login` writes. +let cipher = StackCipher::builder() +# .kms(stack_encrypt::kms::FakeDataKeySource::new()) + .init() + .await?; +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 seals the four sealed fields of both rows. +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(); + +// Each decrypted user is wiped when its `Protected` is dropped. +let opened = Vec::>::decrypt_from(matched, &cipher).await?; +assert_eq!(opened.len(), 1); +# assert_eq!(opened[0].risky_ref().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 -one a ciphertext is sealed under and the PRF one a term is derived under — so -building it needs a `T` that is both `IntoAad` and `IntoPrfContext`. A record -made only of ciphertexts needs only the first, and the leaf it wraps -(`StackCipherText`) asks for only that: its context is `AeadContext`. Such a -record says so with `#[stash(context_type = AeadContext)]`, and then accepts -every context the canonical `keyset.encrypt_tree(value, context)` path accepts, -including a type that implements `IntoAad` alone: +A `CallerContext` contains two encodings of the context. A ciphertext is +sealed under the AEAD encoding. A search term is derived under the PRF +encoding. So a `CallerContext` can only be built from a type that has both, +`IntoAad` and `IntoPrfContext`. + +A record made only of ciphertexts needs the AEAD encoding alone. Declare it +with `#[stash(context_type = AeadContext)]`. The record then accepts any +context that `StackCipherText` accepts, including a type that implements +only `IntoAad`: ```rust,no_run -use stack_encrypt::{DecryptInto, EncryptFrom, NonEmpty, StackCipherText}; -use stack_encrypt::target::{AeadContext, legacy::EncryptInto}; +use stack_encrypt::{EncryptInto, NonEmpty, StackCipherText}; +use stack_encrypt::target::{AeadContext, DecryptSource, EncryptTarget}; -#[derive(EncryptFrom, DecryptInto)] +#[derive(EncryptTarget, DecryptSource)] #[stash(plaintext = String, context_type = AeadContext)] struct SealedName { c: StackCipherText, @@ -91,108 +356,131 @@ let record: SealedName = name.encrypt_into_with_context(&keyset, NonEmpty::new(t # } ``` -The derive cannot pick this for you: it sees the field types' names, not -what they declare. The type you name must convert into every field's own -`Context` (`AeadContext` does not convert into `CallerContext`, so a term -field beside it is a compile error at the record, which is the point). A -record with a `context_field`, or a `struct` derive, settles its context -itself and refuses the attribute. +The derive cannot choose this for you, because it sees only the names of +the field types. The type you name must convert into the context of every +field. An `AeadContext` does not convert into a `CallerContext`, so a search +term beside it is a compile error at the record. That is intended. -A declaration is executed by `keyset.encrypt_as(&value, context)` and -`cipher.decrypt_as(record, context)`, or through the blanket -`stack_encrypt::target::legacy::{EncryptInto, DecryptFrom}` traits for the source-side spelling (`value.encrypt_into(&keyset)`, -`record.decrypt_into(&cipher, context)`). A hand-written target implements the -same two declaration methods the derives emit, `EncryptFrom::encryption` and -`DecryptInto::decryption`; neither receives the plaintext or a cipher. +A record with a `context_field`, or a `struct` record, decides its own +context. It refuses `context_type`. -Every attribute except `plaintext` is singular, and repeating one is a -compile error rather than a silent overwrite (`plaintext` is repeatable, -but each listed type only once). +A hand-written target implements the same methods that the derives write: +`EncryptTarget::encryption` and `DecryptSource::decryption`. Neither method +is given the plaintext together with a cipher. + +Every attribute except `plaintext` may be given once. Repeating one is a +compile error. You can repeat `plaintext`. You can list each type only +once. ## Structs, field by field -A `struct = ..` derive needs no attribute on its fields. With -`#[stash(struct = User, context = "users")]`, a field `age` is derived from -`user.age` under the **pair** `("users", "age")` — two context parts, which -render the ZeroKMS descriptor `users/age`; a field `email` from `user.email` -under `("users", "email")`. The first part is the container's `context` and -the second the *plaintext* field's name. Both must be plain descriptor -segments (no `/`, `(`, `)`, control or invisible character; not beginning -with `b64:`, a digit or `-`), or the descriptor would render escaped and the -ZeroKMS log would not name the column: the derive refuses a prefix such as -`"public/users"` (write `"users"`), and a tuple field — whose index begins -with a digit — must name its segment with `identity = ".."`. So `#[stash(from = email_address)] email: ..` is derived under -`("users", "email_address")`: both parts name the stored field, not the -encrypted struct. Nothing is pluralised or otherwise guessed. The pair is -what `nonempty!("users").with("age")` spells at a call site, and what a -two-segment `Label` spells. `#[stash(identity = "nickname")] name: ..` -replaces the second part only: the field is derived under -`("users", "nickname")`. A field cannot be given a context of its own -(`context = ".."` on it is refused): it always sits under the record's. Each plaintext field has exactly one output, and each output -its own segment: a ciphertext with search terms beside it is one field of -type `Encrypted` (`email: Encrypted<(EqualityTerm, MatchTerms)>`), -not a ciphertext field plus term fields `from` the same plaintext field. A -field whose type is itself a record (a `struct` derive, say) -is an ordinary field: it is handed its pair, and the inner record's own -fields sit under it, their contexts extended by it. - -A context passed by the caller extends every field's: under -`user.encrypt_into_with_context(&keyset, 7u64)` the `age` field is derived -under `(("users", "age"), 7u64)`, and a query site probes it under -`nonempty!("users").with("age").with(7u64)`. This is how a field is bound to its -record as well as its name — a record id, say — without the type having to -know the id. Decryption takes the same extension. The extension may be borrowed at the call site: its Vitamin C encodings are -owned by the declaration before execution. - -The context is part of the stored data's identity: it is the AAD of every -ciphertext in the column and the domain of every term. That is why the prefix -is required and explicit rather than inferred from the Rust type's name: two -plaintext types with the same name in different modules would otherwise -silently share every column context — equal plaintexts would produce -identical index terms across their tables, and ciphertexts would be -transplantable between them — and a rename would silently change the AAD of -everything stored. The field half *is* inferred from the plaintext field's -name, so renaming a plaintext field still changes that field's context and -stored data stops decrypting — `Error::Kms` against ZeroKMS, which refuses -the key retrieval under the changed descriptor before the AEAD runs, and -`Error::Aead` under a key source that ignores descriptors, such as the fake -one in tests — silently at the call site, with no compile-time signal. -Before such a rename, pin the old name as the field's segment with -`#[stash(identity = "old_name")]`, which keeps the pair -`("", "old_name")` while the plaintext field moves. - -A `struct` derive has no field derived from the whole plaintext, and a -`plaintext` record has none derived from a field of it: `from` and -`identity` exist only with `struct`, and the two container attributes are -exclusive. +A `struct = ..` record needs no attribute on its fields. With +`#[stash(struct = User, scope = "users")]`: + +- the field `age` is derived from `user.age`, under the context + `("users", "age")`; +- the field `email` is derived from `user.email`, under + `("users", "email")`. + +Each context has two parts: the record's `scope`, then the name of the +plaintext field. ZeroKMS logs it as the descriptor `users/age`. The same +context at a call site is `nonempty!("users").with("age")`. + +Both parts must be plain descriptor segments, so that the ZeroKMS log names +the column. A plain segment has none of these: + +- a `/`, `(` or `)`; +- a control or invisible character; +- a start of `b64:`, a digit, or `-`. + +So the derive refuses a scope such as `"public/users"`. Write `"users"`. + +A tuple field's name is its index, and an index starts with a digit. So a tuple +field must name its segment with `identity = ".."`. + +Both parts name the stored data, not the encrypted struct. With +`#[stash(from = email_address)] email: ..`, the field is derived under +`("users", "email_address")`. Nothing is made plural or guessed. + +`#[stash(identity = "nickname")] name: ..` replaces the second part only. +The field is derived under `("users", "nickname")`. + +Each plaintext field has exactly one output, with its own segment. A +ciphertext with search terms beside it is one field of type +`Encrypted`, such as `email: Encrypted<(EqualityTerm, MatchTerms)>`. +It is not a ciphertext field and term fields `from` the same plaintext +field. + +A field whose type is another `struct` record is an ordinary field. It is +given its own context, and that context extends the contexts of the inner +record's fields. + +A `struct = ..` record takes no context from the caller. A `plaintext = ..` +record and a record with a `context_field` do take one. See [The context a +call takes](#the-context-a-call-takes). A plan that contains a `struct = ..` +record can still extend each field's context. Under a plan extension `7u64`, the `age` field +is derived under `(("users", "age"), 7u64)`. + +### Why the scope is required + +The context is part of the stored data's identity. It is the associated +data of every ciphertext in the column, and the domain of every search +term. So the derive never takes the scope from the Rust type's name: + +- Two types with the same name in different modules would share every + context. Equal plaintexts would give equal search terms in both tables. + A ciphertext could be moved from one table to the other. +- Renaming the type would change the context of everything stored. + +### Renaming a plaintext field + +The second part of each context is the plaintext field's name. So renaming +a plaintext field changes that field's context, and stored data no longer +decrypts. Nothing warns you when it compiles. ZeroKMS refuses the key +request under the changed descriptor, with `Error::Kms`. A key source that +ignores descriptors, such as the fake one in tests, fails at the AEAD with +`Error::Aead` instead. + +Before you rename a field, pin its old name with +`#[stash(identity = "old_name")]`. The field keeps the context +`("", "old_name")`. + +`from` and `identity` work only with `struct`. A `struct` record has no +field derived from the whole plaintext. A `plaintext` record has no field +derived from a part of it. So you cannot give both `struct` and +`plaintext`. ## Which field decryption opens -`DecryptInto` does not need to be told: every field type says whether it is a -ciphertext or a one-way index term (`Decryptable`), and the derive requires -exactly one ciphertext — among all derived fields for a record, or in the -one field derived from each plaintext field for a `struct` derive. Too few or -too many is a compile error at the record's definition (at its first use, if -the record is generic). A derived record is itself `Decryptable` if any of -its fields is, so records nest in structs without ceremony; a type of your own -implements `Decryptable` and `DecryptField` by hand. - -`decrypt` is the override for the shapes the types cannot settle: two -ciphertexts of which one is to be opened, or a field type that is not -`Decryptable`. Once any field is marked, only the marked fields are -considered — one opened as the whole plaintext, or several with `from = ..` -rebuilding the plaintext field by field — and the field types need not be -`Decryptable`. The record's own `Decryptable` impl (emitted by -`#[derive(EncryptFrom)]`) is then `true` outright — the marker says -decryption opens the record — so a marked record still nests in structs. - -`DecryptInto` consumes the record, moving each opened field out of `self`, so -the record must not implement `Drop` (including via `ZeroizeOnDrop`); wrap the -fields that need zeroizing instead. - -Terms open nothing, so a context handed to a term field on decrypt is -checked for nothing; the ciphertext field is what authenticates, under its -own context extended with the caller's exactly as it was sealed. A record -sealed with `encrypt_into` opens with `decrypt_from` and not under any -`NonEmpty`; one sealed under an extension opens only under the same -extension. +Usually the field types decide it. Each field type says whether it is a +ciphertext or a one-way search term (`Decryptable`). The derive requires +exactly one ciphertext: + +- among all the fields of a `plaintext` record; +- among the fields derived from each plaintext field, in a `struct` record. + +Too few or too many is a compile error at the record's definition. For a +generic record, the error is at its first use. + +A derived record is itself `Decryptable` if any of its fields is, so a +record can be a field of another record. A type of your own implements +`Decryptable` and `DecryptField` by hand. + +Mark a field `decrypt` when the types do not decide it: + +- the record has two ciphertexts, of which decryption opens one; +- a field's type is not `Decryptable`. + +Once any field is marked, only the marked fields count. One marked field is +opened as the whole plaintext. Several marked fields, each with +`from = ..`, rebuild the plaintext field by field. The record's own +`Decryptable` impl is then `true`, so a marked record can still be a field +of another record. + +Decryption consumes the record, and moves each opened field out of it. So +the record type must not implement `Drop`. That rules out `ZeroizeOnDrop`. +Wrap the fields that need wiping instead. + +A search term opens nothing, so decryption ignores the context given to a +term field. The ciphertext field is what is checked, under the context it +was sealed under. diff --git a/packages/stack-encrypt-derive/src/attrs.rs b/packages/stack-encrypt-derive/src/attrs.rs index 511ef2f7ce..aaa3e6ee6a 100644 --- a/packages/stack-encrypt-derive/src/attrs.rs +++ b/packages/stack-encrypt-derive/src/attrs.rs @@ -15,15 +15,15 @@ pub(crate) struct ContainerAttrs { /// `#[stash(struct = Type)]`: the record encrypts the struct `Type` /// field by field. Every derived field is derived from the plaintext /// field of its own name (`from`), under a context made of the - /// container's `context` and the plaintext field's name, unless the - /// field says otherwise. Exclusive with `plaintext`; requires `context`. + /// container's `scope` and the plaintext field's name, unless the + /// field says otherwise. Exclusive with `plaintext`; requires `scope`. pub(crate) by_field: Option, - /// `#[stash(context = "...")]` on the container: the first half of every - /// field's inferred context, the pair `("", "")`. Names the stored - /// data, not the Rust type: it is part of the stored data's identity, so - /// it is given explicitly rather than inferred from a name a refactor - /// can change. Only meaningful with `struct`. - pub(crate) context: Option, + /// `#[stash(scope = "...")]` on the container: the record's name and the + /// first scope of every field's context, the pair `("", "")`. + /// Names the stored data, not the Rust type: it is part of the stored + /// data's identity, so it is given explicitly rather than inferred from a + /// name a refactor can change. Only meaningful with `struct`. + pub(crate) scope: Option, /// `#[stash(context_type = Type)]`: the record's associated `Context`, /// when its fields take the caller's context. The default, /// `CallerContext`, derives ciphertext and terms alike and so needs both @@ -35,7 +35,7 @@ pub(crate) struct ContainerAttrs { } const CONTAINER_KEYS: &str = "unsupported container attribute; expected `plaintext = Type`, \ - `struct = Type`, `context = \"...\"` (with `struct`), `context_type = Type` or \ + `struct = Type`, `scope = \"...\"` (with `struct`), `context_type = Type` or \ `crate = \"...\"`"; impl ContainerAttrs { @@ -43,7 +43,7 @@ impl ContainerAttrs { let mut krate: Option = None; let mut plaintexts: Vec = Vec::new(); let mut by_field: Option = None; - let mut context: Option = None; + let mut scope: Option = None; let mut context_type: Option = None; for attr in attrs.iter().filter(|a| a.path().is_ident("stash")) { @@ -59,13 +59,24 @@ impl ContainerAttrs { krate = Some(lit.parse()?); return Ok(()); } - if meta.path.is_ident("context") { - if context.is_some() { - return Err(meta.error("`context` is given twice; a struct has one prefix")); + if meta.path.is_ident("scope") { + if scope.is_some() { + return Err(meta.error( + "`scope` is given twice; a record has one name, its first scope", + )); } - context = Some(meta.value()?.parse()?); + scope = Some(meta.value()?.parse()?); return Ok(()); } + // Renamed rather than unknown, so the message can say what to + // write instead. + if meta.path.is_ident("context") { + return Err(meta.error( + "the record's name is now `scope = \"..\"` (`context = \"..\"` before): \ + it is the record's first scope, and each field's name is the next. \ + The stored data does not change", + )); + } if meta.path.is_ident("context_type") { if context_type.is_some() { return Err(meta.error( @@ -143,43 +154,43 @@ impl ContainerAttrs { // `Account` in different modules would silently share every field // context, making ciphertexts transplantable between them and index // terms comparable across them. - match (&by_field, &context) { + match (&by_field, &scope) { (Some(by_field), None) => { return Err(syn::Error::new_spanned( by_field, - "`struct = ..` needs a `context = \"..\"` beside it naming the stored data \ - (e.g. `#[stash(struct = User, context = \"users\")]`): each field is derived \ - under the pair `(\"\", \"\")`, and the prefix is part of the stored data's \ + "`struct = ..` needs a `scope = \"..\"` beside it naming the stored data \ + (e.g. `#[stash(struct = User, scope = \"users\")]`): each field is derived \ + under the pair `(\"\", \"\")`, and the record's name is part of the stored data's \ identity, so it is given explicitly rather than inferred from the Rust \ type's name", )); } - (None, Some(context)) => { + (None, Some(scope)) => { return Err(syn::Error::new( - context.span(), - "a container `context` is the prefix of the per-field contexts and applies \ - only with `struct = ..`; a `plaintext` record's fields take the caller's \ - context", + scope.span(), + "a container `scope` is the record's name, the first scope of each field's \ + context, and applies only with `struct = ..`; a `plaintext` record's fields \ + take the caller's context", )); } _ => {} } - if let Some(context) = &context { - if context.value().is_empty() { + if let Some(scope) = &scope { + if scope.value().is_empty() { return Err(syn::Error::new( - context.span(), - "an empty `context` is rejected when a value is encrypted: name the stored \ + scope.span(), + "an empty `scope` is rejected when a value is encrypted: name the stored \ data (e.g. \"users\")", )); } - if !is_plain_segment(&context.value()) { + if !is_plain_segment(&scope.value()) { return Err(syn::Error::new( - context.span(), - "a container `context` is the first segment of every field's ZeroKMS \ + scope.span(), + "a container `scope` is the first segment of every field's ZeroKMS \ descriptor, so it must be plain: no `/`, `(`, `)`, control or invisible \ character, and not beginning with `b64:`, a digit or `-`; otherwise it \ would render escaped and the log would not name the table. For the table \ - `public.users` write `context = \"users\"`", + `public.users` write `scope = \"users\"`", )); } } @@ -188,8 +199,8 @@ impl ContainerAttrs { let mut err = syn::Error::new_spanned( context_type, "`context_type` names what the caller passes to a record whose fields take the \ - caller's context; a `struct` derive's fields carry their own, so the record \ - takes `DeclaredContext` and a caller's context extends them", + caller's context; a `struct` derive's fields carry their own contexts, so it does \ + not apply", ); err.combine(syn::Error::new_spanned(by_field, "`struct` given here")); return Err(err); @@ -199,7 +210,7 @@ impl ContainerAttrs { krate: krate.unwrap_or_else(|| syn::parse_quote!(::stack_encrypt)), plaintexts, by_field, - context, + scope, context_type, }) } @@ -211,7 +222,7 @@ pub(crate) struct FieldAttrs { pub(crate) context_field: bool, /// `#[stash(identity = "...")]`: with `struct = ..`, the label segment /// this field is keyed under, in place of the plaintext field's name: - /// the field is derived under `("", "")`. The plan + /// the field is derived under `("", "")`. The plan /// builder's `.identity(segment)`. pub(crate) identity: Option, /// `#[stash(from = field)]` / `#[stash(from = 0)]`: with `struct = ..`, @@ -223,6 +234,10 @@ pub(crate) struct FieldAttrs { pub(crate) default: Option>, /// `#[stash(decrypt)]`: decryption opens this field. pub(crate) decrypt: bool, + /// `#[stash(passthrough)]`, on a field of a `struct = ..` record with the + /// owned derives. The plaintext field is moved into the record in the + /// clear, not encrypted, and moved back when the record is decrypted. + pub(crate) passthrough: bool, } impl FieldAttrs { @@ -296,13 +311,20 @@ 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") { return Err(meta.error( "`nested` is no longer accepted (0.2.0 accepted it): a field whose type \ is a record is an ordinary field, derived under \ - `(\"\", \"\")` like any other, and its own fields sit \ + `(\"\", \"\")` like any other, and its own fields sit \ under that. Omit `nested`. That is a different context from the one \ `nested` gave the inner fields: data written with `nested` does not \ decrypt without it and its terms do not match, so re-encrypt that data", @@ -310,8 +332,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 1f9b12517c..47d32dce82 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 f7e3ad4595..4776800805 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) }) @@ -50,7 +53,7 @@ fn plan_name(from: &Member) -> LitStr { LitStr::new(&name, from.span()) } -/// `#[stash(struct = User, context = "users")]`: a fields plan under the +/// `#[stash(struct = User, scope = "users")]`: a fields plan under the /// record's context, one typed field per derived field, each read by a /// picker and laid out by its own type (`encrypt_into`). fn struct_derive(input: &DeriveInput, record: &Record, fields: &[&Field]) -> TokenStream { @@ -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 7861385d4f..cc4140f36b 100644 --- a/packages/stack-encrypt-derive/src/lib.rs +++ b/packages/stack-encrypt-derive/src/lib.rs @@ -1,14 +1,28 @@ -//! 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 the declarations of encrypted records. +//! +//! Derive `EncryptTarget

` and `DecryptSource

` on the struct that holds +//! the encrypted form of a plaintext `P`. Then: +//! +//! - `value.encrypt_into(&keyset)` takes the plaintext by value, encrypts +//! it, and wipes it; +//! - `Protected::

::decrypt_from(record, &cipher)` decrypts the record into +//! a `Protected`, which wipes the plaintext when it is dropped. +//! +//! `EncryptFrom

` and `DecryptInto

` are an older pair of derives over a +//! borrowed plaintext. They take the same attributes. Plans and the dynamic +//! engine still use them, and the API plan removes them once those have +//! moved to the new pair. See [Owned records](#owned-records). +//! +//! The generated code describes the encryption. Stack Encrypt runs it. The +//! generated code is never given a cipher. //! //! ``` -//! use stack_encrypt::{EncryptFrom, DecryptInto, StackCipher, StackCipherText, nonempty}; +//! use stack_encrypt::target::{DecryptSource, EncryptTarget}; +//! use stack_encrypt::{nonempty, Controlled, DecryptFrom, EncryptInto, Protected, StackCipher, StackCipherText}; //! use stack_encrypt::sem::EqualityTerm; //! use stack_encrypt::kms::FakeDataKeySource; //! -//! #[derive(EncryptFrom, DecryptInto)] +//! #[derive(EncryptTarget, DecryptSource)] //! #[stash(plaintext = String)] //! struct TextEq { //! #[stash(context_field)] @@ -21,11 +35,13 @@ //! let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; //! let keyset = cipher.default_keyset(); //! let value = "alice@example.com".to_owned(); -//! // The output type selects ciphertext + equality; the context is NonEmpty<&str>, stored in `identifier`. -//! let encrypted: TextEq = keyset.encrypt_as(&value, nonempty!("users/email")).await?; -//! // Naming the destination requires the stored identifier to equal it before any key is retrieved. `ExpectedContext::default()` would check only that it is nonempty and open under it as stored. -//! let opened: String = cipher.decrypt_as(encrypted, nonempty!("users/email").into()).await?; -//! assert_eq!(opened, value); +//! // The output type selects a ciphertext and an equality term. The context +//! // is stored in `identifier`. +//! let encrypted: TextEq = value.clone().encrypt_into_with_context(&keyset, nonempty!("users/email")).await?; +//! // Passing the expected context refuses a value stored under any other +//! // context, before any key is requested. +//! let opened = Protected::::decrypt_from_with_context(encrypted, &cipher, nonempty!("users/email")).await?; +//! # assert_eq!(opened.risky_ref(), &value); //! # Ok::<(), Box> (()) //! # }).unwrap(); //! ``` @@ -35,7 +51,7 @@ //! made only of ciphertexts can declare `context_type = AeadContext` and //! accept a context type that implements `IntoAad` alone, as the ciphertext //! leaf itself does. -//! `struct = User, context = "users"` selects plaintext fields +//! `struct = User, scope = "users"` selects plaintext fields //! and binds each under the pair `("users", "")`, which renders //! `users/` as its ZeroKMS descriptor; `identity = ".."` on a field keys //! it under `users/` instead. The storage envelope itself adds no @@ -55,7 +71,7 @@ //! cannot drift apart (see `stack_encrypt::plan`, "The derive emits a plan", //! for a worked example): //! -//! - a `struct = User, context = "users"` record emits +//! - a `struct = User, scope = "users"` record emits //! `Plan::context("users").fields()` with //! `.encrypt_into::(pick("field", |u: &User| &u.field))` per //! derived field, and `.identity("..")` where one is pinned; @@ -87,7 +103,7 @@ //! On the decrypt side, `DecryptInto` opens a `plaintext = T` record through //! its one decryptable output (or the one `#[stash(decrypt)]` names) under //! the record's context, and a `struct` record opens each field through its -//! own `DecryptField` under `/`, extended by the caller's +//! own `DecryptField` under `/`, extended by the caller's //! context. Neither goes through the plan: each is written out by the //! derive, and the crate's tests open what one writes through the other. //! @@ -106,11 +122,12 @@ //! ``` //! use stack_encrypt::sem::EqualityTerm; //! use stack_encrypt::target::transcode::{Transcode, Visitor}; -//! use stack_encrypt::target::{self, CallerContext}; +//! use stack_encrypt::target::{self, CallerContext, EncryptTarget, RequiredContext}; //! use stack_encrypt::{ -//! CipherText, Decryptable, EncryptFrom, Encryption, Error, Encrypt, SealedValue, +//! CipherText, Decryptable, EncryptInto, Encryption, Error, Encrypt, SealedValue, //! StackCipher, nonempty, //! }; +//! use zeroize::Zeroize; //! use stack_encrypt::kms::FakeDataKeySource; //! //! /// A column that stores one sealed leaf as bytes. @@ -138,9 +155,9 @@ //! } //! // The declaration: the canonical ciphertext operation, read into this type. //! // It seals under the AEAD half of the context the record threads to it. -//! impl EncryptFrom for LeafBytes { -//! type Context = CallerContext; -//! fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> +//! impl EncryptTarget for LeafBytes { +//! type ContextInput = RequiredContext; +//! fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::ContextInput> //! where //! S: 's, //! { @@ -149,7 +166,7 @@ //! } //! //! // The record uses it like any core field type. -//! #[derive(EncryptFrom)] +//! #[derive(EncryptTarget)] //! #[stash(plaintext = String)] //! struct TextEq { //! #[stash(context_field)] @@ -163,7 +180,7 @@ //! let keyset = cipher.default_keyset(); //! let value = "alice@example.com".to_owned(); //! let context = nonempty!("users/email"); -//! let encrypted: TextEq = keyset.encrypt_as(&value, context.clone()).await?; +//! let encrypted: TextEq = value.clone().encrypt_into_with_context(&keyset, context.clone()).await?; //! //! // What the column holds is the leaf itself: the canonical path opens it. //! let leaf = SealedValue::from_bytes(&encrypted.c.0)?; @@ -173,10 +190,10 @@ //! # }).unwrap(); //! ``` //! -//! To recover the plaintext through the record rather than the canonical path, -//! the field type also implements `DecryptInto` and `DecryptField`, and the -//! record derives `DecryptInto`; the crate's `transcode` integration test shows -//! the full set. A field of a `struct = ..` record needs `DecryptField` even +//! To decrypt the plaintext through the record, the field type also +//! implements `DecryptSource` and `DecryptField`, and the record derives +//! `DecryptSource`. The crate's `transcode` integration test shows the full +//! set. A field of a `struct = ..` record needs `DecryptField` even //! when the record derives `EncryptFrom` alone, because the record's plan //! opens each field (see "The record's plan"). //! @@ -204,6 +221,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 +248,38 @@ 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. The +/// derive also implements the structural `EncryptFrom`, so the record works +/// where a structural record is expected. +/// +/// The derive takes the same attributes as `EncryptFrom`. It also takes +/// `#[stash(passthrough)]` on a field of a `struct = ..` record. A +/// passthrough field is not encrypted and not authenticated. See +/// [Owned records](crate#owned-records). +#[doc = include_str!("../docs/attributes.md")] +#[proc_macro_derive(EncryptTarget, attributes(stash))] +pub fn derive_encrypt_target(input: TokenStream) -> TokenStream { + 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, with one impl for each `plaintext` type. The derive also +/// implements the structural `DecryptInto` and `DecryptField`, so the record +/// works where a structural record is expected. +/// +/// The derive also implements `DecryptSource<Protected<Plaintext>>` for each +/// listed plaintext, so `Protected::<User>::decrypt_from(record, &cipher)` +/// returns a value that is wiped when it is dropped. A bare `User` is not. +/// 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 0000000000..1f99bdaf31 --- /dev/null +++ b/packages/stack-encrypt-derive/src/owned_decrypt.rs @@ -0,0 +1,323 @@ +//! Emit an owned `DecryptSource` declaration. Also emit the structural +//! `DecryptInto` and `DecryptField`, so the record works where a structural +//! record is expected. +//! +//! Decryption follows the same layout as the owned encryption: +//! +//! - A `struct = ..` record opens each field through the field type's +//! `DecryptField`. Each field opens under its context, extended by the +//! caller's context if there is one. The passthrough fields are moved back +//! into the plaintext. +//! - A `plaintext = T` record opens its one recoverable field under the +//! context the caller supplies. A `context_field` record opens it under the +//! context it stores, once that context has passed the caller's check. +use crate::decrypt::check; +use crate::owned_encrypt::{encrypt_input, spanned_predicate, threaded_input}; +use crate::shape::{trait_impl, zip_wiping, Field, Flavor, Record}; +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, or one plaintext field. `None` means + // the whole value. + let groups: Vec<(Option<&Member>, Vec<&Field>)> = if explicit { + let opened: Vec<&Field> = record.fields.iter().filter(|f| f.decrypt).collect(); + if record.by_field { + 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)); + if !generic { + impls.push(protected(&input, &record, &plaintext)); + } + } + + // `DecryptField`, emitted the same way as the structural derive emits it. + let mut generics = input.generics.clone(); + generics.params.push(parse_quote!(__P)); + generics.params.push(parse_quote!(__Ctx)); + 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. A `context_field` record takes a +/// check of the stored context. A `struct` record takes an extension. Any +/// other record takes the caller context the encrypt side required. +fn decrypt_input(record: &Record) -> Type { + let krate = &record.krate; + if let Some(field) = record.context_field() { + let ty = &field.ty; + parse_quote!(#krate::target::ExpectedContext<#ty>) + } else { + encrypt_input(record) + } +} + +/// A `struct = ..` record. Each field opens under its context, extended by the +/// caller's context. The fields are zipped so that a field recovered before a +/// later field fails is wiped. The plaintext is then rebuilt, with the +/// passthrough fields moved back. +fn struct_body( + record: &Record, + plaintext: &Type, + 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),* }); + // 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 opens under the +/// required context. A `context_field` record first builds that context from +/// the context it stores, and checks it against the caller's expectation. +fn value_body( + record: &Record, + plaintext: &Type, + 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), + }; + } + }); + // In a generic impl, the where clause states, for each field, what the + // body is checked against. A concrete impl reports the error at the field + // alone. + for field in fields.iter().filter(|_| generic) { + let ty = &field.ty; + if explicit { + 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(); + // Run every inspection before chaining them. A context error belongs + // to the whole declaration, not to an `Option::or_else` closure. + let locals: Vec<_> = (0..calls.len()) + .map(|n| Ident::new(&format!("__candidate_{n}"), Span::call_site())) + .collect(); + 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`. The record's structural context is converted +/// into its owned input. +fn bridge(input: &DeriveInput, record: &Record, plaintext: &Type, generic: bool) -> TokenStream { + let krate = &record.krate; + let legacy = record.context_type(true); + 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)) + } + }, + ) +} + +/// `DecryptSource<Protected<P>>`, so a caller can decrypt straight into a +/// `Protected` that wipes the plaintext when it is dropped: +/// `Protected::<User>::decrypt_from(record, &cipher)`. It takes the same +/// context input as `DecryptSource<P>`. A record generic over its plaintext +/// gets none, because its generic impl could already cover `Protected<_>`. +fn protected(input: &DeriveInput, record: &Record, plaintext: &Type) -> TokenStream { + let krate = &record.krate; + let mut generics = input.generics.clone(); + generics + .make_where_clause() + .predicates + .push(parse_quote!(Self: #krate::target::DecryptSource<#plaintext>)); + trait_impl( + input, + &generics, + quote!(#krate::target::DecryptSource<#krate::Protected<#plaintext>>), + quote! { + type ContextInput = <Self as #krate::target::DecryptSource<#plaintext>>::ContextInput; + fn decryption<__K: 'static>(self, __context: Self::ContextInput) -> #krate::target::Decryption<#krate::Protected<#plaintext>, __K> { + <Self as #krate::target::DecryptSource<#plaintext>>::decryption::<__K>(self, __context) + .map(#krate::Protected::new) + } + }, + ) +} 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 0000000000..520e8d4577 --- /dev/null +++ b/packages/stack-encrypt-derive/src/owned_encrypt.rs @@ -0,0 +1,463 @@ +//! Emit an owned `EncryptTarget` declaration. Also emit the structural +//! `EncryptFrom`. Then the record can be a field of a plan or of a structural +//! record, as well as an owned target. +//! +//! The derive composes the declarations of the fields: +//! +//! - A `struct = ..` record takes each field out of the plaintext and runs the +//! field's own target under the field's context. The borrowed form uses +//! `project`. The owned form uses a chain of `split` calls. +//! - A `plaintext = T` record runs every field's target over the whole value, +//! under the one context the caller supplies. +//! +//! The field target's `ContextInput` decides which context the field can be +//! given. So a target that cannot take the record's context is a compile error +//! at that field. +use crate::shape::{fresh_lifetime, trait_impl, ByField, Field, Flavor, Kind, OwnContext, Record}; +use proc_macro2::{Span, TokenStream}; +use quote::{quote, quote_spanned}; +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. A record whose fields have their +/// own contexts takes an extension. A record that stores its context requires +/// the `NonEmpty<T>` it stores. Any other record requires the caller context, +/// or the type `context_type` names. +pub(crate) fn encrypt_input(record: &Record) -> Type { + let krate = &record.krate; + if let Some(field) = record.context_field() { + let ty = &field.ty; + parse_quote!(#krate::target::RequiredContext<#krate::NonEmpty<#ty>>) + } else if record.by_field { + parse_quote!(#krate::target::DeclaredScopes) + } else { + let caller = caller_context(record); + parse_quote!(#krate::target::RequiredContext<#caller>) + } +} + +/// The context that every field of a `plaintext` record runs under. This is +/// the context the record is given, or the `CallerContext` built from the +/// stored context. +pub(crate) fn threaded_input(record: &Record) -> Type { + let krate = &record.krate; + let caller = caller_context(record); + parse_quote!(#krate::target::RequiredContext<#caller>) +} + +/// The context type that the fields of a `plaintext` record take. This is the +/// type `context_type` names, or `CallerContext`. +fn caller_context(record: &Record) -> Type { + let krate = &record.krate; + record + .context_type + .clone() + .unwrap_or_else(|| parse_quote!(#krate::target::CallerContext)) +} + +/// A where-predicate with the span of the field it is about. So an error about +/// what the field's type refuses is reported at that field. +pub(crate) fn spanned_predicate(span: Span, tokens: TokenStream) -> WherePredicate { + let tokens = quote_spanned!(span=> #tokens); + syn::parse2(tokens).unwrap_or_else(|error| { + 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 field gets a local variable for its +/// output. +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 local variables, and the pattern that binds each +/// one back. +fn zip_chain(locals: &[&Ident]) -> (TokenStream, TokenStream) { + let mut chain = TokenStream::new(); + let mut pattern = TokenStream::new(); + 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 that fills every field. A participating field takes its +/// local variable, and a `default` field takes its default. +fn literal(record: &Record, stored: Option<TokenStream>) -> TokenStream { + let assignments = record.fields.iter().map(|field| { + let member = &field.member; + 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, scope = "users")]`. Each field is taken out of +/// the plaintext and run by its own target, under the field's context. A +/// passthrough field is passed through. +/// +/// - The borrowed form projects each field. +/// - The owned form divides the struct between the fields with `split`. A +/// passthrough field is moved. Each sealed field is guarded by its own +/// target. +fn struct_derive(input: &DeriveInput, record: &Record) -> TokenStream { + let krate = &record.krate; + let source = &record.plaintexts[0]; + let fields = participating(record); + let locals: Vec<&Ident> = fields.iter().map(|field| &field.local).collect(); + + // The borrowed form: `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() + }; + + // The owned form: each field's own owned description, with the struct + // divided between them from right to left. + let owned: Vec<TokenStream> = fields + .iter() + .map(|field| { + 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(); + // Destructure the plaintext into one local variable for each participating + // field. The pattern has no `..`, so it must name every field. + // + // The owned form does not guard the whole value. So a plaintext field that the + // record neither seals nor moves would be dropped without being wiped. + // Such a field is a compile error at the `struct = ..` path ("pattern does + // not mention field"). The decrypt side already refuses a plaintext that + // it cannot rebuild. + let bindings = fields.iter().map(|field| { + let from = field.from().unwrap_or_else(|| unreachable!("a `from`")); + let local = &field.local; + quote!(#from: #local) + }); + let nested = nested_tuple(&locals); + // A pattern writes generic arguments with a turbofish, as a struct + // literal does: `Pt::<u64> { .. }`, not `Pt<u64> { .. }`. + let literal_path = match crate::decrypt::struct_literal_path(source) { + Ok(path) => path, + Err(error) => return error.to_compile_error(), + }; + let pattern = quote_spanned!(source.span()=> #literal_path { #(#bindings),* }); + let divide = quote!(|__source: #source| { + let #pattern = __source; + #nested + }); + let split = match locals.as_slice() { + [single] => quote!(#single.project_owned(#divide)), + [first, rest @ ..] => { + // Build `rest` as one description over the nested tuple of its + // parts, divided by identity. Then divide the struct into `first` + // and that tuple. Each `split` is given the function that wipes + // the parts still waiting for their descriptions, in case an + // earlier field's description panics. + let mut tail = { + let last = rest[rest.len() - 1]; + quote!(#last) + }; + 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::DeclaredScopes; + 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` function that a `split` is given for the parts still waiting. +/// Those parts are the nested tuple `waiting`. The function wipes every sealed +/// field in the tuple, and leaves every passthrough field as it is. +/// +/// The plaintext of a sealed field already implements `Zeroize`, as the source +/// of every `EncryptTarget` does, so this adds no bound. A passthrough field +/// is stored in the clear, so it needs no bound. +fn wipe_waiting(krate: &syn::Path, waiting: &[&Field]) -> TokenStream { + let mut wipes = Vec::new(); + let mut base = quote!((*__parts)); + 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 local variables. This is how +/// `split` divides a struct, one part at each 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 no record attribute. Every field's target +/// runs over the whole value, under the one required context, and the results +/// are zipped. A record with a `context_field` requires the `NonEmpty<T>` it +/// stores, and returns it beside the outputs. +fn value_derive(input: &DeriveInput, record: &Record, fields: &[&Field]) -> TokenStream { + let krate = &record.krate; + let input_type = encrypt_input(record); + 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)); + // Check each field, with the error at the field: a target that does + // not accept the record's context is refused there. A generic impl + // states the same check for each field in its where clause, and the + // body is checked against it. A concrete impl lets the body report the + // error at the field alone. That matters because a failure in a where + // clause is reported at the derive. + let descriptions: Vec<TokenStream> = fields + .iter() + .map(|field| { + 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`. The record's structural context is converted +/// into its owned input at the root. This impl is emitted beside every owned +/// declaration. So an owned record can be a field of a plan or of a structural +/// record, with no second derive. +fn bridge( + input: &DeriveInput, + record: &Record, + 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 568a634ccf..e3efb624d8 100644 --- a/packages/stack-encrypt-derive/src/shape.rs +++ b/packages/stack-encrypt-derive/src/shape.rs @@ -51,6 +51,15 @@ pub(crate) enum Kind { /// Not derived: `Default::default()` or the given expression. Default(Option<Expr>), Context, + /// `#[stash(passthrough)]`, on a field of a `struct = ..` record with the + /// owned derives. The plaintext field `from` is moved into the record as + /// it is, and moved back on decryption. It is not encrypted and not + /// authenticated. + Passthrough { + /// The plaintext field that is carried through. This is the field's + /// own name, or the field that `#[stash(from = field)]` names. + from: Member, + }, } /// What a field of a `struct = ..` derive has and a field of a `plaintext` @@ -71,10 +80,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,13 +113,15 @@ 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") + } } } } /// A derived field's own context, which only a `struct` derive's fields -/// have: the pair (container `context` prefix, segment), two parts, so it +/// have: the pair (container `scope`, segment), two parts, so it /// renders `prefix/field` without the field name having to be joined into, /// or kept out of, a string. The segment is the plaintext field's name or /// the field's `identity`. The derive knows no tables (ADR-0003); a consumer @@ -135,6 +151,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` and `DecryptInto`: the structural declarations. They run + /// over a borrowed plaintext. + Structural, + /// `EncryptTarget` and `DecryptSource`: the owned declarations. These also + /// emit the structural pair. + Owned, +} + /// The record a derive input describes. #[cfg_attr(test, derive(Debug))] pub(crate) struct Record { @@ -159,6 +187,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 { @@ -178,9 +210,19 @@ impl Record { } }; - // `ContainerAttrs::parse` has established that `context` is present + // `ContainerAttrs::parse` has established that `scope` is present // exactly when `struct` is. - let fields = collect(&data.fields, attrs.context.as_ref())?; + let fields = collect(&data.fields, attrs.scope.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)) @@ -192,7 +234,7 @@ impl Record { "a record has exactly one `context_field`", )); } - if fields.iter().any(|f| matches!(f.kind, Kind::Context)) && attrs.context.is_some() { + if fields.iter().any(|f| matches!(f.kind, Kind::Context)) && attrs.scope.is_some() { return Err(syn::Error::new_spanned( &input.ident, "`context_field` supplies the complete context; a literal prefix does not apply", @@ -244,7 +286,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 +310,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()) { @@ -306,7 +354,7 @@ pub(crate) fn trait_impl( } /// The fields, with what a `struct` derive (`prefix` is the container's -/// `context`) fills in: `from` is the field's own name and `context` is +/// `scope`) fills in: `from` is the field's own name and `context` is /// the pair `("<prefix>", "<from>")`; `identity` replaces the second half. /// `from` and `identity` reach into the plaintext, so they exist only with /// `struct = ..`. @@ -333,7 +381,7 @@ fn collect(fields: &Fields, prefix: Option<&LitStr>) -> Result<Vec<Field>> { return Err(syn::Error::new( identity.span(), "`identity` names the segment a field of a `struct = ..` derive is keyed \ - under, after the record's `context`; a `plaintext` record's fields are \ + under, after the record's `scope`; a `plaintext` record's fields are \ all derived from the whole value and have no segment of their own", )); } @@ -355,15 +403,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 +661,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 +670,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 +679,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 field +/// fails is wiped, not dropped as it is (`Decryption::zip_wiping`). An owned +/// record's fields open through this. +pub(crate) fn zip_wiping( + operations: Vec<(TokenStream, Ident)>, + result: TokenStream, +) -> TokenStream { + let (chain, pattern) = zip_chain(operations, "zip_wiping"); quote!(#chain.map(move |#pattern| #result)) } @@ -719,7 +803,7 @@ mod tests { // typed) plaintext field — the crossed-field failure the derive // exists to prevent — so every singular attribute rejects a repeat. let err = parse(parse_quote! { - #[stash(struct = User, context = "users")] + #[stash(struct = User, scope = "users")] struct Rec { #[stash(from = expected, from = other)] c: StackCipherText, @@ -729,7 +813,7 @@ mod tests { assert!(err.to_string().contains("`from` is given twice")); let err = parse(parse_quote! { - #[stash(struct = User, context = "users")] + #[stash(struct = User, scope = "users")] struct Rec { #[stash(identity = "email", identity = "name")] c: StackCipherText, @@ -740,7 +824,7 @@ mod tests { // Also across two `#[stash(..)]` attributes on the same field. let err = parse(parse_quote! { - #[stash(struct = User, context = "users")] + #[stash(struct = User, scope = "users")] struct Rec { #[stash(identity = "email")] #[stash(identity = "name")] @@ -779,7 +863,7 @@ mod tests { assert!(err.to_string().contains("`crate` is given twice")); let err = parse(parse_quote! { - #[stash(struct = User, struct = User, context = "users")] + #[stash(struct = User, struct = User, scope = "users")] struct Rec { c: StackCipherText, } @@ -797,7 +881,7 @@ mod tests { for context in ["nickname", "users/nickname", ""] { let inputs: [DeriveInput; 3] = [ parse_quote! { - #[stash(struct = User, context = "users")] + #[stash(struct = User, scope = "users")] struct Rec { #[stash(context = #context)] name: StackCipherText, @@ -849,7 +933,7 @@ mod tests { fn an_identity_is_one_plain_segment_of_a_struct_field() { for identity in ["", "users/name", "0name", "b64:x"] { let err = parse(parse_quote! { - #[stash(struct = User, context = "users")] + #[stash(struct = User, scope = "users")] struct Rec { #[stash(identity = #identity)] name: StackCipherText, @@ -873,7 +957,7 @@ mod tests { assert!(err.to_string().contains("have no segment"), "{err}"); let err = parse(parse_quote! { - #[stash(struct = User, context = "users")] + #[stash(struct = User, scope = "users")] struct Rec { #[stash(identity = "a", identity = "b")] name: StackCipherText, @@ -886,7 +970,7 @@ mod tests { ); let err = parse(parse_quote! { - #[stash(struct = User, context = "users")] + #[stash(struct = User, scope = "users")] struct Rec { name: StackCipherText, #[stash(default, identity = "v")] @@ -900,7 +984,7 @@ mod tests { ); let err = parse(parse_quote! { - #[stash(struct = User, context = "users")] + #[stash(struct = User, scope = "users")] struct Rec { #[stash(context_field, identity = "v")] tenant: String, @@ -941,7 +1025,7 @@ mod tests { #[test] fn a_struct_fills_in_from_and_context() { let record = parse(parse_quote! { - #[stash(struct = crate::model::UserProfile<T>, context = "user_profiles")] + #[stash(struct = crate::model::UserProfile<T>, scope = "user_profiles")] struct EncryptedUser { age: EncryptedAge, #[stash(from = email_address)] @@ -983,7 +1067,7 @@ mod tests { // An index is no name for a context: it begins with a digit, which a // descriptor reserves, so the bare form is refused… let err = parse(parse_quote! { - #[stash(struct = Reading, context = "readings")] + #[stash(struct = Reading, scope = "readings")] struct EncryptedReading(EncryptedAge, StackCipherText); }) .unwrap_err(); @@ -993,7 +1077,7 @@ mod tests { ); // …and each field names its own, still reached by index. let record = parse(parse_quote! { - #[stash(struct = Reading, context = "readings")] + #[stash(struct = Reading, scope = "readings")] struct EncryptedReading( #[stash(identity = "value")] EncryptedAge, #[stash(identity = "unit")] StackCipherText, @@ -1010,7 +1094,7 @@ mod tests { for input in [ // The field named `email` reaches `email`, and so does the term. parse_quote! { - #[stash(struct = User, context = "users")] + #[stash(struct = User, scope = "users")] struct Rec { email: StackCipherText, #[stash(from = email)] @@ -1018,7 +1102,7 @@ mod tests { } }, parse_quote! { - #[stash(struct = User, context = "users")] + #[stash(struct = User, scope = "users")] struct Rec { #[stash(from = email, identity = "a")] a: StackCipherText, @@ -1027,7 +1111,7 @@ mod tests { } }, parse_quote! { - #[stash(struct = Reading, context = "readings")] + #[stash(struct = Reading, scope = "readings")] struct Rec { #[stash(from = 0, identity = "value")] value: StackCipherText, @@ -1044,7 +1128,7 @@ mod tests { // One output per plaintext field, with `from` as a rename, is fine; // so is a `default` field beside it. let record = parse(parse_quote! { - #[stash(struct = User, context = "users")] + #[stash(struct = User, scope = "users")] struct Rec { #[stash(from = email)] address: Encrypted<(EqualityTerm, MatchTerms)>, @@ -1061,7 +1145,7 @@ mod tests { #[test] fn two_fields_are_never_keyed_under_one_segment() { let err = parse(parse_quote! { - #[stash(struct = User, context = "users")] + #[stash(struct = User, scope = "users")] struct Rec { email: StackCipherText, #[stash(identity = "email")] @@ -1077,7 +1161,7 @@ mod tests { #[test] fn a_container_prefix_that_is_not_plain_is_refused() { let err = parse(parse_quote! { - #[stash(struct = User, context = "public/users")] + #[stash(struct = User, scope = "public/users")] struct Encrypted { email: StackCipherText, } @@ -1100,14 +1184,14 @@ mod tests { }) .unwrap_err(); let message = err.to_string(); - assert!(message.contains("needs a `context = \"..\"`"), "{message}"); + assert!(message.contains("needs a `scope = \"..\"`"), "{message}"); assert!(message.contains("naming the stored data"), "{message}"); } #[test] - fn a_container_context_requires_a_struct() { + fn a_container_scope_requires_a_struct() { let err = parse(parse_quote! { - #[stash(plaintext = User, context = "users")] + #[stash(plaintext = User, scope = "users")] struct Rec { c: StackCipherText, } @@ -1116,7 +1200,7 @@ mod tests { assert!(err.to_string().contains("applies only with `struct = ..`")); let err = parse(parse_quote! { - #[stash(context = "users")] + #[stash(scope = "users")] struct Rec { c: StackCipherText, } @@ -1126,9 +1210,23 @@ mod tests { } #[test] - fn an_empty_container_context_is_rejected() { + fn the_old_container_context_names_its_replacement() { + let err = parse(parse_quote! { + #[stash(struct = User, context = "users")] + struct EncryptedUser { + age: EncryptedAge, + } + }) + .unwrap_err(); + let message = err.to_string(); + assert!(message.contains("now `scope = \"..\"`"), "{message}"); + assert!(message.contains("stored data does not change"), "{message}"); + } + + #[test] + fn an_empty_container_scope_is_rejected() { let err = parse(parse_quote! { - #[stash(struct = User, context = "")] + #[stash(struct = User, scope = "")] struct EncryptedUser { age: EncryptedAge, } @@ -1141,7 +1239,7 @@ mod tests { fn nested_is_refused_and_the_message_says_to_omit_it() { for input in [ parse_quote! { - #[stash(struct = Account, context = "accounts")] + #[stash(struct = Account, scope = "accounts")] struct Rec { #[stash(nested)] user: EncryptedUser, @@ -1241,7 +1339,7 @@ mod tests { #[test] fn context_type_applies_only_where_the_caller_settles_the_context() { let err = parse(parse_quote! { - #[stash(struct = User, context = "users", context_type = AeadContext)] + #[stash(struct = User, scope = "users", context_type = AeadContext)] struct Rec { name: StackCipherText, } diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 043ceb1804..cf5205a5e5 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -16,6 +16,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 bound. The change lets `OpaqueBlob<T>` implement `OpaqueDecode`: a blob opens as a bytes leaf and is decoded afterwards, so it cannot implement `Decrypt`. +- **The record attribute `context = ".."` is now `scope = ".."`**, on the + `EncryptFrom` and `DecryptInto` derives as on the owned ones. The value is + the record's name, the first scope of each field's context, and the + stored data does not change. Write `#[stash(struct = User, scope = + "users")]`. The old spelling is a compile error that names the new one. + The plan builder's `Plan::context(..)` and the Go `context=` tag are not + renamed yet. +- **The structural `Vec<T>: DecryptInto<Vec<P>>` and `DecryptField<Vec<P>, _>` + impls require `P: Zeroize`** (#1483). These impls used to decrypt through + `Decryption::all`. When an element failed late, the elements recovered + before it were dropped without being wiped. The impls now decrypt through + the same wiping path as the owned `Vec<T>: DecryptSource<Vec<P>>`, so + those elements are wiped before the error is returned. + - If a structural record decrypts a column into a struct plaintext, derive + `Zeroize` on that struct. + - Every scalar the engine decrypts into already implements `Zeroize`. - Repeated plan-chain `.extend(..)` calls now append parts in order, matching Go's `Cipher.Extend`, instead of replacing earlier extensions. `.extend(())` adds nothing, and grouped `NonEmpty` parts retain their grouping. @@ -55,6 +71,110 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Owned records: `#[derive(EncryptTarget, DecryptSource)]`** (#1483). A + struct now encrypts field by field into a record type that declares its + own layout. The crate docs' "Records" section and the derive crate's + "Owned records" section show each form. The `tests/ui` cases pin the + forms that are refused, with the error at the field. + - With `#[stash(struct = User, scope = "users")]`, each field's target + is given the context `("users", "<field>")`: the record's name, then the + field's name. `#[stash(from = ..)]` + names another source field, and `identity = ".."` sets the second part + of the context, as on the structural derive. + - A `#[stash(passthrough)]` field is moved from the plaintext into the + record, in the clear. It is never copied. A passthrough field is **not + encrypted and not authenticated**. Use it only for values that are not + secret, and treat the value read back as untrusted input. + - `user.encrypt_into(&keyset)` consumes the plaintext, wipes every field + it seals, and moves the passthrough fields. `User::decrypt_from(record, + &cipher)` decrypts each field under its context, and moves the + passthrough fields back. + - A `struct` record takes no context from the caller. Its input is + `DeclaredScopes`, so `encrypt_into_with_context` does not compile for + it. A caller's scope or row binding will be declared on the record, with + attributes that are not built yet. + - Records nest. The outer field's context extends the contexts of the + inner record's fields. + - A `#[stash(plaintext = String)]` record can be searched, for example a + `Ciphertext` beside an `EqualityTerm`. It can be a field of a `struct` + record. + - A `context_field` record stores its context, and decrypts under an + `ExpectedContext`. + - A `Vec<User>` encrypts into a `Vec<Record>` with one batched key + request. + - The plaintext type must implement `Zeroize`. A `struct` record must name + every field of its plaintext, as a sealed field or a passthrough field. + A field it leaves out is a compile error, so no field is dropped without + being wiped. For the same reason, the plaintext type cannot implement + `Drop`, so it cannot derive `ZeroizeOnDrop`. + - The decrypt derive also implements `DecryptSource<Protected<P>>` for + each plaintext type the record names. `Protected::<User>::decrypt_from( + record, &cipher)` returns the plaintext inside a `Protected`, which + wipes it when it is dropped. A `Vec` of records decrypts into a + `Vec<Protected<User>>`. `User::decrypt_from` still returns a bare + `User`, which the caller must wipe. + - If a later field is refused during decryption, the fields already + decrypted are wiped. + - Both derives also implement the structural `EncryptFrom`, + `DecryptInto`, and `DecryptField`. So an owned record can be a field of a + `#[derive(EncryptFrom)]` record, or a typed plan field, with no second + derive. Derive one pair on a record, not both. +- **A `Ciphertext` decrypts into a `Protected`.** `OpaqueDecode` now covers + every `Decrypt + Zeroize` type, not only the `FromScalar` ones. So + `keyset.decrypt::<Protected<String>>(ct)` and + `Protected::<i32>::decrypt_from_with_context(ct, &keyset, ctx)` work, and + the `Protected` wipes the plaintext when it is dropped. A target that is + not a scalar, such as `Vec<String>`, now compiles too. Opening refuses it + with `Error::OpenedType`. +- **`Decryption::zip_wiping`**, a `zip` for recovered plaintext. The right + side can fail after its key arrives, or panic in its conversion. The + plaintext that the left side recovered is then wiped, not dropped as it + is. The owned record derive decrypts its fields through this method. +- **`EncryptTarget::owned_encryption` and `EncryptTarget::indexes`.** + - `owned_encryption` describes the encryption over the plaintext by + value, in `Owned` source mode. By default, it guards the value with + `Protected`, runs `encryption` over a borrow of it, and wipes it before + the pending request is returned. `EncryptInto` and the owned column run + this method. + - A record overrides `owned_encryption`. It divides the struct between its + fields with the new `Encryption::split`. `split` runs two descriptions + over two parts of one value, under one context. `split` also takes a + `wipe` function for the part that waits while the first description + runs. If that description panics, `wipe` wipes the waiting part. + - `Encryption::project_owned` is also new, for generated code. The method is + hidden from the docs. It drops the other fields of the struct without + wiping them. The derive calls it only when the plaintext has one field. + - `EncryptInto` guards the plaintext until the declaration and its context + have been built. So a declaration that panics while it is being built + wipes the plaintext. + - `indexes` returns the indexes that a plan field typed with the target + supports. The terms, `Encrypted<Terms>`, and `plaintext` records report + their indexes. +- **The core leaves implement the owned declarations.** + - `StackCipherText` takes a `RequiredContext<AeadContext>`, to encrypt + and to decrypt. The owned form seals the value without a copy. When the + context is refused, the value is wiped. + - `Encrypted<Terms>` takes a `RequiredContext<CallerContext>` to encrypt, + and a `RequiredContext<AeadContext>` to decrypt. The owned form wipes + the ciphertext's copy of the value when the context is refused. + - `EqualityTerm`, `MatchTerms`, `OreTerm`, `OpeTerm`, and `OreBlockTerm` + implement `EncryptTarget` alone. + - `Ciphertext` implements `Decryptable` and `DecryptField`, so it can be a + record field. +- **`DeclaredScopes`** is the input of a `struct` record, whose fields have + their own contexts. The caller passes no context. A record that contains + the record, or a plan, passes the field's context, which extends each of + the inner record's contexts. +- **`ExpectedContext<T>` is a `ContextInput`.** If the caller leaves the + context out, the record decrypts under the context it stores, unchecked. + If the caller passes a `NonEmpty<T>`, the stored context must equal it. + - `DeclaredScopes` and `ExpectedContext` convert from the structural + contexts, `DeclaredContext` and `CallerContext`. So do the + `RequiredContext` wrappers of the core contexts. This is how a record + field is given its context. +- **`FieldsBuilder::target::<T, _>()`** adds a plan field laid out by an + owned target that has no structural declaration, such as a `Ciphertext`. + It lowers to `<T as EncryptTarget<F>>::encryption().accepting::<CallerContext>()`. - **Owned collections: `EncryptInto` encrypts a `Vec<S>` into a `Vec<T>`, and `DecryptFrom` decrypts it back** (#1482). `Vec<T>` implements `EncryptTarget<Vec<S>>` and `DecryptSource<Vec<P>>` for any owned element @@ -75,9 +195,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 call for each keyset to decrypt it. An empty collection makes no call. The key source can still split a logical batch into several requests to its provider. - - The plaintext `Vec` is wiped before the pending request is returned, even - when the context is refused. The elements do not need to implement - `Clone`. + - Each element is wiped by its own declaration before the pending request + is returned, even when the context is refused. If a declaration panics, + the elements not yet handed over are wiped too. The elements do not need + to implement `Clone`. - If an element fails after earlier elements were decrypted, the earlier plaintexts are wiped before the error is returned. If an element's conversion panics, they are wiped before the panic unwinds. @@ -196,6 +317,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Before, it was built only with the `dynamic` feature. The `dynamic` feature now adds no dependency. +### Deprecated + +- **The structural derives `#[derive(EncryptFrom, DecryptInto)]` and the + `target::legacy` conversion traits.** New records derive `EncryptTarget` + and `DecryptSource`. These derives also implement the structural traits. The + structural API stays for now, because the plans and the dynamic engine + run it. It is where a migration starts, not where it ends. + - The structural API will be removed once every consumer has an owned + equivalent. That removal is tracked in #1488. + - No `#[deprecated]` attribute is applied yet, because the crate's own + plan and dynamic modules still implement these traits. + +### Fixed + +- **A refused context no longer drops a copy of the plaintext unwiped** + (#1504). `target::ciphertext()` cloned a borrowed plaintext before it + checked the context. When the context was too long for a ZeroKMS + descriptor, the clone was dropped without being wiped. This affected + `encrypt_as`, plans, and every owned target with a `StackCipherText` or + `Encrypted<Terms>` field. + - `ciphertext()` now checks the context before it takes the plaintext. + So a refused context makes no copy. + - In `Owned` mode, a refused context still drops the value as it is, + because `ciphertext()` does not require `Zeroize`. The owned forms of + `StackCipherText` and `Encrypted<Terms>` wipe it instead. + - `json_document()` now guards its copy of the document before it checks + the context. So a refused context wipes that copy. + ## [0.3.0] - 2026-10-27 ### Breaking @@ -1394,7 +1543,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `/`). A data key minted through 0.1.0 was bound to the old rendering of the same context and cannot be retrieved under this one. 0.1.0 had one known consumer, aware of this; see ADR-0006. -- `#[derive(EncryptFrom)]` with `struct = User, context = "users"` binds each +- `#[derive(EncryptFrom)]` with `struct = User, scope = "users"` binds each field under the pair `("users", "<field>")`, the same context EQL's `Identifier` is. A `#[stash(context = "…")]` literal on a field stays one text part, exactly as written. 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 0000000000..e019fe8afa --- /dev/null +++ b/packages/stack-encrypt/docs/adr/0012-owned-record-declarations.md @@ -0,0 +1,273 @@ +--- +status: accepted +date: 2026-11-03 +revised: 2026-11-09 +extends: ADR-0003, ADR-0004 +--- + +# Records are owned targets, and the structural traits become a bridge + +## Summary + +A record is a struct whose fields are encrypted one by one. Some of its +fields can be stored in the clear. This ADR records how a record is +declared, encrypted and decrypted: + +- You derive `EncryptTarget` and `DecryptSource` on the encrypted struct. + The plaintext struct carries no attributes. +- `user.encrypt_into(&keyset)` takes the plaintext by value. It encrypts and + wipes each field it encrypts. It moves each passthrough field into the + record, with no copy. +- `Protected::<User>::decrypt_from(record, &cipher)` decrypts the record + into a `Protected`. The `Protected` wipes the plaintext when it is + dropped. +- A record's name is its first scope, `#[stash(scope = "users")]`. Each + field's context is the record's name, then the field's name. The caller + adds nothing to it. +- The same derives implement the older structural traits, `EncryptFrom` and + `DecryptInto`, until the API plan removes them. + +The API plan, `docs/plans/2026-10-24-stack-encrypt-api-plan.md`, sets the +direction. This ADR is the part of it that the record derives build. + +## Context + +Stack Encrypt has two APIs for declaring what a value encrypts into: + +- **The owned API.** The conversions `EncryptInto` and `DecryptFrom` run the + declarations `EncryptTarget` and `DecryptSource`. Encryption takes the + plaintext by value and wipes it. Before this change, it covered one value, + and a `Vec` of values. +- **The structural API.** The declarations `EncryptFrom` and `DecryptInto` + encrypt a borrowed plaintext. The record derives, the plan builder, and + the dynamic engine used them. They take their context from `Default`. + +A typical database row has some encrypted columns, such as `name` and +`email`, and some plain ones, such as `id` and `created_at`. To write that +row as a record, a developer needed three things that did not exist: + +- a derive that takes the plaintext by value and wipes it; +- a way to say "store this field in the clear"; +- one rule for the context of each field, shared with the one-value API. + +## Decision + +### 1. One declaration trait, with two forms of the description + +`EncryptTarget<S>` keeps `encryption()`. It describes the encryption of a +borrowed `&S`. This change adds `owned_encryption()`. It describes the +encryption of `S` by value. + +By default, `owned_encryption()` puts the value in a `Protected`, runs the +borrowed description over it, and wipes it before the pending request is +returned. A declaration that only composes operations therefore needs no new +code. `EncryptInto` runs `owned_encryption()`. So does a `Vec` of targets, +for each element. + +| Method | Given | Who wipes the plaintext | +|---|---|---| +| `encryption()` | A borrowed `&S` | The caller, or the owner of the borrow | +| `owned_encryption()` | `S` by value | The declaration. By default, it guards the value and wipes it | +| `decryption(self, input)` | The stored record | The engine until the call returns. Then the caller, or the `Protected` the caller decrypts into | + +### 2. A record divides its plaintext between its fields + +A `struct = User` record composes the targets of its fields. + +- **The borrowed form** takes each field out of the borrow, with + `Encryption::project`. +- **The owned form** divides the struct with a chain of `Encryption::split` + calls. `split` gives two different parts of one value to two descriptions, + under one context. + - A passthrough field is moved into the record, and never copied. + - Each encrypted field runs its own target's `owned_encryption()`. So a + `String` field is guarded and wiped by the `Ciphertext` target that + encrypts it. + +The function that divides the struct is a plain function pointer that +destructures it. It captures nothing, so it cannot reach a cipher. It can +only move values. The selector that `project` takes has the same limits. +This is how the code makes sure that a conversion wipes what it encrypts and +moves what it stores in the clear. + +`split` runs the description of its first part while the second part waits. +It is given a `wipe` function for the waiting part. If the first description +panics, the waiting part is passed to `wipe`, so it is not dropped without +being wiped. The derive writes each `wipe` to wipe the encrypted fields that +are still waiting, and to leave the passthrough fields alone. A `Vec` treats +the elements it has not reached yet the same way. + +`EncryptInto` keeps the whole plaintext in a `Protected` until the +declaration and its context have been built. So a declaration that panics +while it is being built still wipes the plaintext. + +The destructure names every field. The owned form does not guard the whole +value. So a plaintext field that the record neither encrypts nor moves would +be dropped without being wiped. Such a field is a compile error instead. + +That is one of the plan's two safety rules for records. The other is that a +field is stored in the clear only where `passthrough` is written on it. + +On decryption, the fields are opened through `Decryption::zip_wiping`. If a +later field is refused, the fields already opened are wiped. + +A passthrough field is not encrypted and not authenticated (ADR-0010). The +field's type must implement `Clone`, because the borrowed form copies it. +The owned form never copies it. A test with a `Clone` that panics checks +that. + +### 3. The context of a record + +A record's name is its first scope: `#[stash(struct = User, scope = +"users")]`. The field's name is the next scope. So the `email` field is +encrypted under `("users", "email")`. These are the record's declared +scopes, in the plan's terms. + +Each declaration has a `ContextInput` type. The type says whether the caller +must pass a context, may pass one, or must not pass one. This change adds +two: + +- **`DeclaredScopes`**, for a `struct` record. The caller passes no context. + `encrypt_into_with_context` does not compile for such a record. A record + that contains this record passes it the field's context, and so does a + plan. That context extends each of the inner record's contexts. +- **`ExpectedContext<T>`**, for decrypting a record that stores its own + context in a `context_field`. If the caller passes no context, the record + opens under the context it stores. If the caller passes one, the stored + context must equal it. + +A `plaintext = T` record requires a `RequiredContext<CallerContext>`, or a +`RequiredContext<AeadContext>` with `context_type = AeadContext`. A record +that stores its context requires the `NonEmpty<T>` it stores. No context is +ever taken from `Default`. + +A record gives each field its context through `Encryption::under` +(ADR-0004). `under` requires the field's input to implement +`From<CallerContext>`. That one bound gives a compile error at the field, +when the field's target cannot take a context from the record: + +- These inputs implement it: `OptionalContext`, a `RequiredContext` of a + core context, and the `DeclaredScopes` of a nested record. +- These do not: `NoCallerContext`, and a `RequiredContext` of another type, + such as `RequiredContext<u64>`. + +A record does not take a caller's scope or row binding yet. The plan decides +how it will: + +- a record declares each part it accepts, as a scope or a binding; +- the caller passes them as `Scope::new(..)` and `Bind::new(..)`; +- the search terms leave out a binding, so a query still finds the row. + +That work is tracked separately. If a record took a caller's row ID before +then, the row ID would also change the search terms. The plan rejects that. + +### 4. A record decrypts into a `Protected` + +The plaintext type of a `struct` record cannot wipe itself when it is +dropped. The owned form moves the fields out of the struct, and Rust does +not allow that for a type that implements `Drop`. + +So the decrypt derive also implements `DecryptSource<Protected<P>>` for each +plaintext type `P` the record names. `Protected::<User>::decrypt_from(..)` +wraps the plaintext as soon as it is recovered. A `Vec` of records decrypts +into a `Vec<Protected<User>>`. `User::decrypt_from(..)` still works, and +returns a bare `User` that the caller must wipe. + +A single `Ciphertext` decrypts into a `Protected` too: +`keyset.decrypt::<Protected<String>>(ct)`. Its bound is `Decrypt + Zeroize`, +as for the other stored types. So a target that is not a scalar, such as +`Vec<String>`, compiles, and opening refuses it. A compile error for that +case needs a trait for "a scalar, or a `Protected` of one". Rust allows +that trait to be implemented for both only in Vitamin C, the crate that +defines `FromScalar`. + +### 5. The structural traits are a bridge + +Each owned derive also implements the structural traits: + +- `EncryptTarget` also implements `EncryptFrom` and `Decryptable`. +- `DecryptSource` also implements `DecryptInto` and `DecryptField`. + +The core targets implement both pairs by hand. So an owned record can be a +field of a structural record, or a typed field of a plan, with no second +derive. The plan builder gains `target::<T, _>()`, for a target that +implements only the owned traits, such as `Ciphertext`. + +The dynamic engine does not change. A test encrypts one record three ways: +with a hand-written `EncryptTarget`, with the derive, and with a data plan. +All three give the same descriptors and the same search term bytes, and each +decrypts the records of the other two. + +The plan removes the structural traits, the `encrypt_as` and `decrypt_as` +calls, and the helpers in `target::legacy`. They stay until plans, EQL, Go, +and the guest have moved to the owned API. + +### 6. The structural `Vec` decrypts through the wiping path + +`Vec<T>: DecryptInto<Vec<P>>` and `DecryptField<Vec<P>, _>` used to decrypt +through `Decryption::all`. If an element failed late, the elements decrypted +before it were dropped without being wiped. They now decrypt through +`all_wiping`, as the owned `Vec` does. + +That adds the bound `P: Zeroize` to those public impls. Every value the +engine decrypts into already implements `Zeroize`. A structural caller with +a struct plaintext must derive it. + +## Options considered + +- **A `Zeroize` bound on the waiting part of `split`.** This would do the + same job as the `wipe` function. It was rejected because the bound would + also apply to passthrough fields. Those are stored in the clear, so their + types have no reason to implement `Zeroize`. +- **A destructure that does not name every field (`..`).** This would let + the plaintext type implement `Drop`, and so wipe itself. It was rejected + because a field that the record does not name would then be dropped + without being wiped. Nothing would report it. Decrypting into a + `Protected` gives the wipe on drop instead. +- **An optional extension from the caller (`ContextExtension<C>`).** An + earlier version of this change let the caller extend every field's + context, for example with a row ID. It was removed after the naming review + of 8 November 2026. The row ID also changed the search terms, so a query + had to know the row. The plan replaces it with declared scopes and + bindings. +- **A second declaration for the structural traits.** It was rejected + because two declarations of one record could disagree about contexts. The + equivalence test shows that one declaration, bridged, gives the same bytes + as a data plan. + +## Consequences + +- `user.encrypt_into(&keyset)` wipes `name` and `email`, and moves `id`. + Encrypting a `Vec<User>` into a `Vec<EncryptedUser>` makes one key + request. +- A record's plaintext must implement `Zeroize`. The bound says who wipes + the plaintext. It promises nothing about copies the caller keeps, or + copies the compiler makes. The limits in ADR-0010 still apply. + +### What is left to the caller + +These are deliberate. Each one is documented where the caller meets it. + +- **Decrypting into a bare plaintext.** `User::decrypt_from(..)` returns a + value that is not wiped when it is dropped. Use + `Protected::<User>::decrypt_from(..)`. +- **A passthrough field is not encrypted and not authenticated.** Anyone who + can write to the database can change it, and the encrypted fields beside + it still decrypt. Do not decide access from it alone. +- **A `context_field` record decrypted with no expected context.** It opens + under whatever context it stores. A record copied whole, with its stored + context, opens in its new place. Pass the context you expect, to refuse a + record from anywhere else. +- **`Encryption::project_owned` drops the other fields of the struct without + wiping them.** Use it only when the other fields contain nothing secret. A + record divides its struct with `split` instead. + +### What is not done yet + +- A caller's scope or row binding, declared on the record. See decision 3. +- `ExpectedContext` stays public until the plan's `Planned` value replaces + it. A context passed on decrypt will then be checked the same way. +- Configured targets, such as a JSON index with options, cannot be fields of + an owned record yet. Their plaintext, `serde_json::Value`, does not + implement `Zeroize`. +- `Option<T>` and tuple targets have only the structural form. diff --git a/packages/stack-encrypt/examples/encrypted_record.rs b/packages/stack-encrypt/examples/encrypted_record.rs index 3e0c8a978f..24bc9a86cd 100644 --- a/packages/stack-encrypt/examples/encrypted_record.rs +++ b/packages/stack-encrypt/examples/encrypted_record.rs @@ -1,83 +1,108 @@ -//! A searchable encrypted struct, end to end. +//! Encrypt a table of users, query it by equality and by order, and decrypt +//! only the rows that match. //! -//! The point of target-directed encryption: define the encrypted *shape* of -//! a value — "the ciphertext plus the index terms this field needs" — derive -//! `EncryptFrom` / `DecryptInto` for it, and every insert is one -//! `encrypt_into(..).await`. A tiny in-memory "table" then answers equality -//! and range queries purely by comparing terms, decrypting only the rows -//! that match. +//! Each user is encrypted into an `EncryptedUser`. That type's fields say what +//! is stored for each plaintext field: //! -//! The async shape is the other half of the point: nothing here does I/O -//! until the `.await`. Terms derive locally; each ciphertext queues its -//! data-key request; the derive combines the field pendings with `zip` / -//! `map` — so a whole `Vec` of structs, encrypted through the `Vec` -//! implementation, settles in **one** batched ZeroKMS call, and the matching -//! rows decrypt in one more. +//! - The `age` field stores a ciphertext, an equality term, and an order term. +//! - The `email` field stores a ciphertext. //! -//! Run with: +//! One `encrypt_into(..).await` call encrypts the whole table. Queries compare +//! terms only, so they need no decryption and no ZeroKMS call. +//! +//! Nothing calls ZeroKMS until an `.await`. Encrypting the table makes one +//! batched `generate_keys` call. Decrypting the matching rows makes one +//! batched `retrieve_keys` call. +//! +//! Each field's context is the record's name, `users`, then the field's +//! name. The caller adds nothing to it. So two rows of the table can swap a +//! sealed field and both still decrypt. Binding each value to its row needs +//! a row ID declared on the record as a binding. That is not built yet. +//! +//! The plaintext type, `User`, follows the rules for a type that contains +//! secrets: +//! +//! - It derives `Zeroize`, so that it can be wiped. +//! - It derives `OpaqueDebug`, not `Debug`. Printing a `User` shows its +//! field names, with each value hidden. +//! - It derives `TimingSafeEq`, not `PartialEq`, so that a comparison runs in +//! constant time. +//! - It does not derive `Clone`, so there are no copies to wipe. +//! +//! Encrypting a `User` consumes it and wipes it. The matching rows decrypt +//! straight into `Protected<User>` values, which wipe the plaintext when they +//! are dropped. +//! +//! Run it with: //! //! ```sh //! cargo run -p stack-encrypt --example encrypted_record //! ``` //! -//! Talks to real ZeroKMS. On a developer machine, `npx stash auth login` is -//! sufficient: the cipher finds both the access token and the client key in -//! the CLI's profile directory. In CI, set `CS_CLIENT_ACCESS_KEY` / -//! `CS_WORKSPACE_CRN` and `CS_CLIENT_ID` / `CS_CLIENT_KEY` instead (see the -//! `zerokms_auth` example for the lookup order). +//! The example calls the real ZeroKMS service. On a developer machine, run +//! `npx stash auth login` first: the cipher reads the access token and the +//! client key from the profile that command writes. In CI, set +//! `CS_CLIENT_ACCESS_KEY`, `CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, and +//! `CS_CLIENT_KEY` instead. The `zerokms_auth` example shows the order in +//! which the cipher looks for credentials. use stack_encrypt::sem::{EqualityTerm, OreTerm}; -use stack_encrypt::target::legacy::{DecryptFrom, EncryptInto}; -use stack_encrypt::{nonempty, DecryptInto, EncryptFrom, Error, StackCipher, StackCipherText}; - -// --- The shapes ---------------------------------------------------------------- - -/// "An encrypted `u32`, stored as its ciphertext plus an equality term and an -/// ORE term." The same shape as an EQL `integer_ord_ore` payload, minus the -/// EQL wire encoding. Every field is derived from the one `u32`, under one -/// context: the context authenticates the ciphertext (AAD) and -/// domain-separates both terms (PRF context). `DecryptInto` opens the -/// ciphertext field and passes over the terms — the field types say which is -/// which, so no attribute is needed. -#[derive(EncryptFrom, DecryptInto)] +use stack_encrypt::target::{DecryptSource, EncryptTarget}; +use stack_encrypt::{ + nonempty, Ciphertext, Controlled, DecryptFrom, EncryptInto, Protected, StackCipher, +}; +use vitaminc_protected::{OpaqueDebug, TimingSafeEq}; +use zeroize::Zeroize; + +// --- The types ----------------------------------------------------------------- + +/// An encrypted `u32`, stored as a ciphertext, an equality term, and an +/// order term. This is the layout of an EQL `integer_ord_ore` payload, without the +/// EQL encoding. +/// +/// All three fields are derived from the one `u32`, under one context. The +/// context is the associated data of the ciphertext, and it keeps each term +/// separate from terms derived for other fields. Decryption opens the +/// ciphertext and ignores the terms. +#[derive(EncryptTarget, DecryptSource)] #[stash(plaintext = u32)] struct EncryptedAge { - c: StackCipherText, + c: Ciphertext, eq: EqualityTerm, ord: OreTerm<u32>, } -/// The plaintext. -#[derive(Debug, Clone, PartialEq)] +/// The plaintext. The module documentation explains its derives. +#[derive(Zeroize, OpaqueDebug, TimingSafeEq)] struct User { age: u32, email: String, } -/// `User`, encrypted field by field: `age` from `user.age` under -/// `("users", "age")`, `email` from `user.email` under `("users", "email")`. The prefix -/// is named once, explicitly — it is part of the stored data's identity, so -/// it is never inferred from a Rust type name — and the field half follows -/// the plaintext field. -#[derive(EncryptFrom, DecryptInto)] -#[stash(struct = User, context = "users")] +/// A `User`, encrypted field by field. +/// +/// The `age` field is derived from `user.age` under the context +/// `("users", "age")`, and the `email` field from `user.email` under +/// `("users", "email")`. The first part of each context, `"users"`, is +/// written here once. It is part of what identifies the stored data, so the +/// derive never takes it from the name of a Rust type. +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = User, scope = "users")] struct EncryptedUser { age: EncryptedAge, - email: StackCipherText, + email: Ciphertext, } #[tokio::main(flavor = "current_thread")] async fn main() -> Result<(), Box<dyn std::error::Error>> { - // One cipher does everything the shapes need: ZeroKMS-backed AEAD (every - // leaf sealed under its own data key) and SEM term derivation under the - // keyset's index key, which `init` loads. Data keys and terms are bound to - // the same keyset by construction — there is no way to mix them up. + // The cipher seals each value under its own ZeroKMS data key. `new` also + // loads the keyset's index key. The index key derives the terms. Data keys + // and terms always come from the same keyset. let cipher = StackCipher::new().await?; - // Sealing and term derivation bind to a keyset; this is the client's - // default one. + // The client's default keyset. let keyset = cipher.default_keyset(); - // --- Write side: encrypt a table of users --------------------------------- + // --- Encrypt a table of users ------------------------------------------- let users: Vec<User> = [ (29, "ada"), @@ -93,18 +118,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { }) .collect(); - // Every field carries its own context, so nothing is needed from the - // caller — and one await seals the whole table: the `Vec` implementation - // merges every struct's pending, so five users (two ciphertexts and two - // terms each) settle in a single batched generate_keys call. + // Each field has its own context, so the call needs no context from the + // caller. The `Vec` conversion combines every user's key requests, so + // five users make one batched `generate_keys` call. The call consumes + // `users` and wipes each plaintext. let table: Vec<EncryptedUser> = users.encrypt_into(&keyset).await?; println!("stored {} encrypted users in one ZeroKMS call", table.len()); - // --- Query side: terms only, no plaintext, no decryption ------------------ + // --- Query the table with terms ----------------------------------------- - // Term probes derive under the index key the cipher already holds, under - // the same context the field was stored under: building a query never - // calls ZeroKMS at all. + // A query term is derived under the index key that the cipher already + // loaded, and under the context the field was stored under. Building one + // never calls ZeroKMS. // WHERE age = 34: compare equality terms. let probe: EqualityTerm = 34u32 @@ -115,7 +140,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { .collect(); println!("WHERE age = 34 => rows {equal:?}"); - // WHERE age > 40: compare ORE terms. + // WHERE age > 40: compare order terms. let bound: OreTerm<u32> = 40u32 .encrypt_into_with_context(&keyset, nonempty!("users").with("age")) .await?; @@ -124,79 +149,43 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { .collect(); println!("WHERE age > 40 => rows {over_40:?}"); - // ORDER BY age: sort by ORE term. + // ORDER BY age: sort by order term. let mut by_age: Vec<usize> = (0..table.len()).collect(); by_age.sort_by(|&a, &b| table[a].age.ord.cmp(&table[b].age.ord)); println!("ORDER BY age => rows {by_age:?}"); - // --- Read side: decrypt only the rows a query matched --------------------- + // --- Decrypt only the rows a query matched ------------------------------ - // A separate client: any process holding the same ZeroKMS credentials and + // A second client. Any process with the same ZeroKMS credentials and // keyset can decrypt what this one wrote. let decryptor = StackCipher::new().await?; - // Collect the matching rows and decrypt them together: one batched - // retrieve_keys call, however many rows matched. Each field opens under - // the context it was sealed under — it is bound into the AAD, so a - // ciphertext cannot be replayed against a different field. + // Decrypting the matching rows together makes one batched + // `retrieve_keys` call, however many rows match. Each field opens under + // the context it was sealed under. That context is the ciphertext's + // associated data, so a ciphertext copied into another field does not + // open. let mut table = table; let mut matches: Vec<EncryptedUser> = Vec::new(); - // Descending index order keeps earlier indices valid across swap_remove. + // Take the rows in descending order, so that `swap_remove` does not move + // a row that is still to be taken. for i in over_40.into_iter().rev() { matches.push(table.swap_remove(i)); } - let matched: Vec<User> = Vec::<User>::decrypt_from(matches, &decryptor).await?; - for user in &matched { - println!("decrypted matching row: {user:?}"); - } - - // --- Binding a value to its record ---------------------------------------- - - // A context the caller passes *extends* every field's own: under the - // record's id, `age` is sealed under `(("users", "age"), id)` and opens only - // there — a ciphertext can no longer be moved between records of the - // same table. The price is that its terms are scoped to that record too: - // a probe built under `("users", "age")` alone never matches them, so extend - // where a value is read by id, not where it is searched across rows. - let id = 42u64; - let alice = User { - age: 34, - email: "alice@example.com".into(), - }; - let record: EncryptedUser = alice.clone().encrypt_into_with_context(&keyset, id).await?; - let unscoped: Vec<usize> = std::iter::once(&record) - .enumerate() - .filter(|(_, r)| r.age.eq == probe) - .map(|(i, _)| i) - .collect(); - println!( - "record-scoped terms match the table probe: {}", - !unscoped.is_empty() - ); - let scoped: EqualityTerm = 34u32 - .encrypt_into_with_context(&keyset, nonempty!("users").with("age").with(id)) - .await?; + // Each user is decrypted straight into a `Protected`, which wipes it + // when it is dropped. `risky_ref` marks the place the plaintext is read. + let matched = Vec::<Protected<User>>::decrypt_from(matches, &decryptor).await?; + println!("decrypted {} matching rows", matched.len()); + // `OpaqueDebug` prints the field names, with each value hidden. println!( - " ...and a probe built under the same id: {}", - record.age.eq == scoped - ); - - let opened = User::decrypt_from_with_context(record, &decryptor, id).await?; - assert_eq!(opened, alice); - // The record id is in every field's context, and the context is the - // ZeroKMS descriptor of every data key: opening under another id fails - // at ZeroKMS, before any key material moves. (Against a source that - // does not enforce descriptors — the fake — the AEAD refuses instead.) - let record: EncryptedUser = alice.encrypt_into_with_context(&keyset, id).await?; - let wrong_id = User::decrypt_from_with_context(record, &decryptor, 43u64).await; - println!( - "opening under another id: {}", - match wrong_id { - Err(Error::Kms(e)) => format!("refused by ZeroKMS ({e})"), - Err(Error::Aead) => "refused (AEAD)".to_owned(), - _ => "unexpected".to_owned(), - } + "the first one is {:?}", + matched.first().map(Controlled::risky_ref) ); + // `TimingSafeEq` compares two users in constant time. + if let [first, second, ..] = matched.as_slice() { + let same = first.risky_ref().ts_eq(second.risky_ref()); + println!("the first two match: {}", bool::from(same)); + } Ok(()) } diff --git a/packages/stack-encrypt/src/descriptor.rs b/packages/stack-encrypt/src/descriptor.rs index adb6b47b8e..bf61d81abc 100644 --- a/packages/stack-encrypt/src/descriptor.rs +++ b/packages/stack-encrypt/src/descriptor.rs @@ -442,7 +442,7 @@ impl<'a> IntoContext<'a> for Description { /// `documents/v2/body` is never read as a scoped column. /// /// A two-segment `Label` binds the same context a -/// `#[stash(struct = .., context = "<table>")]` derive gives a field, which +/// `#[stash(struct = .., scope = "<table>")]` derive gives a field, which /// the derive spells `nonempty!("users").with("email")`. That is what lets a /// label open a row a derive wrote, and a probe built from the label match /// the terms the derive produced. @@ -460,7 +460,7 @@ impl<'a> IntoContext<'a> for Description { /// let tenant_7 = NonEmpty::from(email.clone()).with(7u64); /// assert_eq!(Descriptor::of(tenant_7).as_str(), "(users/email)/7u64"); /// -/// // What a `struct = .., context = "users"` derive binds its `email` field under. +/// // What a `struct = .., scope = "users"` derive binds its `email` field under. /// assert_eq!(Descriptor::of(&email), Descriptor::of(nonempty!("users").with("email"))); /// /// // Not a label: the separator inside a segment, and an empty segment. diff --git a/packages/stack-encrypt/src/json/derive.rs b/packages/stack-encrypt/src/json/derive.rs index dd145f7f32..73bf163f0f 100644 --- a/packages/stack-encrypt/src/json/derive.rs +++ b/packages/stack-encrypt/src/json/derive.rs @@ -782,13 +782,14 @@ pub(crate) fn seal_pending<'a, K: 'static>( cipher: &'a KeysetCipher<'_, K>, context: NonEmpty<CallerContext>, ) -> Pending<'a, JsonDocument, K> { + // The caller's document, owned here: wiped when this returns, also when + // the context is refused below. + let value = PlainJson::protect(value); let piece = context.into_context(); let descriptor = Descriptor::from_piece(&piece); if let Err(error) = descriptor.check() { return Pending::failed(cipher, error); } - // The caller's document, owned here: wiped when this returns. - let value = PlainJson::protect(value); let prf_context = piece.clone().into_prf_context().into_owned(); let aad = piece.into_aad().into_owned(); // One generator for the document: its members' ordinals, its stored diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 486506f231..21e6f63f71 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -351,12 +351,126 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(example()).unwrap(); //! ``` //! -//! The structural plans and derives still use [`StackCipherText`], a tree -//! of sealed leaves. Their low-level entry points are -//! [`KeysetCipher::encrypt_tree`] and [`KeysetCipher::decrypt_tree`]. Their -//! borrowed conversion helpers are in [`target::legacy`]. +//! # Records +//! +//! A record is a struct that is encrypted field by field. You declare the +//! stored form of a plaintext struct as a second struct, and derive +//! `EncryptTarget` and `DecryptSource` on it. In +//! `#[stash(struct = User, scope = "users")]`, `User` is the plaintext +//! type. The string `"users"` is the record's name. It is the first scope of +//! every field's context, and the field's name is the next. +//! +//! Each field of the record is one of these: +//! +//! - **A sealed field.** Its type is a target, such as a [`Ciphertext`], a +//! term or another record. It is derived from the plaintext field of the +//! same name, under the context `("users", "<field>")`. Use +//! `#[stash(from = ..)]` when the plaintext field has another name. +//! - **A passthrough field,** marked `#[stash(passthrough)]`. The plaintext +//! field is moved into the record as it is. A passthrough field is **not +//! encrypted and not authenticated**. Anyone with access to the stored +//! record can read it, change it, or remove it, and the sealed fields +//! still decrypt. Use it only for values that are not secret. Treat the +//! value you read back as untrusted input. Never let it decide +//! authorisation, tenancy, or which record to trust. A value that must be +//! readable and also tamper-evident belongs in the context instead. +//! +//! The record must name every field of the plaintext, as a sealed or a +//! passthrough field. A plaintext field it leaves out is a compile error. +//! +//! Encrypting a plaintext consumes it. The conversion wipes each field it +//! seals and moves each passthrough field, so no field is copied. This is +//! why the plaintext type must implement `Zeroize`. +//! +//! **Decrypt into a [`Protected`].** The plaintext type cannot wipe itself +//! when it is dropped. The derive moves the fields out of the plaintext +//! struct, and Rust does not allow that for a type that implements `Drop`. +//! So the derive also implements `DecryptSource<Protected<User>>`: +//! `Protected::<User>::decrypt_from(record, &cipher)` returns the plaintext +//! inside a `Protected`, which wipes it when it is dropped. A `Vec` of +//! records decrypts into a `Vec<Protected<User>>`. +//! +//! On the plaintext type: +//! +//! - derive `OpaqueDebug` from `vitaminc::protected`, not `Debug`, so that +//! printing the value shows its field names with each value hidden; +//! - derive `TimingSafeEq` from `vitaminc::protected`, not `PartialEq`, so +//! that a comparison runs in constant time; +//! - do not derive `Clone`, because each clone is another copy to wipe. +//! +//! Each field is sealed under its own context and nothing else, and the +//! caller cannot add to it ([`target::DeclaredScopes`]). So two records of +//! the same type can exchange a sealed field, and both still decrypt. +//! Binding each field to its row needs the row ID declared on the record as +//! a binding. The attributes that declare a caller's scope or binding are +//! not built yet. //! -//! The structural API is separate from the owned scalar API. A struct sealed through them is one +//! ``` +//! # async fn example() -> Result<(), Box<dyn std::error::Error>> { +//! use stack_encrypt::target::{DecryptSource, EncryptTarget}; +//! use stack_encrypt::{Ciphertext, Controlled, DecryptFrom, EncryptInto, Protected, StackCipher}; +//! +//! // The plaintext derives `Zeroize` and nothing else. +//! #[derive(zeroize::Zeroize)] +//! struct User { id: u64, name: String, email: String } +//! +//! #[derive(EncryptTarget, DecryptSource, serde::Serialize, serde::Deserialize)] +//! #[stash(struct = User, scope = "users")] +//! struct EncryptedUser { +//! // Not encrypted and not authenticated. +//! #[stash(passthrough)] +//! id: u64, +//! name: Ciphertext, // sealed under ("users", "name") +//! email: Ciphertext, // sealed under ("users", "email") +//! } +//! +//! // With no `.kms(..)`, the cipher uses ZeroKMS. It reads credentials from +//! // the environment, or from the profile that `npx stash auth login` writes. +//! let cipher = StackCipher::builder() +//! # .kms(stack_encrypt::kms::FakeDataKeySource::new()) +//! .init() +//! .await?; +//! let keyset = cipher.default_keyset(); +//! let id = 42u64; +//! let user = User { id, name: "Alice".into(), email: "alice@example.com".into() }; +//! +//! // One call, and one batched key request for both sealed fields. +//! let encrypted: EncryptedUser = user.encrypt_into(&keyset).await?; +//! let stored = serde_json::to_vec(&encrypted)?; +//! +//! let loaded: EncryptedUser = serde_json::from_slice(&stored)?; +//! let restored = Protected::<User>::decrypt_from(loaded, &cipher).await?; +//! # assert_eq!(restored.risky_ref().id, id); +//! // `restored` wipes the plaintext when it is dropped. +//! # Ok(()) +//! # } +//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(example()).unwrap(); +//! ``` +//! +//! A record can also be derived from one whole value, with +//! `#[stash(plaintext = String)]`. Every field is then derived from that +//! value, under one context that the caller must supply +//! ([`target::RequiredContext`]). For example, a [`Ciphertext`] field and an +//! [`sem::EqualityTerm`] field make a value you can search for. A record of +//! this kind can be a field of a `struct` record, like any target. +//! +//! A `Vec` of plaintext structs encrypts into a `Vec` of records with one +//! batched key request. The derive crate's documentation lists every +//! attribute. The [`target`] module describes the traits a hand-written +//! target implements, and how the context of a record field is built. +//! +//! The older structural plans and derives (`#[derive(EncryptFrom, +//! DecryptInto)]`) encrypt a borrowed plaintext into a [`StackCipherText`]. +//! A `StackCipherText` is a tree of sealed leaves. The low-level entry +//! points of the structural API are [`KeysetCipher::encrypt_tree`] and +//! [`KeysetCipher::decrypt_tree`]. Its conversion helpers are in +//! [`target::legacy`]. The structural API is separate from the owned scalar +//! API. +//! +//! The owned derives also implement the structural traits. So an owned +//! record can be a field of a structural record, or a typed field of a plan. +//! +//! A struct sealed through the structural API, or as an owned record, is one //! ciphertext per field. A struct in an [`OpaqueBlob`] is one ciphertext. //! //! # What is authenticated diff --git a/packages/stack-encrypt/src/plan/build.rs b/packages/stack-encrypt/src/plan/build.rs index 3cbf85ca18..fd41e01d7e 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,95 @@ impl<S: 'static, K: 'static> FieldsBuilder<S, K> { ) } + /// Lay out the field `field` as the owned target `T` lays it out. `T`'s + /// own [`EncryptTarget<F>`] decides what is sealed and which terms are + /// stored beside it, all under `<context>/<identity>`. The record + /// contains a `T`. + /// + /// This is the owned form of [`encrypt_into`](Self::encrypt_into), for a + /// target that implements only `EncryptTarget`. Examples are an opaque + /// [`Ciphertext`](crate::Ciphertext), or a record that derives + /// `EncryptTarget`. A derived record also works with `encrypt_into`, + /// because the derive implements the structural traits too. + /// + /// The field lowers to + /// `<T as EncryptTarget<F>>::encryption().accepting::<CallerContext>()`. + /// So the field's context reaches the target as its caller context, and + /// the target's `ContextInput` must take a `CallerContext`. A target + /// that accepts no caller context + /// ([`NoCallerContext`](crate::target::NoCallerContext)), or that + /// requires a context of another type, is a compile error at the call. + /// + /// The plan runs the target over a borrow of the value, as it does for + /// every field. So the value stays the caller's, and this method wipes + /// nothing. The owned conversions consume the value and wipe it. They are + /// in [`EncryptInto`](crate::EncryptInto). + /// + /// ``` + /// # use stack_encrypt::plan::pick; + /// # struct User { age: u32 } + /// # async fn example() -> Result<(), stack_encrypt::Error> { + /// use stack_encrypt::{Ciphertext, Plan, StackCipher}; + /// + /// // With no `.kms(..)`, the cipher uses ZeroKMS. + /// let cipher = StackCipher::builder() + /// # .kms(stack_encrypt::kms::FakeDataKeySource::new()) + /// .init() + /// .await?; + /// let users_plan = Plan::context("users") + /// .fields() + /// .target::<Ciphertext, _>(pick("age", |u: &User| &u.age)) + /// .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 context. + /// 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/plan/mod.rs b/packages/stack-encrypt/src/plan/mod.rs index bb4c39726d..428b27b733 100644 --- a/packages/stack-encrypt/src/plan/mod.rs +++ b/packages/stack-encrypt/src/plan/mod.rs @@ -200,7 +200,7 @@ //! record's attributes, and the record's `EncryptFrom::encryption()` is that //! plan's description, mapped into the struct: //! -//! - `#[stash(struct = User, context = "users")]` emits +//! - `#[stash(struct = User, scope = "users")]` emits //! `Plan::context("users").fields()` with one //! `encrypt_into::<FieldType, _>(pick("name", |u: &User| &u.name))` per //! field (and `.identity(..)` where one is pinned). The field's own type @@ -230,7 +230,7 @@ //! } //! //! #[derive(EncryptFrom)] -//! #[stash(struct = User, context = "users")] +//! #[stash(struct = User, scope = "users")] //! struct EncryptedUser { //! email: Encrypted<EqualityTerm>, //! #[stash(identity = "handle")] diff --git a/packages/stack-encrypt/src/scalar.rs b/packages/stack-encrypt/src/scalar.rs index 276ae9dc52..5e41010bcc 100644 --- a/packages/stack-encrypt/src/scalar.rs +++ b/packages/stack-encrypt/src/scalar.rs @@ -4,9 +4,8 @@ use crate::target::{ DecryptFrom, DecryptSource, Decryption, EncryptInto, EncryptTarget, Encryption, OptionalContext, Pending, ScalarContext, }; -use crate::{CipherText, ContextPiece, Error, KeysetCipher, LeafBytesError, SealedValue}; +use crate::{CipherText, ContextPiece, Decrypt, Error, KeysetCipher, LeafBytesError, SealedValue}; use vitaminc_aead::plaintext::{Encrypt, Scalar, Sink}; -use vitaminc_aead::FromScalar; use vitaminc_protected::OpaqueDebug; use zeroize::Zeroize; @@ -118,14 +117,20 @@ pub trait OpaqueEncode: sealed::Encode + Zeroize + Send + 'static {} /// A plaintext type that one opaque [`Ciphertext`] decrypts to. /// -/// `OpaqueDecode` is implemented for every [`FromScalar`] type that also +/// `OpaqueDecode` is implemented for every [`Decrypt`] type that also /// implements [`Zeroize`], and for [`OpaqueBlob<T>`](crate::OpaqueBlob). -/// Every built-in [`OpaqueEncode`] type is one. The trait is sealed, as -/// `OpaqueEncode` is. +/// The `Decrypt` types include each scalar, such as `String`, and a +/// [`Protected`](vitaminc_protected::Protected) of one, such as +/// `Protected<String>`. The trait is sealed, as `OpaqueEncode` is. /// -/// A successful scalar decrypt returns an ordinary owned value, and nothing -/// wipes it when it is dropped: `Zeroize` is the bound, not a drop guard. A -/// decrypted `OpaqueBlob<T>` is the exception: it wipes its value on drop. +/// A scalar decrypted into a bare type, such as `String`, is an ordinary +/// owned value, and nothing wipes it when it is dropped: `Zeroize` is the +/// bound, not a drop guard. Decrypt into a `Protected` to have it wiped on +/// drop. A decrypted `OpaqueBlob<T>` also wipes its value on drop. +/// +/// A `Ciphertext` holds one scalar. A target that is not a scalar, such as +/// `Vec<String>`, compiles, but decryption refuses it with +/// [`Error::OpenedType`]. pub trait OpaqueDecode: sealed::Decode + Zeroize + Send + 'static {} // The bridge from an owned `S` to its borrow's walk: `OpaqueEncode`'s @@ -161,7 +166,7 @@ where for<'a> &'a S: Scalar, { } -impl<P: FromScalar + Zeroize + Send + 'static> sealed::Decode for P { +impl<P: Decrypt + Zeroize + Send + 'static> sealed::Decode for P { fn open<'a, K: 'static>( leaf: SealedValue, cipher: &'a crate::StackCipher<K>, @@ -170,7 +175,7 @@ impl<P: FromScalar + Zeroize + Send + 'static> sealed::Decode for P { crate::target::core::open_opaque(CipherText::Single(leaf), cipher, context) } } -impl<P: FromScalar + Zeroize + Send + 'static> OpaqueDecode for P {} +impl<P: Decrypt + Zeroize + Send + 'static> OpaqueDecode for P {} pub(crate) struct ScalarRef<'a, S>(pub &'a S); // The owned conversion keeps the source in Protected and hands this a borrow. @@ -296,6 +301,18 @@ impl<P: OpaqueDecode> DecryptSource<P> for Ciphertext { Decryption::from_open(move |cipher| P::open(self.0, cipher, context)) } } +/// A ciphertext is what a record decrypts. As a field of a derived record, it +/// is the field that can be decrypted, beside any terms. +impl crate::target::Decryptable for Ciphertext { + const DECRYPTABLE: bool = true; +} +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. @@ -319,6 +336,8 @@ impl<K: 'static> KeysetCipher<'_, K> { } /// Open a scalar sealed with empty context, constrained to this keyset. /// The caller owns and is responsible for wiping the returned plaintext. + /// To have it wiped on drop, decrypt into a `Protected`: + /// `keyset.decrypt::<Protected<String>>(ciphertext)`. /// /// # Errors /// Refuses foreign keysets before retrieval; also reports provider, diff --git a/packages/stack-encrypt/src/target/collections.rs b/packages/stack-encrypt/src/target/collections.rs index 08be9547e3..a3c512caa3 100644 --- a/packages/stack-encrypt/src/target/collections.rs +++ b/packages/stack-encrypt/src/target/collections.rs @@ -57,6 +57,53 @@ where }, ) } + /// Each element is moved into the owned form of the element's own + /// declaration. So an element that moves part of itself into its output, + /// such as a record's passthrough fields, does so here too. Each element's + /// own declaration guards it and wipes it before the column's pending + /// request is returned. + fn owned_encryption<'s, K: 'static>( + ) -> Encryption<'s, Vec<S>, Self, K, Self::ContextInput, super::Owned> + where + S: 's, + { + Encryption::from_build(move |source: Vec<S>, cipher, context: T::ContextInput| { + // Run every element, whatever happened to the elements before it. + // Each element's own declaration is what wipes it, so an element + // left unvisited after an earlier failure would be dropped as it + // is. The column still reports a refused context once, because + // `collect` keeps the first failure. + // + // The elements not yet handed over stay in a guard. The guard + // wipes them if a declaration panics, as the guard over the whole + // vector did before. An element is taken out only once its + // description and context exist, so nothing that can panic runs + // while an element is unguarded. + let mut unvisited = Unvisited(source.into_iter()); + let mut elements = Vec::with_capacity(unvisited.0.len()); + while !unvisited.0.as_slice().is_empty() { + 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 that are not yet handed to their +/// declarations. The guard is dropped when a declaration unwinds, or when +/// every element has been handed over. When it is dropped, it wipes the +/// elements that are left in it. +struct Unvisited<S: Zeroize>(std::vec::IntoIter<S>); + +impl<S: Zeroize> Drop for Unvisited<S> { + 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/context.rs b/packages/stack-encrypt/src/target/context.rs index 3e9ca028b7..a93c292b4d 100644 --- a/packages/stack-encrypt/src/target/context.rs +++ b/packages/stack-encrypt/src/target/context.rs @@ -104,6 +104,16 @@ impl AeadContext { pub(super) fn validated(self) -> Result<NonEmpty<Self>, Error> { nonempty(self) } + /// [`validated`](Self::validated), and also check that the descriptor + /// of this context fits in a ZeroKMS request. + /// + /// [`ciphertext`](super::ciphertext) runs this check before it takes the + /// plaintext. So when the context is refused, no copy of the plaintext + /// has been made. + pub(super) fn sealable(self) -> Result<NonEmpty<Self>, Error> { + Descriptor::from_piece(&self.0).check()?; + self.validated() + } /// The own context `own`, extended by this caller context, as /// [`CallerContext::extend`] does for a record that derives terms: the /// field's own context is the prefix, this context the extension. diff --git a/packages/stack-encrypt/src/target/conversion.rs b/packages/stack-encrypt/src/target/conversion.rs index 223eb4afbb..3c5728b613 100644 --- a/packages/stack-encrypt/src/target/conversion.rs +++ b/packages/stack-encrypt/src/target/conversion.rs @@ -1,27 +1,63 @@ //! 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}; use zeroize::Zeroize; -/// Declare how to encrypt an owned plaintext into this target. +/// A declaration has two forms of one description. The library runs the form +/// that matches how the call passes the plaintext: /// -/// 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. +/// - [`encryption`](Self::encryption) is given the plaintext **by reference**. +/// A declaration composes this form when it takes a field out of a borrowed +/// struct ([`Encryption::project`]). A collection or a plan runs it for each +/// element. +/// - [`owned_encryption`](Self::owned_encryption) is given the plaintext **by +/// value**. [`EncryptInto`] runs this form. By default, it guards the value +/// with [`Protected`], runs the borrowed description over it, and wipes it +/// before the pending request is returned. The value is wiped even when +/// building or running the description fails. A record overrides this form, +/// field by field ([`Encryption::split`]). It moves the fields it stores in +/// the clear into its output, and guards the fields it seals. +/// +/// Neither form is given the cipher and the plaintext together. The source +/// does not need to implement `Clone`. pub trait EncryptTarget<S: Zeroize>: Sized + 'static { /// What the caller must or may supply, independently of declared context. type ContextInput: ContextInput; - /// Describe the encryption; key I/O is deferred to the returned pending. + /// Describe the encryption over a borrowed plaintext. Key requests wait + /// until the returned pending request runs. fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::ContextInput> where S: 's; + /// Describe the encryption over the plaintext by value. This form is + /// responsible for wiping what it does not move into the output. + /// + /// The default guards the whole value, and runs + /// [`encryption`](Self::encryption) over a borrow of it. So a declaration + /// that only composes operations over a borrow does not need to implement + /// this method. + 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 that this target's terms support, as data. A plan field + /// typed with this target lets a query use these indexes. The default is + /// none. That is correct for a ciphertext, and for a record whose fields + /// have contexts of their own. + fn indexes() -> Vec<IndexSpec> { + vec![] + } } /// Declare how stored output recovers plaintext, without performing key I/O. @@ -40,12 +76,15 @@ pub trait EncryptInto: Zeroize + Sized { T: EncryptTarget<Self>, T::ContextInput: AllowsNoContext, { + // The plaintext stays guarded while the declaration is built. It also + // stays guarded while the omitted context is initialised. Either step + // can panic, because a record's declaration builds its fields' + // declarations here. The plaintext must not then be dropped without + // being wiped. The declaration takes over the guard once both exist. let source = Protected::new(self); - 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,17 +97,26 @@ pub trait EncryptInto: Zeroize + Sized { T: EncryptTarget<Self>, T::ContextInput: AcceptsContext, { + // The plaintext stays guarded while the caller's context is converted. + // It also stays guarded while the declaration is built. Either step + // can panic. If one does, the plaintext must not be dropped without being + // wiped. The declaration takes over the guard once both exist. let source = Protected::new(self); let context = T::ContextInput::supplied(context.into()); - 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 {} /// Construct owned plaintext from a declared encrypted source. /// -/// Successful return transfers cleanup responsibility to the caller: a -/// `Zeroize` bound does not make ordinary returned values wipe themselves on drop. +/// **Decrypt a record into a [`Protected`].** A `Zeroize` bound does not make +/// a returned value wipe itself when it is dropped, and the plaintext of a +/// derived `struct = ..` record cannot implement `ZeroizeOnDrop` at all. A +/// derived record implements `DecryptSource<Protected<P>>` as well, so +/// `Protected::<P>::decrypt_from(record, &cipher)` returns a value that is +/// wiped when it is dropped. A bare `P` must be wiped by the caller. pub trait DecryptFrom: Zeroize + Sized + 'static { /// Decrypt with explicitly omitted caller context. fn decrypt_from<'a, S, K: 'static>( diff --git a/packages/stack-encrypt/src/target/core.rs b/packages/stack-encrypt/src/target/core.rs index 39aa3b3739..cbc024d876 100644 --- a/packages/stack-encrypt/src/target/core.rs +++ b/packages/stack-encrypt/src/target/core.rs @@ -44,6 +44,10 @@ pub(crate) fn open_native<'a, 'c, P: Decrypt + Send + 'static, K, T: IntoContext /// [`encrypt_native`] for the opaque scalar operation, whose context may /// be empty. Mapping its encrypted output preserves that policy. Structural /// target operations use `encrypt_native` and retain its nonempty requirement. +/// +/// A refused context drops `source` as it is. Every caller passes a borrow, +/// or checks the context first ([`ciphertext`](super::ciphertext) does), so +/// no plaintext copy reaches that path. pub(crate) fn encrypt_opaque<'a, 'c, S: Encrypt, K, T: IntoContext<'c>>( source: S, cipher: &'a KeysetCipher<'_, K>, diff --git a/packages/stack-encrypt/src/target/index.rs b/packages/stack-encrypt/src/target/index.rs index ac86f45fcb..f2b5872ecb 100644 --- a/packages/stack-encrypt/src/target/index.rs +++ b/packages/stack-encrypt/src/target/index.rs @@ -56,9 +56,14 @@ use std::fmt; 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::operations::{ + ciphertext, equality, json_terms, matching, ope, open, ore, ore_block, wiping_ciphertext, +}; +use super::source::{ConsumeSource, Owned, ShareSource}; +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 +619,43 @@ where } } +/// The owned declaration of the same layout: the ciphertext with its terms, +/// under the one caller context that the record field or the caller supplies. +/// +/// The owned form is handed the value. The ciphertext takes a copy, and the +/// terms take the value. When the context is refused, the ciphertext wipes its +/// copy, and each term scheme consumes its own. +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 owned_encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::ContextInput, Owned> + where + S: 's, + { + indexed_beside(wiping_ciphertext::<S, K, Owned>(), 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 +666,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())) } } @@ -723,7 +765,22 @@ where M: ConsumeSource<'s, S> + ShareSource<'s, S>, X: Indexes<S>, { - ciphertext::<S, K, M>() + indexed_beside(ciphertext::<S, K, M>(), indexes) +} +/// The terms of `indexes` beside the ciphertext that `sealed` describes. This +/// is the body of [`indexed`]. The owned declaration of [`Encrypted`] passes a +/// ciphertext that wipes its plaintext when the context is refused. +fn indexed_beside<'s, S, K, M, X>( + sealed: Encryption<'s, S, StackCipherText, K, AeadContext, M>, + indexes: X, +) -> Encryption<'s, S, Encrypted<X::Terms>, K, CallerContext, M> +where + S: crate::Encrypt + 's, + K: 'static, + M: ConsumeSource<'s, S> + ShareSource<'s, S>, + X: Indexes<S>, +{ + sealed .accepting::<CallerContext>() .zip(indexes.operations()) .map(|(ciphertext, terms)| Encrypted { ciphertext, terms }) diff --git a/packages/stack-encrypt/src/target/input.rs b/packages/stack-encrypt/src/target/input.rs index 81abebd5ec..d32ca76c6b 100644 --- a/packages/stack-encrypt/src/target/input.rs +++ b/packages/stack-encrypt/src/target/input.rs @@ -1,6 +1,20 @@ //! Explicit caller-context capabilities. `Default` never grants permission to omit context. +//! +//! | Input | Context left out | Context passed | What the input means | +//! |---|---|---|---| +//! | [`NoCallerContext`] | Allowed | Refused | The declaration builds every context itself. | +//! | [`RequiredContext<C>`] | Refused | `C` | The caller supplies the context, or the part of it that the declaration selects. | +//! | [`OptionalContext<C>`] | Allowed | `C` | For an opaque scalar. A context left out is the empty context. | +//! | [`DeclaredScopes`] | Allowed | Refused | A derived `struct = ..` record. Each field has its own context. A record that contains it, or a plan, passes that field's context. | +//! | [`ExpectedContext<T>`](super::ExpectedContext) | Allowed | `NonEmpty<T>` | A context left out opens under the stored context, unchecked. A context passed is checked against the stored one. | +//! +//! A trait bound decides whether a method is available ([`AllowsNoContext`] +//! or [`AcceptsContext`]). It is never inferred from `C: Default`. For +//! example, `RequiredContext<u64>` stays required, although `u64` has a +//! default of zero. -use crate::{ContextPiece, IntoContext}; +use super::context::{CallerContext, DeclaredContext, ExpectedContext}; +use crate::{ContextPiece, IntoContext, NonEmpty}; mod sealed { pub trait Sealed {} @@ -75,6 +89,139 @@ impl<C> AcceptsContext for OptionalContext<C> { } } +/// The input of a derived `struct = ..` record: the caller passes no context. +/// +/// Each field of the record has its own context, such as `("users", "name")`. +/// The record's name is the first scope, and the field's name is the next. +/// So the caller has nothing to add, and `encrypt_into_with_context` does not +/// compile for the record. A part that the caller supplies, such as a tenant +/// scope or a row binding, must be declared on the record first. The +/// declaration attributes for those parts are not built yet. +/// +/// Only the library passes this input a value: +/// +/// - A record that is a field of another record is given that field's +/// context, which then extends each of its own fields' contexts. +/// - A plan or a structural record passes its declared context the same +/// way. +#[derive(Clone, Debug, Default)] +pub struct DeclaredScopes(DeclaredContext); +impl sealed::Sealed for DeclaredScopes {} +impl ContextInput for DeclaredScopes {} +impl AllowsNoContext for DeclaredScopes { + fn omitted() -> Self { + Self::default() + } +} +// The context a record's declaration tree runs under: each field's own +// context, extended by the context of the record that contains it, if any. +impl From<DeclaredScopes> for DeclaredContext { + fn from(scopes: DeclaredScopes) -> Self { + scopes.0 + } +} +// A structural caller with a `DeclaredContext`, such as a plan or a `struct = +// ..` structural derive, passes its context to an owned record field through +// this conversion. +impl From<DeclaredContext> for DeclaredScopes { + fn from(context: DeclaredContext) -> Self { + Self(context) + } +} +// A record field is given its own context (`Encryption::under`). A nested +// record takes that context as the one its own fields extend. +impl From<CallerContext> for DeclaredScopes { + fn from(context: CallerContext) -> Self { + Self(context.into()) + } +} +// A field whose target requires a caller context is given the record's context +// for that field, extended by the caller's context. So a +// `RequiredContext<CallerContext>` field or a `RequiredContext<AeadContext>` +// field can be a field of a `struct = ..` record. +impl<C: From<CallerContext>> From<CallerContext> for RequiredContext<C> { + fn from(context: CallerContext) -> Self { + Self::supplied(C::from(context)) + } +} +// A record that derives terms gives its ciphertext fields the AEAD part of the +// context it requires. +impl From<RequiredContext<CallerContext>> for RequiredContext<super::AeadContext> { + fn from(context: RequiredContext<CallerContext>) -> Self { + Self::supplied(context.into_inner().into()) + } +} +// A record that stores its context (`context_field`) requires the +// `NonEmpty<T>` it stores. It runs its fields under the caller context built +// from that value. +impl<'c, T: IntoContext<'c>> From<RequiredContext<NonEmpty<T>>> for RequiredContext<CallerContext> { + fn from(context: RequiredContext<NonEmpty<T>>) -> Self { + Self::supplied(context.into_inner().into()) + } +} +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 trait as the required input. +impl<T> From<NonEmpty<T>> for RequiredContext<NonEmpty<T>> { + fn from(context: NonEmpty<T>) -> Self { + Self::supplied(context) + } +} +// The contexts of the structural declarations, wrapped as the required input +// of the owned leaf that replaces each one. A plan or a structural record +// gives an owned field its context this way. +impl From<super::AeadContext> for RequiredContext<super::AeadContext> { + fn from(context: super::AeadContext) -> Self { + Self::supplied(context) + } +} +// The core operations take the context the input contains, with no wrapper of +// their own. For example, `ciphertext()` takes a +// `RequiredContext<AeadContext>`, and a term takes a +// `RequiredContext<CallerContext>`. +impl<C: Into<super::AeadContext>> From<RequiredContext<C>> for super::AeadContext { + fn from(context: RequiredContext<C>) -> Self { + context.into_inner().into() + } +} +impl<C: Into<CallerContext>> From<RequiredContext<C>> for CallerContext { + fn from(context: RequiredContext<C>) -> Self { + context.into_inner().into() + } +} + +/// The input of a record that stores its context: the context the caller +/// expects, or none. +/// +/// - If the caller passes a `NonEmpty<T>`, the stored context must equal it. A +/// different stored context is refused before any key is retrieved +/// ([`ExpectedContext::validate`]). +/// - If the caller leaves the context out, the record opens under whatever +/// context it stores, after a check that the context is not empty. +/// +/// The stored context is not authenticated. A ciphertext moved together with +/// its stored context opens in its new place, unless the caller passes the +/// context it expects. +impl<T> sealed::Sealed for ExpectedContext<T> {} +impl<T> ContextInput for ExpectedContext<T> {} +impl<T> AllowsNoContext for ExpectedContext<T> { + 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 b89e6f579b..2689e40069 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -10,10 +10,77 @@ //! target's `ContextInput`. The collection makes one logical batch of key //! requests. Each element is authenticated on its own. //! +//! # The owned declarations +//! +//! | Trait | Implemented on | Methods | Plaintext | +//! |---|---|---|---| +//! | [`EncryptTarget<S>`] | An encrypted output or a term | `encryption` (over `&S`), `owned_encryption` (over `S` by value, which by default guards the value and wipes it), and `indexes` | `S: Zeroize` | +//! | [`DecryptSource<P>`] | An encrypted output that can be decrypted | `decryption(self, ContextInput)` | Decrypts to `P: Zeroize` | +//! | [`EncryptInto`] | Every `Zeroize` plaintext | `encrypt_into` and `encrypt_into_with_context` | Consumed and wiped | +//! | [`DecryptFrom`] | Every `Zeroize` plaintext | `decrypt_from` and `decrypt_from_with_context` | Returned to the caller, who must wipe it | +//! +//! The core leaves implement both owned traits, with these context inputs: +//! +//! - [`Ciphertext`](crate::Ciphertext) takes an [`OptionalContext`]. The +//! empty context is a valid context. +//! - [`StackCipherText`](crate::StackCipherText) and [`Encrypted<Terms>`] +//! require an [`AeadContext`] to decrypt. +//! - The terms require a [`CallerContext`] to derive. The terms are +//! [`EqualityTerm`](crate::sem::EqualityTerm), +//! [`MatchTerms`](crate::sem::MatchTerms), and the order terms. +//! +//! Each required context is a [`RequiredContext`]. A term implements +//! [`EncryptTarget`] alone, because a term cannot be decrypted. +//! +//! # Records +//! +//! `#[derive(EncryptTarget, DecryptSource)]` composes the declarations of +//! the fields. +//! +//! A `struct = User, scope = "users"` record gives each field its context, +//! `("users", "<field>")`, through [`Encryption::under`]. The record's name +//! is the first scope, and the field's name is the next. The record's input +//! is [`DeclaredScopes`]: the caller passes no context. When the record is +//! a field of another record, that field's context extends each of its own. +//! So the field target's `ContextInput` must implement `From<CallerContext>`: +//! +//! - These inputs implement it: [`OptionalContext`], a [`RequiredContext`] +//! of a core context, and the [`DeclaredScopes`] of a nested record. +//! - These inputs do not: [`NoCallerContext`] and `RequiredContext<u64>`. A +//! field whose target takes one of them is a compile error at that field. +//! +//! The borrowed `encryption` of a `struct` record takes each field out of a +//! borrow ([`Encryption::project`]), and clones each passthrough field. Its +//! `owned_encryption` divides the struct between the fields +//! ([`Encryption::split`]). So each `#[stash(passthrough)]` field is moved +//! into the record. Each sealed field is guarded by the owned form of its +//! own target. +//! +//! Decryption opens each field under the same context, and moves the +//! passthrough fields back. A passthrough field is not encrypted and not +//! authenticated. +//! +//! A `plaintext = T` record runs every field over the whole value, under +//! the one context the caller supplies. That context is a +//! [`RequiredContext<CallerContext>`], or a `RequiredContext<AeadContext>` +//! with `context_type = AeadContext`. A `#[stash(context_field)]` record +//! requires the `NonEmpty<T>` it stores, and opens under an +//! [`ExpectedContext`]. +//! +//! Both derives also implement the structural traits ([`EncryptFrom`], +//! [`DecryptInto`] and [`DecryptField`]). So an owned record can be a field +//! of a structural record, or a typed field of a plan. A plan field uses +//! [`FieldsBuilder::encrypt_into`](crate::plan::FieldsBuilder::encrypt_into), +//! or [`target`](crate::plan::FieldsBuilder::target) for a target that is +//! only owned, such as a `Ciphertext`. A hand-written owned target that will +//! be a record field must implement [`Decryptable`] and [`DecryptField`], as +//! well as [`DecryptSource`]. A hand-written structural target does the +//! same. +//! //! # Structural API during migration //! -//! Existing derives, plans and dynamic targets still use the declarations -//! below. Their borrowed convenience methods are isolated in [`legacy`]. +//! The structural derives, plans, and dynamic targets still use the +//! declarations below. Their borrowed convenience methods are isolated in [`legacy`]. //! //! [`EncryptFrom<S>`] describes ciphertext and/or term operations and has an //! associated context type. It does not receive plaintext or a cipher. The @@ -146,6 +213,11 @@ pub mod legacy { } pub use conversion::{DecryptFrom, DecryptSource, EncryptInto, EncryptTarget}; pub use input::{ - AcceptsContext, AllowsNoContext, ContextInput, NoCallerContext, OptionalContext, - RequiredContext, ScalarContext, + AcceptsContext, AllowsNoContext, ContextInput, DeclaredScopes, NoCallerContext, + OptionalContext, RequiredContext, ScalarContext, }; +pub use stack_encrypt_derive::{DecryptSource, EncryptTarget}; +/// The cleanup trait that every owned plaintext must implement. It is +/// re-exported here so that the derives can name it, even when the caller's +/// crate does not depend on `zeroize`. +pub use zeroize::Zeroize; diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index 56d43a12ce..e94fabea5c 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -10,9 +10,11 @@ //! no argument to route. What a subtree may do is take a context of its own, //! by name, with [`Encryption::under`] or [`Encryption::extend`]. use super::context::{AeadContext, CallerContext, DeclaredContext, Extends}; +use super::conversion::{DecryptSource, EncryptTarget}; use super::core::{encrypt_native, open_native, Term}; use super::index::{Index, IndexSpec, Json}; -use super::source::{Borrowed, ConsumeSource, ShareSource, SourceMode}; +use super::input::RequiredContext; +use super::source::{Borrowed, ConsumeSource, Owned, ShareSource, SourceMode}; use super::{CipherScope, Pending}; use crate::json::{JsonDocument, JsonOptions, JsonTerms}; use crate::{Error, IntoContext, KeysetCipher, NonEmpty, StackCipher, StackCipherText}; @@ -124,7 +126,7 @@ type Open<T, K> = Box<dyn for<'a> FnOnce(&'a StackCipher<K>) -> Pending<'a, T, K /// `M` is how the description is handed its plaintext ([`SourceMode`]): /// by reference in the default [`Borrowed`] mode, which every /// [`EncryptFrom`] declaration runs in, or by value in -/// [`Owned`](super::Owned) mode, run with [`KeysetCipher::run`]. In owned mode +/// [`Owned`] mode, run with [`KeysetCipher::run`]. In owned mode /// a single operation consumes the plaintext without copying it, so `S` need /// not be `Clone`; [`zip`](Self::zip) is the one place that asks for it. #[must_use = "an encryption description does nothing until a keyset cipher executes it"] @@ -240,7 +242,7 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's, M: SourceMode<'s, S>> /// /// Both sides also receive the same plaintext. In the default /// [`Borrowed`] mode that is the same reference, and costs nothing. In - /// [`Owned`](super::Owned) mode this side gets a clone and `other` takes + /// [`Owned`] mode this side gets a clone and `other` takes /// ownership, so a chain of `n` operations makes `n - 1` copies rather /// than `n`, and only here does an owned plaintext need to be `Clone`. /// In Owned mode, a match term next to an operation that consumes the @@ -390,6 +392,107 @@ 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 passed by value. It turns a + /// description of one field into a description of the struct that contains + /// the field. `select` moves that field out of the struct. + /// + /// **This method is for generated code.** It is public because the derive + /// emits it, and it is hidden from the docs. The derive calls it only when + /// the plaintext has one field. Its pattern names every field of the + /// plaintext, so no other field is left to drop. + /// + /// **The other fields of the struct are dropped without being wiped.** Use + /// `project_owned` only for a struct whose other fields contain nothing + /// secret. To encrypt a struct with more than one field, divide it with + /// [`split`](Self::split). `split` passes every field on to a description. + /// + /// `select` is a plain function pointer, as the selector of `project` is. + /// The function captures nothing, so it cannot reach a cipher. It can only + /// move values. + #[doc(hidden)] + 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 passed by value between two descriptions, under the + /// one context this description is given. The key requests of both + /// descriptions are made in one batch. This is the owned form of + /// [`zip`](Self::zip), for two descriptions over two *different* parts of + /// one value. + /// + /// `divide` is a plain function pointer. It destructures `P` into the part + /// that `self` takes and the part that `other` takes. A record's + /// `owned_encryption` divides the struct into its fields this way, with + /// one `split` for each field. + /// + /// So a passthrough field is moved into the output and never copied. + /// Each sealed field is guarded by its own + /// target's description. Nothing is cloned, so `P` does not need to + /// implement `Clone`. + /// + /// `self` runs first, while the part for `other` waits. If the description + /// of `self` panics before `other` receives its part, that part is passed + /// to `wipe` during the unwind. Without `wipe`, the part would be dropped + /// as it is. + /// + /// `wipe` must wipe what `other` seals, and leave what `other` stores in + /// the clear. So a part with only passthrough fields takes `|_| {}`. A + /// part that is not sealed needs no `Zeroize` bound. Once `other` has its + /// part, `other` is responsible for wiping it, as it is for any owned + /// source. + pub fn split<P: 's, U: 's, V: 'static>( + self, + other: Encryption<'s, U, V, K, Ctx, Owned>, + 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 +/// description of the other part runs. If it is dropped before +/// [`hand_over`](Self::hand_over), because that description unwound, it passes +/// the part to `wipe`. +struct Waiting<U> { + part: Option<U>, + wipe: fn(&mut U), +} + +impl<U> Waiting<U> { + /// The first description returned, so the part goes to its own + /// description. + 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. @@ -461,18 +564,46 @@ 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. +/// +/// The context is checked before the plaintext is taken. So in [`Borrowed`] +/// mode a refused context makes no copy. In [`Owned`] mode a refused context +/// drops the value without wiping it, because `S` need not be `Zeroize`. The +/// owned declarations of this crate's leaves wipe it instead. pub fn ciphertext<'s, S: crate::Encrypt + 's, K: 'static, M: ConsumeSource<'s, S>>( +) -> Encryption<'s, S, StackCipherText, K, AeadContext, M> { + sealing(drop) +} +/// [`ciphertext`] for a plaintext that is wiped when the context is refused. +/// The owned declarations of [`StackCipherText`] and +/// [`Encrypted`](crate::Encrypted) run this form. +pub(crate) fn wiping_ciphertext<'s, S, K: 'static, M: ConsumeSource<'s, S>>( +) -> Encryption<'s, S, StackCipherText, K, AeadContext, M> +where + S: crate::Encrypt + zeroize::Zeroize + 's, +{ + sealing(|source| { + M::release(source, |value| { + drop(vitaminc_protected::Protected::new(value)) + }) + }) +} +/// The body of [`ciphertext`]. `refused` is given the source when the context +/// is refused. The check runs before [`ConsumeSource::take`], so a refused +/// context never makes a copy of the plaintext. +fn sealing<'s, S: crate::Encrypt + 's, K: 'static, M: ConsumeSource<'s, S>>( + refused: fn(M::Source), ) -> Encryption<'s, S, StackCipherText, K, AeadContext, M> { Encryption { - build: Box::new( - move |source, cipher, cx: AeadContext| match cx.validated() { - Ok(ctx) => encrypt_native(M::take(source), cipher, ctx), - Err(e) => Pending::failed(cipher, e), - }, - ), + build: Box::new(move |source, cipher, cx: AeadContext| match cx.sealable() { + Ok(ctx) => encrypt_native(M::take(source), cipher, ctx), + Err(e) => { + refused(source); + Pending::failed(cipher, e) + } + }), } } /// Encrypt a supported scalar, or an [`OpaqueBlob`](crate::OpaqueBlob), as @@ -510,7 +641,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. @@ -811,6 +942,25 @@ impl<T: 'static, K: 'static> Decryption<T, K> { } } } + /// [`zip`](Self::zip) for recovered plaintext. The right side can fail + /// after its key arrives, or panic in its conversion. The plaintext that + /// the left side recovered is then wiped, not dropped as it is. A record + /// that derives `DecryptSource` opens its fields through this method. So a + /// field opened before a later field is refused is wiped when the refusal + /// is returned. + pub fn zip_wiping<U: 'static>(self, other: Decryption<U, K>) -> Decryption<(T, U), K> + where + T: zeroize::Zeroize, + { + 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 +1062,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 +1354,90 @@ where } } +// The owned declarations of the same leaves. Each one requires the context its +// operation needs, as the caller's required input: a ciphertext needs an +// `AeadContext`, and a term needs a `CallerContext`. A call cannot leave the +// context out, because a structural leaf takes no empty context. `Default` +// does not change that. +// +// The borrowed form of a ciphertext clones the plaintext, as the structural +// declaration does. It checks the context first, so a refused context makes no +// copy. The owned form of a ciphertext is handed the value and seals it without +// a copy. When the context is refused, it wipes the value. +// +// The owned term targets keep the default owned form. It guards the value and +// runs the borrowed form over it, so each term takes a copy. A term derives no +// descriptor, so no context is refused there. Each term scheme consumes its +// copy. +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() + } + fn owned_encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::ContextInput, Owned> + where + S: 's, + { + wiping_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 +1481,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. If an element fails +// after its key arrives, the column fails, and the elements recovered before +// it are wiped. That is why `P` must implement `Zeroize`. Every plaintext the +// engine decrypts into already implements it. +impl<P: zeroize::Zeroize + 'static, T: DecryptInto<P> + 'static> DecryptInto<Vec<P>> for Vec<T> where T::Context: Clone + 'static, { 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 +1520,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 11089a99fa..6b2867b457 100644 --- a/packages/stack-encrypt/src/target/pending.rs +++ b/packages/stack-encrypt/src/target/pending.rs @@ -298,6 +298,31 @@ 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. The right side can fail at + /// fulfilment, with an authentication or type failure after its key + /// arrives. It can also panic in its conversion. + /// + /// In both cases, the left side's recovered value is wiped before the + /// error is returned or the panic unwinds. It is not dropped as it is. A + /// record's fields open through this method. + pub(crate) fn zip_wiping<U: 'a>(self, other: Pending<'a, U, K>) -> Pending<'a, (T, U), K> + where + T: zeroize::Zeroize, + { + self.zip_releasing(other, |mut first: T| zeroize::Zeroize::zeroize(&mut first)) + } + + /// The builder behind every pair. `release` is given the left side's + /// output when the right side fails or panics after it. It is not called + /// when both sides succeed. + fn zip_releasing<U: 'a>( + self, + other: Pending<'a, U, K>, + 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 +342,17 @@ 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| { + // The left side's output is held in a guard. So if the right + // side fails or panics, the guard releases the left side's + // output, as a collection does. + let held = Held { + item: Some(first(responses)?), + release: Some(release), + }; + let second = second(responses)?; + Ok((held.keep(), second)) + }), } } @@ -438,6 +473,33 @@ impl<T, R: FnOnce(Vec<T>)> Drop for Recovered<T, R> { } } +/// The left side of a pair, recovered before the right side is fulfilled. If +/// it is dropped before [`keep`](Self::keep), by an early return or an unwind, +/// it passes the value to `release`. +struct Held<T, R: FnOnce(T)> { + item: Option<T>, + release: Option<R>, +} + +impl<T, R: FnOnce(T)> Held<T, R> { + /// The right side succeeded. So the value is the output. It is 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 50c6568a84..8c70f970fd 100644 --- a/packages/stack-encrypt/tests/collections.rs +++ b/packages/stack-encrypt/tests/collections.rs @@ -525,3 +525,161 @@ 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 test +/// covers the structural `Vec<T>: DecryptInto<Vec<P>>` path. +impl stack_encrypt::DecryptInto<Tracked> for TrackedCiphertext { + type Context = stack_encrypt::target::AeadContext; + fn decryption<K: 'static>(self, context: Self::Context) -> Decryption<Tracked, K> { + 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(); + // This element is sealed under another context. It fails + // authentication only once its key arrives, and by then the first + // element has been recovered. + let second = keyset + .encrypt_with_context("never recovered".to_owned(), "elsewhere") + .await + .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 elements are dropped + // without being wiped. The structural path did that before it decrypted + // through the wiping collection. + let result = cipher + .decrypt_as::<Vec<Tracked>, _>(column, stack_encrypt::target::AeadContext::from(context)) + .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 declaration panics before a ciphertext exists, so the ciphertext is +// never read. +struct PanickingTarget(#[allow(dead_code)] Ciphertext); +impl EncryptTarget<Flagged> for PanickingTarget { + type ContextInput = OptionalContext<ScalarContext>; + 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 { + // The column fails, so no `Rows` is built. This field is never read. + #[allow(dead_code)] + items: Vec<Tracked>, +} +/// A column as a field. The structural `Vec<T>: DecryptField<Vec<P>, _>` impl +/// decrypts `items`. +#[derive(stack_encrypt::DecryptInto)] +#[stash(struct = Rows, scope = "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(); + // This element is sealed under another context. It fails + // authentication only once its key arrives, and by then the first + // element has been recovered. + let second = keyset + .encrypt_with_context("never recovered".to_owned(), "elsewhere") + .await + .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 elements are dropped + // without being wiped. + 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 b208600fa3..c1c497a38b 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -319,7 +319,7 @@ struct Held { } #[derive(DecryptInto)] -#[stash(struct = Held, context = "held")] +#[stash(struct = Held, scope = "held")] struct LyingHeld { value: Lying, } @@ -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` is required: a column of these decrypts through the wiping +// collection path. That path wipes the rows recovered before a late failure. +#[derive(Debug, Clone, PartialEq, Eq, zeroize::Zeroize)] struct User { age: u32, email: String, @@ -498,7 +500,7 @@ struct User { /// it is never inferred from the type's name. `from` is the override for a /// field name that differs; the context then follows the plaintext field. #[derive(EncryptFrom, DecryptInto)] -#[stash(struct = User, context = "user")] +#[stash(struct = User, scope = "user")] struct EncryptedUser { /// A record inside a struct: recursion, not a second mechanism. age: EncryptedAge, @@ -673,7 +675,7 @@ struct Account { /// and the inner struct's own fields sit under that: their contexts are /// extended by it, as by any context the inner struct is handed. #[derive(EncryptFrom, DecryptInto)] -#[stash(struct = Account, context = "accounts")] +#[stash(struct = Account, scope = "accounts")] struct EncryptedAccount { user: EncryptedUser, plan: StackCipherText, @@ -778,7 +780,7 @@ async fn a_struct_nests_in_a_struct_as_an_ordinary_field() { struct Reading(u32, String); #[derive(EncryptFrom, DecryptInto)] -#[stash(struct = Reading, context = "reading")] +#[stash(struct = Reading, scope = "reading")] struct EncryptedReading( #[stash(identity = "value")] EncryptedAge, #[stash(identity = "unit")] StackCipherText, @@ -786,7 +788,7 @@ struct EncryptedReading( /// The same with named fields: `from` by index, and the segment given. #[derive(EncryptFrom, DecryptInto)] -#[stash(struct = Reading, context = "reading")] +#[stash(struct = Reading, scope = "reading")] struct NamedReading { #[stash(from = 0, identity = "value")] value: EncryptedAge, diff --git a/packages/stack-encrypt/tests/derive_plan.rs b/packages/stack-encrypt/tests/derive_plan.rs index 7f8e2c3e70..3f6563b2b2 100644 --- a/packages/stack-encrypt/tests/derive_plan.rs +++ b/packages/stack-encrypt/tests/derive_plan.rs @@ -433,7 +433,7 @@ struct Age { } #[derive(EncryptFrom, DecryptInto)] -#[stash(struct = User, context = "users")] +#[stash(struct = User, scope = "users")] struct EncryptedUser { email: Encrypted<EqualityTerm>, #[stash(identity = "nickname")] @@ -552,7 +552,7 @@ fn a_struct_record_plan_is_the_hand_written_chain() { /// plan refusal the derive cannot see at compile time. #[allow(dead_code)] // never built: refusing it is the point #[derive(EncryptFrom)] -#[stash(struct = User, context = "users")] +#[stash(struct = User, scope = "users")] struct TwoEqualitiesInAField { email: Encrypted<(EqualityTerm, EqualityTerm)>, } diff --git a/packages/stack-encrypt/tests/descriptor.rs b/packages/stack-encrypt/tests/descriptor.rs index 8e69a6683a..f86ad188ee 100644 --- a/packages/stack-encrypt/tests/descriptor.rs +++ b/packages/stack-encrypt/tests/descriptor.rs @@ -31,7 +31,7 @@ struct User { } #[derive(EncryptFrom, DecryptInto)] -#[stash(struct = User, context = "users")] +#[stash(struct = User, scope = "users")] struct EncryptedUser { email: Encrypted<EqualityTerm>, #[stash(identity = "nickname")] diff --git a/packages/stack-encrypt/tests/owned_equivalence.rs b/packages/stack-encrypt/tests/owned_equivalence.rs new file mode 100644 index 0000000000..ad51e9541f --- /dev/null +++ b/packages/stack-encrypt/tests/owned_equivalence.rs @@ -0,0 +1,337 @@ +//! One record, sealed three ways, by one engine. A hand-written +//! `EncryptTarget` (typed), `#[derive(EncryptTarget, DecryptSource)]` +//! (generated), and the data-plan lowering that a binding sends (dynamic) each +//! seal the same mixed, searchable record. All three use the same in-memory +//! provider. The tests check three things: +//! +//! - The descriptors sent to the key provider are the same. +//! - The equality terms are the same, byte for byte. +//! - Each way decrypts what the others sealed, so the associated data is the +//! same too. +//! +//! Ciphertext bytes are random, so the tests do not compare them. +#![cfg(feature = "dynamic")] + +mod common; + +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, DeclaredContext, DeclaredScopes, DecryptSource, Decryption, + EncryptTarget, Encryption, +}; +use stack_encrypt::{ + nonempty, AsProtectedRef, CipherText, Ciphertext, DecryptFrom, EncryptInto, PassthroughFrame, + SealedValue, StackCipher, StackCipherText, +}; +use vitaminc_aead_frame::serde::from_slice; +use zeroize::Zeroize; + +#[derive(Debug, Clone, PartialEq, Zeroize)] +struct User { + id: u32, + name: String, + email: String, +} +fn alice() -> User { + User { + id: 42, + name: "Alice".to_owned(), + email: "alice@example.com".to_owned(), + } +} + +// --- Typed: the operations composed by hand ------------------------------- + +struct TypedUser { + id: u32, + name: Ciphertext, + email: Ciphertext, + email_eq: EqualityTerm, +} +impl EncryptTarget<User> for TypedUser { + type ContextInput = DeclaredScopes; + 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 = DeclaredScopes; + 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, scope = "users")] +struct GeneratedUser { + #[stash(passthrough)] + id: u32, + name: Ciphertext, + email: SearchableEmail, +} + +// --- Dynamic: the declaration as a binding sends it ----------------------- + +fn data_plan() -> DataPlan { + let json = serde_json::json!({ + "id": { "context": ["users", "id"], "outputs": ["passthrough"], "type": "uint32" }, + "name": { "context": ["users", "name"], "outputs": ["c"], "type": "string" }, + "email": { "context": ["users", "email"], "outputs": ["c", "eq"], "type": "string" }, + }); + let declaration = vitaminc_aead_frame::serde::to_vec(&json).expect("frames"); + record::plan(declaration.as_protected_ref()).expect("a plan") +} +fn dynamic_source(user: &User) -> ValueFrame { + object(vec![ + ("id", frame(user.id)), + ("name", frame(user.name.as_str())), + ("email", frame(user.email.as_str())), + ]) +} + +/// The three outputs, in one shape: the stored tree that the dynamic path +/// writes and reads. So one comparison and one decryption 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 way seals the record. The passthrough field requests no key. The + // two sealed fields request one key each, under the field's context. The + // equality term is derived locally. + let typed: TypedUser = user.clone().encrypt_into(&keyset).await.unwrap(); + let generated: GeneratedUser = user.clone().encrypt_into(&keyset).await.unwrap(); + let dynamic = record::encrypt(&keyset, dynamic_source(&user), &plan) + .expect("the source fits the plan") + .await + .unwrap(); + // Within a call, the fields are in the declaration's order. The data plan + // uses the key order of the frame instead. So the test compares each call + // as a set. + let sorted = |call: &Vec<String>| { + let mut call = call.clone(); + call.sort(); + 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 terms are the same bytes. So a query term built any of the three + // ways finds the rows that each way stored. + assert_eq!(typed.email_eq, generated.email_eq); + assert_eq!(generated.email_eq, dynamic.email_eq); + assert_eq!(typed.id, 42); + assert_eq!(generated.id, 42); + assert_eq!(dynamic.id, 42); + + // Each way decrypts the records of every way: the same context gives the + // same associated data. + for stored in [&typed, &generated, &dynamic] { + assert_eq!( + User::decrypt_from(stored.typed(), &cipher).await.unwrap(), + user + ); + assert_eq!( + User::decrypt_from(stored.generated(), &cipher) + .await + .unwrap(), + user + ); + assert_eq!(open_dynamic(&cipher, stored).await, user); + } + // Every decryption also retrieved its keys under the same descriptors. + let retrieved: Vec<_> = sent.lock().unwrap().retrieve.iter().map(sorted).collect(); + assert_eq!(retrieved.len(), 9); + assert!(retrieved + .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 0000000000..6655f18b5f --- /dev/null +++ b/packages/stack-encrypt/tests/owned_records.rs @@ -0,0 +1,1136 @@ +//! Tests of owned record declarations: `#[derive(EncryptTarget, +//! DecryptSource)]` over a plaintext struct. The tests cover passthrough +//! fields, nested records, renamed source fields, a stored context, and one +//! batch for each collection. The mixed-record and searchable-record programs +//! from the public docs run here against the in-memory key source, so no +//! service credentials are needed. +//! +//! The plaintext types in these tests derive `Debug`, `PartialEq`, and `Clone` +//! so that the tests can compare results. The public examples do not, because +//! those traits print, compare, and copy plaintext. +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use stack_encrypt::sem::{EqualityTerm, MatchTerms, OreTerm}; +use stack_encrypt::target::{ + AcceptsContext, AeadContext, DecryptField, DecryptSource, Decryptable, Decryption, + EncryptTarget, Encryption, NoCallerContext, OptionalContext, Owned, RequiredContext, + ScalarContext, +}; +use stack_encrypt::{ + nonempty, Ciphertext, Controlled, DecryptFrom, EncryptInto, Encrypted, Error, NonEmpty, + Protected, 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, +} + +/// `id` and `created_at` are stored in the clear. `name` and `email` are each +/// sealed on their own, under `("users", "name")` and `("users", "email")`. +#[derive(EncryptTarget, DecryptSource, Serialize, Deserialize)] +#[stash(struct = User, scope = "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 makes one batched key request, with one data key for each + // sealed field. + let generated = sent.lock().unwrap().generate.clone(); + assert_eq!(generated, vec![vec!["users/name", "users/email"]]); + + // Store the typed output and read it back. Plaintext fields are stored as + // themselves, and ciphertext fields as Base64 envelopes. + let json = serde_json::to_value(&encrypted).unwrap(); + assert_eq!(json["id"], 42); + assert_eq!(json["created_at"], 1_700_000_000); + 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 field's context is part of the ciphertext's associated data, so a + // field moved to the other column is refused. + assert!(matches!( + keyset + .decrypt_with_context::<String>(encrypted.name, ("users", "email")) + .await, + Err(Error::Aead) + )); +} + +// --- 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. 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 when it is wiped, and fails the test if it +/// is dropped without being wiped. +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(), + }) + } +} +// A hand-written target used as a record field must declare two things. It +// must declare that it contains ciphertext that can be decrypted. It must also +// declare how it decrypts under the context the record gives it. A +// hand-written structural target declares the same. +impl Decryptable for SecretCiphertext { + const DECRYPTABLE: bool = true; +} +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, scope = "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, or `Secret::drop` + // fails the test. + let mut opened = opened; + opened.secret.zeroize(); +} + +#[tokio::test] +async fn a_record_decrypts_straight_into_a_protected_that_wipes_it_on_drop() { + let (cipher, _, _) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let tracked = || Tracked { + handle: Handle(7), + secret: Secret { + value: "secret".to_owned(), + wiped: Arc::default(), + }, + }; + let encrypted: EncryptedTracked = tracked().encrypt_into(&keyset).await.unwrap(); + let opened = Protected::<Tracked>::decrypt_from(encrypted, &keyset) + .await + .unwrap(); + assert_eq!(opened.risky_ref().secret.value, "secret"); + // `Secret::drop` fails the test unless the `Protected` wiped it. + drop(opened); + + // A column decrypts into one `Protected` per row. + let column: Vec<EncryptedTracked> = vec![tracked(), tracked()] + .encrypt_into(&keyset) + .await + .unwrap(); + let rows = Vec::<Protected<Tracked>>::decrypt_from(column, &keyset) + .await + .unwrap(); + assert_eq!(rows.len(), 2); + drop(rows); +} + +/// A record nested under a context too long for a ZeroKMS descriptor. A +/// record that contains another passes it its field's context through +/// `Encryption::under`, as this target does. The key request is refused. +struct Oversized; +impl EncryptTarget<Tracked> for Oversized { + type ContextInput = NoCallerContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, Tracked, Self, K, Self::ContextInput> { + unreachable!("the test runs the owned form only") + } + fn owned_encryption<'s, K: 'static>( + ) -> Encryption<'s, Tracked, Self, K, Self::ContextInput, Owned> { + // Longer than the 512-byte limit of a ZeroKMS descriptor. + <EncryptedTracked as EncryptTarget<Tracked>>::owned_encryption() + .under(NonEmpty::new("x".repeat(65536)).unwrap()) + .map(|_: EncryptedTracked| Self) + .accepting() + } +} + +#[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::<Oversized, _>(&keyset); + assert!(wiped.load(Ordering::SeqCst)); + assert!(pending.await.is_err()); + assert_eq!(generates.load(Ordering::SeqCst), 0); +} + +// --- A refused context drops no copy of a sealed plaintext unwiped --------- + +/// A sealed plaintext that the borrowed form of a `StackCipherText` leaf would +/// clone. Its `Drop` records whether it still held its value. So a copy that +/// is dropped without being wiped is seen, whichever copy it is. +struct Probe { + value: String, + leaked: Arc<AtomicBool>, +} +impl Clone for Probe { + fn clone(&self) -> Self { + Probe { + value: self.value.clone(), + leaked: self.leaked.clone(), + } + } +} +impl Zeroize for Probe { + fn zeroize(&mut self) { + self.value.zeroize(); + } +} +impl Drop for Probe { + fn drop(&mut self) { + if !self.value.is_empty() { + self.leaked.store(true, Ordering::SeqCst); + } + } +} +impl stack_encrypt::Encrypt for Probe { + fn visit<S: vitaminc_aead::Sink>(mut self, sink: S) -> Result<S::Ok, S::Error> { + std::mem::take(&mut self.value).visit(sink) + } +} +fn probe(leaked: &Arc<AtomicBool>) -> Probe { + Probe { + value: "secret".to_owned(), + leaked: leaked.clone(), + } +} +/// A context longer than a ZeroKMS descriptor can hold. +fn too_long() -> NonEmpty<String> { + NonEmpty::new("x".repeat(65536)).unwrap() +} + +#[derive(Zeroize)] +struct ProbeRow { + secret: Probe, +} +#[allow(dead_code)] // These tests only encrypt. +#[derive(EncryptTarget)] +#[stash(struct = ProbeRow, scope = "rows")] +struct EncryptedProbeRow { + secret: StackCipherText, +} +/// The scope literal is longer than a ZeroKMS descriptor can hold. The +/// derive does not check its length, so the key request is refused at run +/// time. +#[allow(dead_code)] // These tests only encrypt. +#[derive(EncryptTarget)] +#[stash(struct = ProbeRow, scope = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")] +struct LongScopeProbeRow { + secret: StackCipherText, +} +#[derive(Zeroize)] +struct ProbeOuter { + inner: ProbeRow, +} +#[allow(dead_code)] // These tests only encrypt. +#[derive(EncryptTarget)] +#[stash(struct = ProbeOuter, scope = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")] +struct LongScopeProbeOuter { + inner: EncryptedProbeRow, +} +#[allow(dead_code)] // These tests only encrypt. +#[derive(EncryptTarget)] +#[stash(plaintext = Probe)] +struct SealedProbe { + c: StackCipherText, +} +#[allow(dead_code)] // These tests only encrypt. +#[derive(EncryptTarget)] +#[stash(plaintext = Probe)] +struct StoredContextProbe { + #[stash(context_field)] + identifier: &'static str, + c: StackCipherText, +} +#[derive(Zeroize)] +struct ProbeColumn { + secrets: Vec<Probe>, +} +#[allow(dead_code)] // These tests only encrypt. +#[derive(EncryptTarget)] +#[stash(struct = ProbeColumn, scope = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")] +struct LongScopeProbeColumn { + secrets: Vec<StackCipherText>, +} + +/// Run `encrypt` over a probe that a refused context must not leak. The test +/// fails if the encryption succeeds, or if any copy of the plaintext is +/// dropped while it still holds its value. +async fn assert_refused_without_a_leak<T, F>(encrypt: F) +where + F: for<'a> FnOnce( + &Arc<AtomicBool>, + &'a stack_encrypt::KeysetCipher<'a, common::CountingSource>, + ) -> stack_encrypt::target::Pending<'a, T, common::CountingSource>, +{ + let (cipher, generates, _) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let leaked = Arc::new(AtomicBool::new(false)); + let result = encrypt(&leaked, &keyset).await; + assert!(result.is_err(), "the context is refused"); + assert!( + !leaked.load(Ordering::SeqCst), + "a copy of the sealed plaintext was dropped unwiped" + ); + assert_eq!(generates.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn a_sealed_field_is_not_leaked_when_the_scope_is_refused() { + // The control: the same record under a scope that fits leaks nothing. + let (cipher, _, _) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let leaked = Arc::new(AtomicBool::new(false)); + let row = ProbeRow { + secret: probe(&leaked), + }; + assert!(row + .encrypt_into::<EncryptedProbeRow, _>(&keyset) + .await + .is_ok()); + assert!(!leaked.load(Ordering::SeqCst)); + + assert_refused_without_a_leak(|leaked, keyset| { + ProbeRow { + secret: probe(leaked), + } + .encrypt_into::<LongScopeProbeRow, _>(keyset) + }) + .await; +} + +#[tokio::test] +async fn a_nested_sealed_field_is_not_leaked_when_the_outer_scope_is_refused() { + assert_refused_without_a_leak(|leaked, keyset| { + ProbeOuter { + inner: ProbeRow { + secret: probe(leaked), + }, + } + .encrypt_into::<LongScopeProbeOuter, _>(keyset) + }) + .await; +} + +#[tokio::test] +async fn a_plaintext_record_is_not_leaked_when_the_caller_context_is_refused() { + assert_refused_without_a_leak(|leaked, keyset| { + probe(leaked).encrypt_into_with_context::<SealedProbe, _>(keyset, too_long()) + }) + .await; +} + +#[tokio::test] +async fn a_plaintext_record_is_not_leaked_when_the_stored_context_is_refused() { + let identifier: &'static str = Box::leak("x".repeat(65536).into_boxed_str()); + assert_refused_without_a_leak(|leaked, keyset| { + probe(leaked).encrypt_into_with_context::<StoredContextProbe, _>( + keyset, + NonEmpty::new(identifier).unwrap(), + ) + }) + .await; +} + +#[tokio::test] +async fn a_sealed_column_is_not_leaked_when_the_scope_is_refused() { + assert_refused_without_a_leak(|leaked, keyset| { + ProbeColumn { + secrets: vec![probe(leaked), probe(leaked)], + } + .encrypt_into::<LongScopeProbeColumn, _>(keyset) + }) + .await; +} + +#[tokio::test] +async fn a_stack_ciphertext_is_not_leaked_when_the_context_is_refused() { + assert_refused_without_a_leak(|leaked, keyset| { + probe(leaked).encrypt_into_with_context::<StackCipherText, _>(keyset, too_long()) + }) + .await; +} + +#[tokio::test] +async fn a_vec_of_stack_ciphertext_is_not_leaked_when_the_context_is_refused() { + assert_refused_without_a_leak(|leaked, keyset| { + vec![probe(leaked), probe(leaked)] + .encrypt_into_with_context::<Vec<StackCipherText>, _>(keyset, too_long()) + }) + .await; +} + +#[tokio::test] +async fn a_searchable_ciphertext_is_not_leaked_when_the_context_is_refused() { + // The ciphertext and the equality term each take the plaintext. The + // term's scheme consumes its copy. The refused ciphertext must not drop + // its copy unwiped. + assert_refused_without_a_leak(|leaked, keyset| { + probe(leaked).encrypt_into_with_context::<Encrypted<EqualityTerm>, _>(keyset, too_long()) + }) + .await; +} + +#[derive(Zeroize)] +struct Pair { + first: Secret, + second: Secret, +} +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = Pair, scope = "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` contains the ciphertext sealed under the context of `first`. So + // `second` is refused only once its key arrives, after `first` has been + // decrypted. `Secret::drop` fails the test if `first` is then dropped + // without being wiped. + let moved = EncryptedPair { + second: SecretCiphertext(encrypted.first.0.clone()), + first: encrypted.first, + }; + 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. A value dropped without +// being wiped during an unwind is detected through the flag, not as an abort. +struct Flagged { + value: String, + wiped: Arc<AtomicBool>, +} +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 `Flagged` whose plaintext +/// is `"panics"`. The panic happens inside the default owned guard, while the +/// field's description runs. +// These tests only encrypt, so the ciphertext is never read. +struct FlaggedCiphertext(#[allow(dead_code)] Ciphertext); +impl EncryptTarget<Flagged> for FlaggedCiphertext { + type ContextInput = OptionalContext<ScalarContext>; + 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, +} +// This record is only encrypted, and the encryption never completes, so its +// fields are never read. +#[allow(dead_code)] +#[derive(EncryptTarget)] +#[stash(struct = Trio, scope = "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(); + // This covers the outer `split`, where the first field panics and the + // other fields wait as one part. It also covers a nested `split`, where + // the third field panics and the fourth waits on its own. + for panicking in ["first", "third"] { + let value = |field: &'static str| if field == panicking { "panics" } else { field }; + let (first, first_wiped) = flagged(value("first")); + 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 that panics while it is built. At +/// that point, `encrypt_into` is holding only the plaintext. +struct UnconstructedCiphertext(#[allow(dead_code)] Ciphertext); +impl EncryptTarget<Flagged> for UnconstructedCiphertext { + type ContextInput = OptionalContext<ScalarContext>; + 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 -------------------- + +/// A scalar target that the application defines: the ciphertext and an +/// equality term, under the one context the record or the caller supplies. +#[derive(EncryptTarget, DecryptSource)] +#[stash(plaintext = String)] +struct SearchableEmail { + ciphertext: Ciphertext, + equality: EqualityTerm, +} + +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = User, scope = "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 makes one batched key request for both users: + // two keys for the names and two keys for the email addresses. + let encrypted: Vec<SearchableUser> = users.clone().encrypt_into(&keyset).await.unwrap(); + assert_eq!(generates.load(Ordering::SeqCst), 1); + assert_eq!(encrypted.len(), 2); + + // Later, another cipher builds a query under the same keyset, field + // context, and operation. This is how a query in another process derives + // it. + let reader = common::stack_cipher().await; + let query: EqualityTerm = "alice@example.com" + .to_owned() + .encrypt_into_with_context(&reader.default_keyset(), nonempty!("users").with("email")) + .await + .unwrap(); + // A scan of the stored rows in memory takes the place of a database index. + let matches: Vec<SearchableUser> = encrypted + .into_iter() + .filter(|row| row.email.equality == query) + .collect(); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].id, 42); + + // The matching rows are decrypted in one batch. + let restored = Vec::<User>::decrypt_from(matches, &cipher).await.unwrap(); + assert_eq!(restored, &users[..1]); + assert_eq!(retrieves.load(Ordering::SeqCst), 1); +} + +#[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] + ); +} + +#[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 the indexes of 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, scope = "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, scope = "accounts")] +struct EncryptedAccount { + /// A passthrough field with another name. + #[stash(passthrough, from = account_id)] + id: u64, + /// A sealed field with another name. Its context uses the name of the + /// plaintext field. + #[stash(from = holder)] + owner: Ciphertext, + /// A sealed field whose context uses a fixed segment. + #[stash(from = opened, identity = "opened_at")] + opened: Ciphertext, + /// A nested record. Its fields have their own contexts, extended by the + /// context of this field. + 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); + + // With no expected context, the record decrypts under the stored + // identifier. + let again = TextEq { + identifier: encrypted.identifier, + c: encrypted.c.clone(), + hm: encrypted.hm.clone(), + }; + assert_eq!(String::decrypt_from(again, &keyset).await.unwrap(), value); + // With an expected context, the stored identifier must equal it. The check + // runs before any key request. + let moved = TextEq { + identifier: "users/name", + c: encrypted.c.clone(), + 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. They 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 a compile error 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: any plaintext that 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 can be a field of a `plaintext` +/// record only if that record also takes no context. Here it is used on its +/// own, and in a column. +struct FixedInteger(Ciphertext); +impl EncryptTarget<i32> for FixedInteger { + type ContextInput = NoCallerContext; + 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 with an owned record as a field. The owned derive + /// also implements `EncryptFrom` with a `DeclaredContext`, so this works. + #[derive(EncryptFrom)] + #[stash(struct = Profile, scope = "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" + ] + ); + + // The owned record also works as a typed field of a plan, for the same + // reason. + let plan = Plan::context("profiles") + .fields::<Profile, _>() + .encrypt_into::<EncryptedUser, _>(pick("user", |p: &Profile| &p.user)) + .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, + ); +} diff --git a/packages/stack-encrypt/tests/scalar.rs b/packages/stack-encrypt/tests/scalar.rs index 66f243b056..79d604eeda 100644 --- a/packages/stack-encrypt/tests/scalar.rs +++ b/packages/stack-encrypt/tests/scalar.rs @@ -76,6 +76,60 @@ async fn rejects_wrong_context_type_and_tampered_ciphertext() { )); } +#[tokio::test] +async fn a_scalar_decrypts_straight_into_a_protected() { + use vitaminc_protected::{Controlled, Protected}; + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + + let ct = keyset + .encrypt("alice@example.com".to_owned()) + .await + .unwrap(); + let email = keyset.decrypt::<Protected<String>>(ct).await.unwrap(); + assert_eq!(email.risky_unwrap(), "alice@example.com"); + + let ct = keyset + .encrypt_with_context(42i32, ("users", "age")) + .await + .unwrap(); + let age = Protected::<i32>::decrypt_from_with_context(ct, &keyset, ("users", "age")) + .await + .unwrap(); + assert_eq!(age.risky_unwrap(), 42); + + // The same context checks apply inside the `Protected`. + let ct = keyset + .encrypt_with_context(42i32, ("users", "age")) + .await + .unwrap(); + assert!(matches!( + keyset + .decrypt_with_context::<Protected<i32>>(ct, ("users", "name")) + .await, + Err(Error::Aead) + )); +} + +#[tokio::test] +async fn a_ciphertext_opens_only_as_a_scalar_whatever_the_target() { + // A `Ciphertext` holds one scalar. The bound is `Decrypt`, so a target + // that is not a scalar compiles. Opening refuses a sequence, and reads a + // nullable target as present. + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset + .encrypt("alice@example.com".to_owned()) + .await + .unwrap(); + assert!(matches!( + keyset.decrypt::<Vec<String>>(ct).await, + Err(Error::OpenedType { .. }) + )); + let ct = keyset.encrypt(42i32).await.unwrap(); + assert_eq!(keyset.decrypt::<Option<i32>>(ct).await.unwrap(), Some(42)); +} + #[tokio::test] async fn scalar_storage_cross_opens_with_existing_leaf_format() { let cipher = common::stack_cipher().await; 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 1087c647c0..056d7150f9 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.rs b/packages/stack-encrypt/tests/ui/bare_context.rs index e70aea8553..5e90952bcc 100644 --- a/packages/stack-encrypt/tests/ui/bare_context.rs +++ b/packages/stack-encrypt/tests/ui/bare_context.rs @@ -16,7 +16,7 @@ struct User { } #[derive(EncryptFrom, DecryptInto)] -#[stash(struct = User, context = "users")] +#[stash(struct = User, scope = "users")] struct EncryptedUser { email: StackCipherText, } diff --git a/packages/stack-encrypt/tests/ui/bare_context.stderr b/packages/stack-encrypt/tests/ui/bare_context.stderr index 327a95d9fa..8eef45acbb 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<DeclaredScopes>` `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<DeclaredScopes>` `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 09e47367d6..bcb6f092c4 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/context_type_conflicts.rs b/packages/stack-encrypt/tests/ui/context_type_conflicts.rs index 2358e0d067..bde8e9d150 100644 --- a/packages/stack-encrypt/tests/ui/context_type_conflicts.rs +++ b/packages/stack-encrypt/tests/ui/context_type_conflicts.rs @@ -4,7 +4,7 @@ use stack_encrypt::{EncryptFrom, StackCipherText}; #[derive(EncryptFrom)] -#[stash(struct = User, context = "users", context_type = stack_encrypt::target::AeadContext)] +#[stash(struct = User, scope = "users", context_type = stack_encrypt::target::AeadContext)] struct ByField { name: StackCipherText, } diff --git a/packages/stack-encrypt/tests/ui/context_type_conflicts.stderr b/packages/stack-encrypt/tests/ui/context_type_conflicts.stderr index 9c37c7706b..4c7a6d298b 100644 --- a/packages/stack-encrypt/tests/ui/context_type_conflicts.stderr +++ b/packages/stack-encrypt/tests/ui/context_type_conflicts.stderr @@ -1,13 +1,13 @@ -error: `context_type` names what the caller passes to a record whose fields take the caller's context; a `struct` derive's fields carry their own, so the record takes `DeclaredContext` and a caller's context extends them - --> tests/ui/context_type_conflicts.rs:7:58 +error: `context_type` names what the caller passes to a record whose fields take the caller's context; a `struct` derive's fields carry their own contexts, so it does not apply + --> tests/ui/context_type_conflicts.rs:7:56 | -7 | #[stash(struct = User, context = "users", context_type = stack_encrypt::target::AeadContext)] - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +7 | #[stash(struct = User, scope = "users", context_type = stack_encrypt::target::AeadContext)] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ error: `struct` given here --> tests/ui/context_type_conflicts.rs:7:18 | -7 | #[stash(struct = User, context = "users", context_type = stack_encrypt::target::AeadContext)] +7 | #[stash(struct = User, scope = "users", context_type = stack_encrypt::target::AeadContext)] | ^^^^ error: `context_field` supplies the complete context, so the record's `Context` is `NonEmpty<T>` of that field's type; `context_type` does not apply diff --git a/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.rs b/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.rs index 65ad08e12f..0b67f58900 100644 --- a/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.rs +++ b/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.rs @@ -5,7 +5,7 @@ struct User { } #[derive(DecryptInto)] -#[stash(struct = User, context = "users")] +#[stash(struct = User, scope = "users")] struct Rec { #[stash(decrypt)] a: StackCipherText, diff --git a/packages/stack-encrypt/tests/ui/decrypt_struct_field_not_recoverable.rs b/packages/stack-encrypt/tests/ui/decrypt_struct_field_not_recoverable.rs index 1da649ff22..77e153456e 100644 --- a/packages/stack-encrypt/tests/ui/decrypt_struct_field_not_recoverable.rs +++ b/packages/stack-encrypt/tests/ui/decrypt_struct_field_not_recoverable.rs @@ -10,7 +10,7 @@ struct User { } #[derive(DecryptInto)] -#[stash(struct = User, context = "users")] +#[stash(struct = User, scope = "users")] struct Opened { email: EqualityTerm, name: StackCipherText, diff --git a/packages/stack-encrypt/tests/ui/duplicate_singleton_attrs.rs b/packages/stack-encrypt/tests/ui/duplicate_singleton_attrs.rs index 7c04625aa2..82ad13e1af 100644 --- a/packages/stack-encrypt/tests/ui/duplicate_singleton_attrs.rs +++ b/packages/stack-encrypt/tests/ui/duplicate_singleton_attrs.rs @@ -6,14 +6,14 @@ struct User { } #[derive(EncryptFrom)] -#[stash(struct = User, context = "users")] +#[stash(struct = User, scope = "users")] struct DupFrom { #[stash(from = expected, from = other)] c: StackCipherText, } #[derive(EncryptFrom)] -#[stash(struct = User, context = "users")] +#[stash(struct = User, scope = "users")] struct DupIdentity { #[stash(identity = "email", identity = "name")] expected: StackCipherText, diff --git a/packages/stack-encrypt/tests/ui/empty_context.rs b/packages/stack-encrypt/tests/ui/empty_context.rs index d6a24797b8..51bc7b98bf 100644 --- a/packages/stack-encrypt/tests/ui/empty_context.rs +++ b/packages/stack-encrypt/tests/ui/empty_context.rs @@ -5,7 +5,7 @@ struct User { } #[derive(EncryptFrom)] -#[stash(struct = User, context = "users")] +#[stash(struct = User, scope = "users")] struct EncryptedUser { #[stash(identity = "")] email: StackCipherText, diff --git a/packages/stack-encrypt/tests/ui/field_context_removed.rs b/packages/stack-encrypt/tests/ui/field_context_removed.rs index 2c8c6743a3..4279e1ad1d 100644 --- a/packages/stack-encrypt/tests/ui/field_context_removed.rs +++ b/packages/stack-encrypt/tests/ui/field_context_removed.rs @@ -11,7 +11,7 @@ struct User { } #[derive(EncryptFrom)] -#[stash(struct = User, context = "users")] +#[stash(struct = User, scope = "users")] struct EncryptedUser { #[stash(context = "nickname")] name: StackCipherText, diff --git a/packages/stack-encrypt/tests/ui/leaf_without_context.stderr b/packages/stack-encrypt/tests/ui/leaf_without_context.stderr index b408e8c031..fc886ada13 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/nested_removed.rs b/packages/stack-encrypt/tests/ui/nested_removed.rs index e841b37a91..784789a852 100644 --- a/packages/stack-encrypt/tests/ui/nested_removed.rs +++ b/packages/stack-encrypt/tests/ui/nested_removed.rs @@ -12,13 +12,13 @@ struct Account { } #[derive(EncryptFrom)] -#[stash(struct = User, context = "users")] +#[stash(struct = User, scope = "users")] struct EncryptedUser { email: StackCipherText, } #[derive(EncryptFrom)] -#[stash(struct = Account, context = "accounts")] +#[stash(struct = Account, scope = "accounts")] struct EncryptedAccount { #[stash(nested)] user: EncryptedUser, diff --git a/packages/stack-encrypt/tests/ui/nested_removed.stderr b/packages/stack-encrypt/tests/ui/nested_removed.stderr index 1e03651795..2529230715 100644 --- a/packages/stack-encrypt/tests/ui/nested_removed.stderr +++ b/packages/stack-encrypt/tests/ui/nested_removed.stderr @@ -1,4 +1,4 @@ -error: `nested` is no longer accepted (0.2.0 accepted it): a field whose type is a record is an ordinary field, derived under `("<context>", "<field>")` like any other, and its own fields sit under that. Omit `nested`. That is a different context from the one `nested` gave the inner fields: data written with `nested` does not decrypt without it and its terms do not match, so re-encrypt that data +error: `nested` is no longer accepted (0.2.0 accepted it): a field whose type is a record is an ordinary field, derived under `("<scope>", "<field>")` like any other, and its own fields sit under that. Omit `nested`. That is a different context from the one `nested` gave the inner fields: data written with `nested` does not decrypt without it and its terms do not match, so re-encrypt that data --> tests/ui/nested_removed.rs:23:13 | 23 | #[stash(nested)] diff --git a/packages/stack-encrypt/tests/ui/non_plain_inferred_context.rs b/packages/stack-encrypt/tests/ui/non_plain_inferred_context.rs index e86222ae92..85fc9cd19c 100644 --- a/packages/stack-encrypt/tests/ui/non_plain_inferred_context.rs +++ b/packages/stack-encrypt/tests/ui/non_plain_inferred_context.rs @@ -7,7 +7,7 @@ struct User { // A schema-qualified table is a natural thing to write, and it would render // `b64:…/email`: the log would not name the table. #[derive(EncryptFrom)] -#[stash(struct = User, context = "public/users")] +#[stash(struct = User, scope = "public/users")] struct SchemaQualified { email: StackCipherText, } @@ -16,7 +16,7 @@ struct Reading(u32); // A tuple index begins with a digit, which a descriptor reserves. #[derive(EncryptFrom)] -#[stash(struct = Reading, context = "readings")] +#[stash(struct = Reading, scope = "readings")] struct EncryptedReading(StackCipherText); fn main() {} diff --git a/packages/stack-encrypt/tests/ui/non_plain_inferred_context.stderr b/packages/stack-encrypt/tests/ui/non_plain_inferred_context.stderr index 5af1823108..e3a7875a98 100644 --- a/packages/stack-encrypt/tests/ui/non_plain_inferred_context.stderr +++ b/packages/stack-encrypt/tests/ui/non_plain_inferred_context.stderr @@ -1,8 +1,8 @@ -error: a container `context` is the first segment of every field's ZeroKMS descriptor, so it must be plain: no `/`, `(`, `)`, control or invisible character, and not beginning with `b64:`, a digit or `-`; otherwise it would render escaped and the log would not name the table. For the table `public.users` write `context = "users"` - --> tests/ui/non_plain_inferred_context.rs:10:34 +error: a container `scope` is the first segment of every field's ZeroKMS descriptor, so it must be plain: no `/`, `(`, `)`, control or invisible character, and not beginning with `b64:`, a digit or `-`; otherwise it would render escaped and the log would not name the table. For the table `public.users` write `scope = "users"` + --> tests/ui/non_plain_inferred_context.rs:10:32 | -10 | #[stash(struct = User, context = "public/users")] - | ^^^^^^^^^^^^^^ +10 | #[stash(struct = User, scope = "public/users")] + | ^^^^^^^^^^^^^^ error: a tuple field has no name to infer a context from: its index `0` begins with a digit, which a descriptor reserves, so `("readings", "0")` would render escaped; give the field `#[stash(identity = "..")]` --> tests/ui/non_plain_inferred_context.rs:18:10 diff --git a/packages/stack-encrypt/tests/ui/opaque_blob_requires_zeroize.stderr b/packages/stack-encrypt/tests/ui/opaque_blob_requires_zeroize.stderr index e1e5eb1364..77e6917d19 100644 --- a/packages/stack-encrypt/tests/ui/opaque_blob_requires_zeroize.stderr +++ b/packages/stack-encrypt/tests/ui/opaque_blob_requires_zeroize.stderr @@ -19,7 +19,7 @@ help: the trait `zeroize::DefaultIsZeroes` is not implemented for `Unwiped` i128 i16 and $N others - = note: required for `Unwiped` to implement `zeroize::Zeroize` + = note: required for `Unwiped` to implement `Zeroize` note: required by a bound in `OpaqueBlob` --> src/blob.rs | @@ -47,7 +47,7 @@ help: the trait `zeroize::DefaultIsZeroes` is not implemented for `Unwiped` i128 i16 and $N others - = note: required for `Unwiped` to implement `zeroize::Zeroize` + = note: required for `Unwiped` to implement `Zeroize` note: required by a bound in `OpaqueBlob` --> src/blob.rs | @@ -94,7 +94,7 @@ help: the trait `zeroize::DefaultIsZeroes` is not implemented for `Unwiped` i128 i16 and $N others - = note: required for `Unwiped` to implement `zeroize::Zeroize` + = note: required for `Unwiped` to implement `Zeroize` note: required by a bound in `OpaqueBlob` --> src/blob.rs | @@ -122,7 +122,7 @@ help: the trait `zeroize::DefaultIsZeroes` is not implemented for `Unwiped` i128 i16 and $N others - = note: required for `Unwiped` to implement `zeroize::Zeroize` + = note: required for `Unwiped` to implement `Zeroize` note: required by a bound in `OpaqueBlob` --> src/blob.rs | @@ -150,7 +150,7 @@ help: the trait `zeroize::DefaultIsZeroes` is not implemented for `Unwiped` i128 i16 and $N others - = note: required for `Unwiped` to implement `zeroize::Zeroize` + = note: required for `Unwiped` to implement `Zeroize` note: required by a bound in `OpaqueBlob` --> src/blob.rs | @@ -195,7 +195,7 @@ help: the trait `zeroize::DefaultIsZeroes` is not implemented for `Unwiped` i128 i16 and $N others - = note: required for `Unwiped` to implement `zeroize::Zeroize` + = note: required for `Unwiped` to implement `Zeroize` note: required by a bound in `OpaqueBlob` --> src/blob.rs | 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 0000000000..81ee1c2d27 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_aead_record_with_term.rs @@ -0,0 +1,15 @@ +//! An owned record with `plaintext` and `context_type = AeadContext` requires +//! an AEAD-only context. A term cannot be derived under that context, so the +//! term field is a compile error where it is declared. +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{AeadContext, EncryptTarget}; +use stack_encrypt::Ciphertext; + +#[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 0000000000..5e130aaf90 --- /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 286be41fa3..bf118a51a1 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 AllowsNoContext for DeclaredScopes { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `DeclaredScopes` +... + | 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 AllowsNoContext for DeclaredScopes { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `DeclaredScopes` +... + | 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,9 @@ help: the following other types implement trait `AcceptsContext` ... | impl<C> AcceptsContext for OptionalContext<C> { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext<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 +94,9 @@ help: the following other types implement trait `AcceptsContext` ... | impl<C> AcceptsContext for OptionalContext<C> { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext<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.rs b/packages/stack-encrypt/tests/ui/owned_collection_elements.rs index 554d75ecd7..f3949c9007 100644 --- a/packages/stack-encrypt/tests/ui/owned_collection_elements.rs +++ b/packages/stack-encrypt/tests/ui/owned_collection_elements.rs @@ -3,14 +3,15 @@ //! byte vector is one `Ciphertext` (`tests/ui/pass/owned_collections.rs`), //! never a column of bytes. A nullable element and a borrowed element are //! not opaque scalars either. No vector of scalars encrypts into one -//! `Ciphertext`, and the decrypt side refuses the same element types. +//! `Ciphertext`. On the decrypt side `u8` is refused, because it does not +//! decrypt at all. A nullable element compiles there, and opens as present +//! (`tests/scalar.rs`). use stack_encrypt::{Ciphertext, DecryptFrom, EncryptInto}; fn unsupported<K: 'static>(keyset: &stack_encrypt::KeysetCipher<'_, K>, column: Vec<Ciphertext>) { let _ = vec![1u8, 2].encrypt_into::<Vec<Ciphertext>, _>(keyset); let _ = vec![Some(1i32), None].encrypt_into::<Vec<Ciphertext>, _>(keyset); let _ = EncryptInto::encrypt_into::<Vec<Ciphertext>, _>(vec!["borrowed", "plaintext"], keyset); let _ = vec![1i32, 2].encrypt_into::<Ciphertext, _>(keyset); - let _ = Vec::<u8>::decrypt_from(column.clone(), keyset); - let _ = Vec::<Option<i32>>::decrypt_from(column, keyset); + let _ = Vec::<u8>::decrypt_from(column, keyset); } fn main() {} diff --git a/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr b/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr index 029645211d..9db8352799 100644 --- a/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr +++ b/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr @@ -1,28 +1,28 @@ error[E0277]: the trait bound `for<'a> &'a u8: Scalar` is not satisfied - --> tests/ui/owned_collection_elements.rs:9:26 - | -9 | let _ = vec![1u8, 2].encrypt_into::<Vec<Ciphertext>, _>(keyset); - | ^^^^^^^^^^^^ the trait `for<'a> Scalar` is not implemented for `&'a u8` - | - = help: the following other types implement trait `Scalar`: - &f32 - &f64 - &i128 - &i16 - &i32 - &i64 - &i8 - &u128 - and $N others - = note: required for `u8` to implement `OpaqueEncode` - = note: required for `stack_encrypt::Ciphertext` to implement `EncryptTarget<u8>` - = note: 1 redundant requirement hidden - = note: required for `Vec<stack_encrypt::Ciphertext>` to implement `EncryptTarget<Vec<u8>>` + --> tests/ui/owned_collection_elements.rs:11:26 + | +11 | let _ = vec![1u8, 2].encrypt_into::<Vec<Ciphertext>, _>(keyset); + | ^^^^^^^^^^^^ the trait `for<'a> Scalar` is not implemented for `&'a u8` + | + = help: the following other types implement trait `Scalar`: + &f32 + &f64 + &i128 + &i16 + &i32 + &i64 + &i8 + &u128 + and $N others + = note: required for `u8` to implement `OpaqueEncode` + = note: required for `stack_encrypt::Ciphertext` to implement `EncryptTarget<u8>` + = note: 1 redundant requirement hidden + = note: required for `Vec<stack_encrypt::Ciphertext>` to implement `EncryptTarget<Vec<u8>>` error[E0277]: the trait bound `for<'a> &'a Option<i32>: Scalar` is not satisfied - --> tests/ui/owned_collection_elements.rs:10:36 + --> tests/ui/owned_collection_elements.rs:12:36 | -10 | let _ = vec![Some(1i32), None].encrypt_into::<Vec<Ciphertext>, _>(keyset); +12 | let _ = vec![Some(1i32), None].encrypt_into::<Vec<Ciphertext>, _>(keyset); | ^^^^^^^^^^^^ the trait `for<'a> Scalar` is not implemented for `&'a Option<i32>` | = help: the following other types implement trait `Scalar`: @@ -40,10 +40,10 @@ error[E0277]: the trait bound `for<'a> &'a Option<i32>: Scalar` is not satisfied = note: 1 redundant requirement hidden = note: required for `Vec<stack_encrypt::Ciphertext>` to implement `EncryptTarget<Vec<Option<i32>>>` -error[E0277]: the trait bound `&str: zeroize::Zeroize` is not satisfied - --> tests/ui/owned_collection_elements.rs:11:13 +error[E0277]: the trait bound `&str: Zeroize` is not satisfied + --> tests/ui/owned_collection_elements.rs:13:13 | -11 | let _ = EncryptInto::encrypt_into::<Vec<Ciphertext>, _>(vec!["borrowed", "plaintext"], keyset); +13 | let _ = EncryptInto::encrypt_into::<Vec<Ciphertext>, _>(vec!["borrowed", "plaintext"], keyset); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `zeroize::DefaultIsZeroes` is not implemented for `&str` | = help: the following other types implement trait `zeroize::DefaultIsZeroes`: @@ -56,13 +56,13 @@ error[E0277]: the trait bound `&str: zeroize::Zeroize` is not satisfied i128 i16 and $N others - = note: required for `&str` to implement `zeroize::Zeroize` + = note: required for `&str` to implement `Zeroize` = note: required for `Vec<stack_encrypt::Ciphertext>` to implement `EncryptTarget<Vec<&str>>` error[E0277]: the trait bound `for<'a> &'a Vec<i32>: Scalar` is not satisfied - --> tests/ui/owned_collection_elements.rs:12:27 + --> tests/ui/owned_collection_elements.rs:14:27 | -12 | let _ = vec![1i32, 2].encrypt_into::<Ciphertext, _>(keyset); +14 | let _ = vec![1i32, 2].encrypt_into::<Ciphertext, _>(keyset); | ^^^^^^^^^^^^ the trait `for<'a> Scalar` is not implemented for `&'a Vec<i32>` | help: the following other types implement trait `Scalar` @@ -87,10 +87,10 @@ help: the following other types implement trait `Scalar` = note: this error originates in the macro `scalar` (in Nightly builds, run with -Z macro-backtrace for more info) error[E0277]: the trait bound `u8: vitaminc_aead::typed::FromScalar` is not satisfied - --> tests/ui/owned_collection_elements.rs:13:13 + --> tests/ui/owned_collection_elements.rs:15:13 | -13 | let _ = Vec::<u8>::decrypt_from(column.clone(), keyset); - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `vitaminc_aead::typed::FromScalar` is not implemented for `u8` +15 | let _ = Vec::<u8>::decrypt_from(column, keyset); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `vitaminc_aead::typed::FromScalar` is not implemented for `u8` | = help: the following other types implement trait `vitaminc_aead::typed::FromScalar`: f32 @@ -102,28 +102,8 @@ error[E0277]: the trait bound `u8: vitaminc_aead::typed::FromScalar` is not sati i8 u128 and $N others + = note: required for `u8` to implement `Decrypt` = note: required for `u8` to implement `OpaqueDecode` = note: required for `stack_encrypt::Ciphertext` to implement `DecryptSource<u8>` = note: 1 redundant requirement hidden = note: required for `Vec<stack_encrypt::Ciphertext>` to implement `DecryptSource<Vec<u8>>` - -error[E0277]: the trait bound `Option<i32>: vitaminc_aead::typed::FromScalar` is not satisfied - --> tests/ui/owned_collection_elements.rs:14:13 - | -14 | let _ = Vec::<Option<i32>>::decrypt_from(column, keyset); - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `vitaminc_aead::typed::FromScalar` is not implemented for `Option<i32>` - | - = help: the following other types implement trait `vitaminc_aead::typed::FromScalar`: - Vec<u8> - [u8; N] - bool - chrono::datetime::DateTime<chrono::offset::utc::Utc> - chrono::naive::date::NaiveDate - f32 - f64 - i128 - and $N others - = note: required for `Option<i32>` to implement `OpaqueDecode` - = note: required for `stack_encrypt::Ciphertext` to implement `DecryptSource<Option<i32>>` - = note: 1 redundant requirement hidden - = note: required for `Vec<stack_encrypt::Ciphertext>` to implement `DecryptSource<Vec<Option<i32>>>` 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 0000000000..c3e369fcc5 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_record_field_context.rs @@ -0,0 +1,58 @@ +//! A field of an owned `struct = ..` record is given the field context that +//! the record builds, extended by the caller's context. A target that accepts +//! no caller context, or that requires a context of another type, cannot take +//! it. That field is a compile error. +use stack_encrypt::target::{ + AcceptsContext, DecryptSource, Decryptable, Decryption, EncryptTarget, Encryption, + NoCallerContext, OptionalContext, RequiredContext, ScalarContext, +}; +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 field context. +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, scope = "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 0000000000..82fca205a9 --- /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 0000000000..64a215d849 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_record_required_context.rs @@ -0,0 +1,20 @@ +//! An owned record with `plaintext` requires the caller's context. A call that +//! leaves it out is a compile error, to encrypt and to decrypt, even if the +//! context type implements `Default`. +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{DecryptSource, EncryptTarget}; +use stack_encrypt::{Ciphertext, DecryptFrom, EncryptInto}; + +#[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 0000000000..c8c7fcd418 --- /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:16:47 + | +16 | let _ = String::from("alice@example.com").encrypt_into::<SearchableEmail, _>(keyset); + | ^^^^^^^^^^^^ the trait `AllowsNoContext` is not implemented for `RequiredContext<CallerContext>` + | +help: the following other types implement trait `AllowsNoContext` + --> src/target/input.rs + | + | impl AllowsNoContext for NoCallerContext { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `NoCallerContext` +... + | impl<C> AllowsNoContext for OptionalContext<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext<C>` +... + | impl AllowsNoContext for DeclaredScopes { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `DeclaredScopes` +... + | 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:17:34 + | +17 | 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 AllowsNoContext for DeclaredScopes { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `DeclaredScopes` +... + | 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 0000000000..eb319b7573 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_struct_plaintext_not_zeroize.rs @@ -0,0 +1,17 @@ +//! The plaintext of an owned record must implement `Zeroize`. The conversion +//! is responsible for wiping what it seals, and this bound declares that +//! responsibility. +use stack_encrypt::target::EncryptTarget; +use stack_encrypt::Ciphertext; + +struct User { + email: String, +} + +#[derive(EncryptTarget)] +#[stash(struct = User, scope = "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 0000000000..70abec3d58 --- /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, scope = "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, scope = "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_record_takes_no_context.rs b/packages/stack-encrypt/tests/ui/owned_struct_record_takes_no_context.rs new file mode 100644 index 0000000000..37f8e12bc7 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_struct_record_takes_no_context.rs @@ -0,0 +1,28 @@ +//! A `struct = ..` record takes no caller context. Each field's context is +//! the record's name, then the field's name. A part the caller supplies must +//! be declared on the record first, so passing one to a record that declares +//! none is a compile error, to encrypt and to decrypt. +use stack_encrypt::target::{DecryptSource, EncryptTarget}; +use stack_encrypt::{Ciphertext, DecryptFrom, EncryptInto}; +use zeroize::Zeroize; + +#[derive(Zeroize)] +struct User { + email: String, +} + +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = User, scope = "users")] +struct EncryptedUser { + email: Ciphertext, +} + +fn supplied<K: 'static>(keyset: &stack_encrypt::KeysetCipher<'_, K>, stored: EncryptedUser) { + let user = User { + email: "alice@example.com".to_owned(), + }; + let _ = user.encrypt_into_with_context::<EncryptedUser, _>(keyset, 42u64); + let _ = User::decrypt_from_with_context(stored, keyset, 42u64); +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/owned_struct_record_takes_no_context.stderr b/packages/stack-encrypt/tests/ui/owned_struct_record_takes_no_context.stderr new file mode 100644 index 0000000000..e574231897 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_struct_record_takes_no_context.stderr @@ -0,0 +1,45 @@ +error[E0277]: the trait bound `DeclaredScopes: AcceptsContext` is not satisfied + --> tests/ui/owned_struct_record_takes_no_context.rs:24:18 + | +24 | let _ = user.encrypt_into_with_context::<EncryptedUser, _>(keyset, 42u64); + | ^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `AcceptsContext` is not implemented for `DeclaredScopes` + | +help: the following other types implement trait `AcceptsContext` + --> src/target/input.rs + | + | impl<C> AcceptsContext for RequiredContext<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `RequiredContext<C>` +... + | impl<C> AcceptsContext for OptionalContext<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext<C>` +... + | impl<T> AcceptsContext for ExpectedContext<T> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ExpectedContext<T>` + +error[E0277]: the trait bound `DeclaredScopes: AcceptsContext` is not satisfied + --> tests/ui/owned_struct_record_takes_no_context.rs:25:45 + | +25 | let _ = User::decrypt_from_with_context(stored, keyset, 42u64); + | ------------------------------- ^^^^^^ the trait `AcceptsContext` is not implemented for `DeclaredScopes` + | | + | required by a bound introduced by this call + | +help: the following other types implement trait `AcceptsContext` + --> src/target/input.rs + | + | impl<C> AcceptsContext for RequiredContext<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `RequiredContext<C>` +... + | impl<C> AcceptsContext for OptionalContext<C> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext<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 + | + | fn decrypt_from_with_context<'a, S, K: 'static>( + | ------------------------- required by a bound in this associated function +... + | S::ContextInput: AcceptsContext, + | ^^^^^^^^^^^^^^ required by this bound in `DecryptFrom::decrypt_from_with_context` 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 0000000000..ec424fd2c4 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_struct_unmapped_field.rs @@ -0,0 +1,21 @@ +//! An owned `struct = ..` record must name every field of its plaintext. The +//! owned conversion wipes the fields it seals, and moves the fields it passes +//! through. A field it does neither would be dropped without being wiped. So +//! that field is a compile error at the `struct = ..` path, and the error +//! names the field. +use stack_encrypt::target::EncryptTarget; +use stack_encrypt::Ciphertext; + +#[derive(zeroize::Zeroize)] +struct User { + email: String, + password: String, +} + +#[derive(EncryptTarget)] +#[stash(struct = User, scope = "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 0000000000..0dce50dd68 --- /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:16:18 + | +16 | #[stash(struct = User, scope = "users")] + | ^^^^ missing field `password` diff --git a/packages/stack-encrypt/tests/ui/pass/owned_generic_plaintext.rs b/packages/stack-encrypt/tests/ui/pass/owned_generic_plaintext.rs new file mode 100644 index 0000000000..9e304d8474 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/pass/owned_generic_plaintext.rs @@ -0,0 +1,55 @@ +//! A `struct = ..` record whose plaintext is a generic struct must compile. +//! +//! The derive names the plaintext in a pattern on the encrypt side and in a +//! struct literal on the decrypt side. Both must write the generic arguments +//! with a turbofish (`Pt::<u64> { .. }`). The forms below cover a record with +//! one field and a record with more than one. +use stack_encrypt::target::{DecryptSource, EncryptTarget}; +use stack_encrypt::{Ciphertext, DecryptFrom, EncryptInto, KeysetCipher}; +use stack_kms::FakeDataKeySource; +use zeroize::Zeroize; + +#[derive(Zeroize)] +struct Pt<T: Zeroize> { + id: T, + secret: String, +} + +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = Pt<u64>, scope = "points")] +struct EncryptedPt { + #[stash(passthrough)] + id: u64, + secret: Ciphertext, +} + +#[derive(Zeroize)] +struct Wrapped<T: Zeroize> { + secret: T, +} + +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = Wrapped<String>, scope = "wrapped")] +struct EncryptedWrapped { + secret: Ciphertext, +} + +#[allow(dead_code)] +async fn use_them(keyset: &KeysetCipher<'_, FakeDataKeySource>) { + let point = Pt { + id: 7u64, + secret: "secret".to_owned(), + }; + let encrypted: EncryptedPt = point.encrypt_into(keyset).await.unwrap(); + let _opened = Pt::<u64>::decrypt_from(encrypted, keyset).await.unwrap(); + + let wrapped = Wrapped { + secret: "secret".to_owned(), + }; + let encrypted: EncryptedWrapped = wrapped.encrypt_into(keyset).await.unwrap(); + let _opened = Wrapped::<String>::decrypt_from(encrypted, keyset) + .await + .unwrap(); +} + +fn main() {} 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 0000000000..0e8bbb1905 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/pass/owned_records.rs @@ -0,0 +1,164 @@ +//! The owned record forms that must compile: +//! +//! - a `struct = ..` record with passthrough, renamed, fixed-segment and +//! nested fields; +//! - `plaintext` records over opaque and structural leaves, including a +//! generic record and a record that stores its context; +//! - a column of records; +//! - an owned record used as a field of a structural record, and of a plan. +//! +//! `tests/ui/owned_record_*.rs` and `tests/ui/passthrough_misuse.rs` pin the +//! forms that must not compile. +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, scope = "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, scope = "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, +} + +/// An owned record as a field of a structural record. +#[derive(Clone)] +struct Profile { + user: User, + age: u32, +} +#[derive(EncryptFrom)] +#[stash(struct = Profile, scope = "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, alone and as a column. It takes no caller context. + let encrypted: EncryptedUser = user.clone().encrypt_into(keyset).await?; + let _: User = User::decrypt_from(encrypted, cipher).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. With no expected context, the record decrypts as + // stored. With an expected context, the stored context is also checked. + let stored: TextEq = user + .email + .clone() + .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?; + + // An owned record in a structural record, and as a typed plan field. + let _: EncryptedProfile = legacy::EncryptInto::encrypt_into(&profile, keyset).await?; + let plan = Plan::context("profiles") + .fields::<Profile, _>() + .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/pass/struct_over_borrowed_source.rs b/packages/stack-encrypt/tests/ui/pass/struct_over_borrowed_source.rs index d4e7e2cdb2..530856cd26 100644 --- a/packages/stack-encrypt/tests/ui/pass/struct_over_borrowed_source.rs +++ b/packages/stack-encrypt/tests/ui/pass/struct_over_borrowed_source.rs @@ -18,7 +18,7 @@ struct User<'a> { } #[derive(EncryptFrom)] -#[stash(struct = User<'a>, context = "users")] +#[stash(struct = User<'a>, scope = "users")] struct EncryptedUser<'a> { email: Encrypted<EqualityTerm>, age: StackCipherText, diff --git a/packages/stack-encrypt/tests/ui/pass/which_form_compiles.rs b/packages/stack-encrypt/tests/ui/pass/which_form_compiles.rs index 32defc8fc4..7678ec80da 100644 --- a/packages/stack-encrypt/tests/ui/pass/which_form_compiles.rs +++ b/packages/stack-encrypt/tests/ui/pass/which_form_compiles.rs @@ -43,7 +43,7 @@ struct User { } #[derive(EncryptFrom, DecryptInto)] -#[stash(struct = User, context = "users")] +#[stash(struct = User, scope = "users")] struct EncryptedUser { age: Foo, email: StackCipherText, 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 0000000000..bc617c4842 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/passthrough_misuse.rs @@ -0,0 +1,50 @@ +//! `passthrough` moves a field of the plaintext struct into an owned record, +//! in the clear. The attribute is refused on the structural derives, on a +//! `plaintext` record, and beside the attributes of a derived field. +use stack_encrypt::target::{DecryptSource, EncryptTarget}; +use stack_encrypt::{Ciphertext, DecryptInto, EncryptFrom, StackCipherText}; + +#[derive(zeroize::Zeroize)] +struct User { + id: u64, + email: String, +} + +/// The structural derive cannot move a field, because it runs over a borrow. +#[derive(EncryptFrom, DecryptInto)] +#[stash(struct = User, scope = "users")] +struct Structural { + #[stash(passthrough)] + id: u64, + email: StackCipherText, +} + +/// A `plaintext` record derives every field from the whole value, so it has no +/// field to pass through. +#[derive(EncryptTarget)] +#[stash(plaintext = String)] +struct WholeValue { + #[stash(passthrough)] + id: u64, + email: Ciphertext, +} + +/// A passthrough field is moved as it is, so it cannot also be derived. +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = User, scope = "users")] +struct Marked { + #[stash(passthrough, decrypt)] + id: u64, + email: Ciphertext, +} + +/// One plaintext field has one output. +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = User, scope = "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 0000000000..a974b9d52d --- /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:28:9 + | +28 | id: u64, + | ^^^ + +error: a `passthrough` field is moved through as it is, so `identity`, `default` and `decrypt` do not apply to it: it is keyed under nothing, filled from the plaintext and moved back on decryption + --> tests/ui/passthrough_misuse.rs:37:9 + | +37 | id: u64, + | ^^^ + +error: `plain_email` and `email` are both derived from the plaintext field `email`: a plaintext field has one output. For a ciphertext with search terms beside it, make it one field of type `Encrypted<Terms>`, e.g. `plain_email: Encrypted<(EqualityTerm, MatchTerms)>` + --> tests/ui/passthrough_misuse.rs:47:5 + | +47 | email: Ciphertext, + | ^^^^^ diff --git a/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.stderr b/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.stderr index ab5de35686..6a2a157b54 100644 --- a/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.stderr +++ b/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.stderr @@ -49,7 +49,7 @@ error[E0277]: the trait bound `&str: OpaqueEncode` is not satisfied i128 i16 and $N others - = note: required for `&str` to implement `zeroize::Zeroize` + = note: required for `&str` to implement `Zeroize` = note: required for `&str` to implement `OpaqueEncode` note: required by a bound in `stack_encrypt::scalar::<impl KeysetCipher<'_, K>>::encrypt` --> src/scalar.rs diff --git a/packages/stack-encrypt/tests/ui/scalar_no_caller_context.stderr b/packages/stack-encrypt/tests/ui/scalar_no_caller_context.stderr index 4bd377c0c2..3a01d9fb9e 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,9 @@ help: the following other types implement trait `AcceptsContext` ... | impl<C> AcceptsContext for OptionalContext<C> { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext<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 +32,9 @@ help: the following other types implement trait `AcceptsContext` ... | impl<C> AcceptsContext for OptionalContext<C> { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext<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 0705bdec4b..2db597e48b 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 AllowsNoContext for DeclaredScopes { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `DeclaredScopes` +... + | 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 AllowsNoContext for DeclaredScopes { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `DeclaredScopes` +... + | impl<T> AllowsNoContext for ExpectedContext<T> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ExpectedContext<T>` note: required by a bound in `stack_encrypt::DecryptFrom::decrypt_from` --> src/target/conversion.rs | diff --git a/packages/stack-encrypt/tests/ui/struct_duplicate_from.rs b/packages/stack-encrypt/tests/ui/struct_duplicate_from.rs index 00077f1f12..c1be2c6a64 100644 --- a/packages/stack-encrypt/tests/ui/struct_duplicate_from.rs +++ b/packages/stack-encrypt/tests/ui/struct_duplicate_from.rs @@ -9,7 +9,7 @@ struct User { } #[derive(EncryptFrom)] -#[stash(struct = User, context = "users")] +#[stash(struct = User, scope = "users")] struct EncryptedUser { email: StackCipherText, #[stash(from = email)] @@ -17,7 +17,7 @@ struct EncryptedUser { } #[derive(DecryptInto)] -#[stash(struct = User, context = "users")] +#[stash(struct = User, scope = "users")] struct Opened { email: StackCipherText, #[stash(from = email)] diff --git a/packages/stack-encrypt/tests/ui/struct_field_missing.rs b/packages/stack-encrypt/tests/ui/struct_field_missing.rs index 8d56354ba9..c06482571c 100644 --- a/packages/stack-encrypt/tests/ui/struct_field_missing.rs +++ b/packages/stack-encrypt/tests/ui/struct_field_missing.rs @@ -7,7 +7,7 @@ struct User { } #[derive(EncryptFrom)] -#[stash(struct = User, context = "user")] +#[stash(struct = User, scope = "user")] struct EncryptedUser { email: StackCipherText, nickname: StackCipherText, diff --git a/packages/stack-encrypt/tests/ui/struct_field_must_open.rs b/packages/stack-encrypt/tests/ui/struct_field_must_open.rs index dfa8c98375..df6f559cdd 100644 --- a/packages/stack-encrypt/tests/ui/struct_field_must_open.rs +++ b/packages/stack-encrypt/tests/ui/struct_field_must_open.rs @@ -40,7 +40,7 @@ impl Decryptable for Opaque { } #[derive(EncryptFrom)] -#[stash(struct = Profile, context = "profiles")] +#[stash(struct = Profile, scope = "profiles")] struct EncryptedProfile { name: StackCipherText, bio: Opaque, diff --git a/packages/stack-encrypt/tests/ui/struct_without_context.rs b/packages/stack-encrypt/tests/ui/struct_without_context.rs index f24c3af8ab..5a99a82b0d 100644 --- a/packages/stack-encrypt/tests/ui/struct_without_context.rs +++ b/packages/stack-encrypt/tests/ui/struct_without_context.rs @@ -1,4 +1,4 @@ -//! `struct = ..` requires an explicit container `context = ".."`: the prefix +//! `struct = ..` requires an explicit container `scope = ".."`: the record's name //! is part of the stored data's identity — the AAD of every ciphertext //! derived from the struct and the domain of every term — so it is never //! inferred from the Rust type's name. Two types named `Account` in diff --git a/packages/stack-encrypt/tests/ui/struct_without_context.stderr b/packages/stack-encrypt/tests/ui/struct_without_context.stderr index ff54b3877c..9fcaca0ebf 100644 --- a/packages/stack-encrypt/tests/ui/struct_without_context.stderr +++ b/packages/stack-encrypt/tests/ui/struct_without_context.stderr @@ -1,4 +1,4 @@ -error: `struct = ..` needs a `context = ".."` beside it naming the stored data (e.g. `#[stash(struct = User, context = "users")]`): each field is derived under the pair `("<context>", "<field>")`, and the prefix is part of the stored data's identity, so it is given explicitly rather than inferred from the Rust type's name +error: `struct = ..` needs a `scope = ".."` beside it naming the stored data (e.g. `#[stash(struct = User, scope = "users")]`): each field is derived under the pair `("<scope>", "<field>")`, and the record's name is part of the stored data's identity, so it is given explicitly rather than inferred from the Rust type's name --> tests/ui/struct_without_context.rs:13:18 | 13 | #[stash(struct = User)]