You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit b62fdf1
Browse filesBrowse the repository at this point in the historyBrowse files
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.
Copy file name to clipboardExpand all lines: docs/target-directed-encryption.md
+3-2Lines changed: 3 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -196,7 +196,8 @@ name, which two types can share and a refactor can change. `#[stash(from = ..)]`
196
196
reads a field from a differently named plaintext field, and
197
197
`#[stash(identity = "..")]` pins the label segment, so the field is keyed under
198
198
`<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.
200
201
A field whose type is itself a record is an ordinary field: it is sealed under
201
202
`<context>/<field>`, and its own contexts are extended by that pair. A struct
202
203
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
257
258
258
259
Context is threaded **per value**, as an argument. It is not baked into the cipher.
259
260
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.
261
262
262
263
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.
Copy file name to clipboardExpand all lines: packages/stack-encrypt-derive/docs/attributes.md
+16-14Lines changed: 16 additions & 14 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -28,25 +28,30 @@ literal.
28
28
29
29
| Attribute | Effect |
30
30
|---|---|
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. |
33
32
|`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)`. |
34
33
|`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). |
35
34
|`default` / `default = expr`| Not derived: filled with `Default::default()` or `expr`. Never encrypted, never authenticated. |
36
35
|`decrypt`| Decryption opens this field (`DecryptInto` only). Needed only when the field types cannot decide it — see below. |
37
36
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
+
38
44
Each derive emits one declaration per plaintext, with an associated `Context`.
39
45
A record with `#[stash(context_field)]` on a field of type `T` requires
40
46
`NonEmpty<T>` for encryption and stores its inner value in that field. Decryption
41
47
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.
42
48
`T` supplies the Vitamin C context encodings and implements `Clone`,
43
49
`MaybeEmpty`, and `PartialEq`. This metadata is not a separate encrypted field.
44
-
It cannot be combined with literal context attributes.
45
50
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
48
53
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
50
55
from a `NonEmpty<T>` or a supported integer. Both encodings and the descriptor's
51
56
structured identity are preserved when borrowing context data is converted into
52
57
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
75
80
The derive cannot pick this for you: it sees the field types' names, not
76
81
what they declare. The type you name must convert into every field's own
77
82
`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.
83
86
84
87
A declaration is executed by `keyset.encrypt_as(&value, context)` and
85
88
`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
110
113
what `nonempty!("users").with("age")` spells at a call site, and what a
0 commit comments