Skip to content

Commit b62fdf1

Browse files
committed
feat(stack-encrypt-derive): all outputs of a plaintext record share the caller's context
A field-level `#[stash(context = "..")]` is now a compile error on every derive form, not only on `struct` derives. A one-value plan has exactly one context source, the caller's, so a `plaintext = T` record whose outputs sit under two different contexts (a ciphertext under the caller's context beside a second one under a literal of its own) has no plan form. The derive is about to emit the plan, so it can say only what the plan can say. The error says that all outputs of a `plaintext = T` record share the caller's context, and that writing one value under two contexts is a fields plan that picks the same source twice. With the literal gone, only a `struct` derive's fields carry a context of their own, and such a record always takes `DeclaredContext`. So the `extend` path for an own context beside a caller-context field, and the `context_type`-versus-all-literal-fields refusal, are unreachable and are removed. Tests: `Doubled` keeps proving that `#[stash(decrypt)]` chooses between two ciphertexts, now both under the caller's context. `WrappedUser` takes the caller's context instead of a literal and derives the same inner term bytes. `AuditedAge` and `PinnedAge` are deleted: what they proved was how a literal-context leaf mixes with caller-context fields, which no longer exists. trybuild: `field_context_removed` covers the three derive forms; `struct_field_context` and `non_plain_literal_context` are folded into it.
1 parent cdeff8b commit b62fdf1

27 files changed

Lines changed: 236 additions & 460 deletions

‎docs/target-directed-encryption.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,8 @@ name, which two types can share and a refactor can change. `#[stash(from = ..)]`
196196
reads a field from a differently named plaintext field, and
197197
`#[stash(identity = "..")]` pins the label segment, so the field is keyed under
198198
`<context>/<identity>`. A field's context is always its record's context plus
199-
its identity: no field escapes it, and a literal context is one plain segment.
199+
its identity: no field escapes it. No field takes a literal context of its own,
200+
on any derive: every output of a `plaintext = T` record shares the caller's.
200201
A field whose type is itself a record is an ordinary field: it is sealed under
201202
`<context>/<field>`, and its own contexts are extended by that pair. A struct
202203
derive takes one output per plaintext field; to store several terms from one
@@ -257,7 +258,7 @@ vitaminc already has the generic notion, twice — `Aad<'a>` (aead) and `PrfCont
257258

258259
Context is threaded **per value**, as an argument. It is not baked into the cipher.
259260

260-
Whether the *caller* owes one is decided by the target type, at compile time. `Ctx` is a parameter of `EncryptFrom` so that each impl can bound it: a leaf is implemented for `NonEmpty<T>` alone, because it has nothing else to authenticate under and `()` is the empty context it must never derive under; a derived record is implemented twice — for `()`, deriving each field under the context it carries itself (the one a `struct = ..` derive infers, `<context>/<identity>`, or a plain-segment literal), and for `NonEmpty<T>`, deriving each field under that context *extended* with the caller's (`("users/age", id)`), or under the caller's as it is for a field with none. No record accepts a context and then discards it. `encrypt_into(&cipher)` passes `()` and therefore compiles only for outputs whose every leaf has a context of its own; everything else takes `encrypt_into_with_context`, and such an output takes that too when the caller has something to add, a record id say, so a field is bound to its record as well as its name. This is vitaminc's `encrypt` / `encrypt_with_aad` split, with the choice made by the type rather than at every call site.
261+
Whether the *caller* owes one is decided by the target type, at compile time. `Ctx` is a parameter of `EncryptFrom` so that each impl can bound it: a leaf is implemented for `NonEmpty<T>` alone, because it has nothing else to authenticate under and `()` is the empty context it must never derive under; a derived record is implemented twice — for `()`, deriving each field under the context it carries itself (the one a `struct = ..` derive infers, `<context>/<identity>`), and for `NonEmpty<T>`, deriving each field under that context *extended* with the caller's (`("users/age", id)`), or under the caller's as it is for a field with none (every field of a `plaintext = T` record). No record accepts a context and then discards it. `encrypt_into(&cipher)` passes `()` and therefore compiles only for outputs whose every leaf has a context of its own; everything else takes `encrypt_into_with_context`, and such an output takes that too when the caller has something to add, a record id say, so a field is bound to its record as well as its name. This is vitaminc's `encrypt` / `encrypt_with_aad` split, with the choice made by the type rather than at every call site.
261262

262263
A scoped cipher (`cipher.for_column("users", "age")`) was considered and rejected: it makes encrypting one record — several fields, several identifiers — into several scoped ciphers, which fights batching for no gain. With context as an argument, a record is one shared `&cipher`, many contexts, one flush.
263264

‎packages/stack-encrypt-derive/docs/attributes.md‎

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,25 +28,30 @@ literal.
2828

2929
| Attribute | Effect |
3030
|---|---|
31-
| `context_field` | Store the caller’s typed context here and recover it on decryption. Exactly one per record; excludes the other field attributes and literal contexts. |
32-
| `context = "..."` | With a `plaintext` record only: derive this field under exactly this context, extended by the one the caller passes for the record like any other. A query-side term built under the same literal — extended the same way — matches it. One plain segment (`"email"`, not `"users/email"`), as a plan's context segments are. Refused on a field of a `struct` derive, which sits under the record's context: use `identity`. |
31+
| `context_field` | Store the caller’s typed context here and recover it on decryption. Exactly one per record; excludes the other field attributes. |
3332
| `identity = "..."` | With `struct` only: key this field under the segment `identity` instead of the plaintext field's name, so it is derived under `("<context>", "<identity>")`. One plain segment. The plan builder's `.identity(segment)`. |
3433
| `from = field` / `from = 0` | With `struct` only: derive this field from `plaintext.field` (or `plaintext.0` for a tuple struct) when its name differs from its plaintext field's. A plaintext field has one output: two fields `from` one plaintext field are refused (a ciphertext with its terms is one `Encrypted<Terms>` field). |
3534
| `default` / `default = expr` | Not derived: filled with `Default::default()` or `expr`. Never encrypted, never authenticated. |
3635
| `decrypt` | Decryption opens this field (`DecryptInto` only). Needed only when the field types cannot decide it — see below. |
3736

37+
A field takes no `context = ".."` of its own, on any derive: it is a compile
38+
error. A plan has exactly one context source, and every output sits under it.
39+
All outputs of a `plaintext = T` record share the caller's context; every
40+
field of a `struct` derive sits under the record's (`identity` keys it under
41+
another segment of it). Writing one value under two different contexts, a
42+
dual write, is a fields plan that picks the same source twice, not a derive.
43+
3844
Each derive emits one declaration per plaintext, with an associated `Context`.
3945
A record with `#[stash(context_field)]` on a field of type `T` requires
4046
`NonEmpty<T>` for encryption and stores its inner value in that field. Decryption
4147
takes `ExpectedContext<T>`. 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<T>.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.
4248
`T` supplies the Vitamin C context encodings and implements `Clone`,
4349
`MaybeEmpty`, and `PartialEq`. This metadata is not a separate encrypted field.
44-
It cannot be combined with literal context attributes.
4550

