From 9cbf5cc666d1159e6420e4b28fce80049567e2c7 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 10 Oct 2026 16:01:51 +1100 Subject: [PATCH 1/5] fix(eql): constant operation count for the ORE block comparator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../eql-ore-block-256-constant-op-count.md | 15 ++ .../src/v3/sem/ore_block_256/functions.sql | 218 ++++++++++++++---- .../sqlx/tests/encrypted_domain/family/sem.rs | 64 +++++ .../sqlx/tests/ore_block_comparator_tests.rs | 8 +- 4 files changed, 253 insertions(+), 52 deletions(-) create mode 100644 .changeset/eql-ore-block-256-constant-op-count.md diff --git a/.changeset/eql-ore-block-256-constant-op-count.md b/.changeset/eql-ore-block-256-constant-op-count.md new file mode 100644 index 000000000..e2d5b0a4f --- /dev/null +++ b/.changeset/eql-ore-block-256-constant-op-count.md @@ -0,0 +1,15 @@ +--- +'@cipherstash/eql': patch +--- + +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 3-8% more; text comparisons now take about +31 µs whatever the operands. Multi-term (text) values now length-check every +paired term, so a malformed term after the first difference raises instead of +being skipped. A timing harness is in `packages/eql/tests/timing/ore_block_256/`. diff --git a/packages/eql/src/v3/sem/ore_block_256/functions.sql b/packages/eql/src/v3/sem/ore_block_256/functions.sql index 45f31fdce..f7a81148e 100644 --- a/packages/eql/src/v3/sem/ore_block_256/functions.sql +++ b/packages/eql/src/v3/sem/ore_block_256/functions.sql @@ -121,18 +121,36 @@ $$ 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: each block evaluates both of its comparisons (integer `|`, which +--! unlike `OR` does not short-circuit), the first differing block is latched +--! 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 +--! 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). This form: t = +1.8 and +--! +1.1, at about 3-8% more time per comparison. The blocks' results are +--! collected one statement per block into a bitmask whose lowest set bit is +--! the first difference. 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. See +--! `tests/timing/ore_block_256/`. 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; @@ -147,7 +165,10 @@ 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; + diff_mask integer := 0; -- bit k set iff block k differs (N <= 14 fits) + still_eq integer; -- 1 if no block 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; @@ -173,64 +194,73 @@ 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, the result recorded as a bit of diff_mask. 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; + diff_mask := diff_mask | (( + (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 + ) << block); 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'); + -- The first differing block is the lowest set bit: isolate it + -- (mask & -mask, a power of two) and take its exact base-2 logarithm, + -- with no loop or branch. An empty mask reads block 0. + still_eq := (diff_mask = 0)::int; + first_diff := round(ln(greatest(diff_mask & -diff_mask, 1)) / ln(2))::int; + -- 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 := ( 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 (1 - still_eq) * (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 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: every +--! paired term is scanned, the first unequal term and its first differing +--! block are latched with arithmetic, and one `encrypt()` resolves the +--! order. 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). This form takes about 31 µs +--! either way (t = +4.5 and +2.7). Term counts and NULL elements +--! are public structure and may branch. If every paired term is equal, the +--! shorter array sorts first. Every paired term is length-checked, including +--! those after the first unequal one. 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; @@ -248,13 +278,105 @@ 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 + left_block_size CONSTANT smallint := 16; + right_block_size CONSTANT smallint := 32; + + m integer; -- paired terms: least(cardinality(a), cardinality(b)) + ab bytea; + bb bytea; + n integer; + left_offset integer; + right_offset integer; + + differs integer; -- 1 if the current term differs + diff_mask integer; -- bit k set iff block k of the current term differs + term_diff integer; -- first differing block within the current term + null_order integer; -- for a pair with a NULL element: -1 / 1 + + still_eq integer := 1; -- 1 until the first unequal term + first_term integer := 1; + first_block integer := 0; + first_null integer := 0; -- the latched pair's NULL ordering, 0 if none + indicator integer; + BEGIN + m := least(cardinality(a), cardinality(b)); + + FOR t IN 1..m LOOP + -- NULL elements are structure, not data: they may branch. + IF a[t] IS NULL OR b[t] IS NULL THEN + differs := ((a[t] IS NULL) <> (b[t] IS NULL))::int; + null_order := CASE WHEN a[t] IS NULL THEN -1 ELSE 1 END; + term_diff := 0; + ELSE + IF bit_length(a[t].bytes) != bit_length(b[t].bytes) THEN + RAISE EXCEPTION 'Ciphertexts are different lengths'; + END IF; + IF octet_length(a[t].bytes) <= 16 OR (octet_length(a[t].bytes) - 16) % 49 != 0 THEN + RAISE EXCEPTION 'Malformed ORE term: % bytes', octet_length(a[t].bytes); + END IF; + + ab := a[t].bytes; + bb := b[t].bytes; + n := (octet_length(ab) - 16) / 49; + left_offset := 1 + n; + + diff_mask := 0; + FOR block IN 0..n-1 LOOP + diff_mask := diff_mask | (( + (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 + ) << block); + END LOOP; + differs := (diff_mask <> 0)::int; + term_diff := round(ln(greatest(diff_mask & -diff_mask, 1)) / ln(2))::int; + null_order := 0; + END IF; + + -- Latch the first unequal pair without a branch. + first_term := first_term + still_eq * differs * (t - 1); + first_block := first_block + still_eq * differs * term_diff; + first_null := first_null + still_eq * differs * null_order; + still_eq := still_eq * (1 - differs); + END LOOP; + + -- Every paired term equal: the shorter array sorts first. + IF still_eq = 1 THEN + RETURN sign(cardinality(a) - cardinality(b))::integer; + END IF; + + -- The first unequal pair had a NULL element: its order is structural. + IF first_null <> 0 THEN + RETURN first_null; + END IF; + + -- One hash and one bit read, on the latched term and block. + ab := a[first_term].bytes; + bb := b[first_term].bytes; + n := (octet_length(ab) - 16) / 49; + left_offset := 1 + n; + right_offset := 17 * n; + indicator := ( + get_bit( + encrypt( + substr(ab, left_offset + left_block_size * first_block, left_block_size), + substr(bb, right_offset + 1, 16), + 'aes-ecb'), + 0) + + get_bit( + substr(bb, right_offset + 17 + right_block_size * first_block, right_block_size), + get_byte(ab, first_block))) % 2; + + RETURN 2 * indicator - 1; + END; END $$ LANGUAGE plpgsql; diff --git a/packages/eql/tests/sqlx/tests/encrypted_domain/family/sem.rs b/packages/eql/tests/sqlx/tests/encrypted_domain/family/sem.rs index dabfdc7c6..17ad5cab0 100644 --- a/packages/eql/tests/sqlx/tests/encrypted_domain/family/sem.rs +++ b/packages/eql/tests/sqlx/tests/encrypted_domain/family/sem.rs @@ -113,6 +113,70 @@ async fn ore_terms_array_null_and_empty_base_cases(pool: PgPool) -> Result<()> { Ok(()) } +/// T4b — Multi-term arrays (text carries up to six terms) are compared across +/// every paired term with one hash for the first unequal pair, instead of +/// term by term. Pins the structural results that path must keep: an equal +/// prefix sorts the shorter array first; NULL elements order before +/// non-NULL ones and equal each other; and every paired term is +/// length-checked, including one after the first unequal pair (the +/// term-by-term form stopped before reaching it). Creds-free: identical +/// hand-built 65-byte terms (N = 1) compare equal by construction. +#[sqlx::test] +async fn ore_terms_array_multi_term_structure(pool: PgPool) -> Result<()> { + let t = "ROW(repeat('a', 65)::bytea)::eql_v3_internal.ore_block_256_term"; + let u = "ROW(repeat('b', 65)::bytea)::eql_v3_internal.ore_block_256_term"; + let n = "NULL::eql_v3_internal.ore_block_256_term"; + let short = "ROW(repeat('a', 64)::bytea)::eql_v3_internal.ore_block_256_term"; + let cmp = |x: String, y: String| { + format!("SELECT eql_v3_internal.compare_ore_block_256_terms({x}, {y})") + }; + let arr = |items: &[&str]| format!("ARRAY[{}]", items.join(", ")); + + let cases = [ + (cmp(arr(&[t, t]), arr(&[t, t])), 0, "equal arrays"), + ( + cmp(arr(&[t, t]), arr(&[t, t, t])), + -1, + "equal prefix, shorter first", + ), + ( + cmp(arr(&[t, t, t]), arr(&[t, t])), + 1, + "equal prefix, longer second", + ), + ( + cmp(arr(&[n, t]), arr(&[t, t])), + -1, + "NULL first element sorts first", + ), + ( + cmp(arr(&[t, t]), arr(&[n, t])), + 1, + "non-NULL first element sorts second", + ), + ( + cmp(arr(&[t, n]), arr(&[t, t])), + -1, + "NULL after an equal term sorts first", + ), + ( + cmp(arr(&[n, t]), arr(&[n, t])), + 0, + "NULL elements equal each other", + ), + ]; + for (sql, expected, what) in cases { + let got: i32 = sqlx::query_scalar(&sql).fetch_one(&pool).await?; + assert_eq!(got, expected, "{what}: {sql}"); + } + + // The first pair differs; the second is malformed. Every paired term is + // checked, so this raises rather than returning on the first pair. + let sql = cmp(arr(&[t, short]), arr(&[u, short])); + assert_raises(&pool, &sql, &[], "Malformed ORE term").await?; + Ok(()) +} + /// T5 — SEM presence checks (`has_ore_block_256`, `has_hmac_256`), the /// extractor's missing-`ob` and non-array-`ob` RAISEs, and its NULL-jsonb /// short-circuit. diff --git a/packages/eql/tests/sqlx/tests/ore_block_comparator_tests.rs b/packages/eql/tests/sqlx/tests/ore_block_comparator_tests.rs index f47b5cd91..c2fd703a4 100644 --- a/packages/eql/tests/sqlx/tests/ore_block_comparator_tests.rs +++ b/packages/eql/tests/sqlx/tests/ore_block_comparator_tests.rs @@ -252,10 +252,10 @@ async fn empty_ore_term_sorts_before_non_empty(pool: PgPool) -> Result<()> { /// /// Valid lengths are pinned to exact return values (verified against /// `src/v3/sem/ore_block_256/functions.sql`): -/// * equal operands take the all-blocks-equal path → return **0** -/// (`functions.sql:166-168`; the `encrypt()` branch is unreachable); -/// * differing operands fall through to the `encrypt()` path → return **±1** -/// (`functions.sql:170-190`), which is the branch the length guard protects. +/// * equal operands → return **0**. The comparator still runs its one +/// `encrypt()` (constant operation count) and discards the result; +/// * differing operands → return **±1** from that `encrypt()`, which is the +/// read the length guard protects. #[sqlx::test] async fn comparator_length_guard_sweep(pool: PgPool) -> Result<()> { // Invalid: not 49*N + 16 (16 and 4 are covered by the dedicated tests above). From 379c16172f5969558ce64878cdd157810cddd8c8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 10 Oct 2026 16:01:52 +1100 Subject: [PATCH 2/5] test(eql): timing harness for the ORE block comparator 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. --- .../eql/tests/timing/ore_block_256/README.md | 84 ++++ .../timing/ore_block_256/ctgen/.gitignore | 1 + .../timing/ore_block_256/ctgen/Cargo.lock | 381 ++++++++++++++++++ .../timing/ore_block_256/ctgen/Cargo.toml | 20 + .../timing/ore_block_256/ctgen/src/main.rs | 59 +++ .../tests/timing/ore_block_256/harness.sql | 92 +++++ .../eql/tests/timing/ore_block_256/run.sh | 51 +++ 7 files changed, 688 insertions(+) create mode 100644 packages/eql/tests/timing/ore_block_256/README.md create mode 100644 packages/eql/tests/timing/ore_block_256/ctgen/.gitignore create mode 100644 packages/eql/tests/timing/ore_block_256/ctgen/Cargo.lock create mode 100644 packages/eql/tests/timing/ore_block_256/ctgen/Cargo.toml create mode 100644 packages/eql/tests/timing/ore_block_256/ctgen/src/main.rs create mode 100644 packages/eql/tests/timing/ore_block_256/harness.sql create mode 100755 packages/eql/tests/timing/ore_block_256/run.sh diff --git a/packages/eql/tests/timing/ore_block_256/README.md b/packages/eql/tests/timing/ore_block_256/README.md new file mode 100644 index 000000000..bd2a24eae --- /dev/null +++ b/packages/eql/tests/timing/ore_block_256/README.md @@ -0,0 +1,84 @@ +# ORE comparator timing harness + +Checks that `eql_v3_internal.compare_ore_block_256_terms`, the comparator +behind every ordered ORE operator and the ORE operator class, takes the same +time wherever its operands first differ. + +## Why it matters + +The comparator's inputs are ciphertexts, so the database operator learns +nothing from its timing: they can run the comparison themselves. The party +who gains is someone who can issue queries and time them without reading the +stored ciphertexts, such as an application user. Query results already tell +them the order of their value relative to stored ones. If the comparison time +depends on where the two values first differ, timing also tells them how long +a prefix they share. For text that was a large effect: about 11 µs per +comparison when the first of six terms differs, 47 µs when only the sixth +does. + +PL/pgSQL is not a constant-time environment. What it can give is a constant +*operation count*: the same statements and function calls on every path, so +nothing at the scale of a statement (fractions of a microsecond) depends on +the data. C-level effects inside those statements (`memcmp` stopping early +within 16 bytes, the offset a `substr` reads at) remain, orders of magnitude +smaller. The Rust comparator in ore.rs is constant-time at the instruction +level; this is the PL/pgSQL counterpart. + +## Running it + +From `packages/eql`: + +```sh +mise run build +# a scratch database with EQL installed, e.g.: +psql "$DATABASE_URL" -f release/cipherstash-encrypt.sql +DATABASE_URL=postgres://... tests/timing/ore_block_256/run.sh # 200k samples, a few minutes +DATABASE_URL=postgres://... tests/timing/ore_block_256/run.sh 20000 # quick check +``` + +It needs `psql` and `cargo`, and no CipherStash credentials: `ctgen/` encrypts +with ore-rs under a random throwaway key. Run it on a quiet machine. It is not +a CI test, because timing on shared runners is too noisy to gate on. + +## Method + +`ctgen` produces real legacy ORE ciphertexts (the 8-bit block scheme, 408 +bytes per u64 term) in two shapes, plus a correctness set: + +- **single term**: pairs differing in the first block against pairs differing + only in the last (block 7); +- **six terms** (text-shaped values): pairs differing in the first term + against pairs differing only in the sixth; +- **checks**: 1,500 random pairs of 1 to 6 terms, with shared prefixes of + every length, compared against plaintext order and for antisymmetry. The + harness refuses to time a comparator that gets any of them wrong. + +Sampling follows dudect, with three lessons from the ore.rs benches built in: + +1. **Both classes share one table, in random order.** With separate storage + per class, placement alone produced a timing difference. +2. **Several comparisons per sample** (16 single-term, 8 six-term), so a + sample spans many ticks of `clock_timestamp()`'s microsecond clock. +3. **The plain Welch t over every sample**, not a percentile-cropped maximum. + Cropping turns a difference in spread into a large t with no difference in + mean. + +Classes are chosen at random per sample, so drift affects both equally. +`|t| < 5` over a few hundred thousand samples counts as no detectable +difference. + +## Results + +PostgreSQL 17.11, aarch64 (Docker on an Apple M1 Max), 200,000 single-term +samples of 16 comparisons and 50,000 six-term samples of 8, two runs each: + +| comparator | single term: t | six terms: t | cost | +|---|---:|---:|---| +| before | +4.9, +13.9 | −1552, −2575 | 155 µs per 16 single-term (median); 11 µs (first term differs) to 47 µs (sixth differs) per six-term comparison | +| after | +1.8, +1.1 | +4.5, +2.7 | about 3–8% more single-term; 31 µs per six-term comparison, either way | + +The "before" single-term figures are small only by coincidence. Each block +after the first difference ran extra statements, and the `OR` short-circuit +skipped a comparison on the same blocks; on this machine the two nearly +cancel. With the short-circuit removed, the same code gave t = +388, about +0.7 µs per comparison. diff --git a/packages/eql/tests/timing/ore_block_256/ctgen/.gitignore b/packages/eql/tests/timing/ore_block_256/ctgen/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/packages/eql/tests/timing/ore_block_256/ctgen/.gitignore @@ -0,0 +1 @@ +/target diff --git a/packages/eql/tests/timing/ore_block_256/ctgen/Cargo.lock b/packages/eql/tests/timing/ore_block_256/ctgen/Cargo.lock new file mode 100644 index 000000000..c3d734e68 --- /dev/null +++ b/packages/eql/tests/timing/ore_block_256/ctgen/Cargo.lock @@ -0,0 +1,381 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", + "zeroize", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "block-modes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2211b0817f061502a8dd9f11a37e879e79763e3c698d2418cf824d8cb2f21e" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "orderable-bytes" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adba0469737879cabc3bdcaa6a7beb4ff7f5bb4aa5d624816c8bb9d425a7d5df" + +[[package]] +name = "ore-rs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d77fb65718f1aba7bb7f568db90a2b17a48871d83cd9d1101d19fce449dd8dea" +dependencies = [ + "aes", + "block-modes", + "byteorder", + "hex", + "lazy_static", + "num", + "orderable-bytes", + "rand", + "rand_chacha", + "subtle-ng", + "thiserror", + "zeroize", +] + +[[package]] +name = "ore-timing-ctgen" +version = "0.0.0" +dependencies = [ + "hex", + "ore-rs", + "rand", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "subtle-ng" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "734676eb262c623cec13c3155096e08d1f8f29adce39ba17948b18dad1e54142" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/packages/eql/tests/timing/ore_block_256/ctgen/Cargo.toml b/packages/eql/tests/timing/ore_block_256/ctgen/Cargo.toml new file mode 100644 index 000000000..c70b02b01 --- /dev/null +++ b/packages/eql/tests/timing/ore_block_256/ctgen/Cargo.toml @@ -0,0 +1,20 @@ +# Ciphertext generator for the ORE comparator timing harness (../run.sh). +# +# A DETACHED crate: the empty `[workspace]` table makes it its own workspace +# root, so it never joins the main workspace or its CI. It encrypts with ore-rs +# directly under a random throwaway key; the harness needs real ciphertexts but +# no CipherStash credentials, and nothing it produces is a test fixture. +[package] +name = "ore-timing-ctgen" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +# The legacy (8-bit block) scheme behind `ore_block_256`; same version as the +# root Cargo.lock. +ore-rs = "=0.8.3" +rand = "0.8" +hex = "0.4" + +[workspace] diff --git a/packages/eql/tests/timing/ore_block_256/ctgen/src/main.rs b/packages/eql/tests/timing/ore_block_256/ctgen/src/main.rs new file mode 100644 index 000000000..eea4e55b3 --- /dev/null +++ b/packages/eql/tests/timing/ore_block_256/ctgen/src/main.rs @@ -0,0 +1,59 @@ +//! Legacy (bit2, 8-bit blocks) u64 ORE ciphertexts for the ORE comparator +//! timing harness (`../run.sh`). Tab-separated rows; arrays are +//! comma-separated hex terms (EQL stores text as up to six u64 terms). +//! timing1 one term; differ in block 0 / only block 7 +//! timing6 six terms; differ in term 1 / only term 6 +//! check arrays of 1..6 terms; expected = sign(a cmp b) +use ore_rs::scheme::bit2::OreAes128ChaCha20; +use ore_rs::{OreCipher, OreEncrypt, OreOutput}; +use rand::Rng; + +fn main() { + let mut rng = rand::thread_rng(); + let (k1, k2): ([u8; 16], [u8; 16]) = (rng.gen(), rng.gen()); + let ore: OreAes128ChaCha20 = OreCipher::init(&k1, &k2).unwrap(); + let ct = |x: u64| hex::encode(x.encrypt(&ore).unwrap().to_bytes()); + let arr = |xs: &[u64]| xs.iter().map(|&x| ct(x)).collect::>().join(","); + let pairs: usize = std::env::args() + .nth(1) + .map(|s| s.parse().unwrap()) + .unwrap_or(256); + for _ in 0..pairs { + let x: u64 = rng.gen(); + println!("timing1\tfirst\t{}\t{}", ct(x), ct(x ^ (1 << 63))); + println!("timing1\tlast\t{}\t{}", ct(x), ct(x ^ 1)); + let xs: Vec = (0..6).map(|_| rng.gen()).collect(); + let mut first = xs.clone(); + first[0] ^= 1 << 63; + let mut last = xs.clone(); + last[5] ^= 1; + println!("timing6\tfirst\t{}\t{}", arr(&xs), arr(&first)); + println!("timing6\tlast\t{}\t{}", arr(&xs), arr(&last)); + } + for i in 0..1500 { + let la = rng.gen_range(1..=6); + let xs: Vec = (0..la).map(|_| rng.gen()).collect(); + // Share a random-length prefix, then diverge (or not) in various ways. + let shared = rng.gen_range(0..=la); + let lb = if i % 3 == 0 { + la + } else { + rng.gen_range(shared.max(1)..=6) + }; + let mut ys: Vec = xs[..shared].to_vec(); + while ys.len() < lb { + let k = ys.len(); + ys.push(match (i % 5, k < la) { + (0, true) => xs[k] ^ (1u64 << rng.gen_range(0..64)), + (1, true) => xs[k], + _ => rng.gen(), + }); + } + let expected = match xs.cmp(&ys) { + std::cmp::Ordering::Less => -1, + std::cmp::Ordering::Equal => 0, + std::cmp::Ordering::Greater => 1, + }; + println!("check\t{expected}\t{}\t{}", arr(&xs), arr(&ys)); + } +} diff --git a/packages/eql/tests/timing/ore_block_256/harness.sql b/packages/eql/tests/timing/ore_block_256/harness.sql new file mode 100644 index 000000000..bfa59243d --- /dev/null +++ b/packages/eql/tests/timing/ore_block_256/harness.sql @@ -0,0 +1,92 @@ +-- ORE comparator timing harness. Loaded by run.sh into a database with EQL +-- installed, after run.sh has created the `ore_timing` schema and loaded the +-- ctgen output into ore_timing.staging (`\copy` cannot take a psql variable). +-- Everything lives in `ore_timing`, which run.sh drops afterwards. + +SET search_path = ore_timing, public; + +CREATE FUNCTION terms_of(hexes text) + RETURNS eql_v3_internal.ore_block_256_term[] + LANGUAGE sql IMMUTABLE AS $$ + SELECT array_agg(ROW(decode(h, 'hex'))::eql_v3_internal.ore_block_256_term ORDER BY o) + FROM unnest(string_to_array(hexes, ',')) WITH ORDINALITY AS u(h, o) +$$; + +CREATE TABLE checks AS + SELECT label::int AS expected, terms_of(a_hex) AS a, terms_of(b_hex) AS b + FROM staging WHERE kind = 'check'; + +-- Timing pairs. Both classes of a kind share one table in random order, so +-- neither sits in a fixed region of memory: with separate per-class storage, +-- placement alone produced a timing difference in the ore.rs benches. +CREATE TABLE pairs AS + SELECT kind, row_number() OVER (PARTITION BY kind ORDER BY random()) AS id, + (label = 'last')::int AS cls, terms_of(a_hex) AS a, terms_of(b_hex) AS b + FROM staging WHERE kind LIKE 'timing%'; + +-- 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() +-- ticks in microseconds, so a sample must span many of them. +CREATE FUNCTION bench(which_kind text, samples int, per_sample int) + RETURNS TABLE (cls int, us double precision) +AS $$ +DECLARE + arrs_a eql_v3_internal.ore_block_256[]; + arrs_b eql_v3_internal.ore_block_256[]; + idx0 int[]; + idx1 int[]; + picks int[]; + c int; + r int; + t0 timestamptz; + t1 timestamptz; +BEGIN + SELECT array_agg(ROW(p.a)::eql_v3_internal.ore_block_256 ORDER BY p.id), + array_agg(ROW(p.b)::eql_v3_internal.ore_block_256 ORDER BY p.id) + INTO arrs_a, arrs_b FROM ore_timing.pairs p WHERE p.kind = which_kind; + SELECT array_agg(p.id::int ORDER BY p.id) FILTER (WHERE p.cls = 0), + array_agg(p.id::int ORDER BY p.id) FILTER (WHERE p.cls = 1) + INTO idx0, idx1 FROM ore_timing.pairs p WHERE p.kind = which_kind; + FOR s IN 1..samples LOOP + c := (random() < 0.5)::int; + IF c = 0 THEN + picks := ARRAY(SELECT idx0[1 + floor(random() * cardinality(idx0))::int] FROM generate_series(1, per_sample)); + ELSE + picks := ARRAY(SELECT idx1[1 + floor(random() * cardinality(idx1))::int] FROM generate_series(1, per_sample)); + END IF; + t0 := clock_timestamp(); + FOREACH r IN ARRAY picks LOOP + PERFORM eql_v3_internal.compare_ore_block_256_terms(arrs_a[r], arrs_b[r]); + END LOOP; + t1 := clock_timestamp(); + cls := c; + us := extract(epoch FROM t1 - t0) * 1e6; + RETURN NEXT; + END LOOP; +END; +$$ LANGUAGE plpgsql; + +-- Uncropped Welch t (every sample counts), with per-class mean and median. +-- Report this, not a percentile-cropped maximum: cropping turns a difference +-- in spread into a large t with no difference in mean. +CREATE FUNCTION welch(label text, tbl regclass) + RETURNS TABLE (run text, n bigint, mean_first numeric, mean_last numeric, + median_first numeric, median_last numeric, t numeric) +AS $$ +BEGIN + RETURN QUERY EXECUTE format($q$ + WITH s AS ( + SELECT count(*) n, + count(*) FILTER (WHERE cls = 0) n0, count(*) FILTER (WHERE cls = 1) n1, + avg(us) FILTER (WHERE cls = 0) m0, avg(us) FILTER (WHERE cls = 1) m1, + stddev(us) FILTER (WHERE cls = 0) s0, stddev(us) FILTER (WHERE cls = 1) s1, + percentile_cont(0.5) WITHIN GROUP (ORDER BY us) FILTER (WHERE cls = 0) p0, + percentile_cont(0.5) WITHIN GROUP (ORDER BY us) FILTER (WHERE cls = 1) p1 + FROM %s) + SELECT %L::text, n, round(m0::numeric, 1), round(m1::numeric, 1), + round(p0::numeric, 1), round(p1::numeric, 1), + round(((m0 - m1) / sqrt(s0 * s0 / n0 + s1 * s1 / n1))::numeric, 2) + FROM s$q$, tbl, label); +END; +$$ LANGUAGE plpgsql; diff --git a/packages/eql/tests/timing/ore_block_256/run.sh b/packages/eql/tests/timing/ore_block_256/run.sh new file mode 100755 index 000000000..2af840551 --- /dev/null +++ b/packages/eql/tests/timing/ore_block_256/run.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Timing harness for the ORE block comparator (compare_ore_block_256_terms). +# By hand, not CI: timing on shared runners is too noisy to gate on. +# +# DATABASE_URL=postgres://... tests/timing/ore_block_256/run.sh [samples] +# +# DATABASE_URL must point at a database with EQL installed (e.g. the release +# installer from `mise run build`). Needs psql and cargo. samples defaults to +# 200000 single-term samples (and a quarter as many six-term ones), a few +# minutes in all. See README.md for the method and how to read the output. +set -euo pipefail + +samples="${1:-200000}" +here="$(cd "$(dirname "$0")" && pwd)" +: "${DATABASE_URL:?set DATABASE_URL to a database with EQL installed}" + +tmp="$(mktemp -d)" +cleanup() { + rm -rf "$tmp" + psql "$DATABASE_URL" -q -c "DROP SCHEMA IF EXISTS ore_timing CASCADE" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +echo "== generating ciphertexts (ore-rs, throwaway key)" +cargo run --quiet --release --manifest-path "$here/ctgen/Cargo.toml" -- 256 >"$tmp/ct.tsv" + +psql "$DATABASE_URL" -q -v ON_ERROR_STOP=1 -c "DROP SCHEMA IF EXISTS ore_timing CASCADE" \ + -c "CREATE SCHEMA ore_timing" \ + -c "CREATE TABLE ore_timing.staging (kind text, label text, a_hex text, b_hex text)" \ + -c "\\copy ore_timing.staging FROM '$tmp/ct.tsv'" >/dev/null +psql "$DATABASE_URL" -q -v ON_ERROR_STOP=1 -f "$here/harness.sql" >/dev/null + +echo "== correctness (must be 0 wrong before timing means anything)" +wrong="$(psql "$DATABASE_URL" -tA -c " + SELECT count(*) FILTER (WHERE eql_v3_internal.compare_ore_block_256_terms(a, b) IS DISTINCT FROM expected) + + count(*) FILTER (WHERE eql_v3_internal.compare_ore_block_256_terms(b, a) IS DISTINCT FROM -expected) + FROM ore_timing.checks")" +echo " wrong: $wrong" +[ "$wrong" = "0" ] || { echo "comparator disagrees with plaintext order; not timing"; exit 1; } + +echo "== timing (two runs per kind; classes: values first differ early vs late)" +for run in 1 2; do + psql "$DATABASE_URL" -q -v ON_ERROR_STOP=1 -c " + CREATE TABLE ore_timing.single_$run AS SELECT * FROM ore_timing.bench('timing1', $samples, 16); + CREATE TABLE ore_timing.six_$run AS SELECT * FROM ore_timing.bench('timing6', $((samples / 4)), 8);" +done +psql "$DATABASE_URL" -c " + SELECT * FROM ore_timing.welch('single term, 16/sample #1', 'ore_timing.single_1') + UNION ALL SELECT * FROM ore_timing.welch('single term, 16/sample #2', 'ore_timing.single_2') + UNION ALL SELECT * FROM ore_timing.welch('six terms, 8/sample #1', 'ore_timing.six_1') + UNION ALL SELECT * FROM ore_timing.welch('six terms, 8/sample #2', 'ore_timing.six_2');" From cdfd573af89a75fec06e2b21c8ba92cb1e8943e8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 11 Oct 2026 22:27:22 +1100 Subject: [PATCH 3/5] fix(eql): close the remaining ORE comparator timing paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../eql-ore-block-256-constant-op-count.md | 8 +- .../src/v3/sem/ore_block_256/functions.sql | 209 ++++++++---------- .../eql/tests/timing/ore_block_256/README.md | 2 +- 3 files changed, 93 insertions(+), 126 deletions(-) diff --git a/.changeset/eql-ore-block-256-constant-op-count.md b/.changeset/eql-ore-block-256-constant-op-count.md index e2d5b0a4f..069f5a6a4 100644 --- a/.changeset/eql-ore-block-256-constant-op-count.md +++ b/.changeset/eql-ore-block-256-constant-op-count.md @@ -9,7 +9,7 @@ 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 3-8% more; text comparisons now take about -31 µs whatever the operands. Multi-term (text) values now length-check every -paired term, so a malformed term after the first difference raises instead of -being skipped. A timing harness is in `packages/eql/tests/timing/ore_block_256/`. +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/`. diff --git a/packages/eql/src/v3/sem/ore_block_256/functions.sql b/packages/eql/src/v3/sem/ore_block_256/functions.sql index f7a81148e..13a77ce13 100644 --- a/packages/eql/src/v3/sem/ore_block_256/functions.sql +++ b/packages/eql/src/v3/sem/ore_block_256/functions.sql @@ -123,26 +123,24 @@ $$ LANGUAGE plpgsql; --! 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: each block evaluates both of its comparisons (integer `|`, which ---! unlike `OR` does not short-circuit), the first differing block is latched ---! 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 ---! 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). This form: t = +1.8 and ---! +1.1, at about 3-8% more time per comparison. The blocks' results are ---! collected one statement per block into a bitmask whose lowest set bit is ---! the first difference. 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. See ---! `tests/timing/ore_block_256/`. +--! 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 @@ -165,8 +163,7 @@ 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) - diff_mask integer := 0; -- bit k set iff block k differs (N <= 14 fits) - still_eq integer; -- 1 if no block differs + 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 @@ -201,20 +198,17 @@ AS $$ 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, the result recorded as a bit of diff_mask. + -- always evaluated, and the first differing block latched as block + 1 + -- while the latch is still 0. FOR block IN 0..n-1 LOOP - diff_mask := diff_mask | (( + 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 - ) << block); + <> substr(bb, left_offset + left_block_size * block, left_block_size))::int); END LOOP; - -- The first differing block is the lowest set bit: isolate it - -- (mask & -mask, a power of two) and take its exact base-2 logarithm, - -- with no loop or branch. An empty mask reads block 0. - still_eq := (diff_mask = 0)::int; - first_diff := round(ln(greatest(diff_mask & -diff_mask, 1)) / ln(2))::int; + -- 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 @@ -231,7 +225,7 @@ AS $$ get_byte(ab, first_diff))) % 2; -- 0 when equal, otherwise +1 / -1 from the indicator; no branch on the data. - RETURN (1 - still_eq) * (2 * indicator - 1); + RETURN sign(latch) * (2 * indicator - 1); END; $$ LANGUAGE plpgsql; @@ -241,21 +235,28 @@ $$ LANGUAGE plpgsql; --! @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 malformed +--! @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: every ---! paired term is scanned, the first unequal term and its first differing ---! block are latched with arithmetic, and one `encrypt()` resolves the ---! order. 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). This form takes about 31 µs ---! either way (t = +4.5 and +2.7). Term counts and NULL elements ---! are public structure and may branch. If every paired term is equal, the ---! shorter array sorts first. Every paired term is length-checked, including ---! those after the first unequal one. +--! 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 @@ -286,96 +287,62 @@ AS $$ -- the top: PL/pgSQL evaluates every initialised declaration on each call, -- and the single-term path above is the operator-class hot path. DECLARE - left_block_size CONSTANT smallint := 16; - right_block_size CONSTANT smallint := 32; - - m integer; -- paired terms: least(cardinality(a), cardinality(b)) ab bytea; bb bytea; - n integer; - left_offset integer; - right_offset integer; - - differs integer; -- 1 if the current term differs - diff_mask integer; -- bit k set iff block k of the current term differs - term_diff integer; -- first differing block within the current term - null_order integer; -- for a pair with a NULL element: -1 / 1 - - still_eq integer := 1; -- 1 until the first unequal term - first_term integer := 1; - first_block integer := 0; - first_null integer := 0; -- the latched pair's NULL ordering, 0 if none - indicator integer; + 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 - m := least(cardinality(a), cardinality(b)); + 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); + 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; - FOR t IN 1..m LOOP -- NULL elements are structure, not data: they may branch. - IF a[t] IS NULL OR b[t] IS NULL THEN - differs := ((a[t] IS NULL) <> (b[t] IS NULL))::int; - null_order := CASE WHEN a[t] IS NULL THEN -1 ELSE 1 END; - term_diff := 0; + 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 bit_length(a[t].bytes) != bit_length(b[t].bytes) THEN + IF octet_length(ab) != octet_length(bb) THEN RAISE EXCEPTION 'Ciphertexts are different lengths'; END IF; - IF octet_length(a[t].bytes) <= 16 OR (octet_length(a[t].bytes) - 16) % 49 != 0 THEN - RAISE EXCEPTION 'Malformed ORE term: % bytes', octet_length(a[t].bytes); - END IF; - - ab := a[t].bytes; - bb := b[t].bytes; - n := (octet_length(ab) - 16) / 49; - left_offset := 1 + n; - - diff_mask := 0; - FOR block IN 0..n-1 LOOP - diff_mask := diff_mask | (( - (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 - ) << block); - END LOOP; - differs := (diff_mask <> 0)::int; - term_diff := round(ln(greatest(diff_mask & -diff_mask, 1)) / ln(2))::int; + -- 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_term := first_term + still_eq * differs * (t - 1); - first_block := first_block + still_eq * differs * term_diff; - first_null := first_null + still_eq * differs * null_order; - still_eq := still_eq * (1 - differs); + first_null := first_null + (latch = 0)::int * differs * null_order; + latch := latch + (latch = 0)::int * differs * t; END LOOP; - -- Every paired term equal: the shorter array sorts first. - IF still_eq = 1 THEN - RETURN sign(cardinality(a) - cardinality(b))::integer; - END IF; - - -- The first unequal pair had a NULL element: its order is structural. - IF first_null <> 0 THEN - RETURN first_null; - END IF; - - -- One hash and one bit read, on the latched term and block. - ab := a[first_term].bytes; - bb := b[first_term].bytes; - n := (octet_length(ab) - 16) / 49; - left_offset := 1 + n; - right_offset := 17 * n; - indicator := ( - get_bit( - encrypt( - substr(ab, left_offset + left_block_size * first_block, left_block_size), - substr(bb, right_offset + 1, 16), - 'aes-ecb'), - 0) - + get_bit( - substr(bb, right_offset + 17 + right_block_size * first_block, right_block_size), - get_byte(ab, first_block))) % 2; - - RETURN 2 * indicator - 1; + -- 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; diff --git a/packages/eql/tests/timing/ore_block_256/README.md b/packages/eql/tests/timing/ore_block_256/README.md index bd2a24eae..98ad7b8b7 100644 --- a/packages/eql/tests/timing/ore_block_256/README.md +++ b/packages/eql/tests/timing/ore_block_256/README.md @@ -75,7 +75,7 @@ samples of 16 comparisons and 50,000 six-term samples of 8, two runs each: | comparator | single term: t | six terms: t | cost | |---|---:|---:|---| | before | +4.9, +13.9 | −1552, −2575 | 155 µs per 16 single-term (median); 11 µs (first term differs) to 47 µs (sixth differs) per six-term comparison | -| after | +1.8, +1.1 | +4.5, +2.7 | about 3–8% more single-term; 31 µs per six-term comparison, either way | +| after | −0.78, +0.48 | +1.10, −1.00 | about 6% more single-term; 18 µs per six-term comparison, either way | The "before" single-term figures are small only by coincidence. Each block after the first difference ran extra statements, and the `OR` short-circuit From 599c0c22bc40f2a828fb8a87831c75213a593864 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 11 Oct 2026 23:06:33 +1100 Subject: [PATCH 4/5] test(eql): ORE comparator ordering test, harness classes for the closed paths Review follow-ups for the ORE block comparator work. - A creds-free ordering test (T4c in encrypted_domain/family/sem.rs). It encrypts u64 arrays in-process with ore-rs (the legacy 8-bit block scheme behind ore_block_256, already in the lockfile through cipherstash-client) under a fresh throwaway key, and checks every ordered pair against plaintext order: single terms first differing in each block, multi-term values sharing prefixes of every length, differences in early and late blocks of later terms, and a NULL second element against real terms. Nothing is committed and no CipherStash credentials are needed. Mutating the pair the comparator hashes, the block latch, or the NULL latch each fails it. - T4b builds its terms with ore-rs too, instead of synthetic blobs (its malformed term stays malformed on purpose). The module docs no longer describe the removed recursion. - The timing harness gains two classes for the paths the review found: an equal prefix of different-length arrays, and a NULL after equal terms, each against a first-term difference. Against the PR's first version they give t = -60 to -70; against the original comparator, +459 to +682; against the current one, |t| < 1 (50k samples of 8). - run.sh refuses to start if an ore_timing schema already exists, and drops only the schema it created. - Dependabot covers the ctgen crate's lockfile. --- .github/dependabot.yml | 8 +- packages/eql/Cargo.lock | 2 + packages/eql/tests/sqlx/Cargo.toml | 8 + .../sqlx/tests/encrypted_domain/family/sem.rs | 155 +++++++++++++++++- .../eql/tests/timing/ore_block_256/README.md | 39 ++++- .../tests/timing/ore_block_256/harness.sql | 20 ++- .../eql/tests/timing/ore_block_256/run.sh | 29 +++- 7 files changed, 233 insertions(+), 28 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6b4c31cb3..6b44c246e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -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: - / @@ -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 diff --git a/packages/eql/Cargo.lock b/packages/eql/Cargo.lock index e053231f3..34324b127 100644 --- a/packages/eql/Cargo.lock +++ b/packages/eql/Cargo.lock @@ -1270,8 +1270,10 @@ dependencies = [ "eql-tests-macros", "hex", "jsonschema", + "ore-rs", "paste", "proptest", + "rand 0.8.6", "rust_decimal", "serde", "serde_json", diff --git a/packages/eql/tests/sqlx/Cargo.toml b/packages/eql/tests/sqlx/Cargo.toml index bcfeea4e6..d7944d53c 100644 --- a/packages/eql/tests/sqlx/Cargo.toml +++ b/packages/eql/tests/sqlx/Cargo.toml @@ -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 diff --git a/packages/eql/tests/sqlx/tests/encrypted_domain/family/sem.rs b/packages/eql/tests/sqlx/tests/encrypted_domain/family/sem.rs index 17ad5cab0..7a53f91b4 100644 --- a/packages/eql/tests/sqlx/tests/encrypted_domain/family/sem.rs +++ b/packages/eql/tests/sqlx/tests/encrypted_domain/family/sem.rs @@ -12,14 +12,21 @@ //! equal length, so the matrix never hits it). //! - T3: NULL-term ordering inside `compare_ore_block_256_term` — the //! `STRICT` comparison wrappers short-circuit before these branches run. -//! - T4: array-level NULL + empty/cardinality base cases of the recursion. +//! - T4: array-level NULL + empty/cardinality base cases of the array +//! comparator. +//! - T4b: the multi-term structure (equal prefixes, NULL elements, the +//! later-term length check). +//! - T4c: ordering against plaintext, single- and multi-term, with real ORE +//! ciphertexts encrypted in-process by ore-rs. //! - T5: presence checks (`has_*`) and the missing-`ob` RAISE. //! -//! These tests build terms directly from hex literals, so they need no fixture -//! data or table setup. +//! None needs fixture data or CipherStash credentials. Apart from T4c, these +//! tests build terms directly from hex literals. use anyhow::Result; use eql_tests::assert_raises; +use ore_rs::scheme::bit2::OreAes128ChaCha20; +use ore_rs::{OreCipher, OreEncrypt, OreOutput}; use sqlx::PgPool; /// A single term built directly from hex — no encryption needed for the @@ -28,6 +35,21 @@ fn term(hex: &str) -> String { format!("ROW(decode('{hex}', 'hex'))::eql_v3_internal.ore_block_256_term") } +/// An ore-rs cipher for the legacy 8-bit block scheme behind `ore_block_256`, +/// under a fresh throwaway key: real ciphertexts without CipherStash +/// credentials. +fn ore_cipher() -> Result { + let (k1, k2): ([u8; 16], [u8; 16]) = (rand::random(), rand::random()); + OreCipher::init(&k1, &k2).map_err(|e| anyhow::anyhow!("ORE init: {e:?}")) +} + +/// The hex of `x`'s ORE term (408 bytes, N = 8). +fn ore_hex(ore: &OreAes128ChaCha20, x: u64) -> Result { + x.encrypt(ore) + .map(|ct| hex::encode(ct.to_bytes())) + .map_err(|e| anyhow::anyhow!("ORE encrypt: {e:?}")) +} + /// T2 — The term comparator must reject ciphertexts of different lengths. This /// guard is unreachable via the matrix (every real fixture is equal length). #[sqlx::test] @@ -72,7 +94,7 @@ async fn ore_term_comparator_null_ordering(pool: PgPool) -> Result<()> { Ok(()) } -/// T4 — Array-level NULL and empty/cardinality base cases of the recursive +/// T4 — Array-level NULL and empty/cardinality base cases of /// `compare_ore_block_256_terms(term[], term[])`. NULL array → NULL; /// both empty → 0; empty vs non-empty → -1; non-empty vs empty → 1. #[sqlx::test] @@ -119,14 +141,16 @@ async fn ore_terms_array_null_and_empty_base_cases(pool: PgPool) -> Result<()> { /// prefix sorts the shorter array first; NULL elements order before /// non-NULL ones and equal each other; and every paired term is /// length-checked, including one after the first unequal pair (the -/// term-by-term form stopped before reaching it). Creds-free: identical -/// hand-built 65-byte terms (N = 1) compare equal by construction. +/// term-by-term form stopped before reaching it). `t` and `u` are real +/// ore-rs terms; `short` is malformed on purpose, one byte short of N = 8. #[sqlx::test] async fn ore_terms_array_multi_term_structure(pool: PgPool) -> Result<()> { - let t = "ROW(repeat('a', 65)::bytea)::eql_v3_internal.ore_block_256_term"; - let u = "ROW(repeat('b', 65)::bytea)::eql_v3_internal.ore_block_256_term"; + let ore = ore_cipher()?; + let t = term(&ore_hex(&ore, 1)?); + let u = term(&ore_hex(&ore, 2)?); let n = "NULL::eql_v3_internal.ore_block_256_term"; - let short = "ROW(repeat('a', 64)::bytea)::eql_v3_internal.ore_block_256_term"; + let short = term(&ore_hex(&ore, 1)?[2..]); + let (t, u, short) = (t.as_str(), u.as_str(), short.as_str()); let cmp = |x: String, y: String| { format!("SELECT eql_v3_internal.compare_ore_block_256_terms({x}, {y})") }; @@ -177,6 +201,119 @@ async fn ore_terms_array_multi_term_structure(pool: PgPool) -> Result<()> { Ok(()) } +/// u64 arrays whose pairwise comparisons reach every part of the ORE +/// comparator: single terms first differing in each of the eight blocks, and +/// multi-term (text-shaped) values that share prefixes of every length, +/// differ in an early or late block of a later term, or extend one another. +fn ore_vectors() -> Vec> { + let b: u64 = 0x8040_2010_0804_0201; + let p: [u64; 6] = [ + 0x0123_4567_89ab_cdef, + 0xfedc_ba98_7654_3210, + 0x0f1e_2d3c_4b5a_6978, + 0x8796_a5b4_c3d2_e1f0, + 0x1122_3344_5566_7788, + 0x99aa_bbcc_ddee_ff00, + ]; + let mut v: Vec> = vec![vec![0], vec![u64::MAX], vec![b]]; + // Block k is byte k from the most significant end. + for k in 0..8 { + v.push(vec![b ^ (0x10u64 << (8 * (7 - k)))]); + } + for len in 1..=6 { + v.push(p[..len].to_vec()); + } + for (term, bit) in [(1, 63), (1, 0), (2, 56), (2, 7), (5, 31), (5, 0)] { + let mut x = p.to_vec(); + x[term] ^= 1 << bit; + v.push(x); + } + v.push(vec![p[0], p[1], p[2], 0]); + v.push(vec![p[0], p[1], p[2], u64::MAX]); + v.push(vec![p[0] ^ 1, p[1]]); + v +} + +/// T4c — The array comparator orders like plaintext. The scalar matrix +/// covers ordering only with credential-generated fixtures, and the text ones +/// rarely share a whole leading term, so this pins the multi-term path (a +/// latched pair after an equal prefix, a first difference in a late block) +/// without credentials. The values in `ore_vectors` are encrypted here with +/// ore-rs (the legacy 8-bit block scheme behind `ore_block_256`) under a +/// fresh throwaway key: real ciphertexts, made on every run. `numeric[]` +/// compares lexicographically with the shorter array first on an equal +/// prefix, as the comparator does. +#[sqlx::test] +async fn ore_terms_array_orders_like_plaintext(pool: PgPool) -> Result<()> { + let ore = ore_cipher()?; + + sqlx::query( + "CREATE TABLE ore_block_256_vectors ( + id integer PRIMARY KEY, + plain numeric[] NOT NULL, + terms eql_v3_internal.ore_block_256_term[] NOT NULL)", + ) + .execute(&pool) + .await?; + for (id, xs) in ore_vectors().iter().enumerate() { + let plain: Vec = xs.iter().map(|x| x.to_string()).collect(); + let terms = xs + .iter() + .map(|&x| ore_hex(&ore, x)) + .collect::>>()?; + sqlx::query( + "INSERT INTO ore_block_256_vectors + SELECT $1, $2::text[]::numeric[], + array_agg(ROW(decode(h, 'hex'))::eql_v3_internal.ore_block_256_term ORDER BY o) + FROM unnest($3::text[]) WITH ORDINALITY AS u(h, o)", + ) + .bind(id as i32) + .bind(&plain) + .bind(&terms) + .execute(&pool) + .await?; + } + + let (pairs, wrong): (i64, Vec) = sqlx::query_as( + "WITH c AS ( + SELECT x.id AS i, y.id AS j, + eql_v3_internal.compare_ore_block_256_terms(x.terms, y.terms) AS got, + CASE WHEN x.plain < y.plain THEN -1 + WHEN x.plain > y.plain THEN 1 ELSE 0 END AS want + FROM ore_block_256_vectors x CROSS JOIN ore_block_256_vectors y) + SELECT count(*), + coalesce(array_agg(format('%s vs %s: got %s, want %s', i, j, got, want)) + FILTER (WHERE got IS DISTINCT FROM want), '{}') + FROM c", + ) + .fetch_one(&pool) + .await?; + assert_eq!(pairs, (ore_vectors().len() * ore_vectors().len()) as i64); + assert!(wrong.is_empty(), "misordered pairs: {wrong:?}"); + + // A NULL second element against a real second term: after an equal first + // term the NULL sorts first; otherwise the first terms decide. Both ways + // round. + let wrong: Vec = sqlx::query_scalar( + "WITH c AS ( + SELECT x.id AS i, y.id AS j, + x.terms[1:1] || NULL::eql_v3_internal.ore_block_256_term AS a, y.terms AS b, + CASE WHEN x.plain[1] = y.plain[1] THEN -1 + ELSE sign(x.plain[1] - y.plain[1])::int END AS want + FROM ore_block_256_vectors x CROSS JOIN ore_block_256_vectors y + WHERE cardinality(y.terms) >= 2) + SELECT coalesce(array_agg(format('[%s, NULL] vs %s', i, j)) + FILTER (WHERE eql_v3_internal.compare_ore_block_256_terms(a, b) IS DISTINCT FROM want + OR eql_v3_internal.compare_ore_block_256_terms(b, a) IS DISTINCT FROM -want), + '{}') + FROM c", + ) + .fetch_one(&pool) + .await?; + assert!(wrong.is_empty(), "misordered NULL pairs: {wrong:?}"); + Ok(()) +} + /// T5 — SEM presence checks (`has_ore_block_256`, `has_hmac_256`), the /// extractor's missing-`ob` and non-array-`ob` RAISEs, and its NULL-jsonb /// short-circuit. diff --git a/packages/eql/tests/timing/ore_block_256/README.md b/packages/eql/tests/timing/ore_block_256/README.md index 98ad7b8b7..945a0647f 100644 --- a/packages/eql/tests/timing/ore_block_256/README.md +++ b/packages/eql/tests/timing/ore_block_256/README.md @@ -40,15 +40,25 @@ It needs `psql` and `cargo`, and no CipherStash credentials: `ctgen/` encrypts with ore-rs under a random throwaway key. Run it on a quiet machine. It is not a CI test, because timing on shared runners is too noisy to gate on. +Everything it creates lives in an `ore_timing` schema, dropped on exit. If that +schema already exists, it refuses to run rather than touch it. + ## Method `ctgen` produces real legacy ORE ciphertexts (the 8-bit block scheme, 408 -bytes per u64 term) in two shapes, plus a correctness set: +bytes per u64 term) in two shapes, from which the harness builds two more, +plus a correctness set: - **single term**: pairs differing in the first block against pairs differing only in the last (block 7); - **six terms** (text-shaped values): pairs differing in the first term against pairs differing only in the sixth; +- **prefix**: five terms against six, where the five equal the first five of + the six (the order is settled by the term counts), against five terms + against six that differ in the first term; +- **NULL element**: five terms and a NULL against six, where the five equal + the first five of the six (the order is settled by the NULL), against the + same with a first-term difference; - **checks**: 1,500 random pairs of 1 to 6 terms, with shared prefixes of every length, compared against plaintext order and for antisymmetry. The harness refuses to time a comparator that gets any of them wrong. @@ -57,7 +67,7 @@ Sampling follows dudect, with three lessons from the ore.rs benches built in: 1. **Both classes share one table, in random order.** With separate storage per class, placement alone produced a timing difference. -2. **Several comparisons per sample** (16 single-term, 8 six-term), so a +2. **Several comparisons per sample** (16 single-term, 8 multi-term), so a sample spans many ticks of `clock_timestamp()`'s microsecond clock. 3. **The plain Welch t over every sample**, not a percentile-cropped maximum. Cropping turns a difference in spread into a large t with no difference in @@ -69,15 +79,26 @@ difference. ## Results -PostgreSQL 17.11, aarch64 (Docker on an Apple M1 Max), 200,000 single-term -samples of 16 comparisons and 50,000 six-term samples of 8, two runs each: +PostgreSQL 17.11, aarch64 (Docker on an Apple M1 Max). Welch t for two runs +each; the current comparator at 200,000 single-term samples of 16 comparisons +and 50,000 multi-term samples of 8 per class: + +| comparator | single term | six terms | prefix | NULL element | +|---|---:|---:|---:|---:| +| original (term by term) | +4.9, +13.9 | −1552, −2575 | +517, +459 ¹ | +520, +682 ¹ | +| first constant-count version ² | +1.8, +1.1 | +4.5, +2.7 | −62, −69 ³ | −60, −70 ³ | +| current | +2.24, +0.15 | +1.03, −0.27 | −0.68, +0.18 | −0.57, +0.14 | + +¹ 5,000 samples per run. ² It scanned every block of every term, and skipped +its hash when the term counts or a NULL settled the order. ³ 10,000 samples +per run. -| comparator | single term: t | six terms: t | cost | -|---|---:|---:|---| -| before | +4.9, +13.9 | −1552, −2575 | 155 µs per 16 single-term (median); 11 µs (first term differs) to 47 µs (sixth differs) per six-term comparison | -| after | −0.78, +0.48 | +1.10, −1.00 | about 6% more single-term; 18 µs per six-term comparison, either way | +Cost: the current comparator takes about 6% more than the original per +single-term comparison, and about 18 µs per six-term comparison whatever the +operands. The original took 11 µs when the first term differs and 47 µs when +only the sixth does; the first constant-count version took 31 µs. -The "before" single-term figures are small only by coincidence. Each block +The original's single-term figures are small only by coincidence. Each block after the first difference ran extra statements, and the `OR` short-circuit skipped a comparison on the same blocks; on this machine the two nearly cancel. With the short-circuit removed, the same code gave t = +388, about diff --git a/packages/eql/tests/timing/ore_block_256/harness.sql b/packages/eql/tests/timing/ore_block_256/harness.sql index bfa59243d..1013e419f 100644 --- a/packages/eql/tests/timing/ore_block_256/harness.sql +++ b/packages/eql/tests/timing/ore_block_256/harness.sql @@ -1,7 +1,7 @@ -- ORE comparator timing harness. Loaded by run.sh into a database with EQL -- installed, after run.sh has created the `ore_timing` schema and loaded the -- ctgen output into ore_timing.staging (`\copy` cannot take a psql variable). --- Everything lives in `ore_timing`, which run.sh drops afterwards. +-- Everything lives in `ore_timing`, which run.sh creates and drops afterwards. SET search_path = ore_timing, public; @@ -24,6 +24,24 @@ CREATE TABLE pairs AS (label = 'last')::int AS cls, terms_of(a_hex) AS a, terms_of(b_hex) AS b FROM staging WHERE kind LIKE 'timing%'; +-- Two shapes built from the six-term pairs, where a and b differ in term 1. +-- Class 1 in both is a's first five terms against b (term 1 differs). Class 0: +-- prefix: a's first five terms against all six of a: every paired term is +-- equal and the term counts differ; +-- null: a's first five terms and a NULL against a: the first unequal pair +-- is the NULL one, after five equal terms. +-- A comparator that skips its hash once the order is settled by structure +-- shows a difference here. +INSERT INTO pairs + SELECT kind, row_number() OVER (PARTITION BY kind ORDER BY random()), cls, a, b FROM ( + SELECT 'prefix' AS kind, 0 AS cls, a[1:5] AS a, a AS b FROM pairs WHERE kind = 'timing6' AND cls = 0 + UNION ALL SELECT 'prefix', 1, a[1:5], b FROM pairs WHERE kind = 'timing6' AND cls = 0 + UNION ALL SELECT 'null', 0, a[1:5] || NULL::eql_v3_internal.ore_block_256_term, a + FROM pairs WHERE kind = 'timing6' AND cls = 0 + UNION ALL SELECT 'null', 1, a[1:5] || NULL::eql_v3_internal.ore_block_256_term, b + FROM pairs WHERE kind = 'timing6' AND cls = 0 + ) s; + -- 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() diff --git a/packages/eql/tests/timing/ore_block_256/run.sh b/packages/eql/tests/timing/ore_block_256/run.sh index 2af840551..f26dd767e 100755 --- a/packages/eql/tests/timing/ore_block_256/run.sh +++ b/packages/eql/tests/timing/ore_block_256/run.sh @@ -14,18 +14,29 @@ samples="${1:-200000}" here="$(cd "$(dirname "$0")" && pwd)" : "${DATABASE_URL:?set DATABASE_URL to a database with EQL installed}" +# Everything lives in the ore_timing schema, which is dropped on exit. Refuse +# to start if it already exists, so a run never drops a schema it did not make. +if [ "$(psql "$DATABASE_URL" -tA -c "SELECT to_regnamespace('ore_timing') IS NOT NULL")" != "f" ]; then + echo "schema ore_timing already exists (or the database is unreachable); not touching it" >&2 + exit 1 +fi + tmp="$(mktemp -d)" +created=0 cleanup() { rm -rf "$tmp" - psql "$DATABASE_URL" -q -c "DROP SCHEMA IF EXISTS ore_timing CASCADE" >/dev/null 2>&1 || true + if [ "$created" = 1 ]; then + psql "$DATABASE_URL" -q -c "DROP SCHEMA ore_timing CASCADE" >/dev/null 2>&1 || true + fi } trap cleanup EXIT echo "== generating ciphertexts (ore-rs, throwaway key)" cargo run --quiet --release --manifest-path "$here/ctgen/Cargo.toml" -- 256 >"$tmp/ct.tsv" -psql "$DATABASE_URL" -q -v ON_ERROR_STOP=1 -c "DROP SCHEMA IF EXISTS ore_timing CASCADE" \ - -c "CREATE SCHEMA ore_timing" \ +psql "$DATABASE_URL" -q -v ON_ERROR_STOP=1 -c "CREATE SCHEMA ore_timing" >/dev/null +created=1 +psql "$DATABASE_URL" -q -v ON_ERROR_STOP=1 \ -c "CREATE TABLE ore_timing.staging (kind text, label text, a_hex text, b_hex text)" \ -c "\\copy ore_timing.staging FROM '$tmp/ct.tsv'" >/dev/null psql "$DATABASE_URL" -q -v ON_ERROR_STOP=1 -f "$here/harness.sql" >/dev/null @@ -38,14 +49,20 @@ wrong="$(psql "$DATABASE_URL" -tA -c " echo " wrong: $wrong" [ "$wrong" = "0" ] || { echo "comparator disagrees with plaintext order; not timing"; exit 1; } -echo "== timing (two runs per kind; classes: values first differ early vs late)" +echo "== timing (two runs per kind; classes: see README.md)" for run in 1 2; do psql "$DATABASE_URL" -q -v ON_ERROR_STOP=1 -c " CREATE TABLE ore_timing.single_$run AS SELECT * FROM ore_timing.bench('timing1', $samples, 16); - CREATE TABLE ore_timing.six_$run AS SELECT * FROM ore_timing.bench('timing6', $((samples / 4)), 8);" + CREATE TABLE ore_timing.six_$run AS SELECT * FROM ore_timing.bench('timing6', $((samples / 4)), 8); + CREATE TABLE ore_timing.prefix_$run AS SELECT * FROM ore_timing.bench('prefix', $((samples / 4)), 8); + CREATE TABLE ore_timing.null_$run AS SELECT * FROM ore_timing.bench('null', $((samples / 4)), 8);" done psql "$DATABASE_URL" -c " SELECT * FROM ore_timing.welch('single term, 16/sample #1', 'ore_timing.single_1') UNION ALL SELECT * FROM ore_timing.welch('single term, 16/sample #2', 'ore_timing.single_2') UNION ALL SELECT * FROM ore_timing.welch('six terms, 8/sample #1', 'ore_timing.six_1') - UNION ALL SELECT * FROM ore_timing.welch('six terms, 8/sample #2', 'ore_timing.six_2');" + UNION ALL SELECT * FROM ore_timing.welch('six terms, 8/sample #2', 'ore_timing.six_2') + UNION ALL SELECT * FROM ore_timing.welch('prefix, 8/sample #1', 'ore_timing.prefix_1') + UNION ALL SELECT * FROM ore_timing.welch('prefix, 8/sample #2', 'ore_timing.prefix_2') + UNION ALL SELECT * FROM ore_timing.welch('NULL element, 8/sample #1', 'ore_timing.null_1') + UNION ALL SELECT * FROM ore_timing.welch('NULL element, 8/sample #2', 'ore_timing.null_2');" From 4be909e9025a39c0bb1b67ca64f4361d94d81af0 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 12 Oct 2026 09:35:29 +1100 Subject: [PATCH 5/5] docs(eql): say the timing harness samples pairs with replacement The harness comment called each sample's pairs distinct; they are drawn independently, with replacement, from a pool of the same size for both classes. --- packages/eql/tests/timing/ore_block_256/README.md | 3 ++- packages/eql/tests/timing/ore_block_256/harness.sql | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/eql/tests/timing/ore_block_256/README.md b/packages/eql/tests/timing/ore_block_256/README.md index 945a0647f..0bc69f22e 100644 --- a/packages/eql/tests/timing/ore_block_256/README.md +++ b/packages/eql/tests/timing/ore_block_256/README.md @@ -68,7 +68,8 @@ Sampling follows dudect, with three lessons from the ore.rs benches built in: 1. **Both classes share one table, in random order.** With separate storage per class, placement alone produced a timing difference. 2. **Several comparisons per sample** (16 single-term, 8 multi-term), so a - sample spans many ticks of `clock_timestamp()`'s microsecond clock. + sample spans many ticks of `clock_timestamp()`'s microsecond clock. Each + comparison's pair is drawn at random, with replacement, from that class. 3. **The plain Welch t over every sample**, not a percentile-cropped maximum. Cropping turns a difference in spread into a large t with no difference in mean. diff --git a/packages/eql/tests/timing/ore_block_256/harness.sql b/packages/eql/tests/timing/ore_block_256/harness.sql index 1013e419f..6e7b1c353 100644 --- a/packages/eql/tests/timing/ore_block_256/harness.sql +++ b/packages/eql/tests/timing/ore_block_256/harness.sql @@ -43,8 +43,10 @@ INSERT INTO pairs ) s; -- 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() +-- `per_sample` comparisons of pairs of that class, each drawn at random with +-- replacement (a sample can repeat a pair; both classes are drawn the same +-- way from pools of the same size), through compare_ore_block_256_terms +-- (the operator-class path). clock_timestamp() -- ticks in microseconds, so a sample must span many of them. CREATE FUNCTION bench(which_kind text, samples int, per_sample int) RETURNS TABLE (cls int, us double precision)