Skip to content
Open
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
15 changes: 15 additions & 0 deletions .changeset/eql-ore-block-256-constant-op-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@cipherstash/eql': patch

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Bump the behavior change as minor

Use a minor changeset here: malformed multi-term payloads can now raise where they previously returned an ordering result, as the entry itself documents below. Shipping that tightened error behavior as a patch can break callers accepting patch upgrades and contradicts the repository's versioning policy for non-breaking behavior changes.

AGENTS.md reference: packages/eql/AGENTS.md:L284-L292

Useful? React with 👍 / 👎.

---

Security: the ORE block comparator (`eql_v3_internal.compare_ore_block_256_term`
and `compare_ore_block_256_terms`, behind every ordered ORE operator and the ORE
operator class) now runs a constant number of operations whatever its operands.
Previously its run time depended on where two values first differ, so a party
able to time queries could learn how long a prefix their query value shares with
stored values. For text the effect was large: a comparison took about 11 µs when
the first term differs and 47 µs when only the sixth does. Results are unchanged.
Single-term comparisons cost about 6% more; text comparisons now take about
18 µs whatever the operands. Multi-term (text) values now check every paired
term, so a malformed term after the first difference, or one paired with a NULL
element, raises instead of being skipped. A timing harness is in `packages/eql/tests/timing/ore_block_256/`.
Comment on lines +13 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Add an upgrade note for the new comparator error

Add a numbered upgrade note covering this new failure mode and cross-link it from the release entry. Applications with malformed later ORE terms, including terms paired with a NULL element, will now receive an exception where comparison previously short-circuited; the repository explicitly requires upgrade notes for tightened error semantics so callers can audit affected payloads.

AGENTS.md reference: packages/eql/AGENTS.md:L273-L282

Useful? React with 👍 / 👎.

8 changes: 5 additions & 3 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -202,9 +202,10 @@ updates:

# ── Cargo (the root workspace — the stack-* crates) ─────────────
# The root workspace (the six stack-* crates and the three node binding
# crates) and the five detached workspaces beside it: the three cargo-fuzz
# crates and the two Go WASI guests. Each has its own Cargo.lock, and each
# is its own workspace root, so each is listed.
# crates) and the six detached workspaces beside it: the three cargo-fuzz
# crates, the two Go WASI guests and the EQL ORE timing harness's ciphertext
# generator. Each has its own Cargo.lock, and each is its own workspace
# root, so each is listed.
- package-ecosystem: cargo
directories:
- /
Expand All @@ -213,6 +214,7 @@ updates:
- /packages/stack-encrypt/fuzz
- /languages/golang/encrypt/guest
- /languages/golang/auth/guest
- /packages/eql/tests/timing/ore_block_256/ctgen
# Monthly, matching the other two cargo entries.
schedule:
interval: monthly
Expand Down
2 changes: 2 additions & 0 deletions packages/eql/Cargo.lock

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

185 changes: 137 additions & 48 deletions packages/eql/src/v3/sem/ore_block_256/functions.sql
Original file line number Diff line number Diff line change
Expand Up @@ -121,18 +121,34 @@ $$ LANGUAGE plpgsql;
--! is itself `IMMUTABLE STRICT PARALLEL SAFE` — so `IMMUTABLE` lets the
--! planner fold/cache these in ordering and index contexts. NOT `STRICT`:
--! the NULL-handling branches below are load-bearing for the array overload.
--! @note Constant operation count. Past the NULL and length checks (public
--! properties), every comparison executes the same statements whatever the
--! operands: one statement per block, which evaluates both of the block's
--! comparisons (integer `|`, which unlike `OR` does not short-circuit) and
--! latches the first differing block with arithmetic rather than an `IF`;
--! then one `encrypt()`, two `get_bit()` and one `get_byte()`, on every
--! path including for equal terms. An earlier form ran extra statements on
--! every block after the first difference and skipped a comparison on each
--! of them, so its time depended on where the operands first differ, which
--! is the information the constant-time comparator in ore.rs keeps from
--! anyone timing a query. Measured on PostgreSQL 17 (aarch64) through
--! `compare_ore_block_256_terms`, 200 k samples of 16 comparisons, operands
--! differing in the first block against operands differing only in the
--! last: the old form gave t = +4.9 and +13.9 (its extra statements and its
--! skipped comparisons mostly cancel on that machine, which no platform
--! guarantees; with the short-circuit removed it gave t = +388, about
--! 0.7 µs per comparison). See `tests/timing/ore_block_256/` for this
--! form's figures. PL/pgSQL is not a constant-time environment, so C-level
--! effects remain (`memcmp` within 16 bytes, the offset `substr` reads at),
--! orders of magnitude below one statement.
CREATE FUNCTION eql_v3_internal.compare_ore_block_256_term(a eql_v3_internal.ore_block_256_term, b eql_v3_internal.ore_block_256_term)
RETURNS integer
IMMUTABLE
SET search_path = pg_catalog, extensions, public
AS $$
DECLARE
eq boolean := true;
unequal_block smallint := 0;
hash_key bytea;
data_block bytea;
encrypt_block bytea;
target_block bytea;
ab bytea;
bb bytea;

left_block_size CONSTANT smallint := 16;
right_block_size CONSTANT smallint := 32;
Expand All @@ -147,7 +163,9 @@ AS $$
left_offset integer; -- ordinal offset of the first left block (1 + N PRP bytes)
right_offset integer; -- ordinal start of the right CT (= total left CT length = 17*N)

indicator smallint := 0;
latch integer := 0; -- 1 + the first differing block, 0 while none differs
first_diff integer; -- first differing block (0 if the terms are equal)
indicator integer;
BEGIN
IF a IS NULL AND b IS NULL THEN
RETURN 0;
Expand All @@ -173,64 +191,77 @@ AS $$
RAISE EXCEPTION 'Malformed ORE term: % bytes', octet_length(a.bytes);
END IF;

n := (octet_length(a.bytes) - 16) / 49;
ab := a.bytes;
bb := b.bytes;
n := (octet_length(ab) - 16) / 49;
left_offset := 1 + n; -- left blocks begin right after the N PRP bytes
right_offset := 17 * n; -- right CT begins right after the 17*N-byte left CT

-- One statement per block, the same for every block: both comparisons
-- always evaluated, and the first differing block latched as block + 1
-- while the latch is still 0.
FOR block IN 0..n-1 LOOP
-- Compare each PRP byte (the first N bytes) and its 16-byte left block.
IF
substr(a.bytes, 1 + block, 1) != substr(b.bytes, 1 + block, 1)
OR substr(a.bytes, left_offset + left_block_size * block, left_block_size) != substr(b.bytes, left_offset + left_block_size * block, left_block_size)
THEN
IF eq THEN
unequal_block := block;
END IF;
eq = false;
END IF;
latch := latch + (latch = 0)::int * (block + 1) * (
(substr(ab, 1 + block, 1) <> substr(bb, 1 + block, 1))::int
| (substr(ab, left_offset + left_block_size * block, left_block_size)
<> substr(bb, left_offset + left_block_size * block, left_block_size))::int);
END LOOP;

IF eq THEN
RETURN 0::integer;
END IF;

-- Hash key is the IV from the right CT of b.
hash_key := substr(b.bytes, right_offset + 1, 16);

-- First right block is at right_offset + nonce_size (ordinally indexed).
target_block := substr(b.bytes, right_offset + 17 + (unequal_block * right_block_size), right_block_size);

data_block := substr(a.bytes, left_offset + (left_block_size * unequal_block), left_block_size);

encrypt_block := encrypt(data_block::bytea, hash_key::bytea, 'aes-ecb');
-- Equal terms read block 0.
first_diff := greatest(latch - 1, 0);

-- One hash and one bit read on every path. For equal terms this reads
-- block 0 and the result is discarded below. The hash key is the IV at
-- the start of b's right CT; the right blocks follow it.
indicator := (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Equal terms now run encrypt() plus two get_bit() calls and a get_byte() (the result is discarded). Duplicates are common in btree builds, ORDER BY ties and equality probes on ORE columns. The quoted ~6% overhead was measured only on differing operands, and the harness has no equal-operands class for single terms, so the cost of this path is unmeasured.


Generated by Claude Code

get_bit(
encrypt_block,
0
) + get_bit(target_block, get_byte(a.bytes, unequal_block))) % 2;

