Skip to content

feat(aead): extract a reusable CipherTree codec for structured ciphers#195

Draft
coderdan wants to merge 2 commits into
mainfrom
feat/cipher-tree-codec
Draft

feat(aead): extract a reusable CipherTree codec for structured ciphers#195
coderdan wants to merge 2 commits into
mainfrom
feat/cipher-tree-codec

Conversation

@coderdan

@coderdan coderdan commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

What

Factors the recursive ciphertext structure out of Aes256Cipher into reusable, crypto-agnostic machinery in vitaminc-aead, so any cipher producing the same shape (single value / sequence / map / option / passthrough) reuses it and supplies only its per-leaf seal/open.

New in vitaminc-aead:

  • CipherTree<L> — the recursive container, generic over the leaf payload L.
  • LeafSealer / LeafOpener — the two small per-cipher operations.
  • TreeCipher<S> / TreeDecipher<O> (+ TreeSeq/TreeMap and the seq/map access drivers) — generic Cipher/Decipher impls that perform the whole structural traversal.

Aes256Cipher is refactored onto it: AesCipherText = CipherTree<LocalCipherText>, AesDecipher = TreeDecipher<AesOpener>, and the hand-written Seq/Map ciphers, Decipher impl, and access structs are deleted (~290 fewer lines).

Why

The structural drive/walk was duplicated verbatim between Aes256Cipher and the ZeroKMS-backed cipher being built in cipherstash-suite (stack-encrypt). This is the shared foundation that cipher can build on — sealing each leaf under its own data key while reusing everything here.

Design note: why not a blanket impl

The generic impls are on the concrete local types TreeCipher/TreeDecipher, not impl<C: LeafSealer> Cipher for &C. A conditional blanket would conflict (E0119) with any direct Cipher/Decipher impl — and those traits are public extension points meant to be implemented directly (e.g. by stack-encrypt, and by the test doubles here). Concrete ciphers wrap themselves in a LeafSealer/LeafOpener instead.

Verification

  • Behaviour unchanged: the full vitaminc-encrypt suite passes on both the aws-lc-rs and RustCrypto (_test-rust-crypto-backend) backends — incl. the map-contract, option-shape, passthrough, AAD-mismatch, and nonce-uniqueness tests.
  • vitaminc-aead 42 unit + 8 doctests; cargo build --workspace --all-features ✅; clippy -D warnings ✅; fmt --check ✅.

Stacking

Stacked on #190 (base claude/vitaminc-aead-issue-178-vCypS). Merge the stack bottom-up, or retarget to main once #190 lands.

Comment thread packages/aead/src/tree.rs Outdated
Comment on lines +94 to +98
let mut out = Vec::with_capacity(items.len());
for item in items {
out.push(item.try_map_leaves(f)?);
}
CipherTree::Sequence(out)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review: could this be done with a combinator/iterator instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 652695e — the Sequence arm is now:

CipherTree::Sequence(items) => CipherTree::Sequence(
    items
        .into_iter()
        .map(|item| item.try_map_leaves(f))
        .collect::<Result<Vec<_>, E>>()?,
),

collect::<Result<_, _>>() short-circuits on the first Err exactly like the old for + ?, so behaviour is identical.

Comment thread packages/aead/src/tree.rs Outdated
Comment on lines +101 to +106
let mut out = Vec::with_capacity(entries.len());
for (k, v) in entries {
out.push((k, v.try_map_leaves(f)?));
}
CipherTree::Map(out)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same — the Map arm in 652695e:

CipherTree::Map(entries) => CipherTree::Map(
    entries
        .into_iter()
        .map(|(k, v)| v.try_map_leaves(f).map(|v| (k, v)))
        .collect::<Result<Vec<_>, E>>()?,
),

The .map(|v| (k, v)) re-pairs each transformed value with its (cleartext) key before collecting into the Result.

