Skip to content

feat(aead): bind cleartext fields into the AAD with #[aead(aad)] - #290

Draft
coderdan wants to merge 1 commit into
feat/aead-derive-macrosfrom
feat/aead-derive-aad-attr
Draft

feat(aead): bind cleartext fields into the AAD with #[aead(aad)]#290
coderdan wants to merge 1 commit into
feat/aead-derive-macrosfrom
feat/aead-derive-aad-attr

Conversation

@coderdan

Copy link
Copy Markdown
Contributor

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. passthrough is the wrong shape: nothing would stop a term being swapped in storage, silently pointing an index at a value it was never computed from.

#[derive(Encrypt, Decrypt)]
struct Row {
    #[aead(aad)]
    ore_term: Vec<u8>,   // clear, so a query can read it
    ssn: String,         // encrypted, and bound to the term above
}

Substituting ore_term in storage, deleting it, or transposing it with another aad field all make ssn fail to decrypt.

Why it needed new protocol surface

The binding cannot live in the map's AAD. Cipher::encrypt_map fixes 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:

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

  • Adding or removing an aad field is wire-breaking, by that same domain separation. Existing data does not decode after the change.
  • The decode becomes 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.

With no aad field the expansion is unchanged — same calls, same wire format — and a test pins that.

Guards

  • aad + passthrough on one field — compile error; aad already stores the value in the clear.
  • aad on a newtype — rejected like passthrough, with the addition that a newtype has no sibling field to bind to.
  • All fields cleartext — rejected whichever attribute is used; an aad field needs at least one encrypted field to bind.

Tests

  • 4 Aad units on field_context (key binding, order sensitivity, unambiguous boundaries, non-empty empty case) and 4 on for_map_entry_with_context (domain separation, context sensitivity, key binding, boundary).
  • 8 expansion units pinning which channel each field routes through, and that a struct with no aad field expands exactly as before.
  • 8 end-to-end tests against real AES-256-GCM: round-trip, term readable without the key, substitution breaks decryption, the same edit to a plain passthrough field 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 its aad field is refused.
  • 3 new trybuild cases for the guards.

Full workspace green (40 targets); clippy, cargo fmt --check, the rustdoc gate, and the wasm32 build all clean. cargo mutants on the derive crate: 23 caught, 0 missed. CRAP under threshold.

https://claude.ai/code/session_011kjxjgxWmT4yi23ZqufSEc

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
coderdan marked this pull request as draft August 25, 2026 10:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant