Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0b001bf
test(stack-encrypt): compile the float round-trip test after the toke…
coderdan Oct 25, 2026
8faa096
feat(stack-encrypt): cascade a JSON node's children's AEAD tags into …
coderdan Oct 26, 2026
d257963
feat(stack-encrypt): store a JSON document's entries in a random order
coderdan Oct 26, 2026
aaf5da0
feat(stack-encrypt): load the keyset when opening a JSON document wit…
coderdan Oct 26, 2026
9cc5827
test(stack-encrypt): find JSON rows by path, and assert where each re…
coderdan Oct 26, 2026
4440890
docs(json-tree): record the cascade and the random stored order
coderdan Oct 26, 2026
7690219
fix(stack-encrypt): tighten the keyset load and compare projections a…
coderdan Oct 26, 2026
5bafdfc
docs(eql): stop promising document order from the json_tree walks
coderdan Oct 26, 2026
810852c
feat(stack-encrypt): send every JSON needle in a random order of its own
coderdan Oct 26, 2026
3ed9d3b
fix(stack-encrypt): wipe the JSON index's key names, values and data …
coderdan Oct 26, 2026
c18f294
refactor(stack-encrypt): draw the JSON index's permutations with rand…
coderdan Oct 26, 2026
b1e3db7
refactor(stack-encrypt): hold the JSON index's owned documents in Pro…
coderdan Oct 26, 2026
93ccdb9
refactor(stack-encrypt): name the JSON index's selector set and compa…
coderdan Oct 26, 2026
731c4ef
chore: record stack-encrypt's rand dependency in the detached lockfiles
coderdan Oct 26, 2026
2f0aa29
refactor(stack-encrypt): name what the JSON index's AAD, rows and wal…
coderdan Oct 26, 2026
d959826
refactor(stack-encrypt): load a root-open document's keyset through t…
coderdan Oct 26, 2026
f6ffc38
feat(stack-encrypt): load keysets beside the key retrieval, failing o…
coderdan Oct 26, 2026
f2b6730
feat(stack-encrypt): report an extracted JSON entry that does not ope…
coderdan Oct 26, 2026
c7b35aa
feat(stack-encrypt): give an object's members random ordinals
coderdan Oct 26, 2026
b5a63af
fix(eql): return a node's children in ordinal order
coderdan Oct 26, 2026
b540fa0
docs(stack-encrypt): draw the cascade and say what the JSON index cod…
coderdan Oct 26, 2026
b79fec3
style(golang): format the encrypt guest's status test
coderdan Oct 26, 2026
f783f8e
Merge next into feat/json-tree-cascade-and-random-order
coderdan Oct 26, 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
5 changes: 5 additions & 0 deletions .changeset/eql-json-tree-cascade-and-random-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cipherstash/eql": patch
---

