Skip to content

spike(stack-encrypt): cascading AEAD tags and a random stored order for json_tree - #1371

Draft
coderdan wants to merge 4 commits into
nextfrom
spike/json-tree-cascading-tags
Draft

coderdan wants to merge 4 commits into
nextfrom
spike/json-tree-cascading-tags

Conversation

@coderdan

Copy link
Copy Markdown
Contributor

Summary

A spike, not a change to merge as it stands. It tries two ideas on the EQL v4 json_tree format (EQL is the SQL library installed into a customer's PostgreSQL; json_tree stores an encrypted JSON document as one small AES-GCM ciphertext per node, plus search terms) and reports what they cost and what they buy:

  1. Cascading tags. Every node's AEAD tag (the last 16 bytes of its ciphertext) is digested into its parent's AAD, from the leaves up. A container's ciphertext then commits to exactly the children it was sealed over, so a client can verify a returned subtree against a database that swaps, drops, adds or reorders rows, with no change to what is stored and no change to the SQL.
  2. A random stored order (json_tree: the stored entry order reveals each row's shape and the alphabetical order of sibling keys #1357). The entries of a document are stored in a random order (root at slot 0) and the containment projection q is shuffled, so the stored order no longer reveals each row's tree, the parent of every node, subtree sizes or the byte order of sibling key names. Behind a process-wide switch (STACK_ENCRYPT_JSON_SHUFFLE=1) so every existing test and the PostgreSQL oracle run under it unchanged.

The two compose: the cascade is what lets the client place rows without reading the stored order, which is what the shuffle takes away. The write-up with the analysis, the measurements and the recommendation is in the report linked from #1357.

Changes

  • packages/stack-encrypt/src/json/derive.rs: sealing runs from the leaves up; entry_aad takes a digest of the children's tags (AAD domain bumped to json/entry/v2); opening a subtree read places rows by the chain's tags (self tags, parent tags, the member and element rules) and opens each node under its children's digest, in whatever order the rows arrive; the consecutive-position rule is gone. Opening a document whose root is open walks the chain the same way, so it needs the keyset loaded (StackCipher::loaded_keyset, new, pub(crate)). The random stored order is slots() plus JsonProjection::shuffle.
  • packages/stack-encrypt/src/cipher.rs: loaded_keyset.
  • packages/stack-encrypt/src/json/query.rs: JsonProjection::shuffle and fisher_yates.
  • packages/stack-encrypt/Cargo.toml, both lockfiles: vitaminc (for SafeRand).
  • Tests: the hostile-row assertions in tests/json_read_checks.rs, tests/json_index.rs and the Postgres oracle accept a refusal at the cipher as well as a JsonWrongEntry, since the cascade moves several refusals from the position check to the AEAD. Extracting a container with children now fails at the cipher (its AAD covers children -> does not return); it was refused by kind after opening before.
  • First commit, separable: tests/json_index.rs on next does not compile (the fix(stack-encrypt): open JSON index floats to the exact value sealed #1366 module calls a helper feat(stack-encrypt, eql): json_tree tokens derivable from the path, a self tag, client-side wildcards #1363 removed); fixed with the helpers the rest of the file uses. tests-crates is red on next at ebdb5de because of it.

Verification

Run on this branch, locally:

  • cargo nextest run -p stack-encrypt --all-features (JSON suites and the lib): 531 passed, 0 failed, with the cascade and the default stored order.
  • The PostgreSQL json_tree oracle (packages/eql/tests/encryption/tests/json_tree.rs, EQL v4 bundle built from this tree, PostgreSQL 17.10): 14 of 14 pass with the cascade: every read equals jsonb #> / jsonb_path_query, containment equals jsonb @>, and every tampered answer is refused.
  • With STACK_ENCRYPT_JSON_SHUFFLE=1: the oracle passes 12 of 14; the two failures compare returned slot numbers with depth-first positions (j = 2 where 8 was expected), not answers. The stack-encrypt suites pass 513 of 543; the 30 failures are tests that pick an entry by its depth-first slot, the pinned derivation vectors (the order is random now), the q-equals-needle wire test, and the leakage-calibration tests that measure that the order reveals the shape, which it no longer does. No hostile row was accepted in any run; one apparent acceptance (a_row_reported_at_the_wrong_depth_is_refused) was the test's slot-numbered row landing on a node at that depth, checked with a throwaway test that shifts every row's depth and is refused every time.
  • cargo fmt --check clean; cargo clippy -p stack-encrypt --all-features --tests clean.
  • Not run: the Go guests, mutants, the benches. The shuffle adds no SQL and no stored field; the cascade adds nothing stored.

Related

Review notes

  • Start with entry_aad, seal and Placing::node in derive.rs.
  • Deliberately not done, discussed in the report: verifying a node's own placement against its ancestors (needs the ancestors' entries, which the database cannot locate without their tokens); the sealed root committing to its heads (the budget fallback returns the root alone); recording the shuffle in the header or as an option instead of an environment switch; json_tree_path_query_first semantics at a wildcard path under a random order.

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

…n-list merge

The test module #1366 added to `tests/json_index.rs` was written against
the gated walk: it called `subtree_nodes(doc, token)`, which the token-list
read (#1363) replaced with `read_rows(doc, key)`, and opened an extracted
element with `decrypt_as::<Value, JsonNode>`, which has no implementation
(an extracted entry opens through `JsonExtracted`, which checks its
selector first). Merged on top of #1363 the file no longer compiled, and
the `tests-crates` workflow is red on `next` at ebdb5de.

Both calls now use the helpers the rest of the file uses. The test's
assertions are unchanged.

Refs #1366, #1363.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
… into its AAD

A spike, not a change to land as it is. It evaluates the proposal that a
client should be able to verify a returned subtree of an encrypted JSON
document against a database that may rewrite, drop or rearrange rows,
without reading the whole document.

Every node of a json_tree document is its own AES-GCM ciphertext. Today
its AAD binds the keyset, the column, the data key, the node's selector
and its position in the stored list, and the client checks a subtree
read by requiring its rows to be the consecutive positions below the node
asked for. That check is sound only because the stored order is the
depth-first order of the tree, which is the order #1357 wants to stop
storing.

With this change a node's AAD also binds a digest of its children's AEAD
tags, in the order the parent tags enumerate them, so a container's
ciphertext commits to exactly the children it was sealed over, and
through their tags to everything beneath them. Sealing runs from the
leaves up. Opening places the rows by the chain the server walks (the
self tags and parent tags, which the client derives from the tokens it
sent), then opens each node under the digest of the children the chain
gave it: a child swapped, dropped, added or moved under another parent
fails at the cipher, in whatever order the rows arrive. The consecutive-
position rule is gone, and nothing reads the stored position as document
order any more.

What it does not do: a node's own placement in the document is still
checked only by its selector and label, since verifying it against an
ancestor needs that ancestor's entry, which the database cannot locate
without the ancestor's token (and sending that token is the capability
the token-list read removed). The sealed root commits to no children,
because the budget fallback returns it alone. Both are discussed in the
write-up this spike accompanies.

Costs: nothing stored changes; the AAD is not stored. Opening a document
with its root open now derives the root's token, so it needs the keyset
loaded; a `->` extraction of a container with children no longer opens
(it was refused by kind before, and is refused by the cipher now). The
AAD domain is bumped to `json/entry/v2` so a v1 ciphertext fails loudly.

Tests: the stack-encrypt JSON suites pass; the hostile-row tests still
refuse every case, several now at the cipher rather than at the position
check, and their assertions accept either.

Refs #1357.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The second half of the spike (#1357): with the cascading tags placing a
read's rows by the chain, nothing reads the stored position as document
order, so the order can be random. Behind `STACK_ENCRYPT_JSON_SHUFFLE=1`
(a process-wide switch, so every existing test and the PostgreSQL oracle
run under it unchanged; a real change records it like `sealed_root`, or
is simply the format), sealing draws a uniform permutation of the slots
`1..n` from a CSPRNG, keeps the root at slot 0 (where the budget fallback
and a whole-document open look for it; every document has a root, so its
slot says nothing), binds each entry's AAD to its slot, and shuffles the
pairs and element groups of the containment projection, which `jsonb @>`
reads as sets.

No SQL changes. The database never derives the permutation: it finds
entries by tag and returns the slot, and containment is set containment.
What the stored order revealed (the tree, every node's parent, subtree
sizes, the byte order of sibling keys) it no longer does; the multiset
of selectors, the element grouping of `q`, which entries are heads and
the ciphertext lengths remain, as the report measures.

Under the switch the PostgreSQL oracle passes 12 of 14 (the two compare
slot numbers with depth-first positions) and the stack-encrypt suites 513
of 543 (slot-numbered tests, pinned vectors and the leakage calibration,
which measures that the order reveals the shape). No hostile row opens.

Refs #1357.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
@changeset-bot

changeset-bot Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: c108cfb

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Mutation testing (cargo-mutants, --in-diff, stack-auth + stack-encrypt)

caught missed unviable timeout
27 7 22 0

Surviving mutants: add a test that fails on each before merging

packages/stack-encrypt/src/json/derive.rs:948:5: replace shuffled -> bool with false
packages/stack-encrypt/src/json/derive.rs:950:90: replace == with != in shuffled
packages/stack-encrypt/src/json/derive.rs:1655:16: delete ! in Placing<'_>::node
packages/stack-encrypt/src/json/query.rs:51:9: replace JsonProjection::shuffle with ()
packages/stack-encrypt/src/json/query.rs:812:5: replace fisher_yates with ()
packages/stack-encrypt/src/json/query.rs:813:39: replace + with - in fisher_yates
packages/stack-encrypt/src/json/query.rs:813:39: replace + with * in fisher_yates

…pers

Three jobs were red on the spike branch. Clippy under `-D warnings` named
a redundant closure around `SafeRand::from_entropy` and `fisher_yates`
sitting after the test module in `query.rs`; both are moved or rewritten
as it asked. The Go job's drift check found the encrypt guest's
`Cargo.lock` without the `vitaminc` dependency the spike added to
`stack-encrypt`; the lock is regenerated (one line). The mutants gate
missed a mutant that replaces `aead_tag` with a single zero byte, which
sealing and opening both tolerate because they share the helper; two
unit tests now pin the tag to the last sixteen bytes of a ciphertext and
the children's digest to those tags, in order, and nothing else.

Refs #1357.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
@coderdan

Copy link
Copy Markdown
Contributor Author

Appended c108cfbce to clear the three red jobs the first push left:

  • Tests (crates): clippy under -D warnings named a redundant closure around SafeRand::from_entropy in derive.rs and fisher_yates placed after the test module in query.rs. Both fixed as it asked.
  • Tests (Go): the drift check found the encrypt guest's Cargo.lock without the vitaminc dependency this branch adds to stack-encrypt. Regenerated, one line.
  • Mutants gate: one missed mutant, aead_tag replaced with a single zero byte, which sealing and opening both tolerate because they share the helper. Two unit tests now pin the tag to the last sixteen bytes of a ciphertext and the children's digest to those tags in order. A targeted cargo mutants run over the three helpers: 5 mutants, 5 caught.

Nothing else changed; the results in the description stand.

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

This branch has not been deployed

No deployments
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