|
| 1 | +//! fixture-suite (CIP-3141) cross-ciphertext equality test. |
| 2 | +//! |
| 3 | +//! Proves "two independent encryptions of one value compare equal" using the |
| 4 | +//! committed `fixtures.eql_v2_<T>_doubles` tables — each plaintext encrypted |
| 5 | +//! twice, so the table carries equal-plaintext / distinct-ciphertext rows. No |
| 6 | +//! fresh encryption, no creds: it reads the already-encrypted doubles, so it |
| 7 | +//! runs in the credential-free `mise run test:sqlx` path. Distinct from the |
| 8 | +//! matrix (which reads the curated `fixtures.eql_v2_<T>`) and from the e2e suite |
| 9 | +//! (which re-encrypts fresh duplicates each run). |
| 10 | +//! |
| 11 | +//! Each type asserts, on its doubles rows: |
| 12 | +//! 1. a distinct-ciphertext pair exists (an equal-plaintext pair whose |
| 13 | +//! `payload_json` differs) — so the equality assertions below are non-trivial; |
| 14 | +//! 2. `=` TRUE / `<>` FALSE across every pair through the `_eq` (hm/HMAC) domain |
| 15 | +//! (`assert_eq_oracle`); |
| 16 | +//! 3. the ordering operators agree with the plaintext oracle on both ordered |
| 17 | +//! twins (`assert_ord_oracle`), PLUS `=` TRUE / `<>` FALSE on an equal pair |
| 18 | +//! through `_ord` and `_ord_ore` — the ORE (`ob`) equality path, which routes |
| 19 | +//! `=` through `compare_ore_block_256_terms(...) = 0` (GUARANTEED equal for |
| 20 | +//! two independent encryptions of one value; see the ORE finding in the plan). |
| 21 | +//! |
| 22 | +//! `#[sqlx::test]` per type (its own migrated scratch DB), like the rest of the |
| 23 | +//! fixture suite. |
| 24 | +
|
| 25 | +use super::fixture_oracle::load_doubles_rows; |
| 26 | +use anyhow::Result; |
| 27 | +use eql_tests::property::{assert_eq_oracle, assert_ord_oracle, Row}; |
| 28 | +use eql_tests::scalar_domains::{ScalarDomainSpec, ScalarType, Variant}; |
| 29 | +use sqlx::PgPool; |
| 30 | + |
| 31 | +/// Find two rows with equal plaintext but DIFFERENT ciphertext, or fail. The |
| 32 | +/// doubles fixture encrypts each plaintext independently, so an equal-plaintext |
| 33 | +/// pair is expected to differ in ciphertext; a failure here means the fixture |
| 34 | +/// was not regenerated. |
| 35 | +fn first_distinct_ciphertext_pair<T: ScalarType>(rows: &[Row<T>]) -> Result<(&Row<T>, &Row<T>)> { |
| 36 | + for i in 0..rows.len() { |
| 37 | + for j in (i + 1)..rows.len() { |
| 38 | + if rows[i].plaintext == rows[j].plaintext |
| 39 | + && rows[i].payload_json != rows[j].payload_json |
| 40 | + { |
| 41 | + return Ok((&rows[i], &rows[j])); |
| 42 | + } |
| 43 | + } |
| 44 | + } |
| 45 | + anyhow::bail!( |
| 46 | + "doubles fixture for {} has no equal-plaintext/distinct-ciphertext pair; \ |
| 47 | + regenerate via mise run test:sqlx:prep", |
| 48 | + T::PG_TYPE |
| 49 | + ) |
| 50 | +} |
| 51 | + |
| 52 | +/// Assert `=` TRUE / `<>` FALSE for one equal-plaintext distinct-ciphertext pair |
| 53 | +/// on `variant`'s domain. Used for the ORE path (`Ord` / `OrdOre`), which routes |
| 54 | +/// `=` through `compare_ore_block_256_terms(...) = 0` — the assertion the |
| 55 | +/// plaintext ordering oracle does not itself make on the ordered twins. |
| 56 | +async fn assert_pair_eq_on<T: ScalarType>( |
| 57 | + pool: &PgPool, |
| 58 | + variant: Variant, |
| 59 | + a: &Row<T>, |
| 60 | + b: &Row<T>, |
| 61 | +) -> Result<()> { |
| 62 | + let domain = ScalarDomainSpec::new::<T>(variant).sql_domain; |
| 63 | + // `'<json>'::jsonb::<domain>` for each side; escape single quotes the same |
| 64 | + // way property.rs's `cast` does. |
| 65 | + let a_cast = format!("'{}'::jsonb::{domain}", a.payload_json.replace('\'', "''")); |
| 66 | + let b_cast = format!("'{}'::jsonb::{domain}", b.payload_json.replace('\'', "''")); |
| 67 | + let sql = format!("SELECT ({a_cast}) = ({b_cast}), ({a_cast}) <> ({b_cast})"); |
| 68 | + let (eq, neq): (Option<bool>, Option<bool>) = sqlx::query_as(&sql).fetch_one(pool).await?; |
| 69 | + anyhow::ensure!( |
| 70 | + eq == Some(true), |
| 71 | + "cross-ciphertext `=` on {domain} must be TRUE for equal plaintext, got {eq:?}" |
| 72 | + ); |
| 73 | + anyhow::ensure!( |
| 74 | + neq == Some(false), |
| 75 | + "cross-ciphertext `<>` on {domain} must be FALSE for equal plaintext, got {neq:?}" |
| 76 | + ); |
| 77 | + Ok(()) |
| 78 | +} |
| 79 | + |
| 80 | +/// The full cross-ciphertext check for an ordered scalar `T`. |
| 81 | +async fn assert_cross_ciphertext<T: ScalarType>(pool: &PgPool) -> Result<()> { |
| 82 | + let rows = load_doubles_rows::<T>(pool).await?; |
| 83 | + |
| 84 | + // (1) the doubles really are distinct ciphertext. |
| 85 | + let (a, b) = first_distinct_ciphertext_pair::<T>(&rows)?; |
| 86 | + |
| 87 | + // (2) hm/HMAC equality path across all pairs. |
| 88 | + assert_eq_oracle::<T>(pool, &rows).await?; |
| 89 | + |
| 90 | + // (3) ordering oracle on both ordered twins, plus the explicit ORE-path |
| 91 | + // equality on the distinct-ciphertext pair. |
| 92 | + assert_ord_oracle::<T>(pool, Variant::Ord, &rows).await?; |
| 93 | + assert_ord_oracle::<T>(pool, Variant::OrdOre, &rows).await?; |
| 94 | + assert_pair_eq_on::<T>(pool, Variant::Ord, a, b).await?; |
| 95 | + assert_pair_eq_on::<T>(pool, Variant::OrdOre, a, b).await?; |
| 96 | + Ok(()) |
| 97 | +} |
| 98 | + |
| 99 | +macro_rules! cross_ciphertext_test { |
| 100 | + ($name:ident, $ty:ty) => { |
| 101 | + #[sqlx::test] |
| 102 | + async fn $name(pool: PgPool) -> Result<()> { |
| 103 | + assert_cross_ciphertext::<$ty>(&pool).await |
| 104 | + } |
| 105 | + }; |
| 106 | +} |
| 107 | + |
| 108 | +cross_ciphertext_test!(cross_ciphertext_int2, i16); |
| 109 | +cross_ciphertext_test!(cross_ciphertext_int4, i32); |
| 110 | +cross_ciphertext_test!(cross_ciphertext_int8, i64); |
| 111 | +cross_ciphertext_test!(cross_ciphertext_date, chrono::NaiveDate); |
| 112 | +cross_ciphertext_test!(cross_ciphertext_timestamptz, chrono::DateTime<chrono::Utc>); |
| 113 | +cross_ciphertext_test!(cross_ciphertext_numeric, rust_decimal::Decimal); |
| 114 | +cross_ciphertext_test!(cross_ciphertext_text, String); |
0 commit comments