Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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-v4-block-ore-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cipherstash/eql': minor
---

**EQL v4 compares ore-rs's 6-bit block ORE, and Stack Encrypt produces the block ORE types `IntegerOrdOre`, `SmallintOrdOre`, `BigintOrdOre`, `RealOrdOre`, `DoubleOrdOre`, `TextOrdOre` and `TextSearchOre`.** A v4 `ob` term is one ore-rs ciphertext: the fixed-width scheme for numbers, the chained scheme for text (padded to a power-of-two length bucket of at least 16 bytes). The v4 bundle names its block ORE objects `ore_block_v2` (`eql_v4_internal.ore_block_v2_term`, `compare_ore_block_v2_term`) and compares them with a new comparator that checks each term's shape, raises on malformed or mismatched terms, and runs a constant operation count for terms of one shape. The EQL v3 bundle is unchanged, byte for byte: its `ob` is still cipherstash-client's 8-bit block ORE under `ore_block_256`. See [U-002](../../docs/upgrading/v3.1.md#u-002-the-block-ore-term-is-ore-rss-6-bit-scheme): the comparator refuses the 8-bit terms the previous `eql_v4` bundle compared, so rows or operands holding them must be written again through Stack Encrypt.
2 changes: 1 addition & 1 deletion .changeset/eql-v4-text-search-optional-terms.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
'@cipherstash/eql': minor
---

**An EQL v4 text search query carries only the terms its operator reads.** `eql_v4.query_text_search` and `eql_v4.query_text_search_ore` now require at least one of their terms (`hm`, `op` or `ob`, `bf`) instead of all of them, so `col = $1` and `col < $1` work on text too short to cut a 3-gram, which has no match term. Each extractor over such a query raises (`invalid_parameter_value`) wherever it reads the operand when the query lacks the term it reads, or holds it as JSON `null` or an empty array, naming the domain, the term and the operators that compare it: `=` without `hm`, `<` without `op`, `@@` without `bf`. So a missing term raises rather than matching no row silently, or every row; the raise is at evaluation, so a generic plan that never reaches the predicate returns no row and no error. Stored `eql_v4_text_search` values still carry every term. The `eql_v3` domains are unchanged: the v3 bundle is byte for byte what it was. With it, **Stack Encrypt produces `TextSearch`** (`eql-bindings`, `stack-encrypt` feature): `TextSearchQuery`'s terms are `Option`s, built per index through `TextSearchEqualityQuery`, `TextSearchOrderQuery` and `TextSearchMatchQuery`, and the stored value carries every term: text too short for a 3-gram stores the empty filter (`"bf": []`, the new `v4::terms::SearchBloomFilter` field type), so `=` and `<` find it and no `@@` query does, where `TextMatch` still refuses it. `TextSearchQuery`'s `Option` terms and `TextSearch`'s `SearchBloomFilter` change the fields of the unreleased `v4` module only; no published `eql-bindings` has it.
**An EQL v4 text search query carries only the terms its operator reads.** `eql_v4.query_text_search` and `eql_v4.query_text_search_ore` now require at least one of their terms (`hm`, `op` or `ob`, `bf`) instead of all of them, so `col = $1` and `col < $1` work on text too short to cut a 3-gram, which has no match term. Each extractor over such a query raises (`invalid_parameter_value`) wherever it reads the operand when the query lacks the term it reads, or holds it as JSON `null` or an empty array, naming the domain, the term and the operators that compare it: `=` without `hm`, `<` without `op`, `@@` without `bf`. So a missing term raises rather than matching no row silently, or every row; the raise is at evaluation, so a generic plan that never reaches the predicate returns no row and no error. Stored `eql_v4_text_search` values still carry every term. The `eql_v3` domains are unchanged: the v3 bundle is byte for byte what it was. With it, **Stack Encrypt produces `TextSearch`** (`eql-bindings`, `stack-encrypt` feature): `TextSearchQuery`'s terms are `Option`s, built per index through `TextSearchEqualityQuery`, `TextSearchOrderQuery` and `TextSearchMatchQuery`, and the stored value carries every term: text too short for a 3-gram stores the empty filter (`"bf": []`, the new `v4::terms::SearchBloomFilter` field type), so `=` and `<` find it and no `@@` query does, where `TextMatch` still refuses it. `TextSearchQuery`'s `Option` terms and `TextSearch`'s `SearchBloomFilter` change the fields of the unreleased `v4` module only; no published `eql-bindings` has it. See [U-001](../../docs/upgrading/v3.1.md#u-001-a-text-search-query-carries-only-the-terms-its-operator-reads) for what a hand-built query must carry, the raise, and the rollback.
6 changes: 6 additions & 0 deletions .github/workflows/test-eql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,12 @@ jobs:
rustup component add --toolchain "${active_rust_toolchain}" rustfmt clippy
mise run test:crates

