Repository navigation
fix(stack-encrypt): check that a json_tree read returns the entry asked for - #1358
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: fe55bf1 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 |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the
|
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
Mutation testing (cargo-mutants,
|
| caught | missed | unviable | timeout |
|---|---|---|---|
| 54 | 0 | 31 | 0 |
Every mutant in the changed lines was caught by a test.
A walk's rows that stopped before a node's authenticated child count was
met were refused as `OpenedType` ("a complete JSON subtree"), while the
same row left out between two others was `JsonWrongEntry`. Which error
the client raised for a dropped row depended on where the database
dropped it.
The SQL walk never returns part of a subtree: it returns the node's whole
subtree, or past its budget the sealed root alone, and an open root has
no budget. So rows that run out early are always the database leaving a
row out, and `rebuild_walked` now refuses them the same way it refuses a
gap. `open_document` keeps `OpenedType` for a stored document cut short,
which is a stored container's shape (ADR 0010), not a read's answer.
Tests: the last row of a gated walk's subtree and of a walk without gates,
left out; both failed with `OpenedType` before this change.
Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…s first element
`json_tree_subtree` (a key with no gates) returns the descendants of the
token's node but not the node, so nothing before the first row bounds
where the children begin. The consecutive-row check caught a child left
out between two others, but not the first: for `{"arr": [10, 20, 30]}`
a database that dropped element 0's row had `$.arr` open as `[20, 30]`,
every element moved down one position, with no error.
An array's children are its elements from 0, so `place` now requires the
nth row at depth 1 that is an element to be element n. A member's place
is a keyed rank the client does not hold, so a member left out, and the
last children of either kind, are still undetected; the rustdoc on
`JsonSubtreeKey::open` says so. Both close once the walk returns the node
it starts from (#1356), whose child count bounds its children.
Tests: the first element left out, opened as a value and as a list
(both opened before this change), and a pin of the trailing case that
stays open.
Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The leakage page's "What is authenticated" said a dropped row is refused and that each decrypted value is at the path asked for. For a walk with no gates neither held in full: the walk does not return the node it starts from, so children left out after the last row (and members left out anywhere) are not detected, and for a token chained through array positions (`$.l[3]`) the children can be another element's. The second was in the rustdoc and the design record but not on the page customers read. The page now lists both under what the checks do not cover, with the issue that closes them (#1356), and says what is checked for that walk: no gap between children, an array starting at element 0, and no row left out of a node's subtree. The design record's bullet says the same. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Review of
|
…e-derivable-tokens Brings in the review fixes on #1358: a walk cut short is JsonWrongEntry, a walk with no gates refuses an array that does not start at element 0, and the leakage page says what that walk still does not check. The leakage page conflicted. This branch's "What is authenticated" describes the derivable-token design, in which every read returns the node it names first, so the ungated-walk paragraph #1358 added does not apply here; this branch's text is kept. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…rivable-tokens Brings in the review fixes on #1358 through #1361. Conflicts in `json/derive.rs`, `json/query.rs` and `tests/json_read_checks.rs`, all where this branch replaced the gated and ungated walks with token reads. This branch's `place` and `JsonSubtreeKey::open` are kept: the leading-element check #1358 added belongs to the walk with no gates, which no longer exists here, and every read now returns the node it names with the child count that bounds its children. The truncation fix is carried over by hand: `rebuild_walked` here still refused rows that ran out before a child count was met as `OpenedType`, while the leakage page lists a truncated row among the `JsonWrongEntry` refusals. It is now `JsonWrongEntry`, like a gap. `a_truncated_read_is_refused` now expects that. #1358's ungated-walk tests are replaced by one that pins what this design closes: an array read missing its first or its last element is refused. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…e-leakage-profile Brings in the review fixes on #1358: a walk cut short is JsonWrongEntry, a walk with no gates refuses an array that does not start at element 0, and the leakage page says what that walk still does not check. No conflicts; the leakage profile's tests pass unchanged. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Fixes pushed (
|
next now carries #1345, which rewrote packages/stack-encrypt/tests/json_index.rs into given_<scenario> modules with an assertions module and proptest cases, and added the generated EQL oracle (packages/eql/tests/encryption/tests/json_tree/generated.rs). Conflict: packages/stack-encrypt/tests/json_index.rs only. Resolved by taking #1345's file as the base and re-applying this branch's changes to it, test by test: - imports JsonExtracted and JsonSelector; adds the selector_of and extracted helpers (now over the file's Cipher alias), and an assertions::refused_as_wrong_entry beside the existing refusal helpers. - given_an_extracted_node::opens_to_its_scalar_and_a_walked_subtree_to_its_value: each scalar opens through the selector of its path ($.hello, $.nested.list[0].x, $ for the sealed root, $.nested for the container), and the list opens from the gated walk to $.nested.list. - given_an_extracted_node::round_trips_with_the_envelope_the_sql_grafts and given_a_tampered_document's lifted node open through JsonSelector::open. - given_gated_walk_rows::open_to_the_reached_nodes_value: the ungated walk's rows under the gated key for $.nested.list are refused as JsonWrongEntry, where they used to assemble. - given_a_derived_record: the node field is an extracted entry and the subtree comes from the gated walk, five rows with the list itself. - given_a_fallback_root::is_only_the_sealed_root_alone_at_depth_zero: the root at depth 1 is refused as JsonWrongEntry. - given_rows_the_server_chose: both lines of containers are JsonWrongEntry; the repeated row stays OpenedType, as on this branch. No assertion of either side was dropped, and every #1345 test is kept. Compile fix outside the conflict: #1345's generated arrow test opened the entry -> returned as a bare JsonTreeEntry, which this branch no longer allows. It now keeps each derived JsonSelector and opens through JsonTreeExtracted::from_selector. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…e-leakage-profile Brings in next after #1345, through #1358's merge of it. Conflict: packages/stack-encrypt/Cargo.toml only. Both sides added the same proptest dev-dependency line (#1345 for the generated containment property, this branch for tests/json_leakage) with different comments. Kept the one line and one comment naming both uses. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
|
…e-derivable-tokens Brings in next after #1345, through #1358's merge of it. No conflict: the only line of these two documents that #1345 touched is the design record's "Where it is" paragraph (the generated oracle and the proptest properties), which this branch does not change. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
…rivable-tokens Brings in next after #1345 (through #1358 and #1361). Nothing in this branch's walk, derivations, SQL, EQL types or leakage page changes: the merge touches only tests, the proptest regressions and the oracle. Conflicts: - packages/stack-encrypt/Cargo.toml: both sides added the same proptest dev-dependency line with different comments. One line, one comment naming both uses. - packages/stack-encrypt/tests/json_index.rs: #1345 rewrote it into given_<scenario> modules. I took that file (with #1358's changes already in it) as the base and re-applied this branch's changes to the tests they now live in: - helpers: gated_walk becomes read_rows (the rows json_tree_subtree returns for key.tokens()); subtree_ordinals takes the token list; child_token and subtree_nodes are gone, with nothing left to use them. - given_the_fixture_document: stores_the_wire_form_the_sql_domain_checks expects the self tag `x`; places_the_chain_fields_where_the_design_says checks one distinct self tag per entry; stores_no_token follows the token list and checks self tags as well as parent tags. - given_a_subtree_key: #1345 split this branch's old token test three ways, and each part is ported to the token list. unlocks_exactly_its_subtree_from_the_path_alone is renamed finds_exactly_its_node_from_the_path_alone (one token, no probe, the node itself at depth 0). gates_each_key_step_below_its_head is renamed derives_every_token_below_its_head (the second element's own token, the server deriving the token the client sends, find). Its wildcard gates assertion is dropped, because a wildcard key carries no gates; the wildcard's tokens and probe are pinned in given_wildcard_rows. has_no_token_at_a_sealed_root_and_derives_from_an_open_one is renamed reads_the_root_under_either_option: `$` is now a read under the sealed root, so its TermError::JsonSealedRoot refusals (`$`, `$[*].x`) and the open-root gate count are gone, as on this branch. - given_a_position: reaches_no_object_member takes this branch's form, over `$.a[0]` and `$.h.a[0]`. That covers gates_on_the_elements_selector_below_a_key_step (a gates() assertion), which is removed. - given_an_extracted_node: the walk reads by token, and the $.nested read opens as a list to the one node at the path. - given_gated_walk_rows::open_to_the_reached_nodes_value becomes given_wildcard_rows::open_to_one_value_per_element_or_fail_with_the_bound, this branch's test. With it goes #1358's "ungated rows under a gated key" refusal: no walk is gated now. - given_a_derived_record: rows from read_rows. - given_a_fallback_root::opens_to_what_the_walk_opens_to takes this branch's form (paths compared against JsonPath::select, as a list and as an optional value). - given_rows_the_server_chose: the repeated row is JsonWrongEntry, as febc4b2 decided. #1345's generated oracle (json_tree/generated.rs) already opens through JsonTreeExtracted::from_selector and builds against this branch's types unchanged. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
cipherstash-agent
left a comment
There was a problem hiding this comment.
Automated review attestation (Claude Code, /code-review medium + cross-check-review-findings)
Head reviewed: fe55bf1a4. Result: no new findings. No critical or blocking issues.
What was read at head
place,rebuild_walked,open_subtreeandchild_selector_bytesinpackages/stack-encrypt/src/json/derive.rs.JsonSelector::openandJsonSubtreeKey::openinquery.rs.JsonExtractedandJsonSubtreeinjson/mod.rs.- The
eql-bindingsJsonTreeExtractedchange. - The leakage doc and the changeset.
Checks
- The
->selector check runs before key retrieval. The sealed root opens only for$. - A gated top must carry the path's selector, depth 0, and the last-step position (
reaches). - Below a top, the checks cover consecutive
j, depth, parent-path selector plus decrypted label, and authenticated child counts. The ungated leading-element fix is present (out_of_place). - Key material stays in
Protected.JsonSubtreeKeyDebugandSerializeexpose no PRF or selectors. - The leakage page now matches the code, including the gaps it lists as not covered (#1356, ADR 0010).
- CI is green at head: the nextest, doctest, rustdoc, mutants (0 missed), CRAP, fuzz-regression, Postgres-17 encryption and Go jobs.
Cross-check against existing feedback
The earlier review by @coderdan on this PR (c3d7fee74) raised 6 findings. I checked that findings 1–3 are fixed at head (9ec5c59f1, aa591f240, efc6bba23). Findings 4–6 are accepted or informational on that thread. My own candidates matched those and added nothing material, so I'm not repeating them. Bot comments (changeset-bot, CodeRabbit, mutants) carry no findings. The Codex review was still running when I read the PR.
Non-blocking, already known
JsonWrongEntryfalls through toSTATUS_INTERNALinlanguages/golang/encrypt/guest/src/status.rs. This is unreachable until a Gojson_treereader exists (#1352). Please classify it there.- Repeated or mixed-header rows still return
OpenedType, notJsonWrongEntry. The author deferred this, and it needs a decision on the #1363 wording.
Generated by Claude Code
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 is the issue; feat(stack-encrypt, eql): json_tree tokens derivable from the path, a self tag, client-side wildcards #1363, stacked on this PR through docs(plans): design json_tree tokens derivable from the path alone #1361, fixes it, 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:829 tests run: 829 passed, 1 skippedat the first head;833 tests run: 833 passed, 1 skippedafter the review fixes (9ec5c59f1,aa591f240,efc6bba23; see the review comments)cargo 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 (fixed by #1363, stacked on this PR through #1361), #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