Skip to content

test(stack-encrypt): check the json_tree leakage page against the scheme - #1362

Draft
coderdan wants to merge 1 commit into
fix/json-tree-extracted-entry-checkfrom
test/json-tree-leakage-profile
Draft

coderdan wants to merge 1 commit into
fix/json-tree-extracted-entry-checkfrom
test/json-tree-leakage-profile

Conversation

@coderdan

Copy link
Copy Markdown
Contributor

Summary

EQL v4's json_tree column 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, entry tests/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.rs holds 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: proptest as a dev-dependency. It is already in the workspace graph (stack-auth, stack-guest-abi), so Cargo.lock gains 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 doc was not run. Everything added is under tests/, which rustdoc does not build.
  • Mutants: not run. No file under src/ changed.
  • No changeset, because this is test-only. No skill mentions json_tree (grep -rn "json_tree\|json-tree" skills/ is empty).
  • The positive control is deterministic. The simulator, the mutants and the proptest runner all draw from one fixed seed. The real side's random IVs and nonces reach a feature only through equality patterns, which change only on a random collision, below about 10^-13 at these sizes.

Calibration: which check catches each dropped clause

Every mutant is caught. None is unnoticed.

mutant clause dropped caught by (selection)
selectors_all_distinct, selectors_per_row_only, positions_in_selectors selector equality, column-wide, array positions folded selector_pattern_*, projections, token_reach, …
entry_count_padded entry count entry_counts, …
order_random_permutation, members_unsorted the stored order (#1357) selector_precedence, chain_head_runs, depth_recovery, …
parent_tag_on_every_entry, parent_tag_on_no_entry which entries lack p parent_tag_presence, chain_head_runs
ciphertext_length_bucketed, sealed_root_length_hidden ciphertext length ciphertext_lengths
value_selectors_all_distinct, value_selectors_path_only value-selector equality value_selector_partitions, containment_matches
projection_flat, projection_shuffled element grouping, pair order in q projections
ordering_terms_absent, _unordered, _all_distinct op presence, order, equality ordering_term_order, ordering_matches
ordering_term_length_constant op length (not on the page) ordering_term_lengths
ordering_terms_per_path equal values at two ordered paths share a term ordering_term_order
member_ranks_follow_key_order a member's ordinal is a keyed rank member_ordinals_follow_key_order
sealed_flag_flipped, data_key_shared, keyset_per_row header headers (and parent_tag_equalities for a shared IV)
tokens_per_row, parent_tags_unchained, position_tokens_unchained tokens column-wide and chained token_reach, token_derivability, gated_walk_results
token_reaches_named_nodes_only a token reaches its whole top-level field, not only the path asked (#1360) token_reach
extraction_selector_fresh, queries_nondeterministic -> sends the stored selector; queries are deterministic transcripts, extraction_matches
slot_positions_hidden, gates_dropped gates, positions in the clear transcripts, gated_walk_results
needle_flat, needle_alt_dropped, bound_rank_random needle grouping, alt, the bound's rank transcripts, containment_matches, ordering_term_order

The full table, with every distinguisher that fires, prints with --no-capture on every_clause_is_load_bearing.

Findings against the page

  1. An ordering term's length is not on the page. A CLLW OPE term is 8 × n + 1 bytes for an n-byte encoding: 65 bytes for every number, and 8 × (len + 1) + 1 for 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 by the_ordering_term_length_separates_it_from_the_real_scheme: with only what the page states, exactly ordering_term_lengths separates the worlds.
  2. The page contradicts itself on equal values at two paths. Its summary says "Not revealed at rest: […] whether two different paths hold the same value". Its own op row 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 the op row and passes. A profile that follows the summary sentence (ordering_terms_per_path) is caught by ordering_term_order. The summary sentence needs "except through ordering terms, when two or more paths are ordered".
  3. "The sorted order of their key names" means UTF-8 byte order. It is not jsonb's order (shorter keys first). Read the jsonb way, the profile is caught (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:

  • A token's holder also sees each array element's position and each member's keyed rank, in the ordinals it probes.
  • A scalar containment needle carries an alt projection.
  • "A number's length in digits" is the length of serde_json's text for it, sign and exponent included.
  • The root lacks p under 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 by token_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

  • Start with tests/json_leakage/mod.rs, then profile.rs. The profile and real.rs are meant to change with the 2026-10-24 design change (ordinal-free member tokens, a self tag x, client-side wildcard expansion, no gates). mod.rs lists the steps.
  • Out of reach, with reasons in mod.rs:
    • the inside of an ordering term. Its shared-prefix leakage (which the page admits) is not an exact function of the plaintexts, because of the carry byte.
    • the keyed member rank as a permutation. It is decidable exactly only on wide objects, which have their own column and a bound of (1/8!)^3 < 2^-40.
    • equality of derived tokens across rows, the primitives themselves, SQL-side timing and the budget fallback, and cross-column linkage.
  • Tension, stated and not resolved. fix(stack-encrypt): check that a json_tree read returns the entry asked for #1358's read check requires a subtree's rows to sit at consecutive positions in 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.
  • The profile sorts object members itself rather than trusting serde_json's iteration order. If a build ever turned on serde_json's preserve_order feature, the positive control would fail on the handcrafted rows whose keys are written out of order.

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

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
@changeset-bot

changeset-bot Bot commented Oct 24, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: effe92b

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 24, 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

Copy link
Copy Markdown
Contributor

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

No mutants were generated for the changed lines.

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