# The block ORE timing harness's ciphertext generator is a detached
# workspace with its own Cargo.lock that no job builds, so its lock is
# checked here rather than left to drift until someone runs the harness.
- name: Check the timing harness's Cargo.lock
run: cargo metadata --locked --format-version 1 --manifest-path tests/timing/ore_block_v2/ctgen/Cargo.toml > /dev/null

# The three encryption checks are mise tasks rather than raw cargo here,
# so `eql-suite-ci.test.mjs` counts them among the cargo tasks a root
# workflow must reach, and `eql-workflow-filters.test.mjs` derives the
Expand Down
8 changes: 6 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -695,8 +695,12 @@ exact pin would stop the suite sharing one `cts-common` with it.
- **Three Cargo workspaces, not one.** The root workspace (the six stack-*
crates and the three node binding crates), protect-ffi's and EQL's. The root
`Cargo.toml` excludes the other two, and they pin their own vitaminc. The
three fuzz crates and the two Go guests are detached workspaces too, each
with its own `Cargo.lock`.
three fuzz crates, the two Go guests and the EQL v4 block ORE timing
harness's ciphertext generator
(`packages/eql/tests/timing/ore_block_v2/ctgen`) are detached workspaces
too, each with its own `Cargo.lock`. None of them has a Dependabot entry.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW (docs): this is wrong for five of the six. .github/dependabot.yml lines 254–260 list /packages/stack-auth/fuzz, /packages/stack-kms/fuzz, /packages/stack-encrypt/fuzz, /languages/golang/encrypt/guest and /languages/golang/auth/guest under a lockfile-only cargo entry (held by scripts/__tests__/dependabot-cargo-cross-workspace.test.mjs). Only ctgen is unlisted, and that file's own comment still says "five detached workspaces". Either add /packages/eql/tests/timing/ore_block_v2/ctgen to that directories: list (it shares no crate by path, so the cross-workspace rule does not apply) and fix the count there, or reword this to say ctgen alone has no entry and why.

CI builds the generator nowhere, so `test-eql.yml` checks its lock with
`cargo metadata --locked`, and `run.sh` builds it with `--locked`.
- **The toolchain is pinned in the root `mise.toml`:** Rust 1.94.1 (the
`stack-encrypt` `tests/ui` trybuild snapshots record its diagnostics) and
Go 1.26. EQL's `mise.toml` overrides the Rust pin in its folder.
Expand Down
93 changes: 93 additions & 0 deletions Cargo.lock

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

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ recipher = "=0.3.1"
# which Stack Encrypt's order terms are encrypted through. It first ships
# in a pre-release, and Cargo resolves a pre-release only when named.
cllw-ore = { version = "=0.5.2-rc.1", default-features = false, features = ["vitaminc"] }
# ore-rs's `vitaminc` feature implements the same traits for block ORE:
# `Bit6BlockOre` (fixed width up to ten bytes) and `ChainedBlockOre`
# (variable length), the 6-bit schemes EQL v4's `_ord_ore` domains store.
# Both wire formats are frozen as of 0.9.0. ore-rs feeds only stack-encrypt.
ore-rs = { version = "0.9.0", default-features = false, features = ["vitaminc"] }