@coderdan coderdan marked this pull request as draft June 22, 2026 10:41
@coderdan coderdan force-pushed the claude/vitaminc-aead-issue-178-vCypS branch from 5279c64 to 51a3fb3 Compare June 22, 2026 10:52
@coderdan coderdan force-pushed the feat/cipher-tree-codec branch from 652695e to 4eda233 Compare June 22, 2026 10:52
@coderdan coderdan force-pushed the claude/vitaminc-aead-issue-178-vCypS branch from 51a3fb3 to 0dfde9b Compare June 22, 2026 11:37
@coderdan coderdan force-pushed the feat/cipher-tree-codec branch from 4eda233 to 32d5142 Compare June 22, 2026 11:37
@coderdan coderdan force-pushed the claude/vitaminc-aead-issue-178-vCypS branch from 0dfde9b to 1cee66d Compare June 22, 2026 12:37
@coderdan coderdan force-pushed the feat/cipher-tree-codec branch from 32d5142 to 927299a Compare June 22, 2026 12:37
@coderdan coderdan force-pushed the claude/vitaminc-aead-issue-178-vCypS branch from d7f2e9e to ee7419f Compare June 24, 2026 00:44
coderdan added 2 commits June 24, 2026 11:22
The structural drive and visitor walk for a recursive ciphertext (single value,
sequence, map, option, passthrough) were identical between Aes256Cipher and any
other cipher producing the same shape — only the per-leaf seal/open differs.

Factor that out into vitaminc-aead:

- CipherTree<L>: the recursive container, generic over the leaf payload.
- LeafSealer / LeafOpener: the two small per-cipher operations.
- TreeCipher<S> / TreeDecipher<O>: generic Cipher/Decipher (plus TreeSeq/TreeMap
  and the SeqAccess/MapAccess drivers) that do the whole structural traversal.

These are generic impls on the concrete local types TreeCipher/TreeDecipher, not
a blanket impl over &C — a conditional blanket would conflict (E0119) with the
direct Cipher/Decipher impls those traits exist to allow.

Aes256Cipher is refactored onto it: AesCipherText is now CipherTree<LocalCipherText>,
AesDecipher is TreeDecipher<AesOpener>, and the hand-written Seq/Map ciphers,
Decipher impl, and access structs are gone (~290 fewer lines). Behaviour is
unchanged — the full encrypt suite passes on both the aws-lc-rs and RustCrypto
backends, including the map-contract, option-shape, passthrough, AAD, and
nonce-uniqueness tests.

This is the shared foundation a ZeroKMS-backed cipher (cipherstash-suite
stack-encrypt) can build on, sealing each leaf under its own data key while
reusing all of the structural machinery here.
…reuse

The `LeafSealer` seam sealed each leaf eagerly during the encrypt
traversal, behind a `Copy` bound. That fits an eager single-key cipher
but not a batching one (e.g. ZeroKMS), which must collect all leaves,
fetch data keys in one request, then seal — it could only satisfy the
old seam with a no-op `seal` and a second pass anyway.

Split structure from sealing so one codec serves both:

- `TreeCipher`/`TreeSeq`/`TreeMap` are now a non-generic structural codec
  that collects each byte leaf (plaintext + bound AAD) into a
  `CipherTree<PendingLeaf>` without sealing. `LeafSealer` is removed.
- Sealing is a separate per-cipher step. `Aes256Cipher` seals the
  collected tree via `try_map_leaves` (eager, fresh nonce per leaf); a
  batch cipher can size a key request with `leaf_count` and seal the same
  way. `value.encrypt(&cipher)` is unchanged for callers.
- `LeafOpener` is kept (decrypt openers are stateless per-leaf lookups,
  so `Copy` is correct there) and its three recursion sites now use
  `TreeDecipher::new`.

Also:
- Add `Nonce::as_array` so `seal` copies the nonce bytes without the
  previously-dead fallible slice conversion (it needs the nonce twice:
  for the AEAD call and to append to the ciphertext).
- Pin the leaf-ordering contract (`leaf_count`/`for_each_leaf`/
  `try_map_leaves` agree on set and order) with a test.
- Rewrite `try_map_leaves` seq/map arms as iterator combinators.
- Document `CipherTree::None`'s per-leaf key cost and `Passthrough`'s
  non-serializable type erasure for the future batch cipher.
@coderdan coderdan force-pushed the feat/cipher-tree-codec branch from 2fb6233 to c93f2f1 Compare June 24, 2026 01:24
@coderdan coderdan changed the base branch from claude/vitaminc-aead-issue-178-vCypS to main June 24, 2026 01:24
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