IF indicator = 1 THEN
RETURN 1::integer;
ELSE
RETURN -1::integer;
END IF;
encrypt(
substr(ab, left_offset + left_block_size * first_diff, left_block_size),
substr(bb, right_offset + 1, 16),
'aes-ecb'),
0)
+ get_bit(
substr(bb, right_offset + 17 + right_block_size * first_diff, right_block_size),
get_byte(ab, first_diff))) % 2;

-- 0 when equal, otherwise +1 / -1 from the indicator; no branch on the data.
RETURN sign(latch) * (2 * indicator - 1);
END;
$$ LANGUAGE plpgsql;


--! @brief Compare arrays of ORE block terms recursively
--! @brief Compare arrays of ORE block terms lexicographically
--! @internal
--! @param a eql_v3_internal.ore_block_256_term[] First array
--! @param b eql_v3_internal.ore_block_256_term[] Second array
--! @return integer -1/0/1, or NULL if either array is NULL
--! @throws Exception if paired terms are different lengths, or any paired
--! term is malformed
--! @note Single-term values (every type but text) go straight to
--! `compare_ore_block_256_term`. Multi-term values (text, up to six terms)
--! are compared with a constant operation count across the terms. A
--! term-by-term comparison that stops at the first unequal term takes time
--! proportional to how many leading terms are equal, which reveals the
--! length of the shared prefix: measured on PostgreSQL 17, six-term values
--! took about 11 µs when the first terms differ and 47 µs when only the
--! sixth does (t below -1500). Instead, every paired term is compared whole
--! in one statement (its PRP bytes and left blocks, which are deterministic)
--! and the first unequal one is latched with arithmetic. Then exactly one
--! call to `compare_ore_block_256_term` runs on every path: on the latched
--! pair, or, when every paired term is equal or the latched pair has a NULL
--! element, on a pair whose result is discarded. The result is selected
--! with arithmetic, so neither a shared prefix of different-length arrays
--! nor a NULL element after equal terms skips the hash. Term counts and the
--! positions of NULL elements are public structure and may branch. If every
--! paired term is equal, the shorter array sorts first. C-level effects
--! remain: the whole-term `memcmp` stops at the first differing byte, a
--! few nanoseconds over at most a few hundred bytes. See
--! `tests/timing/ore_block_256/`.
CREATE FUNCTION eql_v3_internal.compare_ore_block_256_terms(a eql_v3_internal.ore_block_256_term[], b eql_v3_internal.ore_block_256_term[])
RETURNS integer
IMMUTABLE
SET search_path = pg_catalog, extensions, public
AS $$
DECLARE
cmp_result integer;
BEGIN
IF a IS NULL OR b IS NULL THEN
RETURN NULL;
Expand All @@ -248,13 +279,71 @@ AS $$
RETURN 1;
END IF;

cmp_result := eql_v3_internal.compare_ore_block_256_term(a[1], b[1]);

IF cmp_result = 0 THEN
RETURN eql_v3_internal.compare_ore_block_256_terms(a[2:array_length(a,1)], b[2:array_length(b,1)]);
IF cardinality(a) = 1 AND cardinality(b) = 1 THEN
RETURN eql_v3_internal.compare_ore_block_256_term(a[1], b[1]);
END IF;

RETURN cmp_result;
-- Multi-term path. Its variables are declared in this inner block, not at
-- the top: PL/pgSQL evaluates every initialised declaration on each call,
-- and the single-term path above is the operator-class hot path.
DECLARE
ab bytea;
bb bytea;
differs integer; -- 1 if the current pair differs
null_order integer; -- the current pair's order if it has a NULL element, else 0