# External dependencies, with the suite's feature lists.
base64 = "0.22.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,9 @@ equality term on the same column still reveals which rows share a timestamp.
problem, as it is for a plaintext float column.
- **Order:** every scalar kind except `Bool`, under all three schemes, with
one exception: block ORE for text, which waits for ore-rs's variable-length
block ORE (see Deferred).
block ORE (see Deferred). *Amended 2026-10-19:* the exception is gone.
ore-rs 0.9.0 released its chained scheme, and Stack Encrypt orders text
with it (see "EQL v4").
- **`Bool` has a ciphertext only.** A keyed hash or an order term over a
domain of two values hides nothing: it splits the rows into two groups, and
an order term also says which group is `true`. Stack Encrypt stops
Expand Down Expand Up @@ -472,6 +474,38 @@ without loss, and refuses it otherwise.
`-- {{#v4}}` … `-- {{/v4}}`), which the renderer keeps for their own
version and drops from the other, so the v3 bundle renders exactly as it
did before the block was written.
- *Amended 2026-10-19:* **a v4 `ob` term is ore-rs's 6-bit block ORE.** The
key name is v3's, but the format is not. Stack Encrypt writes one term per
value, the whole ore-rs ciphertext (left and right halves) as one hex
element: the fixed-width scheme (`Bit6BlockOre`, wire header `02 02`) for
the integers and floats, and the chained scheme (`ChainedBlockOre`, `02
03`, then the padding policy) for text, padded with NUL bytes to a
power-of-two bucket of at least 16 bytes. v3's `ob` is cipherstash-client's
8-bit block ORE, up to six terms per text value. The two need different
comparators, so the v4 SQL holds its own (a version block in
`sem/ore_block_256/functions.sql`), and every name built on the block ORE
type is a placeholder too: `{{ore_block}}` renders as `ore_block_256` in
v3 and `ore_block_v2` (ore-rs's wire version 2) in v4, so no v4 object
carries the 8-bit format's name. The v3 bundle renders byte for byte as
before.

The v4 comparator checks both terms' shape before it compares (header,
block count, exact length, padding policy, every permuted symbol below 64)
and raises, naming the length and never the bytes, on a malformed term, on
terms of different schemes, on fixed-width terms of different block counts
and on chained terms padded under different policies. For terms of one
shape it runs a constant operation count: one statement per block
evaluates both of its comparisons and latches the first difference
arithmetically, and the hash runs exactly once on every path
(`packages/eql/tests/timing/ore_block_v2/` measures it).
The hash is ore-rs's σ-MMO over AES under a fixed public key, which
pgcrypto computes. A query carries the full ciphertext too, not the left
half alone, because a btree index or `between` can pass the stored value
first, and the comparator reads its second operand's right half.

A comparison of two chained terms reveals the length of their common
prefix in 6-bit blocks, and a chained term reveals its text's length
bucket. Both are ore-rs's documented leakage for the chained scheme.

### Review

Expand Down Expand Up @@ -563,9 +597,12 @@ through a wildcard.

## Deferred

- **Block ORE for text** waits for ore-rs's chained, variable-length block ORE
- ~~**Block ORE for text** waits for ore-rs's chained, variable-length block ORE
to be reviewed and released, then arrives in a minor release of
`vitaminc-ore`. Until then it is an entry in the exceptions list.
`vitaminc-ore`. Until then it is an entry in the exceptions list.~~
*Amended 2026-10-19:* done. ore-rs 0.9.0's `vitaminc` feature implements
`OrderScheme` for its chained scheme, and Stack Encrypt produces
`TextOrdOre` and `TextSearchOre` with it (see "EQL v4").
- **Block ORE for `Bool`.** Block ORE stored as right ciphertexts only is
fully randomised and semantically secure, so a two-value domain leaks
nothing through it. `Bool` could support that scheme alone, enforced by a
Expand Down
11 changes: 11 additions & 0 deletions languages/golang/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,17 @@ listed under **Breaking**. Releases are tagged `languages/golang/vX.Y.Z`.
that orders by ADR-0002's order key: floats as PostgreSQL's `float8`
sorts them, text by its fold. No `Between` yet: it needs EQL v4's range
query domains.
- **`IntegerOrdOre`, `SmallintOrdOre`, `BigintOrdOre`, `RealOrdOre`,
`DoubleOrdOre`, `TextOrdOre` and `TextSearchOre`**: a field tagged `encrypt_into=` one of
them gets `Lt`, `Lte`, `Gt` and `Gte` (and on text `Eq`, and on
`TextSearchOre` `Matches`), as the `Ord` types do. They store a block ORE
term (ore-rs's 6-bit schemes), compared by EQL v4's comparator; each
write and each query carries a fresh nonce, so two terms of one value
never share bytes. Text whose fold is longer than 1023 bytes or ends in
NUL is refused, stored or queried. The engine reports their ordering
operators through the index `ore_block` (`record.OreBlock`,
`stashgen.IndexOreBlock`), which a struct reaches only by naming one of
these types: it is not a tag word.
- **`TextSearch`**: a `string` field tagged `encrypt_into=TextSearch` gets
`Eq`, `Lt`, `Lte`, `Gt`, `Gte` and `Matches`, one column for all of them.
The stored value keeps all three terms. Text shorter than three
Expand Down
14 changes: 7 additions & 7 deletions languages/golang/cmd/stashgen/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,18 +158,18 @@ The engine produces these EQL types today, each from one Go field type:

| `encrypt_into` | Go field |
|---|---|
| `TextEq`, `Text`, `TextMatch`, `TextOrd`, `TextOrdOpe`, `TextSearch` | `string` |
| `Smallint`, `SmallintEq`, `SmallintOrd`, `SmallintOrdOpe` | `int16`, or any Go integer type |
| `Integer`, `IntegerEq`, `IntegerOrd`, `IntegerOrdOpe` | `int32`, or any Go integer type |
| `Bigint`, `BigintEq`, `BigintOrd`, `BigintOrdOpe` | `int64`, or any Go integer type |
| `TextEq`, `Text`, `TextMatch`, `TextOrd`, `TextOrdOpe`, `TextOrdOre`, `TextSearch`, `TextSearchOre` | `string` |
| `Smallint`, `SmallintEq`, `SmallintOrd`, `SmallintOrdOpe`, `SmallintOrdOre` | `int16`, or any Go integer type |
| `Integer`, `IntegerEq`, `IntegerOrd`, `IntegerOrdOpe`, `IntegerOrdOre` | `int32`, or any Go integer type |
| `Bigint`, `BigintEq`, `BigintOrd`, `BigintOrdOpe`, `BigintOrdOre` | `int64`, or any Go integer type |
| `Boolean` | `bool` |
| `Real`, `RealEq`, `RealOrd`, `RealOrdOpe` | `float32` |
| `Double`, `DoubleEq`, `DoubleOrd`, `DoubleOrdOpe` | `float64` |
| `Real`, `RealEq`, `RealOrd`, `RealOrdOpe`, `RealOrdOre` | `float32` |
| `Double`, `DoubleEq`, `DoubleOrd`, `DoubleOrdOpe`, `DoubleOrdOre` | `float64` |
| `Date`, `DateEq` | `encrypt.Date` |
| `Timestamp`, `TimestampEq` | `time.Time` |
| `Numeric`, `NumericEq` | `encrypt.Decimal` |

The `Eq` types answer `Eq`; the `Ord` and `OrdOpe` types answer `Lt`, `Lte`, `Gt` and `Gte`, and on text `Eq` too; `TextMatch` answers `Matches`, and `TextSearch` all of `Eq`, `Lt`, `Lte`, `Gt`, `Gte` and `Matches`, each query carrying only the term its operator reads; `Text`, `Smallint`, `Integer`, `Bigint`, `Boolean`, `Real`, `Double`, `Date`, `Timestamp` and `Numeric` are stored and read only, with no query method.
The `Eq` types answer `Eq`; the `Ord`, `OrdOpe` and `OrdOre` types answer `Lt`, `Lte`, `Gt` and `Gte`, and on text `Eq` too; `TextMatch` answers `Matches`, and `TextSearch` and `TextSearchOre` all of `Eq`, `Lt`, `Lte`, `Gt`, `Gte` and `Matches`, each query carrying only the term its operator reads; `Text`, `Smallint`, `Integer`, `Bigint`, `Boolean`, `Real`, `Double`, `Date`, `Timestamp` and `Numeric` are stored and read only, with no query method.
The ordered date, timestamp and numeric types are refused: the engine derives no order term for these kinds yet.
An ordered type has no `Between` method yet, because a `BETWEEN` operand needs EQL v4's range query domains.

Expand Down
4 changes: 2 additions & 2 deletions languages/golang/cmd/stashgen/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,15 +142,15 @@ func TestRunAsksTheEmbeddedEngine(t *testing.T) {
t.Fatalf("the generated file does not name the EQL type:\n%s", written)
}
// A type the engine cannot produce yet: refused by name, nothing written.
unproducible := strings.Replace(userSource, "encrypt_into=TextEq", "encrypt_into=TextOrdOre", 1)
unproducible := strings.Replace(userSource, "encrypt_into=TextEq", "encrypt_into=DateOrdOre", 1)
dir = writeModule(t, map[string]string{"model.go": unproducible})
stderr.Reset()
if code := run([]string{"-type", "User"}, dir, &stdout, &stderr, stashgen.GuestEngine); code != 1 {
t.Fatalf("exit %d, want 1\n%s", code, stderr.String())
}
// Refused by the generator with the engine's reason, before the engine
// is asked: se_targets lists the type as not producible.
if !strings.Contains(stderr.String(), "cannot produce the EQL type TextOrdOre yet") {
if !strings.Contains(stderr.String(), "cannot produce the EQL type DateOrdOre yet") {
t.Fatalf("stderr = %q", stderr.String())
}
if _, err := os.Stat(filepath.Join(dir, "user_stash.go")); !os.IsNotExist(err) {
Expand Down
Loading
Loading