Skip to content

Commit 327b4db

Browse files
authored
Merge pull request #1076 from cipherstash/feat/stack-encrypt-derive-emits-plan
feat(stack-encrypt-derive): the derive emits the plan
2 parents eaa0ee4 + 1b188dc commit 327b4db

128 files changed

Lines changed: 16031 additions & 1339 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/fuzz.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ jobs:
7777
- { task: "fuzz:sealed-value", slug: sealed-value }
7878
- { task: "fuzz:term-decode", slug: term-decode }
7979
- { task: "fuzz:check-record", slug: check-record }
80+
- { task: "fuzz:plan-build", slug: plan-build }
8081
steps:
8182
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
8283
with:
@@ -122,6 +123,7 @@ jobs:
122123
- { task: "fuzz:sealed-value", slug: sealed-value, dir: packages/stack-encrypt, target: sealed_value_decode }
123124
- { task: "fuzz:term-decode", slug: term-decode, dir: packages/stack-encrypt, target: term_decode }
124125
- { task: "fuzz:check-record", slug: check-record, dir: packages/stack-encrypt, target: check_record }
126+
- { task: "fuzz:plan-build", slug: plan-build, dir: packages/stack-encrypt, target: plan_build }
125127
steps:
126128
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
127129
with:

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ node_modules
7575

7676
# ffi
7777
target
78+
# A source module named `target`, not a build directory: keep it tracked.
79+
!packages/stack-encrypt/src/target/
7880
index.node
7981
**/node_modules
8082
**/.DS_Store

‎Cargo.lock‎

Lines changed: 26 additions & 26 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -120,12 +120,13 @@ web-time = "1.1"
120120
zeroize = { version = "1.8.1", features = ["derive"] }
121121
# This is needed because lock_api 0.4.6 was breaking things and the branch forces the use of 0.4.12
122122
temp-env = { git = "https://github.com/cipherstash/temp-env", branch = "main" }
123-
vitaminc = { version = "0.5.0", features = ["random", "protected", "encrypt"] }
124-
vitaminc-aead = "0.5.0"
123+
vitaminc = { version = "0.5.1", features = ["random", "protected", "encrypt"] }
124+
vitaminc-aead = "0.5.1"
125125
# The dynamic value model (`FfiValue`) and its FFI transport codec, shared by
126126
# every language binding. Optional in stack-encrypt (the `dynamic` feature).
127-
vitaminc-aead-value = "0.5.0"
128-
vitaminc-encrypt = "0.5.0"
129-
vitaminc-hmac = "0.5.0"
130-
vitaminc-prf = "0.5.0"
131-
vitaminc-protected = "0.5.0"
127+
# 0.5.1 adds `ValueKind`, the field-type vocabulary a dynamic plan declares.
128+
vitaminc-aead-value = "0.5.1"
129+
vitaminc-encrypt = "0.5.1"
130+
vitaminc-hmac = "0.5.1"
131+
vitaminc-prf = "0.5.1"
132+
vitaminc-protected = "0.5.1"

‎docs/fuzzing.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@ Current targets:
2525
| `fuzz:jwt-decode` | `stack-auth` | `jwt_decode` | JWT claims (`Token::fuzz_decode_claims`) |
2626
| `fuzz:client-key` | `stack-kms` | `client_key_encoded` | `ClientKey::from_encoded_v1` (hex or base64) |
2727
| `fuzz:sealed-value` | `stack-encrypt` | `sealed_value_decode` | `SealedValue::from_bytes` (frozen v1 leaf); accepted input must re-encode to itself |
28-
| `fuzz:term-decode` | `stack-encrypt` | `term_decode` | the SEM term decoders (`EqualityTerm`, `MatchTerm`, `OreTerm`, `OpeTerm` `from_bytes`) |
28+
| `fuzz:term-decode` | `stack-encrypt` | `term_decode` | the SEM term decoders (`EqualityTerm`, `MatchTerms`, `OreTerm`, `OpeTerm` `from_bytes`) |
2929
| `fuzz:check-record` | `stack-encrypt` | `check_record` | `dynamic::record::check_record` over an `Arbitrary`-derived plan and tree, against a model of the record rules (structure-aware) |
30+
| `fuzz:plan-build` | `stack-encrypt` | `plan_build` | `plan::FieldsBuilder::build` over `Arbitrary`-derived untrusted names (context, fields, identities); a built field's label must render and parse back, and passthrough names are never refused (structure-aware) |
3031

