Repository navigation
Conversation
compare_ore_block_256_term(s), behind every ordered ORE operator and the ORE operator class, took time that depended on where its operands first differ. A party able to time queries, without reading the stored ciphertexts, could learn how long a prefix their query value shares with stored values: the information the constant-time comparator in ore.rs keeps from them. - Within a term, every block after the first difference ran extra statements, and the `OR` short-circuit skipped a comparison on each of them. On PostgreSQL 17 (aarch64) the two nearly cancel (t = +4.9 and +13.9 through the operator path); with the short-circuit removed the extra statements alone gave t = +388, about 0.7 µs per comparison. - Across terms (text, up to six), the comparison stopped at the first unequal term: about 11 µs when the first term differs, 47 µs when only the sixth does (t below -1500). The term comparator now runs the same statements for every block: both comparisons always evaluated (integer `|`), one statement per block collecting a bitmask of differing blocks, the first difference read off its lowest set bit with arithmetic, and exactly one encrypt() and one get_bit() on every path. The array comparator keeps a fast path for single-term values and, for multi-term values, scans every paired term, latches the first unequal term and block without branching, and hashes once; its multi-term variables live in an inner block so the single-term path does not initialise them. Term counts and NULL elements are public structure and still branch. Measured with tests/timing/ore_block_256 (next commit): single-term t = +1.8 and +1.1 at about 3-8% more time; six-term t = +4.5 and +2.7 at about 31 µs either way. Results are unchanged: all 1,500 harness checks (1-6 terms, every shared-prefix length) match plaintext order and are antisymmetric, and the structural edge cases (NULL elements, equal prefixes of different lengths, empty and NULL arrays) match the previous implementation exactly. Behaviour change: multi-term values length-check every paired term, so a malformed term after the first unequal one now raises instead of being skipped. A new creds-free test pins that and the multi-term structure.
tests/timing/ore_block_256 runs by hand against a database with EQL installed (run.sh, DATABASE_URL). ctgen encrypts real legacy ORE ciphertexts with ore-rs under a throwaway key, so no CipherStash credentials are needed; it is a detached crate with its own workspace. The harness first checks the comparator against plaintext order on 1,500 pairs of 1-6 terms, then times two classes (operands that first differ early vs late) for single-term and six-term values through compare_ore_block_256_terms. Both classes share one randomly ordered table, each sample times several comparisons, and it reports the plain Welch t: separate per-class storage and percentile-cropped statistics each produced false signals in the ore.rs timing work. Against the previous comparator it reports t of -460 to -2575 for six-term values; against the current one every |t| is below 5.
🦋 Changeset detectedLatest commit: cdfd573 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 |
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
Comment |
There was a problem hiding this comment.
🟡 Changes recommended
Multi-term equality still changes the operation count, and fixed-width masks break otherwise valid wider terms.
3 open findings
What changed in this PR
Makes the EQL ORE comparator’s operation count independent of the first differing ciphertext block or term and adds a manual timing harness.
Changes:
- Reworks single- and multi-term comparator logic.
- Adds correctness coverage and timing infrastructure.
- Adds an EQL patch changeset.
| File | Description |
|---|---|
.changeset/eql-ore-block-256-constant-op-count.md |
Documents the security fix. |
packages/eql/src/v3/sem/ore_block_256/functions.sql |
Implements constant-operation comparison. |
packages/eql/tests/sqlx/tests/ore_block_comparator_tests.rs |
Updates comparator expectations. |
packages/eql/tests/sqlx/tests/encrypted_domain/family/sem.rs |
Tests multi-term structure and validation. |
packages/eql/tests/timing/ore_block_256/README.md |
Documents timing methodology. |
packages/eql/tests/timing/ore_block_256/run.sh |
Runs correctness and timing measurements. |
packages/eql/tests/timing/ore_block_256/harness.sql |
Implements sampling and Welch analysis. |
packages/eql/tests/timing/ore_block_256/ctgen/src/main.rs |
Generates ORE ciphertext samples. |
packages/eql/tests/timing/ore_block_256/ctgen/Cargo.toml |
Configures the detached generator crate. |
packages/eql/tests/timing/ore_block_256/ctgen/Cargo.lock |
Locks generator dependencies. |
packages/eql/tests/timing/ore_block_256/ctgen/.gitignore |
Excludes generator build output. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| -- Every paired term equal: the shorter array sorts first. | ||
| IF still_eq = 1 THEN | ||
| RETURN sign(cardinality(a) - cardinality(b))::integer; | ||
| END IF; |
| --! with arithmetic rather than an `IF`, and exactly one `encrypt()` and one | ||
| --! `get_bit()` run on every path, including for equal terms. An earlier form |
| -- dudect-style sampling. Each sample picks a class at random and times | ||
| -- `per_sample` comparisons of distinct random pairs of that class through | ||
| -- compare_ore_block_256_terms (the operator-class path). clock_timestamp() |
Code review (Claude Code,
|
Review of the constant-operation-count comparator found three remaining data-dependent paths and a width limit: - When paired terms were all equal and the arrays had different term counts, the array comparator returned without hashing, so timing showed whether one value's terms are a prefix of the other's (about 7 µs against 9 µs). - When the first unequal pair had a NULL element, it also returned without hashing, so timing showed whether the terms before the NULL were equal. - The first differing block was read off a bitmask with round(ln(mask & -mask) / ln(2)). glibc's log() returns early for exactly 1.0, which is the first-block case, and the int4 mask failed at 32 or more blocks: "integer out of range" at 32, the wrong block at 33. The term comparator now latches the first differing block with one arithmetic statement per block (latch := latch + (latch = 0) * (block + 1) * differs): no mask, no float, no width limit. The array comparator compares each paired term whole in one statement (its PRP bytes and left blocks, the deterministic part) and latches the first unequal pair. It then calls compare_ore_block_256_term exactly once on every path: on the latched pair, or, when every pair is equal or the latched pair has a NULL element, on a NULL-free pair whose result is discarded. The result is selected arithmetically. This removes the copy of the block scan and hash from the array comparator, and every element is read once per iteration. A malformed term paired with a NULL element now raises too. Measured on PostgreSQL 17.11 (aarch64), through the operator path: single-term t = -0.78 and +0.48 (200k samples of 16), six-term t = +1.10 and -1.00 (50k samples of 8), and two new shapes at 100k samples of 8: an equal prefix of different-length arrays against a first-term difference, t = -0.45 and +0.79; a NULL after equal terms against a first-term difference, t = +0.26 and +1.24. A six-term comparison now takes about 18 µs either way (31 µs before this change); single-term comparisons cost about 6% more than the original comparator. Results are unchanged: 21,000 random arrays of 1-6 terms with NULL elements and truncation, and random terms of 8 to 64 blocks, give exactly what the original comparator gives, and the harness's 1,500 plaintext checks pass.


Summary
The ORE block comparator (
eql_v3_internal.compare_ore_block_256_term(s), behind every ordered ORE operator and the ORE operator class) took time that depended on where its operands first differ. A party who can time queries but cannot read the stored ciphertexts, such as an application user, could learn how long a prefix their query value shares with stored values. That is the information the constant-time comparator in ore.rs keeps from them. This PR makes the PL/pgSQL comparator run a constant number of operations whatever its operands, and adds a timing harness that measures it.PL/pgSQL is not a constant-time environment. The target here is a constant operation count: the same statements and function calls on every path. C-level effects inside those statements remain (
memcmpstopping early within 16 bytes, the offset asubstrreads at), orders of magnitude below one statement.What was wrong
ORshort-circuit skipped a comparison on each of those blocks. On the machine measured the two nearly cancel (t = +4.9 and +13.9). With the short-circuit removed, the extra statements alone gave t = +388, about 0.7 µs per comparison. Nothing guarantees the cancellation on other hardware or Postgres builds.Changes
packages/eql/src/v3/sem/ore_block_256/functions.sql|), and the result is collected into a bitmask of differing blocks. The first difference is the mask's lowest set bit, read with arithmetic (mask & -mask, then an exact base-2 log, checked against all 16,383 masks). Exactly oneencrypt()and oneget_bit()run on every path, equal terms included. The result is computed without a branch on the data.packages/eql/tests/timing/ore_block_256/: the timing harness (see below).@cipherstash/eqlpatch.Results
PostgreSQL 17.11, aarch64 (Docker on an Apple M1 Max), through
compare_ore_block_256_terms. 200,000 single-term samples of 16 comparisons and 50,000 six-term samples of 8, two runs each. The classes are operands that first differ early against operands that first differ late;|t| < 5means no detectable difference.Behaviour change
Multi-term values now length-check every paired term, so a malformed term after the first unequal one raises an error instead of being skipped. Results are otherwise unchanged.
Verification
mise run build,codegen:parity,test:self_contained_v3,docs:validateandcargo fmt --checkall pass.Timing harness
DATABASE_URL=... packages/eql/tests/timing/ore_block_256/run.shruns by hand against any database with EQL installed. It needs no CipherStash credentials: a detachedctgencrate encrypts real legacy ORE ciphertexts with ore-rs under a throwaway key. It refuses to time a comparator that fails its correctness checks. It applies three lessons from timing work in ore.rs, each of which produced a false signal there:Against the previous comparator it reports six-term t of −460 to −2575.
It is not wired into CI, because timing on shared runners is too noisy to gate on.
Closes #1149