Skip to content

Commit a32ad4b

Browse files
committed
feat(stack-encrypt): render descriptors with /, and bind (table, column) as a pair
One column had two spellings. The derive joined `struct = .., context = "users"` and a field into the one text part "users/email" (descriptor `users/email`); eql-bindings' Identifier binds the pair ("users", "email") (descriptor `users|email`). They are different contexts in the AAD, the PRF and at ZeroKMS, so a derive writer and an EQL reader of one column cannot interoperate, and nothing fails until a read or a search. - Descriptor::SEPARATOR is `/`, and `/` joins the characters that force a text part into its escaped form, so the rendering stays one-to-one: the pair ("users", "email") renders `users/email`, while the single text "users/email" is escaped and can never read as it. Escaped parts use URL-safe base64, because the standard alphabet's `/` would read as a separator. - The derive infers the pair `nonempty!(prefix).with(column)` instead of a joined literal. A `#[stash(context = "..")]` literal stays one text part, exactly as written. - `under` / `extend` (and `CallerContext::extend`, `AeadContext::extend`, `DeclaredContext::under`, `Extends`) take any `NonEmpty<impl IntoContext>` own context rather than only `NonEmpty<&'static str>`; turbofish callers are unaffected. The rendering is frozen once keys exist under it; nothing is published yet (stack-encrypt 0.1.0 goes out with #1047), so it changes now. Refs #1049
1 parent a86711f commit a32ad4b

19 files changed

Lines changed: 291 additions & 185 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ pub(crate) struct ContainerAttrs {
1919
/// field says otherwise. Exclusive with `plaintext`; requires `context`.
2020
pub(crate) by_field: Option<Type>,
2121
/// `#[stash(context = "...")]` on the container: the first half of every
22-
/// field's inferred context — `"<context>/<field>"`. Names the stored
22+
/// field's inferred context, the pair `("<context>", "<field>")`. Names the stored
2323
/// data, not the Rust type: it is part of the stored data's identity, so
2424
/// it is given explicitly rather than inferred from a name a refactor
2525
/// can change. Only meaningful with `struct`.
@@ -149,7 +149,7 @@ impl ContainerAttrs {
149149
by_field,
150150
"`struct = ..` needs a `context = \"..\"` beside it naming the stored data \
151151
(e.g. `#[stash(struct = User, context = \"users\")]`): each field is derived \
152-
under `\"<context>/<field>\"`, and the prefix is part of the stored data's \
152+
under the pair `(\"<context>\", \"<field>\")`, and the prefix is part of the stored data's \
153153
identity, so it is given explicitly rather than inferred from the Rust \
154154
type's name",
155155
));

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@
3535
//! `context_type = AeadContext` and accept a context type that implements
3636
//! `IntoAad` alone, as the ciphertext leaf itself does.
3737
//! `struct = User, context = "users"` selects plaintext fields
38-
//! and binds them under `"users/<field>"`. The storage envelope itself adds no
38+
//! and binds each under the pair `("users", "<field>")`, which renders
39+
//! `users/<field>` as its ZeroKMS descriptor. A `context = ".."` literal on a
40+
//! field is one text part, exactly as written. The storage envelope itself adds no
3941
//! cryptographic map-entry context. Vitamin C still binds keys inside plaintext
4042
//! maps and preserves authenticated absence and empty-container markers.
4143
//!

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

Lines changed: 51 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,9 @@ pub(crate) enum Kind {
6464
/// Derived from the source through the field type's own `EncryptFrom`.
6565
Derived {
6666
/// This field's own context, if it has one: a `#[stash(context =
67-
/// "...")]` literal, or the `"<prefix>/<field>"` a `struct` derive
67+
/// "...")]` literal, or the `(prefix, field)` pair a `struct` derive
6868
/// infers. A context the caller passes extends it either way.
69-
context: Option<LitStr>,
69+
context: Option<OwnContext>,
7070
/// With `struct = ..`: the plaintext field this one is derived
7171
/// from — its own name, or the `#[stash(from = field)]` override.
7272
/// `None` for a `plaintext` record, whose fields are all derived
@@ -96,7 +96,7 @@ impl Field {
9696
///
9797
/// A field with a context of its own — a literal, or the one a `struct`
9898
/// derive infers — is derived under it as it is when the caller passes
99-
/// `()`, and under it *extended* with the caller's (`("users/age", id)`)
99+
/// `()`, and under it *extended* with the caller's (`(("users", "age"), id)`)
100100
/// when the caller passes a `NonEmpty<_>`. A field with none is handed
101101
/// the caller's context as it is, and its type decides what that means:
102102
/// a nested `struct` derive composes it with its own contexts; a leaf
@@ -118,13 +118,35 @@ impl Field {
118118
}
119119
}
120120

121+
/// A derived field's own context.
122+
#[cfg_attr(test, derive(Debug))]
123+
pub(crate) enum OwnContext {
124+
/// `#[stash(context = "...")]`: one text part, exactly as written.
125+
Literal(LitStr),
126+
/// What a `struct` derive infers: the pair (container prefix, column),
127+
/// two parts, so it renders `prefix/column` without the column name
128+
/// having to be joined into, or kept out of, a string.
129+
Column { table: LitStr, column: LitStr },
130+
}
131+
impl OwnContext {
132+
/// The `NonEmpty` the derive hands `under` / `extend`.
133+
fn expr(&self, krate: &Path) -> TokenStream {
134+
match self {
135+
Self::Literal(lit) => quote!(#krate::nonempty!(#lit)),
136+
Self::Column { table, column } => {
137+
quote!(#krate::nonempty!(#table).with(#column))
138+
}
139+
}
140+
}
141+
}
142+
121143
/// Where a derived field's context comes from. See [`Field::field_context`].
122144
#[cfg_attr(test, derive(Debug))]
123145
pub(crate) enum FieldContext<'a> {
124146
/// A context of the field's own — `#[stash(context = "...")]`, or the
125-
/// `"<prefix>/<field>"` a `struct` derive infers: as it is under `()`,
147+
/// `(prefix, field)` pair a `struct` derive infers: as it is under `()`,
126148
/// extended with the caller's context under `NonEmpty<_>`.
127-
Own(&'a LitStr),
149+
Own(&'a OwnContext),
128150
/// No context of its own: handed the caller's as it is — `()`, or the
129151
/// record's associated context.
130152
Caller,
@@ -270,7 +292,7 @@ pub(crate) fn trait_impl(
270292

271293
/// The fields, with what a `struct` derive (`prefix` is the container's
272294
/// `context`) fills in: `from` is the field's own name and `context` is
273-
/// `"<prefix>/<from>"`, each unless the field gives its own.
295+
/// the pair `("<prefix>", "<from>")`, each unless the field gives its own.
274296
/// `#[stash(nested)]` opts a field out of the inferred context — it is handed
275297
/// the caller's as it is, which a nested `struct` derive (a type carrying its
276298
/// own contexts) composes with them and a leaf accepts only as a
@@ -311,8 +333,8 @@ fn collect(fields: &Fields, prefix: Option<&LitStr>) -> Result<Vec<Field>> {
311333
if context.value().is_empty() {
312334
let message = if prefix.is_some() {
313335
"an empty `context` is rejected when a value is encrypted: name the \
314-
field (e.g. \"users/email\"), or drop the attribute to use the inferred \
315-
`\"<context>/<field>\"`"
336+
field (e.g. \"email\"), or drop the attribute to use the inferred \
337+
pair `(\"<context>\", \"<field>\")`"
316338
} else {
317339
"an empty `context` is rejected when a value is encrypted: name the \
318340
field (e.g. \"users/email\"), or drop the attribute to hand the field \
@@ -359,22 +381,24 @@ fn collect(fields: &Fields, prefix: Option<&LitStr>) -> Result<Vec<Field>> {
359381
// is handed the caller's (`FieldContext::Caller`).
360382
None
361383
} else if let Some(lit) = attrs.context {
362-
Some(lit)
384+
Some(OwnContext::Literal(lit))
363385
} else {
364386
let column = match &from {
365387
Member::Named(ident) => ident.to_string(),
366388
Member::Unnamed(index) => index.index.to_string(),
367389
};
368-
let prefix = prefix.value();
369-
Some(LitStr::new(&format!("{prefix}/{column}"), member.span()))
390+
Some(OwnContext::Column {
391+
table: prefix.clone(),
392+
column: LitStr::new(&column, member.span()),
393+
})
370394
};
371395
Kind::Derived {
372396
context,
373397
from: Some(from),
374398
}
375399
}
376400
None => Kind::Derived {
377-
context: attrs.context,
401+
context: attrs.context.map(OwnContext::Literal),
378402
from: None,
379403
},
380404
},
@@ -482,12 +506,13 @@ impl Record {
482506
let krate = &self.krate;
483507
let span = field.ty.span();
484508
match field.field_context() {
485-
FieldContext::Own(lit) => {
509+
FieldContext::Own(own) => {
510+
let own = respan(own.expr(krate), span);
486511
if self.declared_contexts() {
487-
quote_spanned!(span=> .under(#krate::nonempty!(#lit)))
512+
quote_spanned!(span=> .under(#own))
488513
} else {
489514
let threaded = respan(self.threaded_context().into_token_stream(), span);
490-
quote_spanned!(span=> .extend::<#threaded>(#krate::nonempty!(#lit)))
515+
quote_spanned!(span=> .extend::<#threaded>(#own))
491516
}
492517
}
493518
FieldContext::Caller => {
@@ -530,13 +555,14 @@ impl Record {
530555
let krate = &self.krate;
531556
match field.field_context() {
532557
FieldContext::Caller => quote!(::core::clone::Clone::clone(&__context)),
533-
FieldContext::Own(lit) => {
558+
FieldContext::Own(own) => {
534559
let method = if self.declared_contexts() {
535560
quote!(under)
536561
} else {
537562
quote!(extend)
538563
};
539-
quote!(::core::clone::Clone::clone(&__context).#method(#krate::nonempty!(#lit)))
564+
let own = own.expr(krate);
565+
quote!(::core::clone::Clone::clone(&__context).#method(#own))
540566
}
541567
}
542568
}
@@ -584,7 +610,10 @@ mod tests {
584610
/// The field's own context, for assertions.
585611
fn own(field: &Field) -> String {
586612
match field.field_context() {
587-
FieldContext::Own(lit) => lit.value(),
613+
FieldContext::Own(OwnContext::Literal(lit)) => lit.value(),
614+
FieldContext::Own(OwnContext::Column { table, column }) => {
615+
format!("({}, {})", table.value(), column.value())
616+
}
588617
other => panic!("expected a context of the field's own, got {other:?}"),
589618
}
590619
}
@@ -824,10 +853,10 @@ mod tests {
824853
);
825854
// Own name under the container's prefix.
826855
assert!(matches!(age.from(), Some(Member::Named(m)) if m == "age"));
827-
assert_eq!(own(age), "user_profiles/age");
856+
assert_eq!(own(age), "(user_profiles, age)");
828857
// `from` overrides the field; the context follows the plaintext field.
829858
assert!(matches!(email.from(), Some(Member::Named(m)) if m == "email_address"));
830-
assert_eq!(own(email), "user_profiles/email_address");
859+
assert_eq!(own(email), "(user_profiles, email_address)");
831860
// `context` is taken verbatim; like the inferred ones, the caller's
832861
// context extends it.
833862
assert!(matches!(name.from(), Some(Member::Named(m)) if m == "name"));
@@ -846,8 +875,8 @@ mod tests {
846875
})
847876
.unwrap();
848877
assert!(matches!(record.fields[1].from(), Some(Member::Unnamed(i)) if i.index == 1));
849-
assert_eq!(own(&record.fields[0]), "readings/0");
850-
assert_eq!(own(&record.fields[1]), "readings/1");
878+
assert_eq!(own(&record.fields[0]), "(readings, 0)");
879+
assert_eq!(own(&record.fields[1]), "(readings, 1)");
851880
}
852881

853882
#[test]

‎packages/stack-encrypt/CONTEXT.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,17 @@ leaf requires a nonempty context, validated by Vitamin C and owned in a
4646
record deriving terms threads to every field) or an `AeadContext` (the AAD
4747
encoding alone — what a ciphertext is sealed and opened under; a record
4848
deriving terms hands its ciphertext fields that half of its `CallerContext`);
49-
a `nonempty!("users/email")` literal, a `NonEmpty::new(value)?` at runtime,
50-
or a bare integer. It becomes the ciphertext's associated data,
49+
a `nonempty!("users").with("email")` pair (a table and a column are two
50+
parts, rendered `users/email`), a `NonEmpty::new(value)?` at runtime, or a
51+
bare integer. It becomes the ciphertext's associated data,
5152
the term's PRF context, and the ZeroKMS descriptor of the data key.
5253
_Avoid_: AAD (that is one of its encodings, not the concept), lock context
5354

5455
**Own context**:
55-
The context a field carries itself: a `context = ".."` literal, or the one a
56-
`struct = ..` derive infers as `<struct context>/<field>`. A caller's context
57-
*extends* it (`("users/age", id)`); it is never discarded. A subtree of a
56+
The context a field carries itself: a `context = ".."` literal (one text
57+
part, exactly as written), or the pair a `struct = ..` derive infers,
58+
`(<struct context>, <field>)`. A caller's context *extends* it
59+
(`(("users", "age"), id)`); it is never discarded. A subtree of a
5860
declaration is given one with `under` (the caller's is then optional) or
5961
`extend` (the caller's stays required).
6062
_Avoid_: default context, field prefix

‎packages/stack-encrypt/examples/encrypted_record.rs‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ struct User {
5555
}
5656

5757
/// `User`, encrypted field by field: `age` from `user.age` under
58-
/// `"users/age"`, `email` from `user.email` under `"users/email"`. The prefix
58+
/// `("users", "age")`, `email` from `user.email` under `("users", "email")`. The prefix
5959
/// is named once, explicitly — it is part of the stored data's identity, so
6060
/// it is never inferred from a Rust type name — and the field half follows
6161
/// the plaintext field.
@@ -108,7 +108,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
108108

109109
// WHERE age = 34: compare equality terms.
110110
let probe: EqualityTerm = 34u32
111-
.encrypt_into_with_context(&keyset, nonempty!("users/age"))
111+
.encrypt_into_with_context(&keyset, nonempty!("users").with("age"))
112112
.await?;
113113
let equal: Vec<usize> = (0..table.len())
114114
.filter(|&i| table[i].age.eq == probe)
@@ -117,7 +117,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
117117

118118
// WHERE age > 40: compare ORE terms.
119119
let bound: OreTerm<u32> = 40u32
120-
.encrypt_into_with_context(&keyset, nonempty!("users/age"))
120+
.encrypt_into_with_context(&keyset, nonempty!("users").with("age"))
121121
.await?;
122122
let over_40: Vec<usize> = (0..table.len())
123123
.filter(|&i| table[i].age.ord > bound)
@@ -153,10 +153,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
153153
// --- Binding a value to its record ----------------------------------------
154154

155155
// A context the caller passes *extends* every field's own: under the
156-
// record's id, `age` is sealed under `("users/age", id)` and opens only
156+
// record's id, `age` is sealed under `(("users", "age"), id)` and opens only
157157
// there — a ciphertext can no longer be moved between records of the
158158
// same table. The price is that its terms are scoped to that record too:
159-
// a probe built under `"users/age"` alone never matches them, so extend
159+
// a probe built under `("users", "age")` alone never matches them, so extend
160160
// where a value is read by id, not where it is searched across rows.
161161
let id = 42u64;
162162
let alice = User {
@@ -174,7 +174,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
174174
!unscoped.is_empty()
175175
);
176176
let scoped: EqualityTerm = 34u32
177-
.encrypt_into_with_context(&keyset, nonempty!("users/age").with(id))
177+
.encrypt_into_with_context(&keyset, nonempty!("users").with("age").with(id))
178178
.await?;
179179
println!(
180180
" ...and a probe built under the same id: {}",

‎packages/stack-encrypt/examples/mixed_user.rs‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,14 +177,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
177177

178178
// One call, one batched generate_keys round-trip for every encrypted leaf
179179
// in the whole Vec (here: 3 rows x 2 encrypted fields = 6 data keys).
180-
let ciphertext = cipher.default_keyset().encrypt(users, "users/v1").await?;
180+
let ciphertext = cipher
181+
.default_keyset()
182+
.encrypt(users, ("users", "v1"))
183+
.await?;
181184

182185
println!("what the stored ciphertext reveals:");
183186
describe(&ciphertext, 1);
184187

185188
// One batched retrieve_keys round-trip, then a crypto-free structural
186189
// decode back into the typed rows. The AAD must match the encrypt call.
187-
let users: Vec<User> = cipher.decrypt(ciphertext, "users/v1").await?;
190+
let users: Vec<User> = cipher.decrypt(ciphertext, ("users", "v1")).await?;
188191

189192
println!("\ndecrypted rows:");
190193
for user in &users {

‎packages/stack-encrypt/examples/search_terms.rs‎

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,21 +39,22 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
3939

4040
// --- Equality: exact-match lookups --------------------------------------
4141
//
42-
// The context ("users/email") domain-separates terms per field: the same
42+
// The context, the pair ("users", "email"), domain-separates terms per
43+
// field: the same
4344
// value indexed under another field can never produce a colliding term.
4445

4546
let stored: EqualityTerm = "alice@example.com"
46-
.encrypt_into_with_context(&terms, nonempty!("users/email"))
47+
.encrypt_into_with_context(&terms, nonempty!("users").with("email"))
4748
.await?;
4849

4950
let hit: EqualityTerm = "alice@example.com"
50-
.encrypt_into_with_context(&terms, nonempty!("users/email"))
51+
.encrypt_into_with_context(&terms, nonempty!("users").with("email"))
5152
.await?;
5253
let miss: EqualityTerm = "bob@example.com"
53-
.encrypt_into_with_context(&terms, nonempty!("users/email"))
54+
.encrypt_into_with_context(&terms, nonempty!("users").with("email"))
5455
.await?;
5556
let wrong_field: EqualityTerm = "alice@example.com"
56-
.encrypt_into_with_context(&terms, nonempty!("users/name"))
57+
.encrypt_into_with_context(&terms, nonempty!("users").with("name"))
5758
.await?;
5859

5960
println!("\nequality:");
@@ -73,13 +74,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
7374

7475
let bio: MatchTerm = "alice, senior cryptography engineer"
7576
.to_string()
76-
.encrypt_into_with_context(&terms, nonempty!("users/bio"))
77+
.encrypt_into_with_context(&terms, nonempty!("users").with("bio"))
7778
.await?;
7879

7980
for query in ["crypto", "engineer", "plumber"] {
8081
let probe: MatchTerm = query
8182
.to_string()
82-
.encrypt_into_with_context(&terms, nonempty!("users/bio"))
83+
.encrypt_into_with_context(&terms, nonempty!("users").with("bio"))
8384
.await?;
8485
println!("match: bio contains {query:?} => {}", bio.contains(&probe));
8586
}
@@ -97,13 +98,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
9798
// local.
9899

99100
let age_30: OreTerm<u32> = 30u32
100-
.encrypt_into_with_context(&terms, nonempty!("users/age"))
101+
.encrypt_into_with_context(&terms, nonempty!("users").with("age"))
101102
.await?;
102103
let age_45: OreTerm<u32> = 45u32
103-
.encrypt_into_with_context(&terms, nonempty!("users/age"))
104+
.encrypt_into_with_context(&terms, nonempty!("users").with("age"))
104105
.await?;
105106
let query_40: OreTerm<u32> = 40u32
106-
.encrypt_into_with_context(&terms, nonempty!("users/age"))
107+
.encrypt_into_with_context(&terms, nonempty!("users").with("age"))
107108
.await?;
108109

109110
println!("\nore (WHERE age > 40):");
@@ -112,10 +113,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
112113

113114
// Strings order lexicographically.
114115
let apple: OreTerm<&str> = "apple"
115-
.encrypt_into_with_context(&terms, nonempty!("users/name"))
116+
.encrypt_into_with_context(&terms, nonempty!("users").with("name"))
116117
.await?;
117118
let banana: OreTerm<&str> = "banana"
118-
.encrypt_into_with_context(&terms, nonempty!("users/name"))
119+
.encrypt_into_with_context(&terms, nonempty!("users").with("name"))
119120
.await?;
120121
println!(" \"apple\" < \"banana\" => {}", apple < banana);
121122

@@ -137,11 +138,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
137138

138139
let record: SearchableEmail = "alice@example.com"
139140
.to_string()
140-
.encrypt_into_with_context(&terms, nonempty!("users/email"))
141+
.encrypt_into_with_context(&terms, nonempty!("users").with("email"))
141142
.await?;
142143
let probe: MatchTerm = "example"
143144
.to_string()
144-
.encrypt_into_with_context(&terms, nonempty!("users/email"))
145+
.encrypt_into_with_context(&terms, nonempty!("users").with("email"))
145146
.await?;
146147
println!("\nrecord:");
147148
println!(
@@ -157,7 +158,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
157158
record.ord
158159
> "alice"
159160
.to_string()
160-
.encrypt_into_with_context(&terms, nonempty!("users/email"))
161+
.encrypt_into_with_context(&terms, nonempty!("users").with("email"))
161162
.await?
162163
);
163164
let _ = record.c; // the ciphertext, opened with `decrypt_into` under the same context

0 commit comments

Comments
 (0)