Repository navigation
Conversation
…ed for Every entry of a json_tree document is sealed under its own selector and position, and nothing in its AAD says which path a query asked for. The client opened what `->` and the subtree walks returned under the `s` and `j` that came back with each row, so a database could answer `$.salary` with the row's `$.bonus`, or with the sealed root (the whole document), or splice other entries of the row into a walk, and the client would rebuild whatever it was handed (#1355). A row now opens only through what the client derived for the read. `JsonSelector::open(node)` refuses an entry that does not carry the selector, before any key is retrieved. `JsonSubtreeKey::open(rows)` checks every row: a node a gated walk reaches carries the path's selector and the position the path ends in; every other row carries the selector of its parent's path extended by its decrypted label, which the key now derives through the keyset's index-key PRF it holds; and each subtree's rows are consecutive in the document, so a dropped, moved or orphaned row is refused. The refusal is a new `Error::JsonWrongEntry`: each entry is genuine, so it is not `Aead`, and it is not of a wrong shape, so it is not `OpenedType`. `JsonNode` no longer opens on its own. `eql-bindings` follows: an entry `->` returned opens through `JsonTreeExtracted::from_selector(&selector, entry)`, and `JsonTreeEntry` no longer opens on its own. Which row an entry comes from stays the database's word, by design (ADR 0010); the leakage page and the design record say so. Closes #1355 Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
🦋 Changeset detectedLatest commit: c3d7fee The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Contributor
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
Comment |
The design record marks the Phase 4 bullet on extracted entries taken; it now names the pull request that took it, #1358. Refs #1355 Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Contributor
Mutation testing (cargo-mutants,
|
| caught | missed | unviable | timeout |
|---|---|---|---|
| 53 | 0 | 31 | 0 |
Every mutant in the changed lines was caught by a test.
This was referenced Oct 24, 2026
This branch has not been deployed
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.
Summary
EQL v4's
json_treecolumn stores an encrypted JSON document as one entry per JSON node. When the database returns entries for a read (doc -> selector, or a subtree walk), the client decrypted each one under the selector and position the database sent back with it, and never checked that it was the entry it had asked for. Every entry of a row opens that way, so a dishonest database could answer a read of$.salarywith the same row's$.bonus, or with the sealed root (the whole document), or splice other entries into a walk, and the client would hand back the wrong value. Now the client checks every entry against what it asked for and refuses anything else with a new error,Error::JsonWrongEntry.Changes
Stack Encrypt (
packages/stack-encrypt)JsonSelector::open(node) -> JsonExtractedis now the only way to open the entry->returned. It refuses an entry whose selector is not the one asked for, before any key is retrieved. The sealed root opens only when$itself was asked for.JsonSubtreeKey::open(rows) -> JsonSubtreenow checks every row when it is opened. A node a gated walk reaches must carry the path's selector, at depth 0, and the position the path ends in. Every other row must carry the selector of its parent's path plus its own decrypted label. Each subtree's rows must be consecutive in the document, so a dropped, moved or orphaned row is refused. A reported depthdthat disagrees is refused too.JsonSubtreeKeynow holds what it needs to derive those selectors: the keyset's index-key PRF (shared with the keyset handle) and the column's context. Neither is printed or serialized.JsonNodeno longer opens on its own: it is the raw row the database returned.Error::JsonWrongEntry { expected }. It is notAead(every entry is genuine) and notOpenedType(the entry is not of a wrong shape; it answers a different question).JsonDocument, the stored form, is unchanged. So are the SQL and the wire format.EQL bindings (
packages/eql/crates/eql-bindings)JsonTreeExtracted::from_selector(&selector, entry)is how an entry->returned opens.JsonTreeEntryno longer opens on its own.JsonTreeSubtree::from_key(&key, rows)is unchanged, and now checks the rows through the key.Docs
packages/eql/docs/reference/json-tree-leakage.md, "What is authenticated") says what is checked now and what is not.@cipherstash/eql(eql-bindingsships in lockstep with it).Not covered, by design
$.salary, returned with that row's header, still opens. Nothing in an entry names its row, and two values sealed under the same context are interchangeable between rows (ADR 0010,packages/stack-encrypt/docs/adr/0010-what-a-stored-value-authenticates.md, and the "Known non-findings" inAGENTS.md).$.a[1].bcan be answered with$.a[0].bof the same row.->already reads a position as the wildcard.$.l[3], whose children share every other element's selectors). The walk does not return the node it starts from yet. json_tree: a subtree read of{},[], a scalar or a missing field all return no rows, so the client cannot tell them apart #1356 (the stacked PR) fixes that, and with it this case.Verification
Run from the worktree root unless noted. PostgreSQL 17 was a disposable container on port 55437, stopped afterwards.
mise x --env test -- cargo nextest run -p stack-encrypt --all-features:Summary [ 40.799s] 829 tests run: 829 passed, 1 skippedcargo test -p stack-encrypt --doc --all-features:test result: ok. 50 passed; 0 failedcargo clippy -p stack-encrypt --all-features --all-targets -- -D warnings: cleancargo fmt --all --checkat the root and inpackages/eql: cleanRUSTDOCFLAGS=-D warnings cargo doc -p stack-encrypt --all-features --no-deps: cleanpackages/eql:cargo test -p eql-bindings --all-features: all pass (18 passed,16 passed; 2 ignored, …)packages/eql:mise run types:check: passespackages/eql:mise run test:encryption: exit 0packages/eql:mise run test:encryption:postgres: exit 0. Thejson_treesuite:test result: ok. 7 passed. That includes the newpostgres_hostile_answers_are_refused_by_the_client, which gets real rows back from PostgreSQL. It answers->with another path's entry and with the sealed root, and a gated walk with another position's subtree. It also tampers with a stored row twice: once by rewriting a top-level field's parent tag so the walk takes it for a member, and once by swapping two entries insv. The client refuses each wrong entry asJsonWrongEntryand each moved entry asAead.packages/stack-encrypt/tests/json_read_checks.rs(a new file, to stay clear of test: hold json_tree containment to jsonb with proptest, and restructure the JSON index tests #1345's rewrite oftests/json_index.rs). It has 23 tests: honest rows open, and each refusal (another path's entry, the sealed root, a relabelled entry, another position, another field of the same shape, a spliced row, a dropped row, a wrong depth, an orphan, a gap) is checked. It also checks what the design leaves open (a position read as the wildcard, another row's entry). There are unit tests insrc/json/derive.rsfor the selector derivation and the label check.mise x --env test -- cargo mutants -p stack-encrypt --in-diff <(git diff origin/next...HEAD): 88 mutants, 4 missed. All 4 were inrebuild_walked's depth counter. I merged that counter into the depth the rows are checked against and added a test at the deepest document the index seals. A re-run of the affected functions found 29 mutants: 24 caught, 5 unviable, 0 missed.Related
Closes #1355
Refs #1356 (stacked on this PR), #1357, #1352 (the Go reader will need the same checks).
Review notes
placeandrebuild_walkedinpackages/stack-encrypt/src/json/derive.rs. Then readJsonSelector::openandJsonSubtreeKey::openinquery.rs.packages/stack-encrypt/tests/json_index.rsand adds an oracle test that opens->results throughJsonTreeEntry. This PR changes a few call sites injson_index.rs(JsonNodeno longer opens on its own, and the hostile line-up tests now expectJsonWrongEntry). Whichever merges second needs those lines carried over. New engine tests went into a new file to keep the overlap small.json_tree(checked with grep), so the change is Rust only. No skill file mentionsjson_tree, so there is no skill update.https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu