Repository navigation
Conversation
|
Important Review skippedToo many files! This PR contains 741 files, which is 641 over the limit of 100. To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch. Upgrade to a paid plan to raise the limit. Usage-priced reviews support at most 300 files.
|
🦋 Changeset detectedLatest commit: b104982 The changes in this PR will be included in the next version bump. This PR includes changesets to release 27 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Mutation testing (cargo-mutants,
|
| caught | missed | unviable | timeout |
|---|---|---|---|
| 583 | 0 | 409 | 0 |
cargo-mutants exited with no surviving mutants recorded. The run may not have completed cleanly; check the workflow run logs.
…n the code is generated gensupport converts a built-in integer to a kind of 64 bits or fewer only, and intoKind took any integer target, so a field of a 128-bit EQL type would generate, build, and fail on every Encrypt. No producible EQL type has a 128-bit kind today; intoKind now refuses one, and lowering names the field and both kinds.
…sed when it seals too The breaking note named the open path only. A host that sends a narrow leaf for a field declared int32 is now refused with FieldType at seal, where it was widened; Go code generated before still seals, because gensupport converts to the declared width. ValueFrame::of's errors no longer name a 128-bit integer and a date as outside the model.
feat(stack-encrypt)!: hold a dynamic plaintext as its frame, not a value tree
feat(stack-encrypt,golang): give every Go integer width, dates, timestamps, decimals and 128-bit integers their own kinds
…rough fields unauthenticated A passthrough field comes back beside the decrypted ones in one map, but nothing authenticates it: a writer to the store can change it and decrypt succeeds. The decrypt docs now say so.
… as OpenedType, not Aead A typed decrypt collapsed every failure vitaminc reports into Error::Aead, the error for a wrong key, a wrong context or a tampered ciphertext. That included a leaf that authenticated and only held another type than the target reads: an integer opened as String, say. The Go guest then reported a value sealed as another type as STATUS_AUTH, i.e. tampering. vitaminc's DecryptError already tells the sink's refusal apart from the cipher's, so open_keyed now maps DecryptError::Sink to the new Error::OpenedType, naming only the type the target reads (never the map key a DecodeError can carry). Every other failure stays Aead. Telling a wrong-type reader apart reveals nothing, because the sink runs only after every leaf has authenticated. The guest maps OpenedType to STATUS_ENCODING, as it already maps a typed record field that opens to another kind. Closes #1118.
fix(deps): take chacha20 0.10.2, since 0.10.0 and 0.10.1 were yanked
…m Aead A container's leaves authenticate one by one, but its membership does not: a map entry's AAD binds only its key, and a sequence element's binds no index. So an entry removed or repeated, or elements reordered or dropped, still authenticate, and the target's refusal of the result (MissingField, DuplicateKey, or Expected on a reshaped container) can be tampering. open_keyed now reports OpenedType only when the tree is one leaf and the target refused its kind. Every other failure, every refusal of a container's value included, stays Aead. Found by review on #1256.
…ed-passthrough note A plan's context field is stored as a passthrough, but a change to it is caught: every other field was sealed under it, so decrypt fails with ContextMismatch against the expected context, or the sealed fields do not open. Found by review on #1255.
…part from Aead", and say why This reverts commit 8e904f8. A container's membership and order are deliberately not authenticated: a stored sequence is a set of rows and a stored map a row's columns, and a database returns rows in any order and a query selects some columns, not all. Authenticating position or presence would break exactly those reads. So a target refusing a container's shape is a shape mismatch, OpenedType, not tampering. The rationale now sits on open_keyed, on Error::OpenedType and in the CHANGELOG, and a test opens a Vec<u32> as u32 and expects OpenedType, so a later review that reads the missing membership check as a defect meets the reason first.
…cated docs(stack-encrypt): say that dynamic::record::decrypt returns passthrough fields unauthenticated
…at is not a finding Agent reviews have flagged, four or five times, that a stored container's membership and order are not authenticated. That is deliberate (vitaminc ADR 0002): stored values are rows and columns, read in any order and in part. stack-encrypt ADR 0010 applies it to what Stack Encrypt stores: each leaf is bound to its context, key, role, type and version; a container's membership and order, a passthrough field (except a context field), and which row a leaf belongs to are not. AGENTS.md gains "Review guidelines" with these as known non-findings (Codex reads it; CLAUDE.md imports it for Claude Code's /code-review), and .github/copilot-instructions.md repeats them for Copilot.
…ild may break The record fixtures prove that two authors of one record agree today, and are regenerated when the encoding moves, so a change that breaks rows already stored can be regenerated away. stored_rows_v1.json is the opposite: rows the next build stored under the deterministic key source, written once by an ignored test that refuses when the file exists, and never regenerated. STACK_ENCRYPT_UPDATE_FIXTURES does not touch it. Every case must open to the frame it opened to, and sealing its source again must give the same tree apart from the leaves' nonces: the same shape and keys, terms and passthroughs. The cases cover every value kind with every index it admits, the non-default match options, composites with empty containers, an untyped field, a renamed field, a passthrough, three-segment labels, one and two extension parts, a context field, and a batch. A one-byte change to a stored term or leaf fails the test. Refs #1119.
…ust and Go v4_rows.json proves the Go SDK and Rust agree today and is regenerated with EQL_UPDATE_FIXTURES=1, so a change that breaks rows already stored can be regenerated away. stored_v4_rows_v1.json is a frozen copy of the Profile rows the Go SDK's generated code stored with the next build: one value of every producible EQL v4 type but TextEq. Nothing writes it. Rust opens every frozen value as its v4 type (frozen_rows_the_go_sdk_stored_still_open). Go seals the frozen plaintexts again and requires the same envelopes and terms, and opens every committed ciphertext (TestFrozenV4RowsStillOpen). A one-byte change to a stored term fails the Go test. Refs #1119.
…ver every producible type Profile stored one value of every EQL v4 type the engine produces except TextEq, so neither v4_rows.json, nor v4_queries.json, nor the frozen stored_v4_rows_v1.json held a TextEq payload. Profile gains Email (encrypt_into=TextEq), and every reader follows: the generated code, the Go tests' row and query helpers, the Rust rows reader, and the Postgres test's table, insert and = query. v4_queries.json gains the email query and is otherwise byte-identical. v4_rows.json is regenerated: the email column is new, and the other ciphertexts carry fresh nonces. stored_v4_rows_v1.json is not merged yet, so it is copied again from the new rows before it is frozen. Refs #1119.
…ot-aead fix(stack-encrypt)!: report a ciphertext that opens as the wrong type as OpenedType, not Aead
…fiValue
eql-bindings' API takes a ValueFrame since the plaintext moved to frames,
but its tests still built every input as an FfiValue and read every
opened value back into one. That kept FfiValue looking like a plaintext
type eql-bindings depends on, which it no longer is.
The tests now write a Rust value straight into a frame
(ValueFrame::of(-7i32), ValueFrame::of("café")) and compare an opened
frame with the frame of the value expected, byte for byte, which checks
the kind and a float's bit pattern too. A null is the frame of
None::<i32>. The README's dispatch example, a Cargo.toml comment and an
unreleased CHANGELOG line still named FfiValue as the dispatch's plaintext
and now say ValueFrame.
No test changed what it asserts. eql-bindings and its encryption tests
no longer name FfiValue anywhere.
Refs #1252.
In a_value_of_another_kind_is_refused_not_converted each case is already built as a ValueFrame. Wrapping it in frame() again ran ValueFrame::of over a frame and wrote the same bytes, which read as if the value were still an FfiValue. Pass the frame straight to targets::encrypt. Refs #1252
…c frame The typed-path paragraph named stack_encrypt::dynamic::ValueFrame to say which leaf each type seals. A typed-only build does not build Stack Encrypt's dynamic module, so point at the Go SDK instead, which is the compatibility the sentence is about. The sentence also read badly. The interoperability matrix still called the other direction "the value enum". The tests now build value frames, so say that. Refs #1252
…, and keep dynamic out of stack-encrypt eql-bindings' stack-encrypt feature turned on Stack Encrypt's dynamic layer for every user, although only the by-name dispatch a language binding needs uses it: encryption::targets and the generated v4::targets take a ValueFrame and name an EQL type at run time. A Rust program that encrypts an IntegerEq through encrypt_as compiled the whole dynamic layer for nothing. stack-encrypt now gives the typed EQL v4 types alone, with Stack Encrypt's default features off and no dynamic. A new targets feature turns on stack-encrypt, Stack Encrypt's dynamic feature, and zeroize and thiserror, which only the dispatch uses, and compiles the two targets modules. The Go guest's eql build and the encryption tests enable targets. test:encryption tests eql-bindings under both feature sets, and the wasm32 check builds targets, as the guest does. docs.rs documents targets. The comment on the encryption::targets declaration is a plain comment: a doc comment there would resolve the module's own intra-doc links from the parent scope. Refs #1252.
Before the targets split, stack-encrypt was the only feature, so clippy with -D warnings and the wasm32-wasip1 check covered the typed path. After it, both ran only with targets on, and the typed set was built only by a host cargo test without -D warnings. A lint that fires only without targets, or a wasm32 break in the typed path, would have passed CI. test:encryption now also runs clippy on eql-bindings with --features stack-encrypt alone, and check:encryption:wasi also checks that set for wasm32-wasip1. Both are clean. Refs #1252
The encryption module docs lost their link to `targets` when the module went behind the feature, because a link to a missing module fails the doc build. Link it with cfg_attr when the feature is on, as lib.rs does, and keep the plain text otherwise. docs.rs builds with targets and the crate has no docsrs cfg, so no feature badge shows. Say in the targets module docs that it needs the feature. Refs #1252
test: freeze fixtures of stored rows that no later build may break
The Phase 4 note said a decrypted value is always one sealed in that row. It is not: the entry's AAD binds the keyset, the column's context, the data key's tag, the selector and the ordinal, and nothing that names a row. An entry the database returns together with its own row's header opens, so `->` can be answered with another row's value at the same path. That is the cross-row interchangeability ADR 0010 accepts for every value sealed under one context, so the fix is to the wording. The note now says what is bound, which substitutions open, and that a selector check would close the path substitution but not the row one. The leakage page's "What is authenticated" paragraph said "under this row's data key"; it now says the key the row's header names and points at the paragraph below it, which already described the row case. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The prepared-statement check ran six EXECUTEs and read EXPLAIN EXECUTE under plan_cache_mode = auto. auto keeps a custom plan whenever it costs less than the generic one, so for the nested table the cache could go on showing a custom plan, and a generic plan that fell back to a sequential scan would fail no test. A pooled driver can reach that generic plan. After the auto assertion, each statement (containment and the range at an ordered path, on both tables) is explained again under force_generic_plan, asserted to carry its operand as $1 (so the plan read is the generic one) and to use the same index, and the setting is reset. v4_range.rs does the same for a range domain. Passes on PostgreSQL 17 (the whole test:encryption:postgres task) and 14 (this test). Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
A path such as `$.items[2147483648]` fits the u32 the subtree key used for ordinals, so its position reached the server as the gate `#2147483648:<selector>`, and `json_tree_children` and `json_tree_subtree_at` cast that ordinal to `integer`: PostgreSQL raised 22003 where the query should have found nothing. The bound now lives in one place, `MAX_ORDINAL` (2^31 - 1), and is applied where the index numbers children: when a document is sealed (an array or object with more children is refused, so every ordinal and child count fits `integer`), and when a subtree key is derived (a position past it is refused with `TermError::JsonTooWide`, whether it becomes a gate or a position the client chains itself). Refusing at derivation rather than at `JsonPath::parse` keeps `JsonPath` a plain description of a path, used also for plaintext selection where any position is meaningful, and keeps the refusal next to the other index-specific one (`JsonSealedRoot`). No stored document has a node past the bound, so no answer is lost. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The trait's docs said what a layout is in the abstract and gave no example, so a reader could not see why it exists beside `EncryptFrom`. They now say it in short sentences and lead with a runnable example: a plan built with `encrypt_with_layout(Json::new(options))` orders `$.age` and answers an ordering query, while the same target laid out by type (`encrypt_into::<JsonDocument>()`) uses the defaults and refuses it with `JsonPathNotOrdered`. The docs also say that the cipher's `encrypt_as` is a different layer: one value, the type's own layout, no plan. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…out docs The module docs were one long sentence naming three layouts, and the method docs explained `encrypt_with_layout` through the `Layout` trait's abstractions before saying what a caller gets. The module docs now list the three ways to choose a one-value plan's output, one line each. The method docs say what it does and what it returns in two sentences, lead with the example, and then say when to use it rather than `encrypt_into::<JsonDocument>()`. The note that the cipher's `encrypt_as` is a different layer moved to the `Layout` trait, which now explains the distinction with an example of its own. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…o serde `JsonOptions::ordered` holds `JsonPath`s, but the wire structs that write and read it held `Vec<String>`: the writer rendered each path to text by hand and the reader re-parsed the strings in a `TryFrom`. The type was lost one step before the serde boundary rather than at it. `JsonPath` now implements `Serialize` (its `Display` text) and `Deserialize` (through `JsonPath::parse`, refusing text that is not a path with the `JsonPathError` message). The wire structs hold the `BTreeSet<JsonPath>` itself, so the paths stay typed until serde turns them into strings, and what the `TryFrom` still checks is only the one rule a parse cannot: an ordered path must not name a position. The wire form is unchanged (paths in canonical order, as text), and the plan error's structured payload writes the set the same way. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The unreleased JSON index entry now says that `JsonTooWide` also refuses a subtree key for a path naming a position past 2^31 - 1, which the SQL walk casts to `integer`. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
… field The json_tree wire types held a bare `String`, so a field's type did not say how its text was to be read, and `h` reused the v3 SteVec `KeyHeader`, whose docs describe base85 text the database never parses, where the v4 header is base64 that the SQL decodes for the `iv` and the sealed-root flag. The new `encoding` module has two `#[serde(transparent)]` newtypes, `Base64Text` (padded standard base64, as `encode(x, 'base64')` writes it) and `Base85Text` (RFC 1924 base85, which no SQL function decodes). The selector, parent tag and ordering term wrap `Base64Text`, the ciphertext wraps `Base85Text`, and `h` is a new `JsonTreeKeyHeader(Base64Text)`. The wire form does not change: each newtype is the bare string in JSON, and `string` in TypeScript and JSON Schema. Deserializing still checks only that the value is a string; Stack Encrypt decodes the bytes and refuses text that is not its encoding. The json_tree family is not in the catalog, so no generated TypeScript, JSON Schema or Go output moves. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
`json_tree_subtree_at` limited its walk only when the root was sealed and the budget was above zero, and `json_tree_children` not at all, so with `budget = 0` or an open root nothing in the SQL itself said how many rows a walk of a hostile stored document could make. The bound held, but only as a consequence of two facts elsewhere: the parent map keeps one entry per tag, so entries that share a `p` collapse into one, and a hit's token is a hash of the probe that found it, so no probe is made twice. Both walks now read their recursion through `LIMIT` the entry count plus the seed row, as #1325 had them before the ordinal chain replaced the stored tokens. PostgreSQL computes a recursive query only as far as its reader pulls rows, so the limit stops the recursion and not only the output, and a walk of a sealed document never reaches it. The budget's fallback is unchanged: `found` reads the bounded walk, so a document with fewer entries than the budget is returned walked, and one with more falls back past the budget as before. The comment block above the walks now states the bound and both reasons for it. The Postgres suite gains a hostile document whose 60 levels are each written twice, same `p` and `s`, different `c`. Walked once per copy it would make 2^60 rows; it returns one row per level under no budget, an open root, and a budget above the entry count, and falls back under a budget below it. It passes with and without the new limit, which is the evidence that the map already collapsed the copies. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The ordinal-chain changeset's byte-encodings paragraph now says that the eql-bindings wire types name their encoding (`Base64Text`, and `Base85Text` for `c`) and that the wire form is unchanged. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…, not copy them When a walk falls back to the sealed root, the client opens the whole document and selects the walked path from it. The selection borrowed the document and cloned the node (or the nodes, or a node's children) out, so for a moment the selected plaintext existed twice: once in the document, which was then wiped, and once in the copy handed over. `JsonPath::select_mut` finds the same nodes as `select`, borrowed mutably, and the new `plain::take_protected` is the moving counterpart of `read_protected`: it borrows the `Protected` inner value through vitaminc's `update`, moves out what the closure returns into a `Protected` of its own, and leaves the source wrapped. Both fallbacks (`DecryptInto<Value>` and `DecryptInto<Vec<Value>>` for `JsonSubtree`) now `mem::take` the selected nodes, leaving `null` in the document, and the children of a `json_tree_subtree` fallback are taken the same way, an object's keys staying behind to be wiped with the document. No `risky_unwrap` was added, and the plaintext exists once. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…nal tokens and the header Two review findings on the record (#1250) read the design as written and found two gaps that the implementation closed in #1329, with nothing in the record saying so. A positional token as first written named the element's children rather than the element, so a scalar element could not be returned or identified; the implementation reaches a position from the parent's token through a `#n:<selector>` gate and returns the reached entry itself. The wire shape called the header opaque to SQL while the walk needs the document's iv and the sealed-root flag from it; the implementation reads both at fixed offsets of the frozen header layout. Each passage now carries a dated note pointing at the implementation and the phase notes, in the form the record already uses for superseded sections. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…pt-spike docs(plans): a JSON index for Stack Encrypt as a new EQL v4 domain, with runnable SQL prototypes
next moved 106 commits past this branch's base while #1250 was open. Conflicts resolved: - dynamic: next's order terms on decimal, date and timestamp removed Reason::OrderTermPending; the JSON index's Reason::JsonIndexPending stays, and IndexSpec::Json keeps its arms beside next's typed order_of. - stack-encrypt Cargo.toml: next's serdect beside this branch's base64 comment. - eql-bindings v4 docs: next's order-term paragraph with the json_tree paragraph. - stack-encrypt CHANGELOG: both sides' entries. - Lockfiles: next's, plus the one sha2 edge this branch adds to stack-encrypt. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…-ordinal-chain Lockfile conflicts only: the base branch's locks, which now carry next, plus this branch's base85-simd 0.1.0, the version it already pinned. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…oding The encoding newtypes exposed their inner String, so anyone could put any text inside one, and a consumer holding a Base64Text could not rely on it being base64. A type that names an encoding is only worth having if holding one means the text is in it. The inner string is now private, and there are two ways to make a value. `encode` takes bytes and writes the text itself. `Deserialize`, the way every wire value arrives, decodes the text with the real decoder and fails with an error naming the encoding when it is not padded standard base64 or RFC 1924 base85. Base85 text whose final group decodes but is not the one `encode` writes for those bytes is refused too, so text and bytes determine each other and equality on the text is equality on the bytes. `decode` is therefore infallible. `as_str`, `AsRef<str>` and `Display` give the text; the wire form is still the bare string, and TypeScript and JSON Schema still render a string. The cost is a decode per field on read: once here and once more in Stack Encrypt for the fields it uses. The module now needs the encoders, so it moves under the `stack-encrypt` feature with the json_tree types that are its only users, and eql-bindings gains base85-simd 0.1.0, the version Stack Encrypt uses (the EQL and Go guest lockfiles record the new edge; neither gains a crate). proptest becomes a dev-dependency for the round-trip properties. The json_tree unit tests built entries from placeholder strings that are not in either encoding; they now build them with `encode`, and one more test pins that a field in the wrong encoding fails the entry. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…crypt gated ones `encoding` now sits behind the `stack-encrypt` feature with the json_tree types that use it, so protect-ffi, which builds eql-bindings with no features, no longer compiles it. The compiled-file walk drops it, and the check that the walk drops only the feature-gated modules named `v4` and `encryption` alone, so it failed on the file it was meant to allow. It now names `encoding` too, as do the two comments describing which modules the binding leaves out. Gating the module is also what clears the earlier failure on this branch: while `encoding` was compiled into the binding, the binding's cache keys did not name it. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
feat(stack-encrypt, eql): the JSON index and the json_tree EQL v4 domain family
…jection perf(eql, stack-encrypt): store the json_tree projection, walk by parent map, budget the subtree
feat(eql, stack-encrypt): chain json_tree tokens by ordinal and probe the walk to the first miss
test(eql): hold the planner to the json_tree indexes on every supported PostgreSQL, and document what the tree reveals
Summary
This is the integration branch for a release candidate of EQL v4 and of Stack Encrypt's Rust and Go support. EQL is the SQL that lets PostgreSQL store and search encrypted values; EQL v4 is a second copy of it for values written by Stack Encrypt, the Rust encryption engine behind the Go SDK. The open pull requests for both are merged here first, so they can be tested together before any of them reaches
main.The release version is not decided yet.
Why a branch, not main
Some of this work depends on vitaminc and ore.rs code that is not yet on crates.io, so it pins those crates to a git commit.
stack-encryptandeql-bindingsare published crates, andcargo publishdrops those git pins, so nothing on this branch can be released until vitaminc 0.6 is published (cipherstash/vitaminc#389, which waits on cipherstash/vitaminc#463). Once it is, the git pins become crates.io versions and this branch merges tomainas a normal release.What lands here, in order
feat/aead-remove-legacy-traitsto one commit on vitamincmain, and takes vitaminc's breaking changes since then.Rules for this branch
rev, neverbranch. A branch is deleted when its stack merges.packages/eqlandpackages/stack-encrypt/fuzz. Two commits mean two copies of vitaminc's traits, and the code stops compiling.Changes
Verification
nextwas created frommainat 2a70a47. Nothing has been built yet, because nothing has changed.Related