46-
Otherwise, a record whose fields all carry their own contexts (including a
47-
`struct` derive) uses `DeclaredContext`. `().into()` or its default selects the
51+
Otherwise, a `struct` derive, whose fields all carry their own contexts, uses
52+
`DeclaredContext`. `().into()` or its default selects the
4853
declared contexts unchanged; a nonempty caller context extends each base context.
49-
A record with fields that need a caller context uses `CallerContext`, constructed
54+
A `plaintext` record, whose fields all take the caller's context, uses `CallerContext`, constructed
5055
from a `NonEmpty<T>` or a supported integer. Both encodings and the descriptor's
5156
structured identity are preserved when borrowing context data is converted into
5257
an owned declaration. No context is inferred from a Rust type's name.
@@ -75,11 +80,9 @@ let record: SealedName = name.encrypt_into_with_context(&keyset, NonEmpty::new(t
7580
The derive cannot pick this for you: it sees the field types' names, not
7681
what they declare. The type you name must convert into every field's own
7782
`Context` (`AeadContext` does not convert into `CallerContext`, so a term
78-
field beside it is a compile error at the record, which is the point), and a
79-
field with a `context = ".."` of its own is derived under that literal
80-
extended by the caller's context through the type's `extend` — `AeadContext`
81-
and `CallerContext` both have one. A record with a `context_field`, or a
82-
`struct` derive, settles its context itself and refuses the attribute.
83+
field beside it is a compile error at the record, which is the point). A
84+
record with a `context_field`, or a `struct` derive, settles its context
85+
itself and refuses the attribute.
8386

8487
A declaration is executed by `keyset.encrypt_as(&value, context)` and
8588
`cipher.decrypt_as(record, context)`, or through the blanket `EncryptInto` and
@@ -110,9 +113,8 @@ encrypted struct. Nothing is pluralised or otherwise guessed. The pair is
110113
what `nonempty!("users").with("age")` spells at a call site, and what a
111114
two-segment `Label` spells. `#[stash(identity = "nickname")] name: ..`
112115
replaces the second part only: the field is derived under
113-
`("users", "nickname")`. A field of a `struct` derive cannot be given a
114-
context of its own (`context = ".."` on it is refused): it always sits under
115-
the record's. Each plaintext field has exactly one output, and each output
116+
`("users", "nickname")`. A field cannot be given a context of its own
117+
(`context = ".."` on it is refused): it always sits under the record's. Each plaintext field has exactly one output, and each output
116118
its own segment: a ciphertext with search terms beside it is one field of
117119
type `Encrypted<Terms>` (`email: Encrypted<(EqualityTerm, MatchTerms)>`),
118120
not a ciphertext field plus term fields `from` the same plaintext field. A

‎packages/stack-encrypt-derive/src/attrs.rs‎

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -209,12 +209,6 @@ impl ContainerAttrs {
209209
#[derive(Default)]
210210
pub(crate) struct FieldAttrs {
211211
pub(crate) context_field: bool,
212-
/// `#[stash(context = "...")]`: with a `plaintext` record, derive this
213-
/// field under exactly this context instead of the one the caller passes
214-
/// for the record. Extended by a caller's context like any other. Refused
215-
/// on a field of a `struct` derive, whose fields sit under the record's
216-
/// context (`identity` names the segment instead).
217-
pub(crate) context: Option<LitStr>,
218212
/// `#[stash(identity = "...")]`: with `struct = ..`, the label segment
219213
/// this field is keyed under, in place of the plaintext field's name:
220214
/// the field is derived under `("<context>", "<identity>")`. The plan
@@ -244,18 +238,25 @@ impl FieldAttrs {
244238
parsed.context_field = true;
245239
return Ok(());
246240
}
241+
// Removed rather than unknown, so the message can say what
242+
// to write instead. A plan has exactly one context source,
243+
// and every field sits under it: a field sealed under a
244+
// literal of its own has no plan form.
247245
if meta.path.is_ident("context") {
248-
// Each of these is singular by meaning, so a repeat is a
249-
// mistake: rejected rather than silently overwritten. A
250-
// silently-winning second `from` would be the worst of
251-
// them — it crosses fields, which is exactly the failure
252-
// the derive exists to prevent.
253-
if parsed.context.is_some() {
254-
return Err(meta.error("`context` is given twice; a field has one context"));
255-
}
256-
parsed.context = Some(meta.value()?.parse()?);
257-
return Ok(());
246+
return Err(meta.error(
247+
"a field-level `context = \"..\"` is no longer accepted: all outputs of a \
248+
`plaintext = T` record share the caller's context, and every field of a \
249+
`struct = ..` derive sits under the record's context (key it under \
250+
another segment with `identity = \"..\"`). Writing one value under two \
251+
contexts (a dual write) is a fields plan that picks the same source \
252+
twice, not a derive",
253+
));
258254
}
255+
// Each of these is singular by meaning, so a repeat is a
256+
// mistake: rejected rather than silently overwritten. A
257+
// silently-winning second `from` would be the worst of them —
258+
// it crosses fields, which is exactly the failure the derive
259+
// exists to prevent.
259260
if meta.path.is_ident("identity") {
260261
if parsed.identity.is_some() {
261262
return Err(meta.error(
@@ -302,7 +303,7 @@ impl FieldAttrs {
302303
));
303304
}
304305
Err(meta.error(
305-
"unsupported field attribute; expected `context_field`, `context = \"...\"`, \
306+
"unsupported field attribute; expected `context_field`, \
306307
`identity = \"...\"`, `from = field`, `default`, `default = expr` or \
307308
`decrypt`",
308309
))

‎packages/stack-encrypt-derive/src/lib.rs‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,11 @@
3030
//! # }).unwrap();
3131
//! ```
3232
//!
33-
//! For a record without a context field, fields use the caller's context or a
34-
//! declared literal. A record made only of ciphertexts can declare
35-
//! `context_type = AeadContext` and accept a context type that implements
36-
//! `IntoAad` alone, as the ciphertext leaf itself does.
33+
//! For a `plaintext` record without a context field, every field uses the
34+
//! caller's context: a field takes no literal context of its own. A record
35+
//! made only of ciphertexts can declare `context_type = AeadContext` and
36+
//! accept a context type that implements `IntoAad` alone, as the ciphertext
37+
//! leaf itself does.
3738
//! `struct = User, context = "users"` selects plaintext fields
3839
//! and binds each under the pair `("users", "<field>")`, which renders
3940
//! `users/<field>` as its ZeroKMS descriptor; `identity = ".."` on a field keys

0 commit comments

Comments
 (0)