3132
Each target is a few lines — `libfuzzer-sys` hands a `&str` (or `&[u8]`
3233
for the byte decoders) to the parser via the `arbitrary` crate:

‎docs/target-directed-encryption.md‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -193,13 +193,19 @@ let row: EncryptedUser = user.encrypt_into(&cipher).await?; // one batch, no c
193193
its context (`"<context>/<plaintext field>"`); the prefix is the required
194194
container `context`, named explicitly — never inferred from the Rust type's
195195
name, which two types can share and a refactor can change. `#[stash(from = ..)]`
196-
and `#[stash(context = "..")]` on a field are the overrides, and
197-
`#[stash(nested)]` marks a field whose type is itself a `struct` derive carrying its own
198-
contexts (it is handed `()`). The context is the AAD of every stored
199-
ciphertext in the column, so renaming a plaintext *field* is still a data
200-
migration: pin the old literal with `context = ".."` first.
201-
202-
Leaf, record and field-by-field struct are the same trait, and a column of any of them is `Vec<T>`'s structural impl over the same trait — `ages.encrypt_into_with_context(&cipher, ctx)` for a `Vec<u32>` is one batched call, and `users.encrypt_into(&cipher)` for a `Vec<User>` likewise. Recursion does the rest. Earlier sketches of this design had a separate input-side derive for rows — that was a second mechanism the naming was hiding. (The field-by-field form shipped as `row = ..` and was renamed `struct = ..` on 2026-09-04: "row" pushed database vocabulary into a general-purpose library. `plaintext = T` derives every field from the whole value whatever `T` is — the derive sees a name, not a definition — and `from` / `nested` exist only with `struct`.)
196+
reads a field from a differently named plaintext field, and
197+
`#[stash(identity = "..")]` pins the label segment, so the field is keyed under
198+
`<context>/<identity>`. A field's context is always its record's context plus
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.
201+
A field whose type is itself a record is an ordinary field: it is sealed under
202+
`<context>/<field>`, and its own contexts are extended by that pair. A struct
203+
derive takes one output per plaintext field; to store several terms from one
204+
field, type that field `Encrypted<Terms>`. The context is the AAD of every
205+
stored ciphertext in the column, so renaming a plaintext *field* is still a
206+
data migration: pin the old name first with `identity = ".."`.
207+
208+
Leaf, record and field-by-field struct are the same trait, and a column of any of them is `Vec<T>`'s structural impl over the same trait — `ages.encrypt_into_with_context(&cipher, ctx)` for a `Vec<u32>` is one batched call, and `users.encrypt_into(&cipher)` for a `Vec<User>` likewise. Recursion does the rest. Earlier sketches of this design had a separate input-side derive for rows — that was a second mechanism the naming was hiding. (The field-by-field form shipped as `row = ..` and was renamed `struct = ..` on 2026-09-04: "row" pushed database vocabulary into a general-purpose library. `plaintext = T` derives every field from the whole value whatever `T` is — the derive sees a name, not a definition — and `from` / `identity` exist only with `struct`.)
203209

204210
### Relationship to `Encrypt`
205211

@@ -252,7 +258,7 @@ vitaminc already has the generic notion, twice — `Aad<'a>` (aead) and `PrfCont
252258

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

255-
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 (a `context = ".."` literal, or the one a `struct = ..` derive infers), 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.
256262

257263
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.
258264

‎languages/golang/stackauth/guest/Cargo.lock‎

Lines changed: 20 additions & 20 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)