Repository navigation
stack-encrypt-derive: #[derive(EncryptFrom)] accepts five shapes a plan cannot express, so it can never emit the plan with identical bytes #1078
Description
Background
stack-encrypt (packages/stack-encrypt) is our Rust library for field-level encryption: each value is sealed under its own data key from ZeroKMS, our key management service, and can carry search terms beside the ciphertext. Every sealed value is bound to a context: a short label (for example users/email) that ties the encrypted data to where it belongs. ZeroKMS sees the context as a descriptor in its logs, and the same context must be supplied to decrypt.
There are two ways to describe how a Rust struct is encrypted:
- The derive,
#[derive(EncryptFrom)]inpackages/stack-encrypt-derive, which reads#[stash(..)]attributes on a struct. - The plan builder (stack-encrypt: one chained plan builder as the single front door, so writes, queries and reads cannot drift #1057, built in feat(stack-encrypt): chained plan builder as the single front door for writes, queries and reads #1071), a chained builder that produces a plan: a saved, checked description of a value's context and, field by field, what each field becomes.
#1058 makes the derive emit a plan instead of hand-writing its own code, so the two can never drift apart. Its acceptance test is strict: the derive's output must be byte-for-byte identical before and after.
Problem
Mechanism. The derive accepts five input shapes that a plan has no way to say. For each one, there is no plan the derive could emit that writes the same bytes, so #1058 cannot be done while the derive still accepts them. All are accepted on main today:
- A field-level literal
context = ".."on astruct = ..derive (packages/stack-encrypt/tests/descriptor.rs:38,#[stash(context = "nickname")]). This seals one field outside the record's context. In a plan, every field sits under the plan's context. - A field-level literal
context = ".."on aplaintext = Trecord (packages/stack-encrypt/tests/derive.rs:183,#[stash(context = "doubled/shadow")]). This writes one output under the caller's context and another under a literal of its own. A one-value plan has exactly one context source, the caller's. - A literal context that is not one plain segment, such as
"readings/unit"(tests/derive.rs:728). The derive turns it into one escaped text part. A plan reads/as a separator, so it would produce two segments: a different context, and therefore different bytes and a different descriptor. #[stash(nested)](tests/derive.rs:647; parsed atpackages/stack-encrypt-derive/src/attrs.rs:285). This opts a record-typed field out of being keyed under<context>/<field>. In a plan every field is keyed that way; nothing opts out.- A second output from the same plaintext field in a
struct = ..derive:emailplus#[stash(from = email)] email_hm(tests/derive.rs:481,tests/descriptor.rs:36). A plan declares each field once.
Impact. The crate is unpublished, so nobody outside this repository depends on these shapes, and no EQL type (EQL is our PostgreSQL encryption library, whose Rust types in eql-bindings use this derive) or Go guest type uses any of them. But while they are accepted, the derive and the plan remain two different languages, and the bindings that go through the plan can drift from the derive, which is what ADR-0007 (on PR #1052) forbids.
Why nothing catches it today. No test compares a derive's output with a plan's, because the derive does not produce one yet.
Proposal
Remove the five shapes on purpose, each as a compile error whose message names the replacement:
- Field-level
contexton astructderive: use a new#[stash(identity = "..")]field attribute. The field is keyed under<context>/<identity>, which is the plan's.identity(segment). - Field-level
contexton aplaintext = Trecord: all outputs share the caller's context. Writing one value under two contexts (a dual write) is a fields plan that picks the same source twice, not a derive. - Non-plain literal: write one plain segment (
"unit", not"readings/unit"). With items 1 and 2 done, this applies to the containercontext, which is the first segment of every descriptor. nested: omit it. A record-typed field is an ordinary field, and its own fields sit under it.- Two outputs from one field: use one field of type
Encrypted<Terms>, for exampleemail: Encrypted<(EqualityTerm, MatchTerms)>. That needsEncrypted<Terms>to be a target the derive can use (implementEncryptFrom, plus the decrypt traits).
Every other existing test must pass unchanged, with the same descriptors and term bytes. Tests may change only because of one of these five removals.
Verified against the code: each shape above is accepted on main at the cited line. On the fix's branch, the refusals are pinned by trybuild compile-fail tests: tests/ui/field_context_removed.rs:16, :24, :30 (items 1 and 2, across all three derive forms), tests/ui/non_plain_inferred_context.rs:10 (item 3), tests/ui/nested_removed.rs:23 (item 4), tests/ui/struct_duplicate_from.rs:15, :23 and tests/ui/decrypt_duplicate_from.rs:12 (item 5).
Relationship to other work
- Blocked by feat(stack-encrypt): chained plan builder as the single front door for writes, queries and reads #1071 (the plan builder): the target shapes are what that plan can express.
- Blocks stack-encrypt-derive:
#[derive(EncryptFrom)]hand-writes combinators — emit the plan builder instead #1058 (the derive emits the plan builder), which needs a fixed target with identical bytes. - Resolved by feat(stack-encrypt-derive): narrow the derive to the plan grammar (identity replaces field contexts; no nested; one output per field) #1073.
Activity