Repository navigation
Conversation
The leakage page for EQL v4's json_tree index states in prose what a database holding the column learns at rest and from each query. Nothing held the code to it: a leak the page leaves out would pass every test. This adds an executable form of the page. `profile.rs` writes each of its sentences as a clause of a leakage function over the plaintext alone, with every keyed value replaced by an opaque name that is equal exactly where the real bytes are. A simulator builds a wire-shaped column and query transcript from that function's output and seeded randomness only, chaining parent tags with the real hash below every token the profile says a query reaches. Twenty-seven distinguishers, each an exact feature an observer of the column could compute (including the database's own walk of the parent tags from a token it was sent), compare the simulated column with one the crate actually sealed under the fake key source. Three kinds of check make that mean something: the full profile is indistinguishable from the real scheme on handcrafted and 48 random columns; each of 34 mutant profiles, one clause dropped, is told apart by at least one distinguisher; and the page as written (without the one clause it lacks) is told apart by exactly the distinguisher that sees the missing clause. What it found: an ordering term's length (65 bytes for a number, 8 * (len + 1) + 1 for a string) is not on the page; the page's summary "not revealed at rest: whether two different paths hold the same value" contradicts its own op row, since equal values at two ordered paths have equal terms; and "sorted order of their key names" is UTF-8 byte order, not jsonb's. The two issues it was built to check (#1357, the stored order reveals shape; #1360, a subtree read's token reaches its whole top-level field) come out as the page states them. The page is left unchanged. The profile and the real-side adapter are expected to change with the 2026-10-24 design change (ordinal-free member tokens, a self tag, no gates); the module docs say how. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
|
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 |
Contributor
Mutation testing (cargo-mutants,
|
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 type stores an encrypted JSON document so PostgreSQL can search it without the key.packages/eql/docs/reference/json-tree-leakage.md(the "leakage page") says in prose what the database learns from the stored rows and from each query. Until now nothing checked the code against it: if the scheme revealed something the page leaves out, every test would still pass.This PR turns the page into a test. It writes the page's claims as code (a "profile"), builds a fake column and fake query log from those claims plus randomness alone (a "simulator"), and runs 27 checks an observer of the database could compute (the "distinguishers") on the fake column and on a real one sealed by Stack Encrypt. If the real scheme leaks more than the page says, some check tells the two apart. It found three gaps in the page, listed below. The page itself is left unchanged.
Changes
packages/stack-encrypt/tests/json_leakage/(new test module, entrytests/json_leakage.rs):profile.rs: the leakage function over plaintext only. Every field is one clause, with the page sentence it encodes quoted in its doc comment. Every keyed value (selector, value selector, token, ordering term) is an opaque integer, equal exactly where the real bytes are.simulator.rs: builds wire-shaped rows and query terms from the profile output and a seeded RNG. It never sees plaintext or keys. Parent tags are chained with the real hash below each token a query sends, as far as the profile says the token reaches. Everywhere else they are random.real.rs: a thin adapter that seals the documents and derives the query terms through the public API, using the fake key source the other JSON tests use.observe.rs: parses both worlds' wire JSON into one shape.distinguishers.rs: 27 exact features compared for equality. They cover field presence, lengths, selector, value-selector and parent-tag equality patterns, the header, the projection, what the stored order reveals (top-level subtree runs, sibling key precedence, an order-only depth-recovery attack), and the database's own walk from each token it was sent, using the real hash. They also cover token derivability, the gated walk, transcripts and the result sets. One feature needs a probability bound, which is written beside it.mutants.rs: 34 mutant profiles, each dropping or coarsening one clause.corpus.rs: four handcrafted columns and a proptest generator of random columns with a shared key vocabulary.calibration.rs: the tests.mod.rsholds the README-style documentation: how the pieces fit, how to add a clause when the format changes, how to read a failure, what is out of reach, and the tension with fix(stack-encrypt): check that a json_tree read returns the entry asked for #1358's check.packages/stack-encrypt/Cargo.toml:proptestas a dev-dependency. It is already in the workspace graph (stack-auth, stack-guest-abi), soCargo.lockgains one line.Verification
mise x --env test -- cargo nextest run -p stack-encrypt --all-features:Summary [ 491.510s] 836 tests run: 836 passed (1 slow), 1 skipped. The run was taken before one last speed-up of one distinguisher. That change was re-verified with the json_leakage binary alone, below.mise x --env test -- cargo nextest run -p stack-encrypt --all-features -E 'binary(json_leakage)':Summary [ 6.324s] 7 tests run: 7 passed, 0 skipped. The slowest test is the calibration, at 6.3 s alone. In the full parallel run it took 42 s under contention, before the speed-up.cargo clippy -p stack-encrypt --all-features --all-targets -- -D warnings: clean.cargo fmt --check -p stack-encrypt: clean.mise run docwas not run. Everything added is undertests/, which rustdoc does not build.src/changed.json_tree(grep -rn "json_tree\|json-tree" skills/is empty).Calibration: which check catches each dropped clause
Every mutant is caught. None is unnoticed.
selectors_all_distinct,selectors_per_row_only,positions_in_selectorsselector_pattern_*,projections,token_reach, …entry_count_paddedentry_counts, …order_random_permutation,members_unsortedselector_precedence,chain_head_runs,depth_recovery, …parent_tag_on_every_entry,parent_tag_on_no_entrypparent_tag_presence,chain_head_runsciphertext_length_bucketed,sealed_root_length_hiddenciphertext_lengthsvalue_selectors_all_distinct,value_selectors_path_onlyvalue_selector_partitions,containment_matchesprojection_flat,projection_shuffledqprojectionsordering_terms_absent,_unordered,_all_distinctoppresence, order, equalityordering_term_order,ordering_matchesordering_term_length_constantoplength (not on the page)ordering_term_lengthsordering_terms_per_pathordering_term_ordermember_ranks_follow_key_ordermember_ordinals_follow_key_ordersealed_flag_flipped,data_key_shared,keyset_per_rowheaders(andparent_tag_equalitiesfor a shared IV)tokens_per_row,parent_tags_unchained,position_tokens_unchainedtoken_reach,token_derivability,gated_walk_resultstoken_reaches_named_nodes_onlytoken_reachextraction_selector_fresh,queries_nondeterministic->sends the stored selector; queries are deterministictranscripts,extraction_matchesslot_positions_hidden,gates_droppedtranscripts,gated_walk_resultsneedle_flat,needle_alt_dropped,bound_rank_randomalt, the bound's ranktranscripts,containment_matches,ordering_term_orderThe full table, with every distinguisher that fires, prints with
--no-captureonevery_clause_is_load_bearing.Findings against the page
8 × n + 1bytes for ann-byte encoding: 65 bytes for every number, and8 × (len + 1) + 1for a string. So at an ordered path the term tells a number from a string, not just "a number or a string" as the page says (a 7-byte string is the exception), and it repeats the string's length. Held bythe_ordering_term_length_separates_it_from_the_real_scheme: with only what the page states, exactlyordering_term_lengthsseparates the worlds.oprow says "leaves at two ordered paths compare with each other too", and the real terms for equal values at two ordered paths are equal. The profile follows theoprow and passes. A profile that follows the summary sentence (ordering_terms_per_path) is caught byordering_term_order. The summary sentence needs "except through ordering terms, when two or more paths are ordered".given_the_jsonb_reading_of_the_key_order). A reader inferring key names from the order needs to know which sort.Smaller vaguenesses, where the profile is as precise as the code forces and says so in the clause:
altprojection.punder the sealed root too.The two issues the harness was built to check match the page. #1357: the stored order does reveal shape, the page admits it, and the order clause is load-bearing. An order-only attack recovers every row's depth sequence on a column whose rows vary (
the_order_reveals_the_shape). #1360: a gated subtree read's token reaches the whole top-level field in every row, not only the path asked. The page says exactly that, and the real walk from the real token confirms it. A page that claimed the narrower reach would be caught bytoken_reach.Because of findings 1 to 3, the "This page is checked by …" pointer was not added to the leakage page.
Related
Refs #1357, #1360, #1355, #1356, #1358.
Review notes
tests/json_leakage/mod.rs, thenprofile.rs. The profile andreal.rsare meant to change with the 2026-10-24 design change (ordinal-free member tokens, a self tagx, client-side wildcard expansion, no gates).mod.rslists the steps.mod.rs:(1/8!)^3 < 2^-40.sv. A stored order independent of shape, which is json_tree: the stored entry order reveals each row's shape and the alphabetical order of sibling keys #1357's goal, would scatter them. Both cannot hold as written.preserve_orderfeature, the positive control would fail on the handcrafted rows whose keys are written out of order.https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu