Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
effe92b
test(stack-encrypt): check the json_tree leakage page against the scheme
coderdan Oct 24, 2026
a0261c8
Merge branch 'fix/json-tree-extracted-entry-check' into test/json-tre…
coderdan Oct 25, 2026
9a7ca37
Merge branch 'fix/json-tree-extracted-entry-check' into test/json-tre…
coderdan Oct 25, 2026
8489400
Merge branch 'feat/json-tree-derivable-tokens' into test/json-tree-le…
coderdan Oct 25, 2026
839c0f6
test(stack-encrypt): port the json_tree leakage profile to the #1363 …
coderdan Oct 25, 2026
fa26cb5
test(stack-encrypt): derive child tokens only from tokens that find a…
coderdan Oct 25, 2026
09a9dfc
docs(plans): compare the json_tree leakage profile before and after #…
coderdan Oct 25, 2026
561f273
Merge branch 'feat/json-tree-derivable-tokens' into test/json-tree-le…
coderdan Oct 25, 2026
64821b0
docs(plans): lead the leakage before-and-after note with the net
coderdan Oct 25, 2026
a386b9c
docs(plans): say which kind of absence each (none) in the clause tabl…
coderdan Oct 25, 2026
d6f02a4
docs(plans): an absent old-profile clause reads 'not measured', not '…
coderdan Oct 25, 2026
bdea70a
Merge branch 'feat/json-tree-derivable-tokens' into test/json-tree-le…
coderdan Oct 25, 2026
7509600
test(stack-encrypt): hold the ordering terms' shared prefixes in the …
coderdan Oct 25, 2026
076a921
test(stack-encrypt): compare the leakage harness's values across kinds
coderdan Oct 25, 2026
e52fe64
test(stack-encrypt): check the wire field names of every query term a…
coderdan Oct 25, 2026
6b38463
test(stack-encrypt): let token_derivability try the element rule past…
coderdan Oct 25, 2026
7ca5b0d
test(stack-encrypt): give the leakage profile's four unchecked clause…
coderdan Oct 25, 2026
faff233
test(stack-encrypt): widen the leakage corpus to the clauses it never…
coderdan Oct 25, 2026
981360f
test(stack-encrypt): say #1363 corrected the probe comment, and give …
coderdan Oct 25, 2026
5455ae0
docs(plans): correct the leakage note's net, and record the review
coderdan Oct 25, 2026
294f03e
Merge branch 'feat/json-tree-derivable-tokens' into test/json-tree-le…
coderdan Oct 25, 2026
905791d
test(stack-encrypt): state what a path ordered over numbers and strin…
coderdan Oct 25, 2026
7c2f470
test(stack-encrypt): hold the page's "equality is as a 64-bit float" …
coderdan Oct 25, 2026
e50c18b
docs(eql): say the ordering terms' shared prefix is CLLW's known profile
coderdan Oct 25, 2026
fd22900
docs(plans): record the owner's decisions in the leakage note
coderdan Oct 25, 2026
7f7f5ce
Merge branch 'feat/json-tree-derivable-tokens' into test/json-tree-le…
coderdan Oct 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 224 additions & 0 deletions docs/plans/2026-10-25-json-tree-leakage-before-and-after.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/eql/docs/reference/json-tree-leakage.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Someone who holds the column and no key, such as a database administrator, a bac
| `p`, the parent tag below the chain's heads | Nothing beyond the entry order on its own. It is salted by the document's `iv`, so the same position in two rows is unrelated bytes, and it does not say which entry is the parent. Which entries carry no `p` does show: with the sealed root on, those are the root's direct members, and since everything beneath a member follows it, **the node count of each top-level field's subtree** can be read off a single row exactly. With the sealed root off, only the root lacks one. |
| `x`, the self tag on every entry | Nothing on its own. It is a hash of the entry's own token salted by the document's `iv`, so it is unrelated bytes in every row and links no entry to another, and since every entry carries one, its presence marks none. Given a token, it is how the database finds that token's node in any row. |
| each ciphertext's length, and the number of entries | **The node count**, and for each node the length of its key name (for an object member) plus, for a scalar, the length of its value, exactly, less the AEAD's fixed overhead. A string's length in bytes and a number's length in digits are therefore visible per node. With the sealed root on, the root entry's length is the document's length as JSON. |
| `op`, the ordering term, only on number and string leaves at the paths the index orders | **The order of that leaf's value among every row that holds one, and which rows hold equal values.** The term is order-preserving encryption (CLLW OPE) under one key per column, so leaves at two ordered paths compare with each other too, and two ordered paths holding equal values carry equal terms. Strings are ordered and compared after their case and accents are folded, so `"Abc"`, `"abc"` and `"ábc"` get the same term: the equality it shows is of folded values. It is deterministic and not a random value: two values whose encodings share leading bytes (for a string, its first characters) get terms that share leading bytes, so the term also shows how long that common prefix is. **Its length is the leaf's length**: one byte per bit of the value's order encoding plus one, so 65 bytes for every number and `8 × (n + 1) + 1` bytes for a string whose folded UTF-8 form is `n` bytes. A term's presence at an ordered path also says the leaf is a number or a string. By default no path is ordered; see the options below. |
| `op`, the ordering term, only on number and string leaves at the paths the index orders | **The order of that leaf's value among every row that holds one, and which rows hold equal values.** The term is order-preserving encryption (CLLW OPE) under one key per column for each kind of leaf, so leaves at two ordered paths compare with each other too, and two ordered paths holding equal values carry equal terms. A number's equality is as a 64-bit float: two different integers past 2⁵³ can round to one float, and then they share a term and tie under `ORDER BY` while their value selectors differ. Strings are ordered and compared after their case and accents are folded, so `"Abc"`, `"abc"` and `"ábc"` get the same term: the equality it shows is of folded values. It is deterministic and not a random value: two values whose encodings share leading bits (for a number, its sign and then its exponent's leading bits; for a string, its first characters) get terms that share leading bytes, one byte per bit, so the term also shows how long that common prefix is, to the bit. So the stored terms alone sort the numbers at the ordered paths into signs and binary orders of magnitude, and show how far into their folded text two strings agree. This is CLLW's known profile; a scheme that hides the prefix is planned for a later version. **Its length is the leaf's length**: one byte per bit of the value's order encoding plus one, so 65 bytes for every number and `8 × (n + 1) + 1` bytes for a string whose folded UTF-8 form is `n` bytes. A term's presence at an ordered path also says the leaf is a number or a string. At an ordered path whose values are numbers in some rows and strings in others, the order between a number and a string depends on the column key and is not jsonb's (jsonb sorts numbers before strings); within each kind the order is the values'. The two kinds' terms are under different keys, so the terms also show which leaves are numbers and which are strings, and the strings sort together, as one block that the key places below the numbers, between the negative and the non-negative ones, or above them. By default no path is ordered; see the options below. |
| `h`, the header | The keyset the data key was minted under, the data key's identifier (unique to the row), and whether the root is sealed. `v` and `i` are the version and the table and column identifier every EQL value carries. |

Not revealed at rest: any value's or key name's text, and, except through the ordering terms at the paths the index orders, whether two different paths hold the same value. Revealed at rest, by the entry order, although no field stores it: the shape of every row, the parent of every node, every array's length, and the byte order of sibling key names.
Expand All @@ -36,7 +36,7 @@ A query hands the database a term derived from the plaintext it asks about, and
- **Ordering at a path (`<`, `<=`, `>`, `>=`).** The path's selector and the bound's own ordering term, which compares with the stored terms exactly as they compare with each other: the database learns the bound's place in the order of the stored values at every ordered path, and whether it equals one. `ORDER BY ord_term(…)` sends no term and shows nothing the stored terms do not.
- **A subtree read (`json_tree_subtree`, `json_tree_children`, `json_tree_child_count`).** A **token** for exactly the node the path names, which the client derives from the path alone. Reading `$.profile.address.city` sends that leaf's token and nothing that covers `$.profile`. No token over a top-level field or an array leaves the client unless that field or array is itself what is read.
- **A wildcard** (`$.items[*].price`) is expanded by the client into one token per concrete path, up to a bound `N` (64 by default, set by the caller), plus a **probe** token for the element at index `N` of every array a wildcard expands. A probe that finds an element means the array is longer than the bound, and the read fails rather than return part of the answer. So the database learns which of the nodes exist in each row, which shows, up to `N`, which elements hold the rest of the path (each array's length, where the path ends at the element; the entry order already shows it), whether each array is longer than `N`, and the structure beneath each node it finds. It learns nothing about the elements' sibling fields. In a row where a probe finds an element, the database also walks that element's subtree, although the read then fails. That exception is in the token, not in what the SQL function does with it: a probe is the element's own token, so the database can find that element in any row, enumerate beneath it and derive its descendants' tokens, in rows written later too, whatever the function returns. It holds for any read that sends the element's token. Two wildcard levels send `N² + N + 1` tokens, `N + 1` of them probes, one at index `N` of each array the read expands: the `N` inner arrays and the outer one.
- **What a token is.** A capability over the structure of its node's subtree in *every row of the column*: whoever holds it can find that node in any row (through its self tag `x`), see how the nodes beneath it nest, link each one to its parent, and derive the tokens of the nodes beneath. It opens no value. Tokens are column-wide and do not expire, so a token sent once is useful to the database for as long as the column lives. Send a token to the database in a query and nowhere else.
- **What a token is.** A capability over the structure of its node's subtree in *every row of the column*: whoever holds it can find that node in any row (through its self tag `x`), see how the nodes beneath it nest, link each one to its parent, and derive the tokens of the nodes beneath. It also learns whether each node beneath is an object member or an array element, and each element's index: nothing stored says which a node is, but to derive a node's token the database tests its self tag against the member rule and then the element rule, and the one that matches says which; an element's index is the ordinal it is found at. It opens no value. Tokens are column-wide and do not expire, so a token sent once is useful to the database for as long as the column lives. Send a token to the database in a query and nowhere else.
- **The root.** A read of `$` sends the root's token, and a read of anything below `$` does not. With the sealed root on (the default) that token covers no document's structure: it finds the root entry, whose ciphertext is the document, and nothing beneath it, since the top-level fields chain from nothing. With it off, the root's token covers every row's whole document.
- **`json_tree_children` and `json_tree_child_count`** take one token, of any node a path names, and send nothing else. Under the sealed root the root's token enumerates nothing (its count is NULL), for the same reason.

Expand Down
10 changes: 6 additions & 4 deletions packages/stack-encrypt/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,12 @@ tokio = { workspace = true, features = ["rt", "macros"] }
# Compile-fail tests for the derive diagnostics (`tests/ui`).
trybuild = "1"
# Property tests of the JSON index's derivations and reads
# (tests/json_properties.rs), and the generated containment property in
# tests/json_index.rs. Default features off, as stack-guest-abi takes it:
# `std` carries the failure persistence and the ChaCha RNG the runs are
# seeded with, and nothing else is needed.
# (tests/json_properties.rs), the generated containment property in
# tests/json_index.rs, and the JSON index's executable leakage profile
# (tests/json_leakage): random columns for its positive control, and the
# seeded generator its simulator draws from. Default features off, as
# stack-guest-abi takes it: `std` carries the failure persistence and the
# ChaCha RNG the runs are seeded with, and nothing else is needed.
proptest = { version = "1.7", default-features = false, features = ["std"] }

# The derive diagnostics are recorded with every feature on, as `test:unit`
Expand Down
6 changes: 6 additions & 0 deletions packages/stack-encrypt/tests/json_leakage.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
//! An executable leakage profile for the JSON index (EQL v4 `json_tree`).
//! See `json_leakage/mod.rs` for what it is and how to read it.

mod common;
#[path = "json_leakage/mod.rs"]
mod leakage;
Loading
Loading