EQL v4 `json_tree` (unreleased): a stored document's entries are now in a random order, root first, an object's members get a random permutation as their ordinals (the `o` the server probes a node's children at, which was a keyed rank), and its containment projection `q` is shuffled the same way, so the stored order no longer shows each row's tree, the size of every subtree or the byte order of sibling key names (#1357). A containment or equality needle is sent in a random order of its own too, so a query no longer shows sibling key order either. Every entry's ciphertext now also commits to its children's AEAD tags, so the client checks a subtree read's rows by the chain rather than by their positions, and a row swapped, dropped, added, reordered or moved beneath the node read is refused by the cipher. Nothing stored changes shape; `json_tree_children` returns a node's children in ordinal order (an array's elements by position), where it returned them by stored slot, and no other SQL function changes (three doc comments stop promising document order); documents sealed by the earlier build of this unreleased family no longer open. `->`, `->>` and an ordering on `eql_v4.ord_term(col -> sel)` at a wildcard path now return an unspecified element of the row (the same one every time), not element 0; read every element with `json_tree_path_query`. `->` on an object or array with members no longer opens on the client (the entry's ciphertext commits to its children, which `->` does not return): Stack Encrypt reports `JsonNotALeaf`, which is also what an altered entry looks like through `->`; read a container with a subtree read. `docs/reference/json-tree-leakage.md` says what the stored row still shows and what a read now proves.
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/fuzzing.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Current targets:
| `fuzz:term-decode` | `stack-encrypt` | `term_decode` | the SEM term decoders (`EqualityTerm`, `MatchTerms`, `OreTerm`, `OpeTerm` `from_bytes`) |
| `fuzz:check-record` | `stack-encrypt` | `check_record` | `dynamic::record::check_record` over an `Arbitrary`-derived plan and tree, against a model of the record rules (structure-aware) |
| `fuzz:plan-build` | `stack-encrypt` | `plan_build` | `plan::FieldsBuilder::build` over `Arbitrary`-derived untrusted names (context, fields, identities); a built field's label must render and parse back, and passthrough names are never refused (structure-aware) |
| `fuzz:json-wire` | `stack-encrypt` | `json_wire_decode` | the JSON index's wire decoders (`JsonDocument`, `JsonNode`, `JsonProjection`, `JsonLeafQuery` from JSON text; `JsonHeader::from_bytes`), over base64 tags and base85 ciphertexts; an accepted document or node must round-trip through its wire form |
| `fuzz:json-wire` | `stack-encrypt` | `json_wire_decode` | the JSON index's wire decoders (`JsonDocument`, `JsonNode`, `JsonSelectorSet`, `JsonLeafQuery` from JSON text; `JsonHeader::from_bytes`), over base64 tags and base85 ciphertexts; an accepted document or node must round-trip through its wire form |

Each target is a few lines — `libfuzzer-sys` hands a `&str` (or `&[u8]`
for the byte decoders) to the parser via the `arbitrary` crate:
Expand Down
26 changes: 24 additions & 2 deletions docs/plans/2026-10-17-json-tree-index.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ The "before" column is the profile at `9a7ca372c`; the "after" column is the por
| `selector_precedence` | kept | which of two selectors comes first across rows: sibling key order |
| `depth_recovery` | kept | an attack that reconstructs every row's depth sequence from the stored order alone |
| `token_reach` | kept | per read, row and token: the node it finds and everything its walk links, with parents and depths |
| `ordinal_patterns` | kept | elements' ordinals in position order; members ranked consistently under one token |
| `ordinal_patterns` | replaced by `member_order_shared_across_rows` | whether one token's objects give their members one order of ordinals across rows, reported only below a `2⁻⁴⁰` chance bound: members' ordinals are now a random permutation per row |
| `member_ordinals_follow_key_order` | kept | whether members' ordinals follow key order, reported only below a `2⁻⁴⁰` chance bound |
| `token_derivability` | kept | which reads' tokens the database can derive from which others' (now by the member rule and by the element rule at an index below 4, from the tokens that find a node in some row) |
| `transcripts` | kept | every query term with its tags named: determinism, shared selectors, needles; a read is now its token list and budget instead of a token and gates |
Expand Down
3 changes: 3 additions & 0 deletions languages/golang/encrypt/guest/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions languages/golang/encrypt/guest/src/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ use stack_encrypt::sem::MatchOptions;
use stack_encrypt::target::IndexSpec;
use stack_encrypt::ErrorPayload;
use stack_encrypt::{CipherText, KeysetCipher, Label, Node, SealedValue, StackCipherText};
use stack_kms::DataKeySource;
use stack_kms::{DataKeySource, IndexKeySource};
use zeroize::Zeroizing;

use stack_guest_abi::last_error;
Expand Down Expand Up @@ -101,7 +101,7 @@ pub async fn term<K>(
kind: u32,
) -> Result<Vec<u8>, u32>
where
K: DataKeySource + Sync + 'static,
K: DataKeySource + IndexKeySource + Sync + 'static,
{
// The same proof every stack-encrypt leaf demands: an empty context is
// `STATUS_ENCODING` here, before any derivation.
Expand Down Expand Up @@ -178,7 +178,7 @@ pub async fn encrypt_record<K>(
plan: &[u8],
) -> Result<Vec<u8>, u32>
where
K: DataKeySource + Sync + 'static,
K: DataKeySource + IndexKeySource + Sync + 'static,
{
let plan = parse_plan(plan)?;
let tree =
Expand Down Expand Up @@ -213,7 +213,7 @@ pub async fn decrypt_record<K>(
expected: Option<Label>,
) -> Result<Vec<u8>, u32>
where
K: DataKeySource + Sync + 'static,
K: DataKeySource + IndexKeySource + Sync + 'static,
{
let plan = parse_plan(plan)?;
let value =
Expand Down Expand Up @@ -255,7 +255,7 @@ pub async fn query_term<K>(
operator: &[u8],
) -> Result<Vec<u8>, u32>
where
K: DataKeySource + Sync + 'static,
K: DataKeySource + IndexKeySource + Sync + 'static,
{
let (plan, field, operator) = parse_query(plan, field, operator)?;
let term = dynamic::query_term(
Expand Down
24 changes: 24 additions & 0 deletions languages/golang/encrypt/guest/src/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ pub fn status_for_error(error: &stack_encrypt::Error) -> u32 {
// retrieved, like a foreign keyset and unlike a wrong-context open.
stack_encrypt::Error::ContextMismatch { .. } => STATUS_CONTEXT_MISMATCH,
stack_encrypt::Error::Kms(kms) => status_for_kms(kms),
// A keyset a JSON document needed could not be loaded: the load's
// own error says what the host can act on.
stack_encrypt::Error::KeysetLoad { source, .. } => status_for_error(source),
// A context that renders past ZeroKMS's descriptor limit is the
// caller's input, refused before any request is sent.
stack_encrypt::Error::DescriptorTooLong { .. } => STATUS_ENCODING,
Expand All @@ -141,6 +144,10 @@ pub fn status_for_error(error: &stack_encrypt::Error) -> u32 {
// one it was opened as: sealed as that type, not tampered with, so
// the caller's input and not STATUS_AUTH.
stack_encrypt::Error::OpenedType { .. } => STATUS_ENCODING,
// The entry `->` returned did not open alone: a container with
// children asked for as a scalar, or an altered entry. The client
// cannot tell which, so neither STATUS_AUTH nor a claim of either.
stack_encrypt::Error::JsonNotALeaf => STATUS_ENCODING,
_ => STATUS_INTERNAL,
}
}
Expand Down Expand Up @@ -283,13 +290,30 @@ mod tests {
ViturRequestError::new(kind, "stubbed", std::io::Error::other("boom"))
}

#[test]
fn a_failed_keyset_load_maps_as_its_cause() {
let load = stack_encrypt::Error::KeysetLoad {
keyset_id: uuid::Uuid::from_u128(1),
source: std::sync::Arc::new(stack_encrypt::Error::Kms(stack_kms::Error::LoadKeyset(
stack_kms::LoadKeysetError::Unauthorized(vitur(
ViturRequestErrorKind::Unauthorized,
)),
))),
};
assert_eq!(status_for_error(&load), STATUS_KMS_UNAUTHORIZED);
}

#[test]
fn aead_and_composition_errors_map_to_the_vitaminc_codes() {
assert_eq!(status_for_error(&stack_encrypt::Error::Aead), STATUS_AUTH);
assert_eq!(
status_for_error(&stack_encrypt::Error::OpenedType { expected: "i32" }),
STATUS_ENCODING
);
assert_eq!(
status_for_error(&stack_encrypt::Error::JsonNotALeaf),
STATUS_ENCODING
);
assert_eq!(
status_for_error(&stack_encrypt::Error::DescriptorTooLong { len: 513 }),
STATUS_ENCODING
Expand Down
3 changes: 3 additions & 0 deletions packages/eql/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion packages/eql/crates/eql-bindings/src/v4/json_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ pub struct JsonTreeKeyHeader(pub Base64Text);
/// `public.eql_v4_json_tree`: a JSON tree document, `{v, i, h, sv:
/// [entry], q: group}`. Strict. `h` is the document's key header, stored
/// once (every entry is sealed under the document's one data key); `sv`
/// holds one entry per node in depth-first order, the root first; `q` is
/// holds one entry per node, the root first and the rest in a random order
/// drawn when the document was sealed; `q` is
/// the document's containment projection, one `{s, vs}` pair per node
/// nested by array element, which the client writes beside the entries
/// and which is the only place the value selectors live: the server's
Expand Down
Loading
Loading