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..069f5a6a4 --- /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 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/.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/src/v3/sem/ore_block_256/functions.sql b/packages/eql/src/v3/sem/ore_block_256/functions.sql index 45f31fdce..13a77ce13 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,34 @@ $$ LANGUAGE plpgsql; --! is itself `IMMUTABLE STRICT PARALLEL SAFE` — so `IMMUTABLE` lets the --! planner fold/cache these in ordering and index contexts. NOT `STRICT`: --! the NULL-handling branches below are load-bearing for the array overload. +--! @note Constant operation count. Past the NULL and length checks (public +--! properties), every comparison executes the same statements whatever the +--! operands: one statement per block, which evaluates both of the block's +--! comparisons (integer `|`, which unlike `OR` does not short-circuit) and +--! latches the first differing block with arithmetic rather than an `IF`; +--! then one `encrypt()`, two `get_bit()` and one `get_byte()`, on every +--! path including for equal terms. An earlier form ran extra statements on +--! every block after the first difference and skipped a comparison on each +--! of them, so its time depended on where the operands first differ, which +--! is the information the constant-time comparator in ore.rs keeps from +--! anyone timing a query. Measured on PostgreSQL 17 (aarch64) through +--! `compare_ore_block_256_terms`, 200 k samples of 16 comparisons, operands +--! differing in the first block against operands differing only in the +--! last: the old form gave t = +4.9 and +13.9 (its extra statements and its +--! skipped comparisons mostly cancel on that machine, which no platform +--! guarantees; with the short-circuit removed it gave t = +388, about +--! 0.7 µs per comparison). See `tests/timing/ore_block_256/` for this +--! form's figures. PL/pgSQL is not a constant-time environment, so C-level +--! effects remain (`memcmp` within 16 bytes, the offset `substr` reads at), +--! orders of magnitude below one statement. CREATE FUNCTION eql_v3_internal.compare_ore_block_256_term(a eql_v3_internal.ore_block_256_term, b eql_v3_internal.ore_block_256_term) RETURNS integer IMMUTABLE SET search_path = pg_catalog, extensions, public AS $$ DECLARE - eq boolean := true; - unequal_block smallint := 0; - hash_key bytea; - data_block bytea; - encrypt_block bytea; - target_block bytea; + ab bytea; + bb bytea; left_block_size CONSTANT smallint := 16; right_block_size CONSTANT smallint := 32; @@ -147,7 +163,9 @@ AS $$ left_offset integer; -- ordinal offset of the first left block (1 + N PRP bytes) right_offset integer; -- ordinal start of the right CT (= total left CT length = 17*N) - indicator smallint := 0; + latch integer := 0; -- 1 + the first differing block, 0 while none differs + first_diff integer; -- first differing block (0 if the terms are equal) + indicator integer; BEGIN IF a IS NULL AND b IS NULL THEN RETURN 0; @@ -173,64 +191,77 @@ AS $$ RAISE EXCEPTION 'Malformed ORE term: % bytes', octet_length(a.bytes); END IF; - n := (octet_length(a.bytes) - 16) / 49; + ab := a.bytes; + bb := b.bytes; + n := (octet_length(ab) - 16) / 49; left_offset := 1 + n; -- left blocks begin right after the N PRP bytes right_offset := 17 * n; -- right CT begins right after the 17*N-byte left CT + -- One statement per block, the same for every block: both comparisons + -- always evaluated, and the first differing block latched as block + 1 + -- while the latch is still 0. FOR block IN 0..n-1 LOOP - -- Compare each PRP byte (the first N bytes) and its 16-byte left block. - IF - substr(a.bytes, 1 + block, 1) != substr(b.bytes, 1 + block, 1) - OR substr(a.bytes, left_offset + left_block_size * block, left_block_size) != substr(b.bytes, left_offset + left_block_size * block, left_block_size) - THEN - IF eq THEN - unequal_block := block; - END IF; - eq = false; - END IF; + latch := latch + (latch = 0)::int * (block + 1) * ( + (substr(ab, 1 + block, 1) <> substr(bb, 1 + block, 1))::int + | (substr(ab, left_offset + left_block_size * block, left_block_size) + <> substr(bb, left_offset + left_block_size * block, left_block_size))::int); END LOOP; - IF eq THEN - RETURN 0::integer; - END IF; - - -- Hash key is the IV from the right CT of b. - hash_key := substr(b.bytes, right_offset + 1, 16); - - -- First right block is at right_offset + nonce_size (ordinally indexed). - target_block := substr(b.bytes, right_offset + 17 + (unequal_block * right_block_size), right_block_size); - - data_block := substr(a.bytes, left_offset + (left_block_size * unequal_block), left_block_size); - - encrypt_block := encrypt(data_block::bytea, hash_key::bytea, 'aes-ecb'); + -- Equal terms read block 0. + first_diff := greatest(latch - 1, 0); + -- One hash and one bit read on every path. For equal terms this reads + -- block 0 and the result is discarded below. The hash key is the IV at + -- the start of b's right CT; the right blocks follow it. indicator := ( get_bit( - encrypt_block, - 0 - ) + get_bit(target_block, get_byte(a.bytes, unequal_block))) % 2; - - IF indicator = 1 THEN - RETURN 1::integer; - ELSE - RETURN -1::integer; - END IF; + encrypt( + substr(ab, left_offset + left_block_size * first_diff, left_block_size), + substr(bb, right_offset + 1, 16), + 'aes-ecb'), + 0) + + get_bit( + substr(bb, right_offset + 17 + right_block_size * first_diff, right_block_size), + get_byte(ab, first_diff))) % 2; + + -- 0 when equal, otherwise +1 / -1 from the indicator; no branch on the data. + RETURN sign(latch) * (2 * indicator - 1); END; $$ LANGUAGE plpgsql; ---! @brief Compare arrays of ORE block terms recursively +--! @brief Compare arrays of ORE block terms lexicographically --! @internal --! @param a eql_v3_internal.ore_block_256_term[] First array --! @param b eql_v3_internal.ore_block_256_term[] Second array --! @return integer -1/0/1, or NULL if either array is NULL +--! @throws Exception if paired terms are different lengths, or any paired +--! term is malformed +--! @note Single-term values (every type but text) go straight to +--! `compare_ore_block_256_term`. Multi-term values (text, up to six terms) +--! are compared with a constant operation count across the terms. A +--! term-by-term comparison that stops at the first unequal term takes time +--! proportional to how many leading terms are equal, which reveals the +--! length of the shared prefix: measured on PostgreSQL 17, six-term values +--! took about 11 µs when the first terms differ and 47 µs when only the +--! sixth does (t below -1500). Instead, every paired term is compared whole +--! in one statement (its PRP bytes and left blocks, which are deterministic) +--! and the first unequal one is latched with arithmetic. Then exactly one +--! call to `compare_ore_block_256_term` runs on every path: on the latched +--! pair, or, when every paired term is equal or the latched pair has a NULL +--! element, on a pair whose result is discarded. The result is selected +--! with arithmetic, so neither a shared prefix of different-length arrays +--! nor a NULL element after equal terms skips the hash. Term counts and the +--! positions of NULL elements are public structure and may branch. If every +--! paired term is equal, the shorter array sorts first. C-level effects +--! remain: the whole-term `memcmp` stops at the first differing byte, a +--! few nanoseconds over at most a few hundred bytes. See +--! `tests/timing/ore_block_256/`. CREATE FUNCTION eql_v3_internal.compare_ore_block_256_terms(a eql_v3_internal.ore_block_256_term[], b eql_v3_internal.ore_block_256_term[]) RETURNS integer IMMUTABLE SET search_path = pg_catalog, extensions, public AS $$ - DECLARE - cmp_result integer; BEGIN IF a IS NULL OR b IS NULL THEN RETURN NULL; @@ -248,13 +279,71 @@ AS $$ RETURN 1; END IF; - cmp_result := eql_v3_internal.compare_ore_block_256_term(a[1], b[1]); - - IF cmp_result = 0 THEN - RETURN eql_v3_internal.compare_ore_block_256_terms(a[2:array_length(a,1)], b[2:array_length(b,1)]); + IF cardinality(a) = 1 AND cardinality(b) = 1 THEN + RETURN eql_v3_internal.compare_ore_block_256_term(a[1], b[1]); END IF; - RETURN cmp_result; + -- Multi-term path. Its variables are declared in this inner block, not at + -- the top: PL/pgSQL evaluates every initialised declaration on each call, + -- and the single-term path above is the operator-class hot path. + DECLARE + ab bytea; + bb bytea; + differs integer; -- 1 if the current pair differs + null_order integer; -- the current pair's order if it has a NULL element, else 0 + + latch integer := 0; -- the first unequal pair's index, 0 while every pair is equal + first_null integer := 0; -- the latched pair's NULL ordering, 0 if it has none + spare integer := 0; -- the first pair with no NULL element (structure), 0 if none + h integer; -- the pair compare_ore_block_256_term runs on + r integer; + BEGIN + FOR t IN 1..least(cardinality(a), cardinality(b)) LOOP + ab := a[t].bytes; + bb := b[t].bytes; + + IF ab IS NOT NULL AND (octet_length(ab) <= 16 OR (octet_length(ab) - 16) % 49 != 0) THEN + RAISE EXCEPTION 'Malformed ORE term: % bytes', octet_length(ab); + END IF; + IF bb IS NOT NULL AND (octet_length(bb) <= 16 OR (octet_length(bb) - 16) % 49 != 0) THEN + RAISE EXCEPTION 'Malformed ORE term: % bytes', octet_length(bb); + END IF; + + -- NULL elements are structure, not data: they may branch. + IF ab IS NULL OR bb IS NULL THEN + differs := ((ab IS NULL) <> (bb IS NULL))::int; + null_order := (bb IS NULL)::int - (ab IS NULL)::int; + ELSE + IF octet_length(ab) != octet_length(bb) THEN + RAISE EXCEPTION 'Ciphertexts are different lengths'; + END IF; + -- The PRP bytes and left blocks: the first 17*N bytes. + differs := (substr(ab, 1, 17 * ((octet_length(ab) - 16) / 49)) + <> substr(bb, 1, 17 * ((octet_length(ab) - 16) / 49)))::int; + null_order := 0; + IF spare = 0 THEN + spare := t; + END IF; + END IF; + + -- Latch the first unequal pair without a branch. + first_null := first_null + (latch = 0)::int * differs * null_order; + latch := latch + (latch = 0)::int * differs * t; + END LOOP; + + -- One term comparison on every path. It runs on the latched pair, or on + -- pair 1 when every pair is equal; if that pair has a NULL element, on + -- the first pair with none. Only when no pair is free of NULLs (which + -- is structure) does it take its NULL branch. + h := greatest(latch, 1); + h := h + ((a[h] IS NULL)::int | (b[h] IS NULL)::int) * (spare - h); + r := eql_v3_internal.compare_ore_block_256_term(a[h], b[h]); + + -- Every pair equal: the shorter array sorts first. Otherwise the + -- latched pair's NULL ordering, or the term comparison. + RETURN (latch = 0)::int * sign(cardinality(a) - cardinality(b))::integer + + (latch > 0)::int * (first_null + (first_null = 0)::int * r); + END; END $$ LANGUAGE plpgsql; 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 dabfdc7c6..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] @@ -113,6 +135,185 @@ 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). `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 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 = 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})") + }; + 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(()) +} + +/// 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/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). 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..0bc69f22e --- /dev/null +++ b/packages/eql/tests/timing/ore_block_256/README.md @@ -0,0 +1,106 @@ +# 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. + +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, 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. + +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. 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. + +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). 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. + +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 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 +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..6e7b1c353 --- /dev/null +++ b/packages/eql/tests/timing/ore_block_256/harness.sql @@ -0,0 +1,112 @@ +-- 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 creates and 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%'; + +-- 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 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) +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..f26dd767e --- /dev/null +++ b/packages/eql/tests/timing/ore_block_256/run.sh @@ -0,0 +1,68 @@ +#!/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}" + +# 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" + 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 "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 + +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: 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.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('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');"