Skip to content

json_tree: a subtree read sends a token for the whole top-level field because member tokens mix in the sibling ordinal — make tokens derivable and drop gates #1360

Open

Description

@coderdan

Background

EQL (Encrypt Query Language) is the SQL library we install into a customer's PostgreSQL database. EQL v4 has a new column type, public.eql_v4_json_tree, that stores an encrypted JSON document as one entry per JSON node, so the database can answer JSON queries without the encryption key. Stack Encrypt, our Rust encryption engine (packages/stack-encrypt), writes these documents and reads the answers back. None of this is released yet.

To read part of a document (a "subtree read", for example the value at $.profile.address.city), the client sends the database a token: a 16-byte secret derived from the path. The database uses it to find the node in each row and walk the entries beneath it. A token is a capability over the structure under its node (which nodes exist and how they nest, not their values), in every row of the column, and it never expires. So the less a read's token covers, the better.

Each node's token is a hash of its parent's token. Today an object member's token also mixes in its ordinal: its place among its siblings, in an order that depends on which other keys the object has in that row (child_token and shuffled_ordinals in packages/stack-encrypt/src/json/derive.rs).

Problem

The client does not know which sibling keys a row has, so it cannot work out an object member's ordinal, and so it cannot derive the token of any node below a top-level field (except through array positions). Instead it sends the deepest token it can derive plus a list of "gates", and the database walks down from there (subtree_key_for in derive.rs, eql_v4.json_tree_subtree_at in packages/eql/src/v3/json_tree/functions.sql). Step by step, for a read of $.profile.address.city:

  1. The client derives the token for $.profile, the top-level field, and two gates (the selectors of $.profile.address and $.profile.address.city).
  2. The database receives $.profile's token.
  3. From then on, for as long as the column exists, the database can map the full structure of $.profile in every row, although the caller only asked for one leaf.

Two more problems come from the same cause:

Who is affected: every customer who reads part of a json_tree column. Nothing catches it, because every answer is correct; the cost is what the database learns. The leakage page (packages/eql/docs/reference/json-tree-leakage.md) states the current behaviour.

Proposal

Agreed with the owner on 2026-10-24. The full write-up, with formulas, the SQL, the client checks, the leakage and open questions, is the "Design change (2026-10-24)" section of docs/plans/2026-10-17-json-tree-index.md, in the draft pull request linked below.

  1. Make an object member's token independent of its siblings. Derive it from the parent's token and the member's own selector (a keyed hash of its path), without the ordinal. Array elements keep their index. Ordinals stay, but only for listing a node's children. Then the client can derive the token of any path without a wildcard from the path alone, and a token always covers exactly the node asked for.
  2. Add a "self tag" to every entry: an 8-byte hash of the entry's own token, salted per document, so the database can find a node from its own token. A read then returns the node itself first, then what is beneath it, so {}, [], a value and "missing" are four different answers, as PostgreSQL's own jsonb gives. It costs about 10% more storage, to be measured.
  3. Expand wildcards on the client. For $.items[*].price, send one token per index up to a bound (64 by default, configurable), plus one "probe" token for the element just past the bound. If the probe finds something, fail loudly naming the bound; never return a partial answer. Remove the gates and json_tree_subtree_at; the read becomes json_tree_subtree(doc, tokens, budget).
  4. Keep the client's checks from fix(stack-encrypt): check that a json_tree read returns the entry asked for #1358, adapted to the new read (the write-up recommends keeping the check that a subtree's rows are consecutive, since the self tag is not authenticated).
  5. Regenerate every fixture, pinned test vector and benchmark table: the stored format changes, and nothing stored breaks because it is unreleased.
  6. Measure in cipherstash/benches (branch feat/json-tree-benches): stored size, read latency against today's walk at bounds of 8, 64 and 256, probe cost and ingest time.

Interim: none needed. Nothing is released, and #1358 keeps reads correct in the meantime.

Relationship to other work

Activity

  1. added
    enhancementNew feature or request
    rustPull requests that update Rust code
    needs-triageMaintainer needs to evaluate the issue
    on Oct 24, 2026
  2. changed the title [-]json_tree: a subtree read sends a token for the whole top-level field because member tokens mix in the stored ordinal — make tokens derivable and drop gates[/-] [+]json_tree: a subtree read sends a token for the whole top-level field because member tokens mix in the sibling ordinal — make tokens derivable and drop gates[/+] on Oct 24, 2026
  3. coderdan commented on Oct 24, 2026

    @coderdan
    ContributorAuthor

    The design write-up is in draft PR #1361 (section "Design change (2026-10-24)" of docs/plans/2026-10-17-json-tree-index.md, plus the leakage page). Its open questions need answers before implementation starts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    SDKenhancementNew feature or requestneeds-triageMaintainer needs to evaluate the issuerustPull requests that update Rust code

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions