Repository navigation
feat(stack-encrypt, eql-bindings, eql): EQL v4 order terms are typed, one hex string, checked when written - #1340
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
🦋 Changeset detectedLatest commit: eb415b4 The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 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 |
|---|---|---|---|
| 0 | 0 | 3 | 0 |
Every mutant in the changed lines was caught by a test.
c753de7 to
7741dde
Compare
2bc7bb9 to
b6f1c7a
Compare
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
cipherstash-agent
left a comment
There was a problem hiding this comment.
Review attestation — source: automated /code-review medium plus company-skills:cross-check-review-findings, run against head b6f1c7a.
Critical / blocking issues: none.
Findings: none. No new defect survived verification, and the PR had no existing review threads or findings to cross-check against (only bot status comments).
Checked:
- v4 SQL:
obextractor andhas_ore_block_v2now read one hex string, and no leftover.termsor array path remains in the v4 render. v3 render is unchanged. - Domain and range CHECKs: the prefixes come from the single
Term::v4_formatentry, so the generated CHECK, JSON Schema pattern and Rust types agree. - Rust:
from_bytestakesVec<u8>, the OPE carry-byte check matches the SQL prefixes (00/01), and serde errors report a length only.cargo test -p stack-encrypt --test term_serde --test ore_blockpasses (16 tests). stack-encryptis 0.3.0 in the tree and unpublished (crates.io has 0.2.0), so the breakingfrom_byteschange does not break the publishedeql-bindingsrequirement.
Not covered: the full workspace, SQLx and PostgreSQL suites were not re-run here; they rely on CI.
Generated by Claude Code
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b6f1c7a722
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
7741dde to
300a799
Compare
b6f1c7a to
8bfaeb1
Compare
EQL v4's bindings are to store stack-encrypt's own order terms, typed by scheme and plaintext, rather than v3's untyped hex newtypes. That needs the terms to be serde types. OreTerm, OpeTerm and OreBlockTerm now serialize as lowercase hex in a human-readable format and as raw bytes in a binary one, through serdect, whose hex (base16ct) is constant-time. Deserializing goes through from_bytes, so a term of another scheme or a malformed one is refused at the boundary, with an error that names a length, never a byte. from_bytes now takes the Vec<u8> it decodes and keeps it, so neither deserializing nor a caller that owns the bytes pays a copy; TryFrom<&[u8]> stays and copies once. OpeTerm also checks its leading carry byte is 0 or 1: clww-ore's comparator refuses any other, but its from_bytes checks only the length. Upstream serde for these types is proposed in cipherstash/ore.rs#137, and constant-time FromHex in clww-ore in cipherstash/ore.rs#138. Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
… terms, stored as one hex string EQL v4 reused v3's term newtypes for its order terms: `ob` was `OreBlock256(Vec<String>)`, a one-element array of hex, and `op` an `OpeCllw(String)`. Neither said which scheme or plaintext it held, and neither checked anything when read. A v4 `ob` field is now `stack_encrypt::sem::OreBlockTerm<T>` and an `op` field `OpeTerm<T>`, over the type's plaintext (`DateOrdOre::ob` is an `OreBlockTerm<Date>`). Each serializes as one lowercase hex string and is decoded through `from_bytes` when a payload is read, so an EQL v3 array, a term of the other scheme or a malformed term is refused there. `v4::terms` no longer re-exports v3's order terms. `OreBlockHex` and `OpeHex`, generated, describe the JSON to TypeScript and JSON Schema. One catalog entry, `eql_domains::Term::v4_format`, names each term's Stack Encrypt type, its JSON shape and the hex prefixes it starts with (block ORE `0202`/`0203`, OPE carry `00`/`01`). Codegen writes the Rust field, the JSON Schema pattern and the v4 domain CHECKs from it: every v4 domain carrying `ob` or `op`, stored, query or range bound, requires a JSON string with one of those prefixes. The CHECK reads only the prefix, so a write costs the same whatever the term's length; the comparator still checks the full structure. The v4 SQL reads `ob` as one hex string: `has_ore_block_v2` tests for a string, the extractor decodes it straight to the composite, which now holds one term rather than an array, and the one-element array overload of the comparator is gone. The v3 bundle is byte-for-byte unchanged. Exact per-type CHECKs (block count and length) wait on a key-free shape API in ore-rs and clww-ore, cipherstash/ore.rs#139. Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
U-003 in the 3.1 upgrade guide says what changed for a hand-written eql_v4 payload: `ob` is one hex string, and every v4 domain carrying `ob` or `op` checks its prefix. A database that installed an earlier v4 bundle, such as the one attached to the Go module's alpha, keeps its stored domains across an install, so it must drop its stored order domains first; the guide gives the query that lists them. A changeset for @cipherstash/eql, CHANGELOG entries for eql-bindings and the Go module, and the bindings README and catalog reference describe the v4 term types. Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
… range bounds The changeset linked U-003 relative to the repository root, but Changesets copies it into packages/eql/packages/eql/CHANGELOG.md, where that path names nothing. It now uses ../../docs, as the U-001 and U-002 changesets do. The v4 range reference still said a block ORE range's bounds are ob arrays, which the range domains now refuse with check_violation. It now says both bounds are one hex string, names the prefixes, and links U-003. It also no longer says the envelope waits on the ordered types becoming producible: they are, and a caller builds the envelope from two single-bound queries. Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
300a799 to
f8fc092
Compare
f699849 to
eb415b4
Compare
Summary
EQL (the SQL extension that stores and queries encrypted columns) keeps each searchable term of an encrypted value in a JSON payload. Order terms let the database sort and compare encrypted values:
obholds a block ORE term (order-revealing encryption, from the ore-rs crate) andopan OPE term (order-preserving encryption, from clww-ore).Until now, EQL v4 reused EQL v3's Rust types for these terms.
obwasOreBlock256(Vec<String>), a one-element array of hex, andopwasOpeCllw(String). Neither said which scheme or which kind of value it held, and nothing checked them until a comparison failed.This PR makes each v4 order term Stack Encrypt's own typed term, stored as one hex string, and checks its shape both when the Rust bindings read a payload and when the database stores one. EQL v3 is untouched: its bundle is byte-for-byte what it was.
Changes
stack-encrypt
OreTerm,OpeTermandOreBlockTermserialize as lowercase hex in JSON and as raw bytes in binary formats. This goes throughserdect, whose hex encoding (base16ct) is constant-time. Deserializing goes throughfrom_bytes, and the errors give a length, never the term's bytes.from_bytestakes theVec<u8>and keeps it, with no copy.TryFrom<Vec<u8>>is added, andTryFrom<&[u8]>stays, copying once.OpeTermchecks its leading carry byte is 0 or 1. clww-ore's comparator already refuses any other value, but itsfrom_byteschecks only the length.eql-domains and eql-codegen
Term::v4_format. For each v4 order term it names the Stack Encrypt type, the JSON shape and the hex prefixes a term starts with:0202or0203for block ORE (the fixed-width and chained engines),00or01for OPE.eql-bindings
obfield isstack_encrypt::sem::OreBlockTerm<T>and anopfield isOpeTerm<T>, over the type's plaintext. For example,DateOrdOre::obis anOreBlockTerm<Date>.OreBlockHexandOpeHexdescribe that JSON to TypeScript and JSON Schema.v4::termsno longer re-exports v3's order types. The old v4 visitors and impls for them are removed.EQL SQL (v4 only)
obis read as one hex string.has_ore_block_v2tests for a string, and the extractor decodes it straight into a composite that now holds one term instead of an array. The one-element array version of the comparator is gone. The comparator logic itself is unchanged.oborop(stored, query and range bound). The value must be a JSON string starting with one of the term's prefixes. The CHECK reads only the prefix, so a write costs the same whatever the term's length; the comparator still checks the full structure.Docs
@cipherstash/eql, CHANGELOG entries for stack-encrypt, eql-bindings and the Go module, and updates to the bindings README and the catalog reference.Verification
cargo nextest run --workspace --all-features: 1285 passed, 1 skipped. Clippy passes with all features and with none, as do rustdoc and the fuzz crate check. Every detached lockfile passes--locked.OreBlockTermhas noDefault. cargo-mutants doesn't mutate code inside macros, so the macro-generated serde impls are pinned by the newtests/term_serde.rs.mise run types:checkandmise run codegen:parityshow no drift;check:encryption:wasi.cmp.cipherstash-encrypt.sqlis byte-for-byte identical; only the v4 bundle changes.mise run test:encryption:postgrespasses every suite. The newpostgres_domains_take_an_order_term_only_as_its_hex_stringchecks that real terms cast into stored, query and range domains. It also checks that each of these is refused withcheck_violation: an EQL v3 array, an unknown engine, wire version 1, an OPE term inob, a carry of 2, a number, and a block ORE term inop.a_v4_type_reads_an_order_term_only_as_its_hex_stringchecks that the bindings refuse the same shapes when they deserialize, and that the errors don't repeat the term.public.eql_v4_integer_ord_orekeeps its old array CHECK, as U-003 describes.mise run go:testandmise run wasm:guest:test(75 passed);go generate ./...makes no diff.pnpm run test:scripts(78 files) andpnpm --filter @cipherstash/eql testpass.Related
serdefeature for the term types (constant-time hex via serdect) ore.rs#137 (serde for the term types), clww-ore:FromHexdecodes terms with the non-constant-timehexcrate ore.rs#138 (constant-timeFromHexin clww-ore), ore-rs, clww-ore: a key-free check that a term is a term of a given OrderTarget ore.rs#139 (a key-free check that a term belongs to a given target).Review notes
public.eql_v4_*domains are created only if they don't exist, so a database that installed an earlier v4 bundle keeps the old array CHECK and would refuse every newob. As agreed, the installer doesn't rewrite existing domains; U-003 tells such a database to drop its stored order domains first. The only released v4 bundle is the one attached to the Go module'sv0.1.0-alpha.1, which produced no ordered type.Tis the caller's claim when decoding.from_byteschecks a term's structure, not that it is a term ofT: the bytes of ani64term decode asOreBlockTerm<Date>. Checking that, and exact per-type CHECKs (block count and length, using a few constant-time string operations), wait on ore-rs, clww-ore: a key-free check that a term is a term of a given OrderTarget ore.rs#139. Until then, the CHECK checks the engine prefix only.EncryptFrom::indexes), which v3's order types didn't. A typed plan field of an ordered v4 type therefore now admits its order index. The typed query chain still answers no ordered type; that gap is unchanged.PartialEqon a v4 struct now comparesobby order, through the comparator, because each block ORE term carries a random nonce, so two terms for one value differ in bytes.ope_cllwand the source directoryore_block_256: both are names only, and renaming would make the diff much bigger;opinside a SteVec JSON entry: v4 has no SteVec producer yet.https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3