feat(aead): bind cleartext fields into the AAD with #[aead(aad)] - #290
Draft
coderdan wants to merge 1 commit into
Draft
feat(aead): bind cleartext fields into the AAD with #[aead(aad)]#290coderdan wants to merge 1 commit into
coderdan wants to merge 1 commit into
Conversation
Searchable encrypted metadata — an ORE term, an index term — has to travel
in the clear so a query can use it without the key, but unlike an ordinary
plaintext column it is *derived from* the value beside it and rewritten
only when that value is. `passthrough` is the wrong shape for it: nothing
would stop a term being swapped in storage, silently pointing an index at
a value it was never computed from.
`#[aead(aad)]` stores the field in the clear like a passthrough and
additionally binds its bytes into the associated data every encrypted field
is sealed against. Substituting the term, deleting it, or transposing it
with another `aad` field all make the encrypted fields fail to open.
The binding cannot live in the map's AAD, which `Cipher::encrypt_map` fixes
before any entry exists — encrypt could fold the term in, but decrypt has
not read it yet. So it moves down to each entry:
Aad::field_context(&[(key, bytes), …]) — canonical context encoding
Aad::for_map_entry_with_context(key, context)
MapCipher::encrypt_value_with_context(value, context)
MapAccess::next_value_with_context::<T>(context)
`field_context` is a labelled `PAE(domain, key, bytes, …)` over the `aad`
fields in *declaration* order, so permuting a stored map cannot change it
and each value stays bound to its own key. `for_map_entry_with_context`
carries a domain label distinct from `for_map_entry`, which is what stops a
context-bearing ciphertext being read as a context-free one — the binding
cannot be dropped by decoding into a type that ignores it.
Two consequences, both documented:
- Adding or removing an `aad` field is wire-breaking, by that same domain
separation.
- The decode is order-dependent. An encrypted entry cannot open until every
`aad` field has been read, and `MapAccess` cannot skip ahead to fetch one,
so the derive now writes cleartext entries first. A map reordered in
storage fails to decrypt — a denial of service, not a forgery, since
entry order was never authenticated.
The coupling runs both ways and the docs lead with it: anything that
rewrites an `aad` field independently breaks decryption of every encrypted
field beside it. A column another process updates on its own is a
`passthrough` field, never an `aad` one.
Guards: `aad` and `passthrough` on one field is a compile error, since
`aad` already stores the value in the clear; `aad` on a newtype is rejected
like `passthrough`, with the addition that a newtype has no sibling field to
bind to; and the all-passthrough check now covers cleartext of either kind,
because an `aad` field needs something to bind.
With no `aad` field the expansion is unchanged — same calls, same wire
format — and a test pins that.
Claude-Session: https://claude.ai/code/session_011kjxjgxWmT4yi23ZqufSEc
coderdan
marked this pull request as draft
August 25, 2026 10:52
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #287 — please merge that first.
Adds
#[aead(aad)]: a field stored in the clear, like#[aead(passthrough)], whose bytes are additionally bound into the associated data every encrypted field is sealed against.Why
Searchable encrypted metadata — an ORE term, an index term — has to travel in the clear so a query can use it without the key. But unlike an ordinary plaintext column it is derived from the value beside it, and rewritten only when that value is.
passthroughis the wrong shape: nothing would stop a term being swapped in storage, silently pointing an index at a value it was never computed from.Substituting
ore_termin storage, deleting it, or transposing it with anotheraadfield all makessnfail to decrypt.Why it needed new protocol surface
The binding cannot live in the map's AAD.
Cipher::encrypt_mapfixes that before any entry exists — encrypt could fold the term in, but decrypt has not read it yet. So the binding moves down to each entry:field_contextis a labelledPAE(domain, key, bytes, …)over theaadfields in declaration order — so permuting a stored map cannot change it, and each value stays bound to its own key.for_map_entry_with_contextcarries a domain label distinct fromfor_map_entry. That is what stops a context-bearing ciphertext being read as a context-free one: the binding cannot be dropped by decoding into a type that ignores it.Consequences
aadfield is wire-breaking, by that same domain separation. Existing data does not decode after the change.aadfield has been read, andMapAccesscannot skip ahead to fetch one, so the derive now writes cleartext entries first. A map reordered in storage fails to decrypt — a denial of service, not a forgery, since entry order was never authenticated.aadfield independently breaks decryption of every encrypted field beside it. A column another process updates on its own is apassthroughfield, never anaadone.With no
aadfield the expansion is unchanged — same calls, same wire format — and a test pins that.Guards
aad+passthroughon one field — compile error;aadalready stores the value in the clear.aadon a newtype — rejected likepassthrough, with the addition that a newtype has no sibling field to bind to.aadfield needs at least one encrypted field to bind.Tests
Aadunits onfield_context(key binding, order sensitivity, unambiguous boundaries, non-empty empty case) and 4 onfor_map_entry_with_context(domain separation, context sensitivity, key binding, boundary).aadfield expands exactly as before.passthroughfield does not, deletion breaks it, transposing two terms breaks it, a bound ciphertext cannot be read as an unbound one (both directions), and an encrypted entry before itsaadfield is refused.trybuildcases for the guards.Full workspace green (40 targets); clippy,
cargo fmt --check, the rustdoc gate, and the wasm32 build all clean.cargo mutantson the derive crate: 23 caught, 0 missed. CRAP under threshold.https://claude.ai/code/session_011kjxjgxWmT4yi23ZqufSEc