latch integer := 0; -- the first unequal pair's index, 0 while every pair is equal
first_null integer := 0; -- the latched pair's NULL ordering, 0 if it has none
spare integer := 0; -- the first pair with no NULL element (structure), 0 if none
h integer; -- the pair compare_ore_block_256_term runs on
r integer;
BEGIN
FOR t IN 1..least(cardinality(a), cardinality(b)) LOOP
ab := a[t].bytes;
bb := b[t].bytes;

IF ab IS NOT NULL AND (octet_length(ab) <= 16 OR (octet_length(ab) - 16) % 49 != 0) THEN
RAISE EXCEPTION 'Malformed ORE term: % bytes', octet_length(ab);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Behavior change with availability impact: every paired term is now validated, so one malformed or mismatched-width term anywhere in a stored text value makes any comparison against it raise, even when the first terms already differ. Before, ORDER BY, index builds and index scans only raised if they reached the bad term. A single bad legacy row can now fail a whole ordered query or CREATE INDEX. The changeset mentions it, but there is no migration or lint path for finding such rows first.


Generated by Claude Code

END IF;
IF bb IS NOT NULL AND (octet_length(bb) <= 16 OR (octet_length(bb) - 16) % 49 != 0) THEN
RAISE EXCEPTION 'Malformed ORE term: % bytes', octet_length(bb);
END IF;

-- NULL elements are structure, not data: they may branch.
IF ab IS NULL OR bb IS NULL THEN
differs := ((ab IS NULL) <> (bb IS NULL))::int;
null_order := (bb IS NULL)::int - (ab IS NULL)::int;
ELSE
IF octet_length(ab) != octet_length(bb) THEN

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Inconsistent error precedence: the single-term comparator checks 'Ciphertexts are different lengths' before 'Malformed ORE term', but here the malformed checks (lines 305-310) run first. A pair like 100 bytes vs 408 bytes reports 'Malformed' in the array path and 'different lengths' in the term path. The 'different lengths' RAISE in this path is also not exercised by any added test (only the malformed case is).


Generated by Claude Code

RAISE EXCEPTION 'Ciphertexts are different lengths';
END IF;
-- The PRP bytes and left blocks: the first 17*N bytes.
differs := (substr(ab, 1, 17 * ((octet_length(ab) - 16) / 49))
<> substr(bb, 1, 17 * ((octet_length(ab) - 16) / 49)))::int;
null_order := 0;
IF spare = 0 THEN
spare := t;
END IF;
END IF;

-- Latch the first unequal pair without a branch.
first_null := first_null + (latch = 0)::int * differs * null_order;
latch := latch + (latch = 0)::int * differs * t;
END LOOP;

-- One term comparison on every path. It runs on the latched pair, or on
-- pair 1 when every pair is equal; if that pair has a NULL element, on
-- the first pair with none. Only when no pair is free of NULLs (which
-- is structure) does it take its NULL branch.
h := greatest(latch, 1);
h := h + ((a[h] IS NULL)::int | (b[h] IS NULL)::int) * (spare - h);
r := eql_v3_internal.compare_ore_block_256_term(a[h], b[h]);

-- Every pair equal: the shorter array sorts first. Otherwise the
-- latched pair's NULL ordering, or the term comparison.
RETURN (latch = 0)::int * sign(cardinality(a) - cardinality(b))::integer
+ (latch > 0)::int * (first_null + (first_null = 0)::int * r);
END;
END
$$ LANGUAGE plpgsql;

Expand Down
8 changes: 8 additions & 0 deletions packages/eql/tests/sqlx/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ eql-tests-macros = { path = "../../crates/eql-tests-macros" }
# test targets (`tests/encrypted_domain`), not just under `cfg(test)`.
proptest = "1"

[dev-dependencies]
# Creds-free ORE comparator ordering test (tests/encrypted_domain/family/sem.rs):
# encrypts u64 arrays in-process under a throwaway key, so the comparator is
# checked against real ciphertexts without CipherStash credentials. ore-rs is
# the version cipherstash-client already pulls in; rand matches its rand 0.8.
ore-rs = "=0.8.3"
rand = "0.8"

[lints]
workspace = true

Expand Down
Loading
Loading