diff --git a/docs/benchmarks/2026-06-13-bit6-prp-results.md b/docs/benchmarks/2026-06-13-bit6-prp-results.md new file mode 100644 index 0000000..ee0c789 --- /dev/null +++ b/docs/benchmarks/2026-06-13-bit6-prp-results.md @@ -0,0 +1,51 @@ +# Benchmark results — Bit6 PRP swap to fixed-draw Fisher–Yates + +Apple M1 Max, rustc 1.87.0, hardware AES (`--cfg aes_armv8`), criterion +medians. Change: Bit6's per-block PRP moved from the rejection-sampled +Knuth shuffle to `LemireFyPrp` (fixed-count wide draws + Lemire reduction), +**seed-keyed shape (i)** — the drop-in replacement that fits the existing +`Prp::new(seed)` signature. + +| Bit6 benchmark | before (Knuth) | after (Lemire FY, shape i) | +|---|---:|---:| +| encrypt-u64 (11 blocks) | 11.5 µs | **8.6 µs** | +| encrypt-left-u64 | ~8.9 µs | **5.7 µs** | +| encrypt-u32 (6 blocks) | ~7.8 µs | **4.8 µs** | +| compare-u64 | 182 ns | 183 ns (unchanged) | + +## Why not the spike's 3.3 µs yet + +The spike's headline (153 ns/block, ≈3.3 µs encrypt) is the **pre-scheduled +stream, shape (ii)** — the PRP keystream produced under an already-scheduled +cipher, eliminating the AES key schedule per block (~172 ns × 11 ≈ 1.9 µs). +Shape (i), shipped here, still keys a fresh AES from each block's seed, so it +pays those 11 schedules — hence ~8.6 µs, not 3.3 µs. + +Shape (i) is the right increment for this PR: it fits the `Prp` trait with no +architectural change, and its security story is "identical key-usage +structure to the old PRP, with rejection sampling replaced by fixed-count +Lemire draws." Shape (ii) requires deriving the PRP stream under k2 / as a +PRF branch family, which is exactly the structure the §5(b) CMAC accumulator +introduces — so it lands with PR 6, under the same crypto review, and takes +Bit6 u64 encrypt from 8.6 µs to a projected ~3.3 µs. + +## What this PR's change actually buys now + +1. **Closes the timing channel.** Draw count is fixed and seed-independent; + no rejection loop. Encryption time no longer varies with the (plaintext- + derived) PRP seed. This is the security-relevant part and it ships now. +2. **Removes modulo/​rejection bias** in favour of a provable ≤ 2⁻⁵⁵ + statistical distance from uniform — a clean statistical term in the + Lewi-Wu argument. +3. **25% faster encrypt** even in shape (i), before the larger shape-(ii) + win. + +## End-to-end (u64 encrypt, for context) + +| Build | encrypt-u64 | +|---|---:| +| pre-v2 default (Bit8, software AES) | 381 µs | +| Bit8, hardware AES + bulk encoding (#80) | 25.1 µs | +| Bit6, Knuth PRP (#82 initial) | 11.5 µs | +| **Bit6, Lemire FY shape (i) (this change)** | **8.6 µs** | +| Bit6, Lemire FY shape (ii) (projected, PR 6) | ~3.3 µs | diff --git a/docs/plans/2026-06-12-ore-v2-architecture.md b/docs/plans/2026-06-12-ore-v2-architecture.md index 3dbb859..dac381e 100644 --- a/docs/plans/2026-06-12-ore-v2-architecture.md +++ b/docs/plans/2026-06-12-ore-v2-architecture.md @@ -281,7 +281,8 @@ software on aarch64 (no key-schedule instruction); estimated 5–20% overhead ag Bit6's ~130 batched AES ops per block. **Benchmark gate:** measure NEON key-expansion overhead before committing. -**Candidate B — CMAC with cached prefix state.** Every published value is a *bona +**Candidate B — CMAC with cached prefix state.** *(SELECTED; full design spec: +`docs/plans/2026-06-15-ore-v2-cmac-accumulator-spec.md`.)* Every published value is a *bona fide* AES-CMAC (NIST SP 800-38B) tag of an injectively encoded message — for block `i`: `enc(x₀‖0) ‖ … ‖ enc(x_{i-1}‖i−1) ‖ final_block(branch, value, i)` — and the per-prefix chaining-state cache is purely an implementation optimization (CMAC is CBC @@ -332,13 +333,115 @@ instance, so right-encryption throughput stays comparable to the packed scheme. **String semantics and leakage.** Strings are encoded as their UTF-8 bytes (optionally case-folded/normalised upstream — out of scope here), decomposed by the chosen width. -Lewi-Wu leaks the index of the first differing block; for strings that is **the length -of the common prefix**, which is materially more revealing than for fixed-width numerics. -This must be documented prominently on the string API, and is a product-level decision -about acceptable leakage, not something the library can engineer away. +A comparison reveals the index of the first differing block; for strings that is **the +length of the common prefix**, which is materially more revealing than for fixed-width +numerics. But this leakage is scoped to the **comparison operation**, not to stored +data, because Lewi-Wu is a left/right scheme: a comparison is only ever evaluated +between a left ciphertext and a right ciphertext, and a right ciphertext in isolation +reveals nothing about order. Three threat tiers follow: + +- **Offline / at rest (right-only storage, the default deployment):** an attacker who + exfiltrates the database holds only right ciphertexts, has no left ciphertext to + compare against, and recovers nothing — not order, and a fortiori not common-prefix + length. The offline case is clean. +- **Query time (legitimate operator):** running a query emits a left ciphertext, and + each comparison against the stored rights reveals first-differing-block (= + common-prefix length for strings) for exactly the pairs that query touches. +- **Online adversary observing queries:** an attacker who can watch enough query + traffic accumulates those per-comparison leakages and can reconstruct prefix + structure across the touched set. + +So the common-prefix disclosure is bounded to the **in-use / online** setting and never +applies to data at rest. This must still be documented prominently on the string API, +and the residual query-time/online leakage is a product-level decision about acceptable +leakage — but it is a narrower decision than the unscoped framing suggests, and not +something the library can engineer away. + +**Block width is a leakage decision, not just a size/perf one.** In Lewi-Wu, the +comparison leaks the index of the first differing block, so **larger blocks leak less**: +Bit8 (8-bit) < Bit6 (6-bit) < CLWW (1-bit, the full first-differing-*bit*). For a u64, +the first differing bit is localised to an **8-bit window** under Bit8 (8 blocks) but a +**6-bit window** under Bit6 (11 blocks) — Bit6 sharpens an online inference adversary's +divergence-point/density estimation by ~1.33×. It is an incremental sharpening, not the +categorical jump to CLWW, but it is real and it is the one axis the library cannot fix. +This sits opposite the **encrypt-side** advantage of Bit6 (the one-cache-line PRP; see +Open Q1 / the A4 review brief): cheap constant-time key generation that Bit8 cannot get +for free. The two pull in opposite directions and live in different threat models: + +- They do **not** net out: the leakage axis is an *online/query-time* property against an + inference adversary; the encrypt-side axis is a *side-channel* property against an + attacker co-resident with the encryptor. At rest, the leakage axis is a **tie** (both + reveal nothing), so there Bit6's win is free. +- The encrypt-side axis is a **cost** difference, not "constant-time vs not": full + oblivious constant-time is available at *both* widths via oblivious-swap Fisher–Yates, + just ~12× cheaper at Bit6 (≈44k ct-ops/u64 vs ≈522k at Bit8). The leakage axis is the + **fundamental, unfixable** one. + +Because leakage is unfixable and encrypt-side CT is purchasable at either width, the +lower-leakage width (Bit8) is the conservative **default**, with Bit6 an explicit opt-in. +Width is therefore a **per-domain / per-deployment policy** keyed on the target data and +threat model, not a global default: + +| Dominant threat | Encryptor environment | Choose | +|---|---|---| +| At-rest exfiltration (right-only) | any | **Bit6** — leakage tie, take the smaller ciphertext + cheap CT | +| Online inference on the plaintext distribution | trusted / dedicated | **Bit8** — encrypt-side moot, take the lower leakage | +| Online inference | hostile / multi-tenant | **Bit8 + oblivious-swap-FY** (low leakage *and* CT, ~522k ct-ops), or Bit6 if perf-bound and 2 bits of resolution is acceptable | + +Default numerics to Bit8 (lower leakage, and it is the wire-frozen compatible scheme); +strings pick width per the size/leakage trade in the PR 6 design. This supersedes the +earlier lean toward "Bit6 as the default" (Open Q3). + +**Numeric encodings: fixed-point vs log/scientific (an encoding-layer option, not a +low-entropy mitigation).** Block ORE is parameterised by *how* a value maps to blocks, +not just by block width. For **wide-dynamic-range numerics** — currency, scientific / +sensor measurements, high-range decimals — a **mantissa+exponent (a.k.a. log-domain) +encoding** is a good fit: write `x ≈ f · base^e`, lay the exponent in the high-order +blocks and the mantissa in the low-order blocks (order-preserving), then encode as +usual. This is the same family as the "scientific notation" encoding used with CLWW ORE +and as `round(scale · log_base(x))`. Benefits, all of which compose with the §5 +variable-block machinery: + +- **Bounds block count over a wide range** (cents-to-billions becomes exponent + a + fixed-width mantissa, not a 60-bit fixed-point integer). +- **Uniform relative precision** — small and large values get the same significant-figure + blocks. (IEEE-754 already has this shape; the existing `f64` path exploits the + exponent-first layout.) +- **Makes prefix leakage a deliberate choice** — "magnitude band + N significant + figures" rather than an accident of fixed-point width. + +Caveats to design in: +- **Benford's law:** leading significant digits of natural numeric data are non-uniform + (1 ≈ 30%, 9 ≈ 5%), so the mantissa's top block stays skewed and inference-exposed. + Log encoding makes leakage *relative*, not *flat*. +- **You are electing to leak the magnitude band** — usually acceptable, but a conscious + leakage decision. +- **Parameters are a leakage surface:** per *Parameter-Hiding ORE* (Cash–Liu–O'Neill– + Zhang, ASIACRYPT 2018), the base/precision/scale leak distribution info if chosen + per value or per dataset. Fix them **per domain**, treat them as public constants, and + document them. + +**Explicit scope — do not conflate with the width/leakage decision above.** This encoding +solves *dynamic range + relative precision + block count*. It is **order-preserving**, so +it does **not** touch the order-leakage floor and is **not** a mitigation for low-entropy / +narrow-domain fields (DOB, names, etc.). Those fail because their *high-order bits are +skewed and ORE exposes them first*, plus the order floor (NKW sorting/cumulative); an +order-preserving re-encoding — log or otherwise — cannot help, and for a narrow domain like +DOB the exponent is near-constant (it *increases* high-order skew). The only lever there is +coarsening the plaintext to the granularity actually queried (year / age-band). Keep the +two ideas distinct: log/scientific encoding is for *wide-range numerics*; plaintext +coarsening is for *low-entropy narrow domains*. ### 6. Random-oracle instantiation (the 1-bit hash H) +> **RESOLVED 2026-06-15 (A1).** Keep the fixed public-key AES construction +> (option 3), upgraded with the BHKR orthomorphism: `H(x, r) = LSB(π(σ(x) ⊕ r) ⊕ +> σ(x) ⊕ r)` with `π = AES_{K₀}` (public `K₀`) and `σ(x) = 2x` in GF(2^128) (the +> BHKR/Zahur σ-MMO). Rationale below; full write-up in the crypto review brief +> A1 (`docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md`). Tweak-as-key +> (eprint 2019/1168 Thm 2) was **declined** (rekeying breaks the keyless/fast +> requirement, and fixes a multi-instance degradation ORE doesn't suffer). + Lewi-Wu models the right-ciphertext mask as a random oracle `H(ro_key, nonce) → Z₂`. Today it is instantiated as `LSB(AES_nonce(ro_key))` — the **nonce as the AES key** — and the code has carried a TODO questioning that construction since the beginning @@ -357,14 +460,25 @@ key / left tag, `r` = nonce): |---|---|---|---|---| | 1 | `LSB(AES_r(x))` — status quo | ideal cipher | — | key is public, so AES's standard PRP assumption gives nothing; security is an ideal-cipher assertion | | 2 | `LSB(AES_r(x) ⊕ x)` — MMO feedforward | ideal cipher | +1 XOR | matches the analyzed blockcipher-hashing shape; feedforward removes the invertible-public-permutation structure; the minimal upgrade | -| 3 | `LSB(π(x ⊕ r) ⊕ x)`, `π = AES_{K₀}`, K₀ public constant | random permutation | **faster** — zero key schedules ever | fixed-key-AES hashing (BHKR13); mine GKWY20 for known `x ⊕ r` tweaking pitfalls (our requirements are weaker than garbling's — no circularity, no correlated keys) | +| 3 | **SELECTED:** `LSB(π(σ(x) ⊕ r) ⊕ σ(x) ⊕ r)`, `π = AES_{K₀}`, `σ(x)=2x` | random permutation | **faster** — zero key schedules ever | BHKR/Zahur σ-MMO. GKWY/half-gates attacks (eprint 2019/1168) need *known* inputs + a global offset; ORE has independent *secret* PRF inputs and no offset, so they don't port. σ is cheap defense-in-depth. 2025/792 attacks (collision/preimage) target unused properties and are round-reduced | | 4 | `LSB(AES_x(r))` — RO key as AES key | **standard model** (PRF) | ~2–4× right-encryption: one key schedule per `(i, j)` | what the old TODO was reaching for; the honest price of standard-model security; composes poorly with accumulator Candidate A (both pay per-block schedules) | | 5 | SHA-256 (HW) / Blake3 over `x ‖ r` | random oracle | 2–5× encrypt path | comparator computes H once per comparison, so query latency is unaffected — only encryption throughput pays | -Proposal into review: **#3, with #2 as the conservative fallback**, and #4 written up -with its *measured* cost so the standard-model option is accepted or declined with the -price visible. The 1-bit truncation (LSB of a pseudorandom block) is uncontroversial -in every model. +Decision (2026-06-15): **#3 with the BHKR orthomorphism `σ(x)=2x`** — `H(x,r) = +LSB(π(σ(x)⊕r) ⊕ σ(x)⊕r)`. The known fixed-key-MMO attacks (GKWY; the half-gates +multi-instance attack of eprint 2019/1168) require the adversary to know the hash +inputs and recover a global Free-XOR offset — ORE's inputs are independent *secret* +PRF outputs and there is no global offset, so neither precondition holds and the +`O(p·C/2^k)` degradation does not arise. The orthomorphism is not strictly needed +in this setting; it is adopted as nearly-free defense-in-depth so security holds by +matching the named BHKR/Zahur construction rather than by a usage argument. The +tight tweak-as-key variant (2019/1168 Thm 2) is declined: rekeying per evaluation +conflicts with the keyless-comparator / performance requirement and addresses a +degradation absent here. AES-hashing cryptanalysis (eprint 2025/792) targets +collision/preimage/one-wayness — not the 1-bit correlation-robustness we rely on — +and reaches only round-reduced AES (7/10 collision), leaving full AES-128's margin. +The 1-bit truncation is uncontroversial in every model. Options #2/#4/#5 remain in +the table as the considered alternatives. ## PR roadmap @@ -405,13 +519,14 @@ PR 2's trait change, which should be called out in the changelog). - [ ] PRP seeds (PRF₂ outputs) structurally separated from serializable `Left` state; no code path can write seed material into a ciphertext (PR 2). - [ ] Domain separation between Bit8/Bit6/chained schemes under shared keys (PR 5, 6). -- [ ] H instantiation (§6) selected and signed off, with its security model - (ideal-cipher / random-permutation / standard) recorded (before PR 5). +- [x] H instantiation (§6) selected and signed off (2026-06-15): BHKR σ-MMO, + random-permutation model; see §6 and review brief A1. - [ ] Selected §5(b) accumulator candidate reviewed and signed off (before PR 6). - [ ] Accumulator chain state treated as key material: zeroized, never serialized, never reachable from `Left`/`Right` types (PR 6). - [ ] GF(2^128) doubling constant-time, if Candidate C is chosen (PR 6). -- [ ] String leakage profile documented and acknowledged at product level (PR 6). +- [ ] String leakage profile documented and acknowledged at product level — scoped to + query-time/online (right-only-at-rest reveals nothing); see §5(b) (PR 6). ## Decisions taken (revisit if needed) @@ -426,24 +541,68 @@ PR 2's trait change, which should be called out in the changelog). `u128`/`Decimal` on Bit6 arrive with PR 6 if wanted. 5. **Strings use the chained-prefix variable-length scheme regardless of width**; width choice (6 vs 8) for strings is a ciphertext-size trade-off left to the PR 6 design. -6. **H decision is pulled forward to before PR 5** (not PR 6): Bit6 is a new scheme and - should ship with the chosen H rather than inherit nonce-as-key for compatibility's - sake. Proposal: fixed-public-permutation MMO (§6 option 3), conservative fallback - MMO-with-nonce-key (option 2). +6. **H = BHKR σ-MMO with fixed public AES key (§6, A1 RESOLVED 2026-06-15):** + `LSB(π(σ(x)⊕r) ⊕ σ(x)⊕r)`, `σ(x)=2x`. Bit6 ships with this rather than inheriting + nonce-as-key. GKWY/half-gates attacks don't port (secret independent inputs, no + global offset); tweak-as-key declined (rekeying); 2025/792 hits only unused + properties on round-reduced AES. Legacy Bit8 keeps the status quo forever. 7. **Accumulator choice is a decision rule, not a fixed pick:** cascade/GGM if NEON key-expansion overhead measures under ~10–15% on Bit6 strings, else CMAC with cached state; XE only if profiling eliminates both (§5(b)). ## Open questions -1. **Cheaper PRP for new schemes:** for Bit6's 64-element domain, a small-domain - constant-time PRP (e.g. swap-or-not or a sorting network) could beat the Knuth - shuffle and be SIMD-friendly. New schemes have no compatibility constraint — worth a - spike during PR 5, not a blocker. +1. **Cheaper PRP for new schemes — RESOLVED by spike (2026-06-13, M1 Max, hw AES; + code at `/tmp/ore-prp-spike`, full analysis in its RESULTS.md):** + - **Winner: fixed-draw Fisher–Yates with pre-scheduled stream derivation** — + 63 Lemire-reduced 64-bit draws (fixed 32 AES-CTR blocks, zero rejection + sampling, branch-free), with the stream produced under an already-scheduled + cipher instead of keying AES per block. **153 ns per block** (construction + + permute + indicator mask) vs ~1.36 µs for a port of today's Knuth-64. + Projected Bit6 u64 encrypt: **≈3.3 µs** (vs 11.5 µs in PR 5). Security story: + exact statistical distance ≤ 2⁻⁵⁵ from a uniformly random permutation — the + object Lewi-Wu already models — so it adds a pure statistical term, no new + assumption. One review item: secret-indexed swaps, defended by the + one-cache-line argument (64-byte `#[repr(align(64))]` table). + - **Swap-or-not is REJECTED for this setting**, not on speed but on proof: the + right ciphertext exposes a block PRP's full codebook across encryptions + sharing a prefix, so the honest query budget is q = N, where the HMR bound + `8N^{3/2}/(r+4)` is vacuous for any practical round count at N = 64. The + full-security fix (Morris–Rogaway 2014 sometimes-recurse) introduces + key-dependent recursion depth (a timing channel) and plain r=64 was 3× + slower than the winner anyway. Kept in the spike as the strictly + constant-time fallback if review rejects the cache-line argument. + - **Status-quo deficiency found:** the current rejection-sampled PRNG's draw + count is seed-dependent, and seeds derive from the plaintext prefix — an + encrypt-side plaintext-dependent timing channel in the legacy scheme (wire- + frozen, so document rather than fix there; the new PRP eliminates it). + - The pre-scheduled stream shape saves ~140–165 ns/block for *every* variant + and composes with §5(b): the CMAC accumulator can emit the fixed-count PRP + stream as one more branch family. + - **SHIPPED in PR 5 (`LemireFyPrp<64>`, Bit6 only — Bit8 stays wire-frozen on + the Knuth shuffle):** the **seed-keyed shape (i)** is in, because it is a + drop-in for the existing `Prp::new(seed)` signature and its security story is + "identical key-usage structure, rejection sampling → fixed-count Lemire + draws." This already closes the timing channel, removes the bias, and takes + Bit6 u64 encrypt **11.5 µs → 8.6 µs** (benchmarks: + `docs/benchmarks/2026-06-13-bit6-prp-results.md`). + - **DEFERRED to PR 6:** the **pre-scheduled shape (ii)** — deriving the PRP + keystream under an already-scheduled cipher (no per-block AES key schedule; + the ~1.9 µs that separates 8.6 µs from the projected ≈3.3 µs). It needs the + PRP stream to come from a PRF/branch family rather than a fresh per-seed key + schedule, which is exactly what the §5(b) CMAC accumulator provides — so it + lands there, under the same crypto review, rather than as a bespoke + key-reuse pattern bolted onto PR 5. 2. **`u16` vs `u8` block count in the v2 header:** u16 chosen for strings; confirm no need for >65 535 blocks (≈48 KiB plaintext at Bit6). -3. **Should Bit6 become the default scheme** recommended in the README once shipped, with - Bit8 positioned as the legacy/compat scheme? Affects docs tone in PR 7. +3. **Block width is a per-domain / per-deployment choice, not a global default + (RESOLVED — see §5(b) "Block width is a leakage decision").** Earlier framing asked + "should Bit6 be the default"; the answer is no, because width trades online + prefix-leakage (Bit8 leaks less — the unfixable axis) against encrypt-side + constant-time cost (Bit6 cheaper — a fixable axis). Default numerics to **Bit8** + (lower leakage + wire-compat); position **Bit6** as an opt-in for at-rest-dominated, + size/perf-sensitive, or encryptor-hostile (with CT budget) deployments. PR 7 docs + present the decision rule (the §5(b) table), not a single recommended scheme. 4. **Pending review outcomes:** the §5(b) accumulator and §6 H selections await the NEON benchmark spike (PR 5) and internal crypto review; decision rules and candidate write-ups are inline in those sections. diff --git a/docs/plans/2026-06-15-ore-v2-cmac-accumulator-spec.md b/docs/plans/2026-06-15-ore-v2-cmac-accumulator-spec.md new file mode 100644 index 0000000..68f6172 --- /dev/null +++ b/docs/plans/2026-06-15-ore-v2-cmac-accumulator-spec.md @@ -0,0 +1,290 @@ +# ORE v2 — chained-prefix CMAC accumulator: design spec (A2) + +**Date:** 2026-06-15 +**Status:** DRAFT for crypto review — the **A2 gate** that must pass before PR 6 +(chained prefix / variable-length / strings) is written. +**Companions:** plan §5(b) (`docs/plans/2026-06-12-ore-v2-architecture.md`), +review brief A2/A3 (`docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md`). + +This is the precise specification the review brief flagged as "still a sketch." +It pins the message encoding (the injectivity-critical part), the per-block +algorithm, the security argument, and the test plan. + +--- + +## 1. What this replaces, and why + +The fixed-N Bit6 scheme (#82) derives every per-block secret by packing the +**prefix** `x[0..n-1]` into one 16-byte AES block and encrypting it: + +- **PRP seed** (`derive_prp_seeds`, key `prf2`): `seed_n = E_{prf2}( x[0..n-1] ‖ 0… ‖ N@[15] )` +- **RO key / left tag** (key `prf1`): `ro(n, v) = E_{prf1}( x[0..n-1] ‖ v@[n] ‖ n@[N] ‖ N@[15] )`, + where `v = j` for the right-vector mask at domain position `j`, and `v = xt[n]` + (the permuted current symbol) for the published left tag `f[n]`. **So + `f[n] = ro(n, xt[n])`** — the same function at the value's permuted position; + this is what makes the masks cancel at compare time. + +Packing the raw prefix into one block caps the plaintext at **≤ 14 blocks** +(`MAX_BLOCKS`). PR 6 needs arbitrary length (strings; also `u128`/`Decimal` +which exceed 14 Bit6 blocks). The fix is an **accumulator**: absorb the prefix +incrementally into a fixed 16-byte state, and derive the per-block secrets by +**CMAC-finalising** that state with a per-output final block — instead of +packing the raw prefix. Candidate B (CMAC, NIST SP 800-38B) was selected by the +§5(b) benchmark gate (cascade/GGM rejected on aarch64 key-expansion cost; XE +held in reserve). + +--- + +## 2. Construction + +Let `E_k` be AES-128 under the accumulator key `k` (§3). Standard CMAC: + +``` +L = E_k(0^128) +K1 = dbl(L) # dbl = GF(2^128) "multiply by x" — the same gf128_double +K2 = dbl(K1) # (reused from the σ-MMO hash; constant 0x87) +``` + +All accumulator messages are an exact multiple of 16 bytes, so **only K1 is ever +used** (K2/padding never occurs). Define, over a running CBC state `S`: + +``` +absorb(S, B) = E_k(S ⊕ B) # extend the prefix chain (no subkey) +finalize(S, F) = E_k(S ⊕ F ⊕ K1) # a published CMAC tag (full last block) +``` + +For a plaintext of blocks `x[0..N-1]`, maintain `S_n` = the CBC chain over the +prefix blocks `P_0 ‖ … ‖ P_{n-1}` (`S_0 = 0^128`). Every per-block secret is a +`finalize(S_n, F)` for a final block `F` that injectively names the output. The +prefix chain is cached and extended incrementally — `clone-state-then-finalize` +*is* incremental CMAC, so each published value is a *bona fide* one-shot CMAC tag +of `P_0 ‖ … ‖ P_{n-1} ‖ F`. + +--- + +## 3. Key derivation & domain separation + +The accumulator uses a **dedicated key `k`**, derived from the master ORE key by +a labelled KDF distinct from every other use (the fixed-N `prf1`/`prf2`, H's +public `π`, the nonce RNG): + +``` +k = E_{k_master}( "ORE.v2.chain.acc\x00" ) # 16-byte ASCII label, single AES call +``` + +(`k_master` = one of the `init(k1, k2)` keys; concrete slot TBD in review.) A +dedicated key makes the accumulator's PRF security self-contained: no cross-use +collisions with the fixed-N schemes or with H. The chained scheme therefore +**unifies the old `prf1` and `prf2`** into one key, domain-separated by the +**branch tag** (the `RO_KEY` / `PRP_STREAM` output families defined in §4) — +PRF₂ is subsumed. + +**Key inventory.** The chained scheme has exactly **one secret key — `k`** — +which produces *both* branches. Branch-tag domain separation under a good PRF is +equivalent to independent per-branch keys (the injectivity argument of §4 + the +CMAC-PRF reduction of §8), and is cheaper: one AES key schedule and one CMAC +subkey pair (`L, K1, K2`) rather than two. The only other key-shaped material is +**public** (H's fixed `π` constant `K₀`) or non-key (the per-ciphertext nonce). +Two consequences for review: + +- **vs fixed-N (#82):** that scheme keeps two secret keys (`prf1`/`prf2`); the + unification is a *chained-scheme* choice, not retroactive. +- **vs the `init(k1, k2)` API:** `k` is KDF-derived, so a single master input + suffices here — `k2` is redundant for this scheme unless retained for API + compatibility (open question 1). The alternative design — two keys, one per + branch, no branch tag — is equivalent in security but costs a second key + schedule/subkey pair; the single-key choice should be explicitly blessed. + +--- + +## 4. Message encoding (injectivity-critical) + +A **branch** names which output family a `finalize` derives. There are two: + +- **`RO_KEY`** (tag `0x01`) — the right-vector mask values `ro(n, j)`, and the + left tag `f[n] = ro(n, xt[n])`; +- **`PRP_STREAM`** (tag `0x02`) — the Fisher–Yates keystream that builds `π_n`. + +These replace the fixed-N scheme's separate `prf1` (ro/f) and `prf2` (PRP) keys +(§3); the branch is carried as the **byte-0 branch tag** of the final block, and +is the `branch` argument in `F(branch, n, s)` below. + +Every block is exactly 16 bytes. Two block types, distinguished by byte 0. + +**Prefix block `P_t`** (absorbed into the chain; carries symbol `x[t]`): + +| byte | 0 | 1–2 | 3 | 4–15 | +|------|---|-----|---|------| +| value | `0x00` (TYPE_PREFIX) | `t` (u16 BE) | `x[t]` | `0x00` | + +**Final block `F(branch, n, s)`** (the last block of a `finalize` message): + +| byte | 0 | 1–2 | 3–4 | 5 | 6–15 | +|------|---|-----|-----|---|------| +| value | branch tag | `n` (u16 BE) | `s` (u16 BE) | `width` (`0x06`) | `0x00` | + +- **branch tag** ∈ `{ 0x01 = RO_KEY, 0x02 = PRP_STREAM }` (both ≠ `0x00`). +- **`n`** = block position being derived. +- **`s`** = sub-index: for `RO_KEY`, the domain value `j` (or `xt[n]` for the + left tag); for `PRP_STREAM`, the keystream counter `c`. +- **`width`** = block width (6) → domain separation from any future Bit8-chained + scheme. + +**No total-length (`N`) binding** — deliberately, and unlike the fixed-N packed +scheme (§7). + +### Injectivity +Every prefix block has `byte0 = 0x00`; every final block has `byte0 ∈ {0x01, +0x02}`. In a message `P_0 ‖ … ‖ P_{i-1} ‖ F`, the final block is the unique +non-`0x00`-byte0 block (and it is last), so the message parses unambiguously into +`((x[0],0),…,(x[i-1],i-1), (branch,n,s,width))`. Hence the map + +``` +(prefix values x[0..i-1], branch, n, s, width) ⟼ message bytes +``` + +is **injective**: distinct logical inputs ⇒ distinct messages. (Claim 1.) + +--- + +## 5. Per-block algorithm + +`STREAM_BLOCKS` = ⌈(DOMAIN−1)·8 / 16⌉ = 32 for Bit6 (63 wide draws → 504 B → 32 +× 16 B). `nonce` is a fresh per-ciphertext random value (unchanged from fixed-N). + +``` +S ← 0^128 +for n in 0..N: + # PRP for block n (shape (ii), A3): keystream straight from the accumulator + stream ← ‖_{c=0}^{STREAM_BLOCKS-1} finalize(S, F(PRP_STREAM, n, c)) + π_n ← LemireFyPrp::from_stream(stream) # new ctor; FY math unchanged + xt[n] ← π_n.permute(x[n]) + + # left tag f[n] = ro(n, xt[n]) — RO_KEY branch at s = xt[n] + f[n] ← finalize(S, F(RO_KEY, n, xt[n])) + + # right block: ro_key per domain value, then mask exactly as fixed-N + for j in 0..DOMAIN: + ro[j] ← finalize(S, F(RO_KEY, n, j)) + encode_right_block(right[n], π_n, x[n], H_nonce, ro) # unchanged: H-mask ⊕ indicator + + # extend the prefix chain + S ← absorb(S, P_n(x[n])) # S_{n+1} +zeroize(S, stream, ro, K1, K2, L, k) +``` + +- `encode_right_block`, `H` (the BHKR σ-MMO, A1), `indicator_mask_xor`, and the + comparator are **unchanged** — only the derivation of `ro`/`f`/PRP changes from + packed-AES to CMAC. `f[n]` reuses the `RO_KEY` finalize at `s = xt[n]`, so the + left/right masks cancel exactly as today. +- Left-only encryption (queries) runs the `PRP_STREAM` + `f[n]` steps only. +- Per-block AES count ≈ `1 (absorb) + 32 (stream) + DOMAIN (ro) ≈ 97` at Bit6, + comparable to the packed scheme's ~130; **no per-block key schedule** (this is + the shape-(ii) win, A3). + +--- + +## 6. Incremental CMAC ↔ one-shot equivalence (Claim 2) + +`finalize(S_n, F)` with `S_n` the CBC chain of `P_0..P_{n-1}` equals one-shot +`CMAC_k(P_0 ‖ … ‖ P_{n-1} ‖ F)` because `F` is a full 16-byte final block (→ K1) +and `S_n` is the standard CBC state. The chain extension `absorb(S_n, P_n)` is a +CBC step with **no subkey**, so it is *not* a published tag. **Test:** assert the +incremental implementation byte-matches the `cmac` crate's one-shot output over +the exact `P/F` messages, across positions/branches/sub-indices. + +--- + +## 7. No length binding — required for cross-length comparison + +The fixed-N scheme binds `N` (byte 15) into every derivation; that is safe +because only equal-length (same-type) ciphertexts are ever compared. The chained +scheme **must not** bind `N`: two strings sharing a prefix (`"app"` vs `"apple"`) +must produce **identical** per-block secrets for the shared blocks so the +comparator finds the first differing block correctly (shorter sorts first). The +per-block secrets therefore depend on `(prefix values, position n, branch, s)` +but **not** total length. + +Length **comparability** is enforced at the comparator, not in the derivation: + +- **Strings:** different lengths allowed; scan `min(len_a, len_b)` blocks; if + equal throughout, the shorter sorts first (lexicographic). Common-prefix-length + leakage is the intended, query-time-scoped leakage (plan §5(b)). +- **Fixed-length types** (`u128`/`Decimal` via the accumulator): the header + carries the block count; the comparator **rejects** mismatched lengths + (cross-type comparison), exactly as fixed-N does. + +--- + +## 8. Security argument + +1. **`k` is a uniform AES key** used *only* by the accumulator (§3), so its PRF + security is self-contained. +2. **CMAC is a secure variable-input-length PRF** in the PRP model (NIST SP + 800-38B; Iwata–Kurosawa OMAC), advantage `≈ (σ)² / 2^128` for `σ` total + blocks processed under `k`, plus the AES PRP term. +3. **Every published secret is `CMAC_k(msg)`** for an injectively-encoded `msg` + (Claim 1). Distinct logical outputs ⇒ distinct messages ⇒ jointly + indistinguishable from independent uniform values. These are precisely the + independent PRF outputs the Lewi-Wu analysis assumes for `ro`/`f`/PRP; ORE + security then follows from the existing Lewi-Wu argument. +4. **The chain state is never published.** `S_n` is an internal CBC value; the + only outputs are `finalize(S_n, F)` with the K1-treated final block. So the + "published-outputs-vs-chaining-value" interaction that complicates cascade/GGM + does **not** arise — it is subsumed by the CMAC PRF abstraction. (`absorb` + and `finalize` from the same `S_n` are E_k at different points: `S_n⊕P_n` vs + `S_n⊕F⊕K1`, with `P_n` byte0=`0x00` and `F` byte0≠`0x00`.) +5. **Many outputs per state** (32 stream + DOMAIN ro per block) is sound for the + same reason: distinct final blocks ⇒ distinct messages ⇒ independent tags. +6. **Birthday budget.** `σ ≈ q · L · (DOMAIN + STREAM + 1)`. For `q = 2^32` + ciphertexts, `L = 14`, Bit6: `σ ≈ 2^44`, term `≈ 2^{88-128} = 2^{-40}` — + comfortable. `log()` the assumption if a deployment expects `q ≫ 2^32`. + +Auditable claims for sign-off: **(1)** encoding injectivity (§4), **(2)** +incremental-vs-one-shot faithfulness (§6), **(3)** zeroization (§9). + +--- + +## 9. State hygiene (Claim 3) + +`k`, `L`, `K1`, `K2`, the cached chain states `S_n`, the `stream` buffer, and the +`ro` buffer are all secret key-equivalent material (anyone holding `S_n` can +derive every output for that prefix). All must be: zeroized on drop; never +serialized; never reachable from `Left`/`Right`/`CipherText` types. (Mirrors the +existing `SeedBuf`/template zeroization in #82.) + +--- + +## 10. Test plan + +- **CMAC faithfulness:** incremental impl == `cmac` crate one-shot, over many + `(prefix, branch, n, s)` (§6). +- **Subkey KAT:** `L/K1/K2` against NIST SP 800-38B AES-128 CMAC test vectors + (also exercises the shared `gf128_double`). +- **Cross-length comparison:** strings sharing a prefix of every length compare + correctly (shorter sorts first); fixed-length mismatches are rejected (§7). +- **Order/roundtrip quickcheck** for the chained scheme (as bit2_w6 has). +- **Shape-(ii) PRP equivalence:** `from_stream(accumulator stream)` yields the + same permutation as the spec's FY over that stream; statistical-distance bound + unchanged (A3). +- **Wire vectors pinned** once the encoding is signed off (mirror + `compat_w6_vectors`), incl. a variable-length string set. +- **Zeroization** assertions / `Drop` coverage. + +--- + +## 11. Open questions for review + +1. **Key slot for `k`** (§3): which `init` key + exact label; confirm the KDF + (single labelled AES call) is acceptable. +2. **`width`/scheme tag** (§4): is a 1-byte width enough domain separation, or + should the final block also carry a scheme id (as the wire header does)? +3. **PRP_STREAM vs a single seed:** spec derives the full FY stream as 32 CMAC + tags (shape ii). Alternative: one `PRP_SEED` tag that keys a fresh AES-CTR + (shape i) — simpler, but reintroduces a per-block key schedule. Confirm shape + (ii) is wanted here (it is the A3 perf rationale). +4. **Birthday budget** (§8.6): acceptable `q` ceiling; whether to document a + re-key guidance for very large datasets. +5. **`u128`/`Decimal`** ride this accumulator purely to exceed 14 blocks — they + are fixed-length; confirm the comparator's length-rejection (§7) is the right + place to keep them non-comparable with strings/other types. diff --git a/docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md b/docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md new file mode 100644 index 0000000..1dd41e3 --- /dev/null +++ b/docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md @@ -0,0 +1,449 @@ +# ORE v2 — crypto review brief (decisions A1–A4) + +**Date:** 2026-06-14 +**Author:** Dan Draper (via Claude Code) +**Audience:** crypto sign-off reviewer (internal) +**Source of truth:** `docs/plans/2026-06-12-ore-v2-architecture.md` (§5b, §6, Open Q1). +This brief is self-contained; section/line refs let you drill in. + +--- + +## 0. What you're being asked to sign off (TL;DR) + +Four crypto decisions gate the v2 work. Two block a PR that is already open +(#82); two block PR 6 (not yet written). + +| # | Decision | Model claimed | Status in code | Blocks | +|---|----------|---------------|----------------|--------| +| **A1** | 1-bit hash `H` instantiation | random-permutation (BHKR σ-MMO) | ✅ **RESOLVED** — `FixedPiZ2Hash` = `LSB(π(σ(x)⊕r)⊕σ(x)⊕r)`, σ(x)=2x | — (was the Bit6-vector gate; now cleared) | +| **A2** | Chained-prefix accumulator = AES-CMAC cached-state | CMAC PRF (standard) + 3 auditable claims | designed, not yet coded | PR 6 (variable-length / strings) | +| **A3** | PRP keystream from the accumulator (shape ii) | statistical (≤2⁻⁵⁵) + branch-family soundness | shape (i) shipped; (ii) deferred | PR 6 perf; couples to A2 | +| **A4** | Secret-indexed swap in PRP key-gen — ratify alignment + MemJam posture? | constant-time / cache-line (sub-line = oblivious tier) | `#[repr(C, align(64))]` + oblivious compare read + `N≤64` guard **applied** (uncommitted) | nothing — fixes are byte-stable; A1 alone gates vectors | + +**Recommended sequencing:** **A1 is resolved** (BHKR σ-MMO) — Bit6 vectors can now +be generated and pinned against it. **A4 is a ratification** of changes already +applied (none of which alter ciphertexts), plus a posture call on MemJam. Then +**A2 + A3 as one pass** (they gate PR 6, and A3 only exists inside A2's +accumulator). + +**What is explicitly *not* in scope:** the legacy Bit8 scheme is wire-frozen and +byte-identical to v1 (`tests/compat_vectors`); it keeps all status-quo +constructions forever and is out of review. Everything below concerns *new, +not-yet-frozen* schemes only. + +--- + +## 1. Construction primer (the parts these decisions touch) + +ORE here is **Lewi-Wu (2016) small-domain "BlockORE"**, a *left/right* scheme: + +- A plaintext is decomposed into blocks. Bit8 → blocks over `DOMAIN = 256`; + the new **Bit6** scheme → `DOMAIN = 64`. +- Per block, a **PRP** `π` permutes the `DOMAIN` symbols (keyed per block from a + prefix-dependent seed — this is **A3/A4**). +- The **right ciphertext** of a block is a length-`DOMAIN` vector: for each + permuted symbol `j`, a comparison result `cmp(π⁻¹(j), x)` masked by a 1-bit + value `H(ro_key(i,j), nonce)` (this is **A1**). +- The **left ciphertext** of a block is the permuted index of the plaintext + symbol plus a published tag. +- **Comparison** is evaluated by a *keyless* comparator (e.g. in Postgres) + between one left and one right ciphertext; it recomputes `H` from public + material only — hence H may use the nonce and published tags but **no + long-term secret** (this constraint drives **A1**). +- For plaintexts longer than one packed AES block (~14 Bit6 blocks), the + per-block secrets must be derived from an **accumulator** over the prefix + (this is **A2**, which also hosts **A3**). + +Leakage (for completeness, not under review): a comparison reveals the index of +the first differing block. For strings that is common-prefix length. This is a +property of *comparison*, not stored data — right-only-at-rest reveals nothing; +the disclosure is query-time/online only (plan §5b, "String semantics and +leakage"). + +--- + +## 2. A1 — the 1-bit hash `H` (§6) — ✅ RESOLVED 2026-06-15 + +### The question +`H(x, r) → Z₂` where `x` = RO key / left tag, `r` = per-ciphertext nonce. It must +be computable by a **keyless** comparator from public ciphertext material (no +long-term secret), so any "PRF under a third key" design is out. + +### Resolution +**Keep option 3 (fixed public-key AES), upgraded with the BHKR orthomorphism.** +Shipped construction (`packages/ore-rs/src/primitives/hash.rs`, `FixedPiZ2Hash`): + +``` +H(x, r) = LSB( π(σ(x) ⊕ r) ⊕ σ(x) ⊕ r ), π = AES-128_{K₀}, σ(x) = 2·x in GF(2^128) +``` + +i.e. the **BHKR/Zahur fixed-key σ-MMO** with output truncated to 1 bit. + +- `K₀ = PI_KEY = b"ORE-rs.v2.H-pi.1"` — nothing-up-my-sleeve, deliberately + public; security rests on AES as a good *public random permutation*, not key + secrecy. Expanded once per process. +- `σ(x) = 2x` is the GF(2^128) doubling orthomorphism (same "multiply by x" as + CMAC subkeys; constant `0x87`), constant-time, branch-free (`gf128_double`). + Both `σ` and `σ⊕id` are permutations. +- `hash` (comparator) and `hash_all_into` (encryptor, scalar + SIMD `lsb_mask`) + verified equivalent (`fixed_pi_scalar_matches_bulk`); doubling checked against + the textbook shift/0x87 rule (`gf128_double_reduction`). + +### Why this is sound for ORE (the core justification) +The known attacks on fixed-key MMO **do not port to ORE**, and we now have the +exact reason from the literature: + +- **eprint 2019/1168** (Guo–Katz–Wang–Weng–Yu, *Better Concrete Security for + Half-Gates*) attacks the fixed-key construction `π(2x⊕i)⊕2x⊕i` in the + multi-instance garbling setting with success `O(p·C/2^k)` — but the attack + works by **recovering a global Free-XOR offset `R` from *known* hash inputs** + (the evaluator holds wire labels `Wa` and gate ids `j`, learns + `H(Wa⊕R, j)`, then meet-in-the-middles over `π`). **ORE has neither + precondition:** its `H` inputs are independent **secret** PRF outputs (the + adversary never learns them, sees only 1-bit masks), and there is **no global + offset**. So the multi-instance degradation mechanism is structurally absent. +- Their tight fix (Theorem 2, `E(i, σ(x))⊕σ(x)` — tweak as the AES *key*) is + **deliberately not adopted**: it requires rekeying per evaluation, which breaks + the keyless-comparator/performance requirement, and it fixes a degradation ORE + doesn't suffer. **Do not chase Theorem 2.** +- **eprint 2025/792** (Chen–Guo–List–Shi–Zhang, *Scrutinizing AES-based Hashing*) + is cryptanalysis of **collision / preimage / one-wayness** — properties this + 1-bit hash does not rely on — and its best AES-128 results are **round-reduced** + (7/10 collision on AES-MMO/MP at 2⁶⁰, 4/10 on AES-DM), never reaching full AES. + Mild evidence *for* the random-permutation assumption at full rounds. + +### Why the orthomorphism (the only change from the original draft) +Plain MMO (`σ = id`) would already be fine for ORE's independent-secret-input +setting. `σ(x)=2x` is adopted as **cheap defense-in-depth** (a few branch-free +ops) so security holds **by matching the named BHKR/Zahur construction** rather +than by a usage argument about input independence — robust-by-construction vs +robust-by-argument. The 1-bit truncation is uncontroversial in every model. + +### Status +Resolved. `FixedPiZ2Hash` updated. This **changes Bit6 right ciphertexts**, so +Bit6 byte vectors are (re)generated against this construction when pinned — A1 is +the gate that was holding that, and it is now cleared. + +--- + +## 3. A2 — chained-prefix accumulator = CMAC cached-state (§5b; blocks PR 6) + +> **Full design spec (2026-06-15):** `docs/plans/2026-06-15-ore-v2-cmac-accumulator-spec.md` +> — pins the injective message encoding, per-block algorithm, security argument, +> and test plan that this section summarises. + +### The question +PR 6 (variable-length / strings) needs an accumulator that absorbs the prefix +incrementally and, at each block position, derives the per-block secrets/outputs +(PRP seed, left tag, RO keys). **Candidate B — AES-CMAC with cached prefix +state** was selected by a benchmark gate; sign off its design before PR 6 is +written. + +### Why B (the gate result, durable record in §5b) +- **Candidate A (Cascade/GGM, chain through the AES key slot)** — cleanest + security story (constrained-PRF/GGM hybrid, no related-key assumptions) but + **rejected on performance**: on Apple M1 Max with hardware AES, an AES-128 key + expansion costs ~172 ns ≈ 160 batched block encryptions (no key-schedule + instruction on aarch64), making per-block overhead **~84%** at Bit6 — ~6× over + the ~10–15% threshold. (Spike at `/tmp/ore-keyexp-spike`.) +- **Candidate C (XE-style GF(2¹²⁸) masked single-key CBC)** — fastest, but the + many-outputs-per-state argument is bespoke (PMAC-lineage analyses publish only + a final tag). Held in reserve. +- **Candidate B** — control measured ~0% overhead and has a citable standards + basis. + +### The design to review +Every published value is a *bona fide* **AES-CMAC (NIST SP 800-38B)** tag of an +injectively encoded message. For block `i`: + +``` +msg(branch, i) = enc(x₀‖0) ‖ enc(x₁‖1) ‖ … ‖ enc(x_{i-1}‖i−1) ‖ final(branch, value, i) +tag = CMAC_k( msg(branch, i) ) +``` + +with separate **branch families**: chain step, `prp_seed` (this hosts A3), +left tag `f`, and `ro_key(i, j)` for `j ∈ 0..DOMAIN`. The per-prefix +chaining-state cache (clone-CBC-state-then-finalize) is **purely an +implementation optimization** — incremental CMAC, not a new primitive. + +Security reduces to **CMAC's PRF security** plus three auditable claims: +1. **Encoding injectivity** — `msg(branch, i)` is injective across blocks, + branches, and block widths (so no two distinct logical outputs share a CMAC + input). +2. **Implementation faithfulness** — the incremental/cached implementation is + bit-identical to one-shot CMAC (will be tested against the `cmac` crate's + vectors). +3. **Zeroization** — cached secret states are zeroized; never serialized; never + reachable from `Left`/`Right` types. + +### What needs scrutiny +1. **The injective encoding is currently a sketch** — it must be specified + exactly (field widths, the `enc(·)` block format, the `final(branch,…)` + block, domain-separation tags between the 4 branch families) and then checked + injective. This is the load-bearing claim. +2. **Many outputs per prefix state.** Standard CMAC analyses publish one tag; + here we publish chain + seed + tag + `DOMAIN` RO keys per position. This is + sound iff all inputs are distinct (claim 1) and CMAC is used purely as a PRF — + confirm there's no subtlety in deriving the *next chain state* from a tag that + is itself published (the GGM design made this vanish structurally; CMAC needs + the chain-state branch domain-separated from published branches). +3. **PRF₂ disposition** — does PRF₂ remain a separate key or become another + branch family? (Either is claimed fine; pick one.) + +### Decision & what it unblocks +Sign off the CMAC design (after the encoding is fully specified) → PR 6 can be +written. Fixed-N schemes never touch the accumulator, so this does **not** block +goals 1–3 or #82. + +--- + +## 4. A3 — PRP keystream from the accumulator, "shape (ii)" (Open Q1; rides with A2) + +### Background: the PRP question is otherwise resolved +Open Q1 (cheaper PRP for new schemes) is **resolved by spike** +(`/tmp/ore-prp-spike`, RESULTS.md). Winner: **fixed-draw Fisher–Yates with +Lemire-reduced wide draws** — `N−1` fixed draws, each a 64-bit value reduced by +multiply-high `((x·m) >> 64)`, zero rejection sampling, branch-free. Security: +exact statistical distance **≤ 2⁻⁵⁵** (for N=64) from a uniformly random +permutation — the object Lewi-Wu already models — so it adds a *pure statistical +term*, no new assumption. + +This is **shipped as `LemireFyPrp<64>`** for Bit6 (PR 5), +`packages/ore-rs/src/primitives/prp.rs:135-229`. It already (a) closes a +plaintext-dependent timing channel in the old rejection-sampled PRNG and (b) +removes a power-of-two modulo bias, and takes Bit6 u64 encrypt 11.5 → 8.6 µs. +**Swap-or-not was rejected** (its `8N^{3/2}/(r+4)` bound is vacuous at q=N, and +ORE exposes a block PRP's full codebook). Bit8 stays on the Knuth shuffle +(wire-frozen). + +### The narrow question for review +Shipped **shape (i)** keys a *fresh AES-128 key schedule per block* to produce +the FY keystream (`prp.rs:164-169`: `Aes128::new(seed)` then 32 CTR blocks). +**Shape (ii)** produces the same keystream from the *already-scheduled +accumulator cipher* (a `prp_seed` branch family of A2's CMAC), eliminating the +per-block key schedule — the ~1.9 µs that separates 8.6 µs from the projected +≈3.3 µs. + +The math (FY + Lemire, ≤2⁻⁵⁵) is unchanged. The **only new question** is whether +sourcing the PRP keystream from the accumulator's PRF output is sound: +1. The `prp_seed` branch must be **domain-separated** from the chain/tag/ro_key + branches (no keystream reuse across roles). +2. The keystream-as-PRP-randomness reuse pattern must not interact badly with + the same cipher's other outputs. + +This is **why A3 is reviewed inside A2** rather than as a bespoke key-reuse hack +bolted onto PR 5. + +### Decision & what it unblocks +Approve (or reject) deriving the PRP stream as a CMAC branch family. Unblocks the +PR 6 PRP perf target (~3.3 µs). No effect on #82 (which ships shape (i)). + +--- + +## 5. A4 — secret-indexed swap in PRP key generation (Open Q1; couples to Bit6 vectors) + +### The question +FY key generation performs a swap whose **address is secret-derived**: + +`prp.rs:189-196`: +```rust +for i in (1..$domain).rev() { + let d = $domain - 1 - i; + let x = u64::from_le_bytes(stream[d*8 .. d*8+8]); + let j = ((x as u128 * (i as u128 + 1)) >> 64) as usize; // secret j + perm.permutation.swap(i, j); // secret-indexed write +} +``` + +`j` depends on the (secret, prefix-derived) keystream, so the *memory address +written* is secret. Do we accept the **one-cache-line argument** (the whole +permutation table fits in a single cache line, so the access pattern leaks +nothing through the cache), or require the strictly-constant-time fallback? + +### Discrepancy found, now fixed (pending scrutiny) +The plan (Open Q1) stated the mitigation is a **64-byte cache-line-aligned +table**, but the originally shipped struct had **no alignment** (default +alignment 1), so the 64-byte `permutation` array could straddle two lines — +weakening, not establishing, the one-cache-line argument. There are in fact +**two** secret-indexed writes during key generation that the argument must +cover, not one: +- the Fisher–Yates `permutation.swap(i, j)` — secret `j` (`prp.rs:195`); +- the inverse fill `inverse[val] = …` — secret `val` (`prp.rs:198-200`). + +**Fix applied** (uncommitted, pending this review): `#[repr(C, align(64))]` on +`LemireFyPrp` (`prp.rs:135-139`). `repr(C)` pins field order so `permutation` is +at offset 0; `align(64)` puts the struct on a line boundary. At `N = 64` each +`[u8; 64]` table is exactly one line — `permutation` → line 0, `inverse` → +line 1 — so both secret-indexed writes are line-uniform. The argument holds only +for **N ≤ 64**; the sole instantiation is `LemireFyPrp<64>`. A `const _ = +assert!(N <= 64)` guard is proposed to make a larger instantiation a compile +error. + +### Context +- The **read** paths are already constant-time: `permute`/`invert` are table + lookups returning the value, and `indicator_mask_xor` scans the *entire* + permutation table via a branch-free `gt_mask` kernel (`prp.rs:225-228`). Only + the **key-generation writes** (above) are secret-indexed. +- Same class of issue exists in **vitaminc** (filed cipherstash/vitaminc#198) + and in the legacy Bit8 Knuth path (wire-frozen — documented, not fixed). +- **Two fallback forms, only one is wire-compatible** (this matters — see "MemJam + & the oblivious tier" below): an **oblivious-swap Fisher–Yates** (same FY, same + Lemire draws, constant-time `swap`/inverse-fill via full-scan conditional + select) produces the **identical permutation** → identical ciphertext → + drop-in, vectors unchanged, ~O(N²) swap cost. A **different construction** + (swap-or-not) produces a *different* permutation → incompatible wire format, + and was rejected on the q=N proof anyway. The oblivious-swap form is the one to + reach for. +- **MemJam caveat (full analysis below):** cache-line alignment defends only at + *line* granularity; the sub-line (4-byte) MemJam channel on SMT-enabled Intel + is closed only by an oblivious construction. + +### Block-width asymmetry — the one-cache-line property is Bit6-only +The construction-time argument does **not** scale to a hypothetical +`LemireFyPrp<256>` (8-bit width), and the asymmetry is a positive argument for +Bit6 as the default (open question 3): +- **Construction (encryptor host) — strictly worse at D = 256.** The table is + 256 bytes = **4 cache lines**, so each swap leaks ~2 bits (which-of-4-lines). + And there are more swaps per block (D−1 = **255 vs 63**): a u64 is **8 blocks × + 255 = 2040** secret-indexed swaps at Bit8 vs **11 × 63 = 693** at Bit6. Larger + domain grows swaps faster than it shrinks block count, so the "fewer blocks" + intuition inverts. No offsetting benefit on this axis. +- **Comparison (comparator host) — tied.** The compare-side secret-indexed + access is `get_bit(target_block, a[l])` (next subsection). The right block is + 256 bits = **32 bytes** at Bit8 and 64 bits = **8 bytes** at Bit6; both are + ≤ 64, so both fit within one cache line given alignment. Bit8 is **not** better + here — both widths are line-uniform. +- These two leaks are on **different machines** (encryptor vs comparator) and + different trust domains, so neither offsets the other; each is evaluated + per-host. Net: 8-bit would be worse on construction and tied on compare — Bit6 + has the stronger constant-time story end to end. + +### Compare-side: oblivious `get_bit` — FIXED (uncommitted, pending review) +Comparison has its own secret-indexed access on the comparator host. After the +constant-time scan finds the first differing block `l` (`bit2.rs:235-242`, done +with `Choice`/`conditional_assign`), it reads byte `a[l] / 8` of the right block, +where `a[l]` (the permuted symbol) is sensitive. The block base was at an +arbitrary buffer offset, so the read could straddle a line. + +**Fix applied:** all four `get_bit` sites (`bit2.rs`, `bit2_w6.rs`, and the +`RightBlock32`/`RightBlock8` inherent methods) now route the byte read through a +new oblivious helper `width::ct_select_byte`, which scans the *entire* block and +constant-time-selects the target byte, so the access address is independent of +`a[l]`. This was chosen over mere alignment deliberately: an aligned direct index +still leaks at sub-line (MemJam) granularity, whereas the full scan closes both +line and sub-line channels. Cost is ≤ 32 byte-ops per comparison — negligible +beside the per-comparison AES hash. Results are unchanged (compat + comparison +vectors pass), so it does **not** touch the wire format. The block-*selection* +index `l` is left as a direct index because `l` (first-differing-block) is leaked +by ORE's definition anyway. +- **Severity it addressed was low** (both `l` and `a[l]` are already in the + ciphertexts the comparator holds; the channel only matters to an attacker who + can time the comparator's cache but not read its memory), but the oblivious + read is cheap and removes the question entirely. +- **Scope note:** production comparison runs in the Postgres extension / proxy + (separate codebase) and must adopt the same oblivious read (or `ore.rs`'s + comparator) — tracked separately; this repo's `compare_raw_slices` and typed + `cmp` are now fixed. + +### MemJam & the oblivious tier — risk, CPU scope, and wire compatibility +This is the one open judgement call on the construction-side (encryptor) swap. +The shipped `#[repr(C, align(64))]` fix makes the swap **line-uniform**, which +closes the broad cache-line channel for everyone (AMD, ARM, and non-SMT Intel). +It does **not** close MemJam. + +**What MemJam is.** 4K aliasing: Intel's memory disambiguation predicts +store→load dependencies from only the low address bits, so an attacker who writes +to an aliasing address forces a false read-after-write dependency and times the +victim's load at **4-byte granularity within a cache line**. That is exactly why +alignment (a 64-byte property) is insufficient. + +**CPU scope.** +- **Intel x86 — effectively all generations, incl. modern parts and SGX.** The + MemJam paper's headline is that it applies to "all major Intel processors + including the latest generations," unlike its predecessor. +- **Older Intel (pre-Haswell)** also fall to **CacheBleed** (cache-bank + conflicts, sub-line). Between the two, treat "intra-line is safe on Intel" as + false across the line. +- **AMD** — not the MemJam target; no demonstrated equivalent, but not provably + immune. +- **ARM (Apple Silicon, AWS Graviton)** — this mechanism does not apply. + Relevant: our benchmarks (M1 Max) and likely Graviton production are unaffected. + +**Threat model — co-residence required.** MemJam is **not remote**: the spy must +run on the **sibling hyperthread (same physical core, SMT enabled)** of the +victim, hammering the aliasing address throughout the computation. So the surface +is: Intel **+** SMT on **+** attacker code on the same core as the *encryptor* +**+** an attacker who cannot already read the victim's memory. Disabling SMT or +core-isolation neutralizes it with no code change; running the encryptor on ARM +sidesteps it entirely. Marginal leakage is small regardless — the swap addresses +reveal partial info about a permutation whose codebook the ciphertext already +largely exposes (the same fact that sank swap-or-not). + +**Recommendation.** +1. **Default:** ship the `#[repr(C, align(64))]` fix; document that the + constant-time guarantee is at **cache-line granularity**, and that sub-line + (MemJam/CacheBleed) resistance on SMT-enabled Intel needs either SMT-off / + core-isolation **or** the oblivious build. +2. **High-assurance tier:** offer the **oblivious-swap Fisher–Yates** builder + (~3× slower, already spiked) for threat models that include a malicious + co-tenant on Intel with SMT. + +This matches field practice — aligned-constant-time is the standard bar, +oblivious is the paranoid tier — and lets the deployment, not the library, pay +the 3× only when it needs to. + +### Relationship to vector pinning (revised) +The MemJam tier does **not** gate Bit6 vector pinning, *provided the oblivious +fallback is the oblivious-swap FY form* (recommended): it yields the identical +permutation, so ciphertexts are byte-stable whether or not it is enabled. Vectors +can be pinned after the A1/H decision, and the oblivious builder added later as a +build option with no re-pin. (Only a *different-construction* fallback such as +swap-or-not would change ciphertexts and force a re-pin — another reason to +prefer oblivious-swap FY.) + +### Decision & what it unblocks +Ratify: (a) `#[repr(C, align(64))]` as the default construction-side fix; (b) the +oblivious compare-side read; (c) the `N≤64` compile guard; and (d) the MemJam +posture (document scope + offer oblivious-swap FY as the high-assurance build). +None of (a)–(d) changes ciphertexts, so Bit6 vectors are gated only by A1. + +--- + +## 6. Sign-off checklist + +**Gate 1 — before #82 merges / Bit6 vectors pinned:** +- [x] **A1** H construction resolved (2026-06-15): keep fixed public-key AES, + upgraded to the BHKR σ-MMO `LSB(π(σ(x)⊕r)⊕σ(x)⊕r)`, σ(x)=2x. GKWY/half-gates + attacks shown not to port (secret independent inputs, no global offset); + tweak-as-key (2019/1168 Thm 2) explicitly declined; 2025/792 targets + properties we don't use and is round-reduced. +- [ ] **A4** ratify the applied construction-side fix `#[repr(C, align(64))]` + (covers both secret-indexed writes; line-uniform at N ≤ 64). +- [ ] **A4** ratify the `const _ = assert!(N <= 64)` compile guard (applied). +- [ ] **A4** ratify the oblivious compare-side read `width::ct_select_byte` + (applied; all four `get_bit` sites; results unchanged). +- [ ] **A4** MemJam posture: accept "default = line-granularity CT; sub-line + resistance via SMT-off / core-isolation or the oblivious-swap-FY build." + Decide whether to build the oblivious-swap-FY tier now or on demand. +- [ ] Bit6 byte vectors regenerated and pinned **after A1** (all A4 fixes are + byte-stable; production comparator adopts `ct_select_byte` separately). + +**Gate 2 — before PR 6 is written:** +- [ ] **A2** CMAC encoding fully specified and checked injective; many-outputs + / chain-state-from-published-tag interaction cleared; impl-faithfulness + + zeroization test plan agreed. +- [ ] **A3** PRP-stream-as-CMAC-branch-family domain separation approved. + +--- + +## 7. References / pointers +- Plan: `docs/plans/2026-06-12-ore-v2-architecture.md` — §5b (accumulator), + §6 (H), Open Q1 (PRP), Security checklist. +- Code: `primitives/hash.rs` (A1), `primitives/prp.rs` (A3/A4), + `scheme/bit2_w6.rs` (Bit6 wiring), `tests/compat_vectors` (Bit8 frozen). +- Spikes: `/tmp/ore-keyexp-spike` (A2 gate), `/tmp/ore-prp-spike` (A3/A4). +- Benchmarks: `docs/benchmarks/2026-06-13-*.md`. +- Lit: Lewi-Wu 2016 (BlockORE); BHKR13 / GKWY20 (fixed-key AES hashing); + NIST SP 800-38B (CMAC); HMR12 + Morris-Rogaway 2014 (swap-or-not); Lemire 2019 + (nearly-divisionless bounded random); Moghimi et al. CT-RSA 2018 (MemJam, + arXiv:1711.08002) + Yarom et al. 2016 (CacheBleed) for the sub-line channels. diff --git a/packages/ore-rs/Cargo.toml b/packages/ore-rs/Cargo.toml index 34850c6..3ddf561 100644 --- a/packages/ore-rs/Cargo.toml +++ b/packages/ore-rs/Cargo.toml @@ -49,6 +49,10 @@ thiserror = "1.0.38" name = "oreaes128" harness = false +[[bench]] +name = "bit6" +harness = false + [[bench]] name = "decimal" harness = false diff --git a/packages/ore-rs/benches/bit6.rs b/packages/ore-rs/benches/bit6.rs new file mode 100644 index 0000000..1d0a8e7 --- /dev/null +++ b/packages/ore-rs/benches/bit6.rs @@ -0,0 +1,42 @@ +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use hex_literal::hex; +use ore_rs::{scheme::bit2_w6::OreAes128Bit6ChaCha20, OreCipher, OreEncrypt, OreOutput}; + +fn init_ore() -> OreAes128Bit6ChaCha20 { + let k1: [u8; 16] = hex!("00010203 04050607 08090a0b 0c0d0e0f"); + let k2: [u8; 16] = hex!("d0d1d2d3 d4d5d6d7 d8d9dadb dcdddedf"); + OreCipher::init(&k1, &k2).unwrap() +} + +fn criterion_benchmark(c: &mut Criterion) { + let ore = init_ore(); + + c.bench_function("bit6-encrypt-u64", |b| { + b.iter(|| black_box(25u64).encrypt(&ore).unwrap()) + }); + + c.bench_function("bit6-encrypt-left-u64", |b| { + b.iter(|| black_box(25u64).encrypt_left(&ore).unwrap()) + }); + + c.bench_function("bit6-encrypt-u32", |b| { + b.iter(|| black_box(25u32).encrypt(&ore).unwrap()) + }); + + let a = 25u64.encrypt(&ore).unwrap(); + let b_ct = 1025u64.encrypt(&ore).unwrap(); + c.bench_function("bit6-compare-u64", |bench| { + bench.iter(|| black_box(&a).partial_cmp(black_box(&b_ct))) + }); + + let a_bytes = a.to_bytes(); + let b_bytes = b_ct.to_bytes(); + c.bench_function("bit6-compare-u64-slice", |bench| { + bench.iter(|| { + OreAes128Bit6ChaCha20::compare_raw_slices(black_box(&a_bytes), black_box(&b_bytes)) + }) + }); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/packages/ore-rs/src/chrono.rs b/packages/ore-rs/src/chrono.rs index 06d770e..1994784 100644 --- a/packages/ore-rs/src/chrono.rs +++ b/packages/ore-rs/src/chrono.rs @@ -9,35 +9,37 @@ //! ordering properties. use crate::ciphertext::*; +use crate::scheme::bit2::OreAes128; use crate::{OreCipher, OreEncrypt, OreError}; use ::chrono::{DateTime, NaiveDate, Utc}; use orderable_bytes::ToOrderableBytes; +use rand::{Rng, SeedableRng}; const NAIVE_DATE_LEN: usize = ::ENCODED_LEN; const DATETIME_UTC_LEN: usize = as ToOrderableBytes>::ENCODED_LEN; -impl OreEncrypt for NaiveDate { - type LeftOutput = Left; - type FullOutput = CipherText; +impl OreEncrypt> for NaiveDate { + type LeftOutput = Left, NAIVE_DATE_LEN>; + type FullOutput = CipherText, NAIVE_DATE_LEN>; - fn encrypt_left(&self, cipher: &T) -> Result { + fn encrypt_left(&self, cipher: &OreAes128) -> Result { cipher.encrypt_left(&self.to_orderable_bytes()) } - fn encrypt(&self, cipher: &T) -> Result { + fn encrypt(&self, cipher: &OreAes128) -> Result { cipher.encrypt(&self.to_orderable_bytes()) } } -impl OreEncrypt for DateTime { - type LeftOutput = Left; - type FullOutput = CipherText; +impl OreEncrypt> for DateTime { + type LeftOutput = Left, DATETIME_UTC_LEN>; + type FullOutput = CipherText, DATETIME_UTC_LEN>; - fn encrypt_left(&self, cipher: &T) -> Result { + fn encrypt_left(&self, cipher: &OreAes128) -> Result { cipher.encrypt_left(&self.to_orderable_bytes()) } - fn encrypt(&self, cipher: &T) -> Result { + fn encrypt(&self, cipher: &OreAes128) -> Result { cipher.encrypt(&self.to_orderable_bytes()) } } diff --git a/packages/ore-rs/src/ciphertext.rs b/packages/ore-rs/src/ciphertext.rs index 8b34b63..ad3df3a 100644 --- a/packages/ore-rs/src/ciphertext.rs +++ b/packages/ore-rs/src/ciphertext.rs @@ -3,6 +3,70 @@ use thiserror::Error; use crate::primitives::NONCE_SIZE; pub use crate::OreCipher; +/// Wire-format header prepended to every serialised artifact (Left, Right +/// or combined ciphertext) of schemes introduced from ORE v2 onwards. +/// +/// Layout: `version ‖ scheme_id ‖ block_count (u16 BE)` — 4 bytes. The +/// legacy [`crate::scheme::bit2`] scheme predates headers and remains +/// headerless forever ([`OreCipher::WIRE_HEADER`] is `None` for it); +/// ciphertexts of headered schemes can never be confused with each other +/// (version + scheme id are validated on parse and compare), and each +/// *type* only ever parses its own format. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WireHeader { + /// Wire format version. `0x02` is the first headered format. + pub version: u8, + /// Scheme identifier (encodes block width, prefix mode and cipher + /// suite). See the scheme modules for assigned values. + pub scheme_id: u8, +} + +/// Serialised size of a [`WireHeader`] plus the block count it carries. +pub(crate) const WIRE_HEADER_LEN: usize = 4; + +impl WireHeader { + pub(crate) fn write(&self, num_blocks: usize, out: &mut Vec) { + debug_assert!(num_blocks <= u16::MAX as usize); + out.push(self.version); + out.push(self.scheme_id); + out.extend_from_slice(&(num_blocks as u16).to_be_bytes()); + } + + /// Validate `data`'s header against `self` and an expected block count, + /// returning the payload after the header. + pub(crate) fn strip<'a>( + &self, + num_blocks: usize, + data: &'a [u8], + ) -> Result<&'a [u8], ParseError> { + let (header, body) = parse_header(data)?; + if header != (self.version, self.scheme_id, num_blocks) { + return Err(ParseError); + } + Ok(body) + } +} + +/// `(version, scheme_id, block_count)` as parsed from a wire header. +pub(crate) type ParsedHeader = (u8, u8, usize); + +/// Split a headered slice into `((version, scheme_id, block_count), body)`. +pub(crate) fn parse_header(data: &[u8]) -> Result<(ParsedHeader, &[u8]), ParseError> { + if data.len() < WIRE_HEADER_LEN { + return Err(ParseError); + } + let count = u16::from_be_bytes([data[2], data[3]]) as usize; + Ok(((data[0], data[1], count), &data[WIRE_HEADER_LEN..])) +} + +fn header_len() -> usize { + if S::WIRE_HEADER.is_some() { + WIRE_HEADER_LEN + } else { + 0 + } +} + /// The trait of any encryption output (either Left, Right or combined). pub trait OreOutput: Sized { /// The size (in bytes) of this encrypted value @@ -72,7 +136,7 @@ pub trait CipherTextBlock: Default + Copy + std::fmt::Debug { } /// Error returned when a serialised ciphertext can't be parsed (wrong -/// length, malformed block, etc.). +/// length, malformed block, bad or mismatched wire header, etc.). #[derive(Debug, Error)] #[error("Unable to parse ORE Ciphertext")] pub struct ParseError; @@ -84,23 +148,23 @@ impl Left { f: [S::LeftBlockType::default(); N], } } -} -impl OreOutput for Left { - fn size() -> usize { + /// Serialised size of the headerless body. + pub(crate) fn body_size() -> usize { N * (S::LeftBlockType::BLOCK_SIZE + 1) } - fn to_bytes(&self) -> Vec { - let mut vec = Vec::with_capacity(N * S::LeftBlockType::BLOCK_SIZE); + fn write_body(&self, vec: &mut Vec) { + vec.extend_from_slice(&self.xt); self.f .iter() .for_each(|&block| vec.append(&mut block.to_bytes())); - - [self.xt.to_vec(), vec].concat() } - fn from_slice(data: &[u8]) -> Result { + fn from_body(data: &[u8]) -> Result { + if data.len() != Self::body_size() { + return Err(ParseError); + } let mut out = Self::init(); out.xt.copy_from_slice(&data[0..N]); for i in 0..N { @@ -114,6 +178,29 @@ impl OreOutput for Left { } } +impl OreOutput for Left { + fn size() -> usize { + header_len::() + Self::body_size() + } + + fn to_bytes(&self) -> Vec { + let mut vec = Vec::with_capacity(Self::size()); + if let Some(header) = S::WIRE_HEADER { + header.write(N, &mut vec); + } + self.write_body(&mut vec); + vec + } + + fn from_slice(data: &[u8]) -> Result { + let body = match S::WIRE_HEADER { + Some(header) => header.strip(N, data)?, + None => data, + }; + Self::from_body(body) + } +} + impl Right { pub(crate) fn init() -> Self { Self { @@ -121,23 +208,23 @@ impl Right { data: [Default::default(); N], } } -} -impl OreOutput for Right { - fn size() -> usize { + /// Serialised size of the headerless body. + pub(crate) fn body_size() -> usize { (N * S::RightBlockType::BLOCK_SIZE) + NONCE_SIZE } - fn to_bytes(&self) -> Vec { - let mut vec = Vec::with_capacity(N * S::RightBlockType::BLOCK_SIZE); + fn write_body(&self, vec: &mut Vec) { + vec.extend_from_slice(&self.nonce); self.data .iter() .for_each(|&block| vec.append(&mut block.to_bytes())); - - [self.nonce.to_vec(), vec].concat() } - fn from_slice(data: &[u8]) -> Result { + fn from_body(data: &[u8]) -> Result { + if data.len() != Self::body_size() { + return Err(ParseError); + } let mut out = Self::init(); out.nonce.copy_from_slice(&data[0..NONCE_SIZE]); for i in 0..N { @@ -150,24 +237,59 @@ impl OreOutput for Right { } } +impl OreOutput for Right { + fn size() -> usize { + header_len::() + Self::body_size() + } + + fn to_bytes(&self) -> Vec { + let mut vec = Vec::with_capacity(Self::size()); + if let Some(header) = S::WIRE_HEADER { + header.write(N, &mut vec); + } + self.write_body(&mut vec); + vec + } + + fn from_slice(data: &[u8]) -> Result { + let body = match S::WIRE_HEADER { + Some(header) => header.strip(N, data)?, + None => data, + }; + Self::from_body(body) + } +} + impl OreOutput for CipherText { fn size() -> usize { - Left::::size() + Right::::size() + header_len::() + Left::::body_size() + Right::::body_size() } - /// Serialize the ciphertext into a vector of bytes + /// Serialize the ciphertext into a vector of bytes. Headered schemes + /// emit exactly one header for the combined artifact (not one per + /// half). fn to_bytes(&self) -> Vec { - [self.left.to_bytes(), self.right.to_bytes()].concat() + let mut vec = Vec::with_capacity(Self::size()); + if let Some(header) = S::WIRE_HEADER { + header.write(N, &mut vec); + } + self.left.write_body(&mut vec); + self.right.write_body(&mut vec); + vec } /// Deserialize from a slice of bytes fn from_slice(data: &[u8]) -> Result { - if data.len() != (Left::::size() + Right::::size()) { + let body = match S::WIRE_HEADER { + Some(header) => header.strip(N, data)?, + None => data, + }; + if body.len() != Left::::body_size() + Right::::body_size() { return Err(ParseError); } - let (left, right) = data.split_at(Left::::size()); - let left = Left::::from_slice(left)?; - let right = Right::::from_slice(right)?; + let (left, right) = body.split_at(Left::::body_size()); + let left = Left::::from_body(left)?; + let right = Right::::from_body(right)?; Ok(Self { left, right }) } diff --git a/packages/ore-rs/src/decimal.rs b/packages/ore-rs/src/decimal.rs index 2253bde..c0d4c68 100644 --- a/packages/ore-rs/src/decimal.rs +++ b/packages/ore-rs/src/decimal.rs @@ -9,21 +9,23 @@ use crate::ciphertext::{CipherText, Left}; use crate::encrypt::OreEncrypt; +use crate::scheme::bit2::OreAes128; use crate::{OreCipher, OreError}; use orderable_bytes::ToOrderableBytes; +use rand::{Rng, SeedableRng}; use rust_decimal::Decimal; const ENCODED_LEN: usize = ::ENCODED_LEN; -impl OreEncrypt for Decimal { - type LeftOutput = Left; - type FullOutput = CipherText; +impl OreEncrypt> for Decimal { + type LeftOutput = Left, ENCODED_LEN>; + type FullOutput = CipherText, ENCODED_LEN>; - fn encrypt_left(&self, cipher: &T) -> Result { + fn encrypt_left(&self, cipher: &OreAes128) -> Result { cipher.encrypt_left(&self.to_orderable_bytes()) } - fn encrypt(&self, cipher: &T) -> Result { + fn encrypt(&self, cipher: &OreAes128) -> Result { cipher.encrypt(&self.to_orderable_bytes()) } } diff --git a/packages/ore-rs/src/encrypt.rs b/packages/ore-rs/src/encrypt.rs index ed37ddb..48b6601 100644 --- a/packages/ore-rs/src/encrypt.rs +++ b/packages/ore-rs/src/encrypt.rs @@ -1,7 +1,9 @@ use crate::ciphertext::*; +use crate::scheme::bit2::OreAes128; use crate::PlainText; use crate::{OreCipher, OreError}; use orderable_bytes::ToOrderableBytes; +use rand::{Rng, SeedableRng}; /// Type-directed entry point for encrypting plaintext values with a given /// [`OreCipher`]. @@ -49,17 +51,24 @@ const CHAR_LEN: usize = ::ENCODED_LEN; const F32_LEN: usize = ::ENCODED_LEN; const F64_LEN: usize = ::ENCODED_LEN; +// These impls are specific to the legacy byte-per-block scheme (one +// plaintext byte = one ORE block, so the orderable-bytes encoding feeds +// the cipher directly). The 6-bit scheme provides its own impls in +// `scheme::bit2_w6`, which interpose the byte->block decomposition. They +// were previously blanket impls over `T: OreCipher`; scheme-specific +// impls keep coherence simple now that block count != byte count for +// some schemes. macro_rules! impl_ore_encrypt_via_orderable_bytes { ($type:ty, $len_const:ident) => { - impl OreEncrypt for $type { - type LeftOutput = Left; - type FullOutput = CipherText; + impl OreEncrypt> for $type { + type LeftOutput = Left, $len_const>; + type FullOutput = CipherText, $len_const>; - fn encrypt_left(&self, cipher: &T) -> Result { + fn encrypt_left(&self, cipher: &OreAes128) -> Result { cipher.encrypt_left(&self.to_orderable_bytes()) } - fn encrypt(&self, cipher: &T) -> Result { + fn encrypt(&self, cipher: &OreAes128) -> Result { cipher.encrypt(&self.to_orderable_bytes()) } } @@ -81,15 +90,15 @@ impl_ore_encrypt_via_orderable_bytes!(char, CHAR_LEN); impl_ore_encrypt_via_orderable_bytes!(f32, F32_LEN); impl_ore_encrypt_via_orderable_bytes!(f64, F64_LEN); -impl OreEncrypt for PlainText { - type LeftOutput = Left; - type FullOutput = CipherText; +impl OreEncrypt> for PlainText { + type LeftOutput = Left, N>; + type FullOutput = CipherText, N>; - fn encrypt_left(&self, cipher: &T) -> Result { + fn encrypt_left(&self, cipher: &OreAes128) -> Result { cipher.encrypt_left(self) } - fn encrypt(&self, cipher: &T) -> Result { + fn encrypt(&self, cipher: &OreAes128) -> Result { cipher.encrypt(self) } } diff --git a/packages/ore-rs/src/lib.rs b/packages/ore-rs/src/lib.rs index 09e2af5..c7e1ca5 100644 --- a/packages/ore-rs/src/lib.rs +++ b/packages/ore-rs/src/lib.rs @@ -190,6 +190,11 @@ pub trait OreCipher: Sized { /// truth-table row). type RightBlockType: CipherTextBlock; + /// Wire-format header for this scheme's serialised artifacts, or + /// `None` for the legacy headerless format. Schemes introduced from + /// ORE v2 onwards must set this; see [`WireHeader`]. + const WIRE_HEADER: Option = None; + /// Initialise the cipher from two 16-byte keys: `k1` for the /// per-block-tag PRF and `k2` for the per-block PRP seed PRF. fn init(k1: &[u8; 16], k2: &[u8; 16]) -> Result; diff --git a/packages/ore-rs/src/primitives/hash.rs b/packages/ore-rs/src/primitives/hash.rs index 9321e1f..eeef30c 100644 --- a/packages/ore-rs/src/primitives/hash.rs +++ b/packages/ore-rs/src/primitives/hash.rs @@ -47,6 +47,130 @@ impl Hash for Aes128Z2Hash { } } +/// Z2 hash instantiated as the BHKR fixed-key-AES **σ-MMO** construction: +/// `H(x, r) = LSB(π(σ(x) ⊕ r) ⊕ σ(x) ⊕ r)`, where `π` is a *fixed public* +/// AES-128 permutation (public key [`PI_KEY`]), `r` is the per-ciphertext +/// nonce, and `σ(x) = 2·x` is the GF(2^128) doubling orthomorphism (the +/// BHKR/Zahur linear orthomorphism; both `σ` and `σ ⊕ id` are permutations). +/// v2 plan §6 option 3 — **A1 resolved 2026-06-15** — analysed in the +/// random-permutation model (BHKR13; GKWY20; Guo–Katz–Wang–Weng–Yu, eprint +/// 2019/1168). +/// +/// The orthomorphism `σ` is the only departure from plain MMO and is adopted +/// as cheap defense-in-depth, not to fix a present weakness: the known attacks +/// on fixed-key MMO (GKWY; the half-gates attack of eprint 2019/1168) require +/// *known, Free-XOR-correlated* hash inputs plus a recoverable global offset — +/// neither of which ORE has, since its `H` inputs are independent **secret** +/// PRF outputs and there is no global offset. `σ` makes the construction +/// secure by matching the named BHKR/Zahur hash rather than by a usage +/// argument. The cryptanalysis of round-reduced AES hashing (eprint 2025/792) +/// targets collision/preimage/one-wayness — properties this 1-bit hash does +/// not rely on — and never reaches full-round AES-128. +/// +/// The `Hash::new` "key" parameter carries the **nonce** `r`; the AES key is +/// the public constant [`PI_KEY`], expanded once per process. +pub struct FixedPiZ2Hash { + nonce: AesBlock, +} + +/// The public, fixed AES key for `π`. Nothing-up-my-sleeve: the ASCII +/// bytes of `"ORE-rs.v2.H-pi.1"`. This key is deliberately *not* secret — +/// the construction's security rests on AES being a good public random +/// permutation, not on key secrecy (the comparator must be able to +/// evaluate H with no key material). +pub const PI_KEY: [u8; 16] = *b"ORE-rs.v2.H-pi.1"; + +fn pi() -> &'static Aes128 { + use std::sync::OnceLock; + static PI: OnceLock = OnceLock::new(); + PI.get_or_init(|| Aes128::new(GenericArray::from_slice(&PI_KEY))) +} + +/// GF(2^128) doubling `2·x` on a big-endian field element packed in a `u128` +/// — the BHKR/Zahur orthomorphism `σ` ("multiply by x"; reduction polynomial +/// x^128 + x^7 + x^2 + x + 1, constant `0x87`). A single shift plus a +/// branch-free conditional XOR, replacing the per-byte carry loop that +/// dominated the σ-MMO hot path (≈704 doublings per Bit6 u64 encrypt). +/// Constant-time: no secret-dependent control flow. +#[inline] +fn gf128_double_u128(x: u128) -> u128 { + // `x << 1` discards the top bit (the GF reduction trigger); fold 0x87 into + // the low byte iff that bit was set. `x >> 127` is 0 or 1. + (x << 1) ^ ((x >> 127) * 0x87) +} + +/// In-place GF(2^128) doubling `b ← 2·b`; `b` must be exactly 16 bytes +/// (big-endian field element). Used by the scalar comparator path; the bulk +/// encrypt path folds the doubling and nonce XOR into one `u128` pass (see +/// `hash_all_into`). +#[inline] +fn gf128_double(b: &mut [u8]) { + debug_assert_eq!(b.len(), 16); + let mut arr = [0u8; 16]; + arr.copy_from_slice(b); + let doubled = gf128_double_u128(u128::from_be_bytes(arr)); + b.copy_from_slice(&doubled.to_be_bytes()); +} + +impl Hash for FixedPiZ2Hash { + fn new(nonce: &HashKey) -> Self { + Self { nonce: *nonce } + } + + fn hash(&self, data: &[u8]) -> u8 { + assert_eq!(data.len(), 16); + // BHKR σ-MMO: m = σ(x) ⊕ r; return lsb(π(m) ⊕ m). + let mut block = [0u8; 16]; + block.copy_from_slice(data); + gf128_double(&mut block); // σ(x) = 2x + for (slot, &r) in block.iter_mut().zip(self.nonce.iter()) { + *slot ^= r; // m = σ(x) ⊕ r + } + let m_lsb = block[0] & 1u8; + let block = GenericArray::from_mut_slice(&mut block); + pi().encrypt_block(block); + (block[0] & 1u8) ^ m_lsb + } + + fn hash_all_into(&self, data: &mut [AesBlock], out: &mut [u8]) { + debug_assert_eq!(out.len() * 8, data.len()); + + // BHKR σ-MMO: m = σ(x) ⊕ r, then out = lsb(m) ^ lsb(π(m)), with + // σ(x) = 2x in GF(2^128). Form m in place first so the feedforward + // captures lsb(m) rather than lsb(x). Doubling and nonce XOR are fused + // into one u128 pass per block — this is the 704-evals/u64 hot loop. + let mut nonce_arr = [0u8; 16]; + nonce_arr.copy_from_slice(self.nonce.as_slice()); + let nonce = u128::from_be_bytes(nonce_arr); + for block in data.iter_mut() { + let mut arr = [0u8; 16]; + arr.copy_from_slice(block.as_slice()); + let m = gf128_double_u128(u128::from_be_bytes(arr)) ^ nonce; + block.copy_from_slice(&m.to_be_bytes()); + } + + // feedforward lsb(m) + if data.len() == 256 { + crate::primitives::simd::lsb_mask_256(data, out); + } else { + crate::primitives::simd::scalar::lsb_mask(data, out); + } + + pi().encrypt_blocks(data); // π(m) + + let mut pi_mask = [0u8; 32]; + let pi_mask = &mut pi_mask[..out.len()]; + if data.len() == 256 { + crate::primitives::simd::lsb_mask_256(data, pi_mask); + } else { + crate::primitives::simd::scalar::lsb_mask(data, pi_mask); + } + for (slot, &m) in out.iter_mut().zip(pi_mask.iter()) { + *slot ^= m; + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -91,4 +215,56 @@ mod tests { hash.hash(&input); } + + // The comparator uses the scalar `hash`; encryption uses the bulk + // `hash_all_into`. For the BHKR σ-MMO they must agree bit-for-bit, over + // both the scalar (n=64, the Bit6 domain) and SIMD (n=256) `lsb_mask` + // backends. + #[test] + fn fixed_pi_scalar_matches_bulk() { + let nonce: [u8; 16] = hex!("0f0e0d0c 0b0a0908 07060504 03020100"); + let h: FixedPiZ2Hash = Hash::new(GenericArray::from_slice(&nonce)); + + for &n in &[64usize, 256usize] { + let mut blocks: Vec = (0..n) + .map(|i| { + let mut b = [0u8; 16]; + for (j, slot) in b.iter_mut().enumerate() { + *slot = (i.wrapping_mul(31).wrapping_add(j)) as u8; + } + *GenericArray::from_slice(&b) + }) + .collect(); + + let mut expected = vec![0u8; n / 8]; + for (i, b) in blocks.iter().enumerate() { + expected[i / 8] |= h.hash(b.as_slice()) << (i % 8); + } + + let mut out = vec![0u8; n / 8]; + h.hash_all_into(&mut blocks, &mut out); + assert_eq!(out, expected, "scalar vs bulk mismatch for n={}", n); + } + } + + // σ(x) = 2x must be an orthomorphism: both σ and σ⊕id are permutations. + // Spot-check the GF(2^128) doubling against the textbook shift/0x87 rule. + #[test] + fn gf128_double_reduction() { + // High bit clear: pure left shift. + let mut b = [0u8; 16]; + b[15] = 0x01; + gf128_double(&mut b); + let mut want = [0u8; 16]; + want[15] = 0x02; + assert_eq!(b, want); + + // High bit set: shift then XOR 0x87 into the low byte. + let mut b = [0u8; 16]; + b[0] = 0x80; + gf128_double(&mut b); + let mut want = [0u8; 16]; + want[15] = 0x87; + assert_eq!(b, want); + } } diff --git a/packages/ore-rs/src/primitives/prp.rs b/packages/ore-rs/src/primitives/prp.rs index 829d68d..70e5855 100644 --- a/packages/ore-rs/src/primitives/prp.rs +++ b/packages/ore-rs/src/primitives/prp.rs @@ -1,6 +1,8 @@ pub mod prng; use crate::primitives::prp::prng::Aes128Prng; -use crate::primitives::{Prp, PrpError, PrpResult}; +use crate::primitives::{AesBlock, Prp, PrpError, PrpResult}; +use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit}; +use aes::Aes128; use zeroize::{Zeroize, ZeroizeOnDrop}; #[derive(Zeroize)] @@ -19,78 +21,243 @@ impl Drop for KnuthShufflePRP { // Impl the ZeroizeOnDrop marker trait since we're zeroizing above impl ZeroizeOnDrop for KnuthShufflePRP {} -impl Prp for KnuthShufflePRP { - /* - * Initialize an 8-bit (256 element) PRP using a KnuthShuffle - * and a 64-bit random seed - */ - fn new(key: &[u8]) -> PrpResult { - let mut rng = Aes128Prng::init(key); // TODO: Use Result type here, too +/// Implements `Prp` for a Knuth-shuffle PRP over a `$domain`-element +/// space (`$domain ≤ 256`), with `$gt_mask` as the bulk indicator kernel. +/// The shuffle algorithm and PRNG byte consumption are identical across +/// domains; only the iteration bounds change. For the 256 domain this is +/// wire-frozen (the legacy bit2 scheme); other domains are used by new +/// schemes only. +macro_rules! impl_knuth_shuffle_prp { + ($domain:literal, $gt_mask:path) => { + impl Prp for KnuthShufflePRP { + /* + * Initialize a ($domain element) PRP using a KnuthShuffle + * seeded from a 16-byte key + */ + fn new(key: &[u8]) -> PrpResult { + let mut rng = Aes128Prng::init(key); // TODO: Use Result type here, too - let mut perm = Self { - permutation: [0u8; 256], - inverse: [0u8; 256], - }; + let mut perm = Self { + permutation: [0u8; $domain], + inverse: [0u8; $domain], + }; - // Initialize values - for i in 0..=255 { - perm.permutation[i] = i as u8; - } + // Initialize values + for i in 0..$domain { + perm.permutation[i] = i as u8; + } - // Iterations stop at i = 1: the i = 0 step always degenerates to - // `swap(0, 0)` after drawing rejection-sampled bytes until one is - // zero (expected 256 draws — a full PRNG regeneration), and the RNG - // is dropped right after this loop, so skipping it consumes no - // observable state and yields a byte-identical permutation. - (1..=255usize).rev().for_each(|i| { - let j = rng.gen_range(i as u8); - perm.permutation.swap(i, j as usize); - }); - - for (index, val) in perm.permutation.iter().enumerate() { - perm.inverse[*val as usize] = index as u8; - } + // Iterations stop at i = 1: the i = 0 step always + // degenerates to `swap(0, 0)` after drawing + // rejection-sampled bytes until one is zero (expected 256 + // draws — a full PRNG regeneration), and the RNG is dropped + // right after this loop, so skipping it consumes no + // observable state and yields a byte-identical permutation. + (1..$domain).rev().for_each(|i| { + let j = rng.gen_range(i as u8); + perm.permutation.swap(i, j as usize); + }); - Ok(perm) - } + for (index, val) in perm.permutation.iter().enumerate() { + perm.inverse[*val as usize] = index as u8; + } - /* - * Permutes a number under the Pseudo-Random Permutation in constant time. - * - * Forward permutations are only used once in the ORE scheme so this is OK - */ - fn permute(&self, input: u8) -> PrpResult { - let index = usize::from(input); - - match self.inverse.get(index) { - Some(i) => Ok(*i), - None => Err(PrpError), - } - } + Ok(perm) + } + + /* + * Permutes a number under the Pseudo-Random Permutation in constant time. + * + * Forward permutations are only used once in the ORE scheme so this is OK + */ + fn permute(&self, input: u8) -> PrpResult { + let index = usize::from(input); + + match self.inverse.get(index) { + Some(i) => Ok(*i), + None => Err(PrpError), + } + } + + /* + * Performs the inverse permutation in constant time. + */ + fn invert(&self, input: u8) -> PrpResult { + let index = usize::from(input); - /* - * Performs the inverse permutation in constant time. - */ - fn invert(&self, input: u8) -> PrpResult { - let index = usize::from(input); + // Forward an inverse permutations are reversed for historical reasons + match self.permutation.get(index) { + Some(i) => Ok(*i), + None => Err(PrpError), + } + } - // Forward an inverse permutations are reversed for historical reasons - match self.permutation.get(index) { - Some(i) => Ok(*i), - None => Err(PrpError), + fn indicator_mask_xor(&self, data: u8, out: &mut [u8]) { + debug_assert_eq!(out.len() * 8, $domain); + + // `invert(j)` is `self.permutation[j]` (see `invert` + // above), so the mask is one pass over the table: a + // bytewise `> data` compare packed to bits — vectorised + // where the target supports it. + $gt_mask(&self.permutation, data, out); + } } - } + }; +} - fn indicator_mask_xor(&self, data: u8, out: &mut [u8]) { - debug_assert_eq!(out.len() * 8, 256); +// The legacy bit2 scheme (256) is wire-frozen on the Knuth shuffle. New +// schemes use [`LemireFyPrp`] below; no other domain is instantiated here. +impl_knuth_shuffle_prp!(256, crate::primitives::simd::gt_mask_xor_256); + +/// PRP over a small domain via a Fisher–Yates shuffle driven by a fixed +/// number of **wide draws** — full 64-bit values reduced to range by +/// Lemire's multiply-high (`(x * range) >> 64`) — with the randomness +/// produced by AES-CTR under the 16-byte seed. Same field/method +/// semantics as [`KnuthShufflePRP`] (`permute` ↦ `inverse`, `invert` and +/// the indicator mask ↦ `permutation`). +/// +/// Contrast with [`KnuthShufflePRP`], which draws single bytes and uses +/// **rejection sampling** to avoid modulo bias: there the number of draws +/// (and PRNG buffer regenerations, and branches taken) depends on the +/// seed, and the seed is `PRF(plaintext prefix)`, so PRP construction time +/// is weakly plaintext-dependent — a timing side-channel. This construction +/// has a seed-independent, branch-free draw count, closing that channel, +/// and is ~9× faster (no rejection loop, no `%`, just multiply-high). +/// +/// Uniformity: each Lemire reduction to range `m` deviates from uniform by +/// at most `m / 2^64`; over the `N-1` draws the output permutation is within +/// statistical distance `< 2^-55` (for `N = 64`) of a uniformly random +/// permutation — exactly the object Lewi-Wu's analysis assumes. That is a +/// pure statistical term on top of the scheme's existing PRF advantage: no +/// new assumption, no new idealised model. +/// +/// New (non-wire-frozen) schemes only. +/// +/// Layout note (constant-time): key generation performs two secret-indexed +/// writes — the Fisher–Yates `permutation.swap(i, j)` (secret `j`) and the +/// `inverse[val] = …` fill (secret `val`). The one-cache-line argument that +/// defends these (any access within a single 64-byte line leaks nothing +/// through the cache) requires each table to occupy exactly one line. At +/// `N = 64` each `[u8; N]` is 64 bytes, so `#[repr(C, align(64))]` places +/// `permutation` at offset 0 (line 0) and `inverse` at offset 64 (line 1): +/// `repr(C)` pins field order (default `repr(Rust)` may reorder), `align(64)` +/// puts the struct on a cache-line boundary. The argument only holds for +/// `N ≤ 64`; the sole instantiation is `LemireFyPrp<64>`. +/// +/// **Under review** — see `docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md` +/// (A4). If the cache-line argument is rejected, this is replaced by a strictly +/// constant-time (oblivious-swap) construction. +#[derive(Zeroize)] +#[repr(C, align(64))] +pub struct LemireFyPrp { + permutation: [u8; N], + inverse: [u8; N], +} - // `invert(j)` is `self.permutation[j]` (see `invert` above), so the - // mask is one pass over the table: a bytewise `> data` compare - // packed to bits — vectorised where the target supports it. - crate::primitives::simd::gt_mask_xor_256(&self.permutation, data, out); +impl Drop for LemireFyPrp { + fn drop(&mut self) { + self.zeroize(); } } +impl ZeroizeOnDrop for LemireFyPrp {} + +/// Implements `Prp` for [`LemireFyPrp`] over `$domain` elements, drawing +/// `$domain - 1` wide values from `$stream_blocks` AES-CTR blocks +/// (`$stream_blocks == ((($domain - 1) * 8) + 15) / 16`). `$gt_mask` is the +/// bulk indicator kernel for the domain. +macro_rules! impl_lemire_fy_prp { + ($domain:literal, $stream_blocks:literal, $gt_mask:path) => { + // The one-cache-line constant-time argument for the secret-indexed + // key-generation writes (see the struct docs / review brief A4) holds + // only when each `[u8; N]` table fits a single 64-byte cache line. A + // larger domain silently spans multiple lines and loses the property, + // so make it a compile error rather than a comment. + const _: () = assert!( + $domain <= 64, + "LemireFyPrp: the one-cache-line constant-time argument requires domain <= 64" + ); + + impl Prp for LemireFyPrp<$domain> { + fn new(key: &[u8]) -> PrpResult { + if key.len() < 16 { + return Err(PrpError); + } + + // Fixed-count AES-CTR keystream from the seed: counter in + // the first 4 bytes (big-endian), matching the existing + // PRNG's counter convention. + let cipher = Aes128::new(GenericArray::from_slice(&key[0..16])); + let mut blocks = [AesBlock::default(); $stream_blocks]; + for (i, b) in blocks.iter_mut().enumerate() { + b[0..4].copy_from_slice(&(i as u32).to_be_bytes()); + } + cipher.encrypt_blocks(&mut blocks); + + let mut stream = [0u8; $stream_blocks * 16]; + for (i, b) in blocks.iter().enumerate() { + stream[i * 16..(i + 1) * 16].copy_from_slice(b); + } + + let mut perm = Self { + permutation: [0u8; $domain], + inverse: [0u8; $domain], + }; + for (i, p) in perm.permutation.iter_mut().enumerate() { + *p = i as u8; + } + + // Fisher–Yates with Lemire-reduced wide draws: draw `d` + // (8 bytes) drives step `i`. Fixed trip count, branch-free + // index reduction (the swap address is secret — defended by + // the single-cache-line argument; the table is `$domain` + // bytes). + for i in (1..$domain).rev() { + let d = $domain - 1 - i; + let mut draw = [0u8; 8]; + draw.copy_from_slice(&stream[d * 8..d * 8 + 8]); + let x = u64::from_le_bytes(draw); + let j = ((x as u128 * (i as u128 + 1)) >> 64) as usize; + perm.permutation.swap(i, j); + } + + for (index, val) in perm.permutation.iter().enumerate() { + perm.inverse[*val as usize] = index as u8; + } + + // The keystream determined the permutation — wipe it. + stream.zeroize(); + for b in blocks.iter_mut() { + b.as_mut_slice().zeroize(); + } + + Ok(perm) + } + + fn permute(&self, input: u8) -> PrpResult { + match self.inverse.get(usize::from(input)) { + Some(i) => Ok(*i), + None => Err(PrpError), + } + } + + fn invert(&self, input: u8) -> PrpResult { + match self.permutation.get(usize::from(input)) { + Some(i) => Ok(*i), + None => Err(PrpError), + } + } + + fn indicator_mask_xor(&self, data: u8, out: &mut [u8]) { + debug_assert_eq!(out.len() * 8, $domain); + $gt_mask(&self.permutation, data, out); + } + } + }; +} + +impl_lemire_fy_prp!(64, 32, crate::primitives::simd::gt_mask_xor_64); + #[cfg(test)] mod tests { use super::*; @@ -138,4 +305,75 @@ mod tests { Ok(()) } + + // ----------------------------------------------------------------- + // LemireFyPrp (Bit6 PRP) + // ----------------------------------------------------------------- + + fn init_fy(seed_byte: u8) -> LemireFyPrp<64> { + Prp::new(&[seed_byte; 16]).unwrap() + } + + #[test] + fn fy_is_a_permutation_and_round_trips() { + for seed in 0u8..32 { + let prp = init_fy(seed); + // Every value 0..64 appears exactly once in `permutation`. + let mut seen = [false; 64]; + for v in 0..64u8 { + let mapped = prp.invert(v).unwrap(); + assert!(mapped < 64); + assert!( + !seen[mapped as usize], + "value {} repeated (seed {})", + mapped, seed + ); + seen[mapped as usize] = true; + } + // Forward/inverse round-trip both directions. + for v in 0..64u8 { + assert_eq!(v, prp.invert(prp.permute(v).unwrap()).unwrap()); + assert_eq!(v, prp.permute(prp.invert(v).unwrap()).unwrap()); + } + } + } + + #[test] + fn fy_is_deterministic() { + let a = init_fy(7); + let b = init_fy(7); + for v in 0..64u8 { + assert_eq!(a.permute(v).unwrap(), b.permute(v).unwrap()); + } + // A different seed gives a different permutation (overwhelmingly). + let c = init_fy(8); + assert!((0..64u8).any(|v| a.permute(v).unwrap() != c.permute(v).unwrap())); + } + + #[test] + fn fy_rejects_short_key() { + assert!( as Prp>::new(&[0u8; 8]).is_err()); + } + + quickcheck! { + /// The bulk indicator mask must agree with the per-bit reference for + /// the Bit6 PRP too (guards the gt_mask_xor_64 kernel path). + fn fy_indicator_mask_matches_reference(key: Vec, x: u8) -> quickcheck::TestResult { + if key.len() < 16 { + return quickcheck::TestResult::discard(); + } + let prp: LemireFyPrp<64> = Prp::new(&key[0..16]).unwrap(); + + let mut mask = [0u8; 8]; + prp.indicator_mask_xor(x, &mut mask); + + let mut reference = [0u8; 8]; + for j in 0..64u8 { + let indicator = u8::from(prp.invert(j).unwrap() > x); + reference[(j / 8) as usize] |= indicator << (j % 8); + } + + quickcheck::TestResult::from_bool(mask == reference) + } + } } diff --git a/packages/ore-rs/src/primitives/simd.rs b/packages/ore-rs/src/primitives/simd.rs index 4aad834..5b15168 100644 --- a/packages/ore-rs/src/primitives/simd.rs +++ b/packages/ore-rs/src/primitives/simd.rs @@ -42,6 +42,7 @@ pub(crate) fn gt_mask_xor_256(table: &[u8; 256], x: u8, out: &mut [u8]) { // SAFETY: NEON is baseline on aarch64; `out` length asserted above. unsafe { neon::gt_mask_xor_256(table, x, out); + return; } #[cfg(target_arch = "x86_64")] @@ -53,10 +54,26 @@ pub(crate) fn gt_mask_xor_256(table: &[u8; 256], x: u8, out: &mut [u8]) { } } - // Scalar fallback; excluded on aarch64 (the NEON path above always handles - // it) so the dead tail needs no blanket `#[allow(unreachable_code)]`. - #[cfg(not(target_arch = "aarch64"))] - scalar::gt_mask_xor_256(table, x, out); + #[allow(unreachable_code)] + scalar::gt_mask_xor(table, x, out); +} + +/// 64-lane variant of [`gt_mask_xor_256`] for the Bit6 block domain. +/// AVX2 gains little at this size (two 32-lane compares vs the scalar +/// loop's ~9 ns); only NEON is dispatched. +#[inline] +pub(crate) fn gt_mask_xor_64(table: &[u8; 64], x: u8, out: &mut [u8]) { + debug_assert_eq!(out.len(), 8); + + #[cfg(target_arch = "aarch64")] + // SAFETY: NEON is baseline on aarch64; `out` length asserted above. + unsafe { + neon::gt_mask_xor_64(table, x, out); + return; + } + + #[allow(unreachable_code)] + scalar::gt_mask_xor(table, x, out); } /// Pack the LSB of byte 0 of each of 256 AES blocks into `out`: @@ -64,34 +81,26 @@ pub(crate) fn gt_mask_xor_256(table: &[u8; 256], x: u8, out: &mut [u8]) { /// (callers pass `out` slots they own; bits are assigned, not accumulated). #[inline] pub(crate) fn lsb_mask_256(blocks: &[AesBlock], out: &mut [u8]) { - // Real asserts (not debug_assert): the NEON path gathers `blocks` via raw - // pointers assuming exactly 256 blocks, so a shorter slice would read out - // of bounds (UB) in a release build without this check. - assert_eq!(blocks.len(), 256); - assert_eq!(out.len(), 32); + debug_assert_eq!(blocks.len(), 256); + debug_assert_eq!(out.len(), 32); #[cfg(target_arch = "aarch64")] // SAFETY: NEON is baseline on aarch64; lengths asserted above. unsafe { neon::lsb_mask_256(blocks, out); + return; } - // Scalar fallback; excluded on aarch64 (the NEON path above always handles - // it) so the dead tail needs no blanket `#[allow(unreachable_code)]`. - #[cfg(not(target_arch = "aarch64"))] + #[allow(unreachable_code)] scalar::lsb_mask(blocks, out); } pub(crate) mod scalar { use super::AesBlock; - // Used by the non-aarch64 `gt_mask_xor_256` dispatcher and as the test - // oracle; on aarch64 the lib always reaches NEON, so outside `cfg(test)` - // this fn is unreferenced there. Compiling it exactly where it's used keeps - // it from being dead code without an `allow`. (`lsb_mask` below stays - // always compiled — `hash.rs` calls it directly for non-256 inputs.) - #[cfg(any(not(target_arch = "aarch64"), test))] - pub(crate) fn gt_mask_xor_256(table: &[u8; 256], x: u8, out: &mut [u8]) { + /// Length-generic scalar indicator pack: `table.len()` must be a + /// multiple of 8 and equal to `out.len() * 8`. + pub(crate) fn gt_mask_xor(table: &[u8], x: u8, out: &mut [u8]) { for (slot, chunk) in out.iter_mut().zip(table.chunks_exact(8)) { let mut byte = 0u8; for (bit, &p) in chunk.iter().enumerate() { @@ -142,6 +151,17 @@ mod neon { } } + #[target_feature(enable = "neon")] + pub(super) unsafe fn gt_mask_xor_64(table: &[u8; 64], x: u8, out: &mut [u8]) { + let xv = vdupq_n_u8(x); + for i in 0..4 { + let t = vld1q_u8(table.as_ptr().add(i * 16)); + let (lo, hi) = pack_addv(vcgtq_u8(t, xv)); + out[2 * i] ^= lo; + out[2 * i + 1] ^= hi; + } + } + /// `IDX[g]` places the 4 block-start bytes of 64-byte group `g` /// (offsets 0, 16, 32, 48) at lanes 4g..4g+3; all other lanes are 0xFF /// (out-of-range, yielding 0 in `tbl`). @@ -230,7 +250,26 @@ mod tests { let mut b = a; gt_mask_xor_256(&table, x, &mut a); - scalar::gt_mask_xor_256(&table, x, &mut b); + scalar::gt_mask_xor(&table, x, &mut b); + assert_eq!(a, b); + } + } + + #[test] + fn gt_mask_64_dispatched_matches_scalar() { + let mut rng = ChaCha20Rng::seed_from_u64(13); + for _ in 0..5_000 { + let mut table = [0u8; 64]; + rng.fill(&mut table[..]); + // Block values and table entries are < 64 in real use, but the + // kernels must agree on all byte values. + let x: u8 = rng.gen(); + let mut a = [0u8; 8]; + rng.fill(&mut a[..]); + let mut b = a; + + gt_mask_xor_64(&table, x, &mut a); + scalar::gt_mask_xor(&table, x, &mut b); assert_eq!(a, b); } } diff --git a/packages/ore-rs/src/scheme.rs b/packages/ore-rs/src/scheme.rs index 33198b4..25df7c0 100644 --- a/packages/ore-rs/src/scheme.rs +++ b/packages/ore-rs/src/scheme.rs @@ -7,5 +7,10 @@ /// 2-bit-indicator BlockORE with AES-128 PRF and Knuth-shuffle PRP. pub mod bit2; +/// 6-bit-block variant of [`bit2`]: 4x less AES work per block and 4x +/// smaller right blocks. Wire format v2 (headered). Not yet frozen — +/// pending crypto review of the Z2 hash (v2 plan, section 6). +pub mod bit2_w6; + pub(crate) mod decompose; pub(crate) mod width; diff --git a/packages/ore-rs/src/scheme/bit2.rs b/packages/ore-rs/src/scheme/bit2.rs index 6a607fb..0a3ff98 100644 --- a/packages/ore-rs/src/scheme/bit2.rs +++ b/packages/ore-rs/src/scheme/bit2.rs @@ -30,7 +30,7 @@ pub use self::block_types::*; /// nonces. The two PRF instances are keyed at construction; the RNG is held /// in a `RefCell` so encryption can take `&self` while still drawing fresh /// randomness. Keys are zeroised on drop. -#[derive(Debug, ZeroizeOnDrop)] +#[derive(ZeroizeOnDrop)] pub struct OreAes128 { prf1: Aes128Prf, prf2: Aes128Prf, @@ -38,6 +38,14 @@ pub struct OreAes128 { rng: RefCell, } +// Opaque Debug: never render key material. (`Aes128`'s own Debug is already +// opaque, but spell it out so the guarantee can't regress.) +impl std::fmt::Debug for OreAes128 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OreAes128").finish_non_exhaustive() + } +} + /// Convenience alias for [`OreAes128`] backed by `ChaCha20Rng` — the RNG /// most callers will want. pub type OreAes128ChaCha20 = OreAes128; @@ -81,11 +89,11 @@ fn derive_prp_seeds(prf2: &Aes128Prf, x: &PlainText) -> SeedB /// one linear pass of its inverse table — no per-bit `invert` lookups, no /// heap allocation. `ro_blocks` holds the PRF₁-encrypted RO keys and is /// trashed by the hash pass. -fn encode_right_block( +pub(crate) fn encode_right_block( block: &mut W::RightBlock, prp: &W::Prp, x: u8, - hasher: &Aes128Z2Hash, + hasher: &H, ro_blocks: &mut [AesBlock], ) { debug_assert_eq!(ro_blocks.len(), W::DOMAIN); @@ -199,7 +207,7 @@ impl OreCipher for OreAes128 { work.copy_from(&template); self.prf1.encrypt_all(work.as_mut_slice()); - encode_right_block::(&mut right.data[n], &prp, x[n], &hasher, &mut work); + encode_right_block::(&mut right.data[n], &prp, x[n], &hasher, &mut work); } self.prf1.encrypt_all(&mut left.f); @@ -280,11 +288,10 @@ fn right_block(input: &[u8], n: usize) -> &[u8] { fn get_bit(block: &[u8], bit: usize) -> u8 { debug_assert!(block.len() == RightBlock32::BLOCK_SIZE); debug_assert!(bit < 256); - let byte_index = bit / 8; - let position = bit % 8; - let v = 1 << position; - - (block[byte_index] & v) >> position + // `bit` is the secret permuted symbol; read the byte obliviously so the + // access address does not depend on it. See `width::ct_select_byte`. + let byte = crate::scheme::width::ct_select_byte(block, bit / 8); + crate::scheme::width::ct_bit(byte, (bit % 8) as u8) } impl PartialEq for CipherText { diff --git a/packages/ore-rs/src/scheme/bit2/block_types.rs b/packages/ore-rs/src/scheme/bit2/block_types.rs index 8502541..6b4a55c 100644 --- a/packages/ore-rs/src/scheme/bit2/block_types.rs +++ b/packages/ore-rs/src/scheme/bit2/block_types.rs @@ -40,11 +40,10 @@ impl RightBlock32 { #[inline] pub fn get_bit(&self, bit: usize) -> u8 { debug_assert!(bit < 256); - let byte_index = bit / 8; - let position = bit % 8; - let v = 1 << position; - - (self.data[byte_index] & v) >> position + // `bit` is the secret permuted symbol; read the byte obliviously so the + // access address does not depend on it. See `width::ct_select_byte`. + let byte = crate::scheme::width::ct_select_byte(&self.data, bit / 8); + crate::scheme::width::ct_bit(byte, (bit % 8) as u8) } } diff --git a/packages/ore-rs/src/scheme/bit2_w6.rs b/packages/ore-rs/src/scheme/bit2_w6.rs new file mode 100644 index 0000000..62bb29d --- /dev/null +++ b/packages/ore-rs/src/scheme/bit2_w6.rs @@ -0,0 +1,587 @@ +//! BlockORE with **6-bit input blocks** (domain 64): the `(Bit6, packed +//! prefix, fixed-N)` scheme from the ORE v2 plan. +//! +//! Plaintext bytes are decomposed MSB-first into 6-bit block values (see +//! [`crate::scheme::decompose`]); `N` in the types below counts *blocks*, +//! not bytes (a `u64` is 11 blocks). Compared to the legacy 8-bit scheme, +//! each block costs 64 random-oracle evaluations instead of 256 and stores +//! an 8-byte right block instead of 32 — a `u64` ciphertext is 295 bytes +//! (vs 408) including the 4-byte v2 wire header. +//! +//! The packed prefix caps `N` at 14 (`prefix ≤ 13 ‖ value ‖ index` plus +//! the block count in byte 15 must fit one AES block), which covers all +//! primitives up to 64 bits. `u128`/`i128`/`Decimal` stay on the legacy +//! scheme until the chained-prefix construction lands (plan §5). +//! +//! **Status: wire format NOT yet frozen.** The Z2 hash is the fixed-π MMO +//! construction proposed in plan §6 (option 3), pending crypto review; +//! flipping [`Z2Hash`] re-keys the right ciphertexts without any other +//! code change. Do not store ciphertexts produced by this scheme until +//! the review lands and vectors are pinned. + +use crate::{ + ciphertext::*, + primitives::{ + hash::FixedPiZ2Hash, prf::Aes128Prf, AesBlock, Hash, HashKey, Prf, Prp, NONCE_SIZE, + }, + scheme::width::{AesBlockBuf, Bit6, BlockWidth}, + OreCipher, OreError, PlainText, +}; + +use aes::cipher::generic_array::GenericArray; +use rand::{Rng, SeedableRng}; +use rand_chacha::ChaCha20Rng; +use std::cell::RefCell; +use std::cmp::Ordering; +use subtle_ng::{Choice, ConditionallySelectable, ConstantTimeEq}; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +/// Per-block ciphertext component types for this scheme. +pub mod block_types; +pub use self::block_types::*; + +/// The Z2 random-oracle instantiation for this scheme (plan §6, option 3 — +/// pending review; option 2 fallback is `Aes128Z2Hash` with an extra +/// feedforward, and the legacy construction is `Aes128Z2Hash`). +type Z2Hash = FixedPiZ2Hash; + +/// Maximum number of blocks: the packed prefix (`prefix ‖ value ‖ index`) +/// plus the block count at byte 15 must fit one 16-byte AES input. +pub const MAX_BLOCKS: usize = 14; + +/// AES-128 BlockORE cipher over 6-bit blocks, generic over the RNG used +/// for per-encryption nonces. Keys are zeroised on drop. +#[derive(ZeroizeOnDrop)] +pub struct OreAes128Bit6 { + prf1: Aes128Prf, + prf2: Aes128Prf, + #[zeroize(skip)] + rng: RefCell, +} + +// Opaque Debug: never render key material (OpaqueDebug discipline). +impl std::fmt::Debug for OreAes128Bit6 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OreAes128Bit6").finish_non_exhaustive() + } +} + +/// Convenience alias for [`OreAes128Bit6`] backed by `ChaCha20Rng`. +pub type OreAes128Bit6ChaCha20 = OreAes128Bit6; + +type EncryptLeftResult = Result, N>, OreError>; +type EncryptResult = Result, N>, OreError>; + +/// v2 wire header for this scheme: version 2, scheme id 0x02 +/// (= AES-128 suite, 6-bit blocks, packed prefix). +const WIRE: WireHeader = WireHeader { + version: 0x02, + scheme_id: 0x02, +}; + +/// PRP seeds are key-equivalent material; see the bit2 sibling for the +/// full rationale. Zeroized on drop. +struct SeedBuf([AesBlock; N]); + +impl Drop for SeedBuf { + fn drop(&mut self) { + for seed in self.0.iter_mut() { + seed.zeroize(); + } + } +} + +impl OreAes128Bit6 { + /// Per-block PRP seeds: `PRF₂(x[0..n] ‖ 0… ‖ N)`. Unlike the legacy + /// scheme, the block count is bound into byte 15 (domain separation + /// across plaintext shapes under shared keys, plan §4) — though the + /// per-block index is still absent by construction (prefix-equal + /// plaintexts must share seeds only up to the first differing block; + /// binding the *count* keeps cross-type prefixes apart). + fn derive_prp_seeds(&self, x: &PlainText) -> SeedBuf { + let mut seeds = [AesBlock::default(); N]; + for (n, block) in seeds.iter_mut().enumerate() { + block[0..n].clone_from_slice(&x[0..n]); + block[15] = N as u8; + } + self.prf2.encrypt_all(&mut seeds); + SeedBuf(seeds) + } +} + +// Right-block encoding is the width-/hash-generic helper shared with the +// legacy scheme: `crate::scheme::bit2::encode_right_block::`. + +impl OreCipher for OreAes128Bit6 { + type LeftBlockType = LeftBlock16; + type RightBlockType = RightBlock8; + + const WIRE_HEADER: Option = Some(WIRE); + + fn init(k1: &[u8; 16], k2: &[u8; 16]) -> Result { + let rng: R = SeedableRng::from_entropy(); + + Ok(OreAes128Bit6 { + prf1: Prf::new(GenericArray::from_slice(k1)), + prf2: Prf::new(GenericArray::from_slice(k2)), + rng: RefCell::new(rng), + }) + } + + /// Encrypt `x`, whose entries are **6-bit block values** (`< 64`, + /// produced by [`crate::scheme::decompose`]); `N` is the block count + /// (≤ [`MAX_BLOCKS`]). The [`crate::OreEncrypt`] impls handle the + /// byte→block decomposition for primitive types. + fn encrypt_left(&self, x: &PlainText) -> EncryptLeftResult { + assert!(N <= MAX_BLOCKS); + debug_assert!(x + .iter() + .all(|&b| (b as usize) < ::DOMAIN)); + + let mut output = Left::::init(); + let seeds = self.derive_prp_seeds(x); + + for n in 0..N { + let prp: ::Prp = Prp::new(&seeds.0[n])?; + output.xt[n] = prp.permute(x[n])?; + + output.f[n][0..n].clone_from_slice(&x[0..n]); + output.f[n][n] = output.xt[n]; + output.f[n][N] = n as u8; + output.f[n][15] = N as u8; + } + self.prf1.encrypt_all(&mut output.f); + + Ok(output) + } + + fn encrypt(&self, x: &PlainText) -> EncryptResult { + assert!(N <= MAX_BLOCKS); + debug_assert!(x + .iter() + .all(|&b| (b as usize) < ::DOMAIN)); + + let mut left = Left::::init(); + let mut right = Right::::init(); + + self.rng.borrow_mut().try_fill(&mut right.nonce)?; + + let seeds = self.derive_prp_seeds(x); + let hasher: Z2Hash = Hash::new(HashKey::from_slice(&right.nonce)); + + // RO key template, maintained incrementally (see the bit2 sibling). + // Entry j for block n is (x[0..n] ‖ j ‖ 0… ‖ n@N ‖ N@15). + let mut template = ::RoKeyBuf::zeroed(); + for (j, entry) in template.iter_mut().enumerate() { + entry[0] = j as u8; + entry[15] = N as u8; + } + let mut work = ::RoKeyBuf::zeroed(); + + for n in 0..N { + let prp: ::Prp = Prp::new(&seeds.0[n])?; + left.xt[n] = prp.permute(x[n])?; + + left.f[n][0..n].clone_from_slice(&x[0..n]); + left.f[n][n] = left.xt[n]; + left.f[n][N] = n as u8; + left.f[n][15] = N as u8; + + if n > 0 { + for (j, entry) in template.iter_mut().enumerate() { + entry[n - 1] = x[n - 1]; + entry[n] = j as u8; + entry[N] = n as u8; + } + } + + work.copy_from(&template); + self.prf1.encrypt_all(work.as_mut_slice()); + + crate::scheme::bit2::encode_right_block::( + &mut right.data[n], + &prp, + x[n], + &hasher, + &mut work, + ); + } + + self.prf1.encrypt_all(&mut left.f); + + for entry in template.iter_mut() { + entry.zeroize(); + } + for entry in work.as_mut_slice() { + entry.zeroize(); + } + + Ok(CipherText { left, right }) + } + + fn compare_raw_slices(a: &[u8], b: &[u8]) -> Option { + if a.len() != b.len() { + return None; + } + let (header_a, a) = parse_header(a).ok()?; + let (header_b, b) = parse_header(b).ok()?; + if header_a != header_b || (header_a.0, header_a.1) != (WIRE.version, WIRE.scheme_id) { + return None; + } + let num_blocks = header_a.2; + // Reject a degenerate count=0 header: no OreEncrypt path produces zero + // blocks, and an empty scan would otherwise return Equal for any pair + // of crafted 0-block ciphertexts. + if num_blocks == 0 || num_blocks > MAX_BLOCKS { + return None; + } + + let left_size = Self::LeftBlockType::BLOCK_SIZE; + let right_size = Self::RightBlockType::BLOCK_SIZE; + if a.len() != num_blocks * (left_size + 1 + right_size) + NONCE_SIZE { + return None; + } + + let mut is_equal = Choice::from(1); + let mut l: u64 = 0; // Unequal block + + // Slices for the PRF ("f") blocks + let a_f = &a[num_blocks..]; + let b_f = &b[num_blocks..]; + + for n in 0..num_blocks { + let prp_eq: Choice = !a[n].ct_eq(&b[n]); + let left_block_comparison: Choice = !left_block(a_f, n).ct_eq(left_block(b_f, n)); + let condition: Choice = prp_eq | left_block_comparison; + + l.conditional_assign(&(n as u64), is_equal & condition); + is_equal.conditional_assign(&Choice::from(0), is_equal & condition); + } + + let l: usize = l as usize; + + if bool::from(is_equal) { + return Some(Ordering::Equal); + } + + let b_right = &b[num_blocks * (left_size + 1)..]; + let hash: Z2Hash = Hash::new(HashKey::from_slice(&b_right[0..NONCE_SIZE])); + let h = hash.hash(left_block(a_f, l)); + + let target_block = right_block(&b_right[NONCE_SIZE..], l); + let test = get_bit(target_block, a[l] as usize) ^ h; + + if test == 1 { + return Some(Ordering::Greater); + } + + Some(Ordering::Less) + } +} + +#[inline] +fn left_block(input: &[u8], n: usize) -> &[u8] { + let f_pos = n * LeftBlock16::BLOCK_SIZE; + &input[f_pos..(f_pos + LeftBlock16::BLOCK_SIZE)] +} + +#[inline] +fn right_block(input: &[u8], n: usize) -> &[u8] { + let f_pos = n * RightBlock8::BLOCK_SIZE; + &input[f_pos..(f_pos + RightBlock8::BLOCK_SIZE)] +} + +#[inline] +fn get_bit(block: &[u8], bit: usize) -> u8 { + debug_assert!(block.len() == RightBlock8::BLOCK_SIZE); + debug_assert!(bit < 64); + // `bit` is the secret permuted symbol; read the byte obliviously so the + // access address does not depend on it. See `width::ct_select_byte`. + let byte = crate::scheme::width::ct_select_byte(block, bit / 8); + crate::scheme::width::ct_bit(byte, (bit % 8) as u8) +} + +impl PartialEq for CipherText { + fn eq(&self, b: &Self) -> bool { + matches!(self.cmp(b), Ordering::Equal) + } +} + +impl Ord for CipherText { + fn cmp(&self, b: &Self) -> Ordering { + let mut is_equal = Choice::from(1); + let mut l: u64 = 0; // Unequal block + + for n in 0..N { + let condition: Choice = + !(self.left.xt[n].ct_eq(&b.left.xt[n])) | !(self.left.f[n].ct_eq(&b.left.f[n])); + + l.conditional_assign(&(n as u64), is_equal & condition); + is_equal.conditional_assign(&Choice::from(0), is_equal & condition); + } + + let l: usize = l as usize; + + if bool::from(is_equal) { + return Ordering::Equal; + } + + let hash: Z2Hash = Hash::new(HashKey::from_slice(&b.right.nonce)); + let h = hash.hash(&self.left.f[l]); + + let test = b.right.data[l].get_bit(self.left.xt[l] as usize) ^ h; + if test == 1 { + return Ordering::Greater; + } + + Ordering::Less + } +} + +impl PartialOrd for CipherText { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Eq for CipherText {} + +// --------------------------------------------------------------------------- +// OreEncrypt impls: byte → 6-bit-block decomposition per primitive type. +// --------------------------------------------------------------------------- + +mod encrypt_impls { + use super::{OreAes128Bit6, MAX_BLOCKS}; + use crate::scheme::decompose::{decompose_6bit, num_blocks_6bit}; + use crate::{CipherText, Left, OreCipher, OreEncrypt, OreError}; + use orderable_bytes::ToOrderableBytes; + use rand::{Rng, SeedableRng}; + + macro_rules! impl_ore_encrypt_bit6 { + ($type:ty, $blocks_const:ident) => { + /// Block count for this type at 6-bit width. + const $blocks_const: usize = num_blocks_6bit(<$type as ToOrderableBytes>::ENCODED_LEN); + // The packed prefix caps the block count; types beyond it + // (u128, i128, Decimal) must not get these impls. + const _: () = assert!($blocks_const <= MAX_BLOCKS); + + impl OreEncrypt> for $type { + type LeftOutput = Left, $blocks_const>; + type FullOutput = CipherText, $blocks_const>; + + fn encrypt_left( + &self, + cipher: &OreAes128Bit6, + ) -> Result { + let bytes = self.to_orderable_bytes(); + let mut blocks = [0u8; $blocks_const]; + decompose_6bit(&bytes, &mut blocks); + cipher.encrypt_left(&blocks) + } + + fn encrypt(&self, cipher: &OreAes128Bit6) -> Result { + let bytes = self.to_orderable_bytes(); + let mut blocks = [0u8; $blocks_const]; + decompose_6bit(&bytes, &mut blocks); + cipher.encrypt(&blocks) + } + } + }; + } + + impl_ore_encrypt_bit6!(bool, BOOL_BLOCKS); + impl_ore_encrypt_bit6!(u8, U8_BLOCKS); + impl_ore_encrypt_bit6!(i8, I8_BLOCKS); + impl_ore_encrypt_bit6!(u16, U16_BLOCKS); + impl_ore_encrypt_bit6!(i16, I16_BLOCKS); + impl_ore_encrypt_bit6!(u32, U32_BLOCKS); + impl_ore_encrypt_bit6!(i32, I32_BLOCKS); + impl_ore_encrypt_bit6!(u64, U64_BLOCKS); + impl_ore_encrypt_bit6!(i64, I64_BLOCKS); + impl_ore_encrypt_bit6!(char, CHAR_BLOCKS); + impl_ore_encrypt_bit6!(f32, F32_BLOCKS); + impl_ore_encrypt_bit6!(f64, F64_BLOCKS); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::encrypt::OreEncrypt; + use crate::OreOutput; + use quickcheck::TestResult; + + type Ore = OreAes128Bit6ChaCha20; + + fn init_ore() -> Ore { + let mut k1: [u8; 16] = Default::default(); + let mut k2: [u8; 16] = Default::default(); + + let mut rng = ChaCha20Rng::from_entropy(); + + rng.fill(&mut k1); + rng.fill(&mut k2); + + OreCipher::init(&k1, &k2).unwrap() + } + + quickcheck! { + fn compare_u64(x: u64, y: u64) -> bool { + let ore = init_ore(); + let a = x.encrypt(&ore).unwrap(); + let b = y.encrypt(&ore).unwrap(); + + match x.cmp(&y) { + Ordering::Greater => a > b, + Ordering::Less => a < b, + Ordering::Equal => a == b + } + } + + fn compare_u64_raw_slices(x: u64, y: u64) -> bool { + let ore = init_ore(); + let a = x.encrypt(&ore).unwrap().to_bytes(); + let b = y.encrypt(&ore).unwrap().to_bytes(); + + match Ore::compare_raw_slices(&a, &b) { + Some(Ordering::Greater) => x > y, + Some(Ordering::Less) => x < y, + Some(Ordering::Equal) => x == y, + None => false + } + } + + fn equality_u64(x: u64) -> bool { + let ore = init_ore(); + let a = x.encrypt(&ore).unwrap(); + let b = x.encrypt(&ore).unwrap(); + + a == b + } + + fn compare_u32(x: u32, y: u32) -> bool { + let ore = init_ore(); + let a = x.encrypt(&ore).unwrap(); + let b = y.encrypt(&ore).unwrap(); + + match x.cmp(&y) { + Ordering::Greater => a > b, + Ordering::Less => a < b, + Ordering::Equal => a == b + } + } + + fn compare_f64(x: f64, y: f64) -> TestResult { + if x.is_nan() || x.is_infinite() || y.is_nan() || y.is_infinite() { + return TestResult::discard(); + } + + let ore = init_ore(); + let a = x.encrypt(&ore).unwrap(); + let b = y.encrypt(&ore).unwrap(); + + match x.partial_cmp(&y) { + Some(Ordering::Greater) => TestResult::from_bool(a > b), + Some(Ordering::Less) => TestResult::from_bool(a < b), + Some(Ordering::Equal) => TestResult::from_bool(a == b), + None => TestResult::failed() + } + } + + fn serialize_roundtrip_u64(x: u64) -> bool { + let ore = init_ore(); + let a = x.encrypt(&ore).unwrap(); + let bytes = a.to_bytes(); + let b = CipherText::::from_slice(&bytes).unwrap(); + a == b + } + } + + #[test] + fn ciphertext_sizes() { + // u64: 11 blocks. header(4) + xt(11) + f(11*16) + nonce(16) + right(11*8) + assert_eq!(CipherText::::size(), 4 + 11 + 176 + 16 + 88); + let ore = init_ore(); + let ct = 456u64.encrypt(&ore).unwrap(); + assert_eq!(ct.to_bytes().len(), 295); + } + + #[test] + fn header_emitted_and_validated() { + let ore = init_ore(); + let mut bytes = 456u64.encrypt(&ore).unwrap().to_bytes(); + assert_eq!(&bytes[0..4], &[0x02, 0x02, 0x00, 11]); + + // Corrupt each header field; parsing must fail. + for i in 0..4 { + let mut bad = bytes.clone(); + bad[i] ^= 0xff; + assert!(CipherText::::from_slice(&bad).is_err()); + } + + // Truncation fails. + bytes.pop(); + assert!(CipherText::::from_slice(&bytes).is_err()); + } + + #[test] + fn cross_scheme_comparison_rejected() { + use crate::scheme::bit2::OreAes128ChaCha20; + + let k1 = [1u8; 16]; + let k2 = [2u8; 16]; + let legacy: OreAes128ChaCha20 = OreCipher::init(&k1, &k2).unwrap(); + let bit6: Ore = OreCipher::init(&k1, &k2).unwrap(); + + let a = 456u64.encrypt(&legacy).unwrap().to_bytes(); + let b = 456u64.encrypt(&bit6).unwrap().to_bytes(); + + assert_eq!(Ore::compare_raw_slices(&a, &b), None); + assert_eq!(Ore::compare_raw_slices(&b, &a), None); + // The legacy comparator infers block count from length; a Bit6 + // u64 ciphertext (295 bytes) never matches a legacy length for + // equal-length inputs, and unequal lengths return None up front. + assert_eq!(OreAes128ChaCha20::compare_raw_slices(&a, &b), None); + } + + #[test] + fn cross_block_count_comparison_rejected() { + let ore = init_ore(); + let a = 456u64.encrypt(&ore).unwrap().to_bytes(); + let b = 456u32.encrypt(&ore).unwrap().to_bytes(); + assert_eq!(Ore::compare_raw_slices(&a, &b), None); + } + + #[test] + fn smallest_to_largest() { + let ore = init_ore(); + let a = 0u64.encrypt(&ore).unwrap(); + let b = u64::MAX.encrypt(&ore).unwrap(); + + assert!(a < b); + } + + #[test] + fn comparisons_in_last_block() { + let ore = init_ore(); + let a = 10u64.encrypt(&ore).unwrap(); + let b = 73u64.encrypt(&ore).unwrap(); + + assert!(a < b); + assert!(b > a); + } + + #[test] + fn different_keys_not_equal() { + let k1 = [1u8; 16]; + let k2 = [2u8; 16]; + let k3 = [3u8; 16]; + + let ore1: Ore = OreCipher::init(&k1, &k2).unwrap(); + let ore2: Ore = OreCipher::init(&k3, &k2).unwrap(); + + let a = 1000u32.encrypt(&ore1).unwrap().to_bytes(); + let b = 1000u32.encrypt(&ore2).unwrap().to_bytes(); + + assert_ne!(Some(Ordering::Equal), Ore::compare_raw_slices(&a, &b)); + } +} diff --git a/packages/ore-rs/src/scheme/bit2_w6/block_types.rs b/packages/ore-rs/src/scheme/bit2_w6/block_types.rs new file mode 100644 index 0000000..510c975 --- /dev/null +++ b/packages/ore-rs/src/scheme/bit2_w6/block_types.rs @@ -0,0 +1,64 @@ +use zeroize::Zeroize; + +use crate::ciphertext::{CipherTextBlock, ParseError}; +use crate::scheme::width::RightBitVec; + +pub use crate::scheme::bit2::block_types::LeftBlock16; + +/// Per-block component of the Right half of a `bit2_w6` ciphertext: an +/// 8-byte (64-bit) bitvector encoding one masked truth-table row, one bit +/// per value in the 6-bit block domain. +#[derive(Debug, Copy, Clone, Default)] +pub struct RightBlock8 { + data: [u8; 8], +} + +impl RightBlock8 { + /// Read bit `bit` (in `0..64`); returns `0` or `1`. + #[inline] + pub fn get_bit(&self, bit: usize) -> u8 { + debug_assert!(bit < 64); + // `bit` is the secret permuted symbol; read the byte obliviously so the + // access address does not depend on it. See `width::ct_select_byte`. + let byte = crate::scheme::width::ct_select_byte(&self.data, bit / 8); + crate::scheme::width::ct_bit(byte, (bit % 8) as u8) + } +} + +impl RightBitVec for RightBlock8 { + fn set_bit(&mut self, bit: usize, value: u8) { + debug_assert!(bit < 64); + let byte_index = bit / 8; + let mask = bit % 8; + self.data[byte_index] |= value << mask; + } + fn get_bit(&self, bit: usize) -> u8 { + RightBlock8::get_bit(self, bit) + } + fn as_mut_bytes(&mut self) -> &mut [u8] { + &mut self.data + } +} + +impl CipherTextBlock for RightBlock8 { + const BLOCK_SIZE: usize = 8; + + fn to_bytes(self) -> Vec { + self.data.to_vec() + } + + fn from_bytes(data: &[u8]) -> Result { + if data.len() != Self::BLOCK_SIZE { + Err(ParseError) + } else { + let mut arr = [0; 8]; + arr.clone_from_slice(data); + + Ok(Self { data: arr }) + } + } + + fn default_in_place(&mut self) { + self.data.zeroize() + } +} diff --git a/packages/ore-rs/src/scheme/width.rs b/packages/ore-rs/src/scheme/width.rs index e07417b..6e06610 100644 --- a/packages/ore-rs/src/scheme/width.rs +++ b/packages/ore-rs/src/scheme/width.rs @@ -13,11 +13,14 @@ use crate::ciphertext::CipherTextBlock; use crate::primitives::{AesBlock, Prp}; use crate::scheme::bit2::block_types::RightBlock32; +use crate::scheme::bit2_w6::block_types::RightBlock8; mod sealed { pub trait Sealed {} impl Sealed for super::Bit8 {} + impl Sealed for super::Bit6 {} impl Sealed for [super::AesBlock; 256] {} + impl Sealed for [super::AesBlock; 64] {} } /// A domain-sized buffer of AES blocks used for per-block random-oracle @@ -35,16 +38,75 @@ pub trait AesBlockBuf: sealed::Sealed { fn copy_from(&mut self, other: &Self); } -impl AesBlockBuf for [AesBlock; 256] { - fn zeroed() -> Self { - [AesBlock::default(); 256] - } - fn as_mut_slice(&mut self) -> &mut [AesBlock] { - self - } - fn copy_from(&mut self, other: &Self) { - self.clone_from_slice(other); +macro_rules! impl_aes_block_buf { + ($n:literal) => { + impl AesBlockBuf for [AesBlock; $n] { + fn zeroed() -> Self { + [AesBlock::default(); $n] + } + fn as_mut_slice(&mut self) -> &mut [AesBlock] { + self + } + fn copy_from(&mut self, other: &Self) { + self.clone_from_slice(other); + } + } + }; +} + +impl_aes_block_buf!(256); +impl_aes_block_buf!(64); + +/// Oblivious byte read: returns `block[idx]` while touching **every** byte of +/// `block`, so the memory-access address is independent of `idx`. +/// +/// The comparators read the right-ciphertext block at `bit / 8` where `bit` is +/// the secret permuted symbol (`a[l]`, the left ciphertext's permuted index at +/// the first differing block). A direct `block[bit / 8]` index makes the +/// touched address secret-dependent, which is a cache-line timing channel — and +/// because the block fits within a line, even an aligned direct index would +/// still leak at sub-line (4-byte) granularity to a MemJam-class attacker on +/// SMT-enabled Intel. Scanning the whole block removes the data-dependent +/// address entirely, closing both. The block is ≤ 32 bytes, so the scan is +/// cheap relative to the per-comparison AES hash. +/// +/// See `docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md` (A4, compare +/// side). The bit within the selected byte is then extracted with [`ct_bit`], +/// which avoids a shift by the secret amount. +#[inline] +pub(crate) fn ct_select_byte(block: &[u8], idx: usize) -> u8 { + use subtle_ng::{ConditionallySelectable, ConstantTimeEq}; + let mut acc = 0u8; + for (i, &b) in block.iter().enumerate() { + // `i` is the public loop counter; `idx` is secret. Both index a block + // of ≤ 32 bytes, so the u8 cast is lossless. + acc.conditional_assign(&b, (i as u8).ct_eq(&(idx as u8))); } + acc +} + +/// Oblivious extraction of bit `pos` (`0..8`) of `byte` — used right after +/// [`ct_select_byte`] to read the target bit of the selected right-block byte. +/// +/// `byte >> pos` would be a shift by a *secret* amount; that is constant-time +/// on x86_64/aarch64 (the targets ore.rs ships to) but not guaranteed so on +/// every architecture. Here every candidate shift is a compile-time constant +/// and the result is chosen with a constant-time select, so the timing is +/// data-independent on all targets — defence-in-depth matching the oblivious +/// byte read above. +#[inline] +pub(crate) fn ct_bit(byte: u8, pos: u8) -> u8 { + use subtle_ng::{ConditionallySelectable, ConstantTimeEq}; + let mut out = 0u8; + out.conditional_assign(&(byte & 1), pos.ct_eq(&0)); + out.conditional_assign(&((byte >> 1) & 1), pos.ct_eq(&1)); + out.conditional_assign(&((byte >> 2) & 1), pos.ct_eq(&2)); + out.conditional_assign(&((byte >> 3) & 1), pos.ct_eq(&3)); + out.conditional_assign(&((byte >> 4) & 1), pos.ct_eq(&4)); + out.conditional_assign(&((byte >> 5) & 1), pos.ct_eq(&5)); + out.conditional_assign(&((byte >> 6) & 1), pos.ct_eq(&6)); + out.conditional_assign(&((byte >> 7) & 1), pos.ct_eq(&7)); + out } /// Per-block bitvector operations on a Right ciphertext block, one bit per @@ -102,3 +164,20 @@ impl BlockWidth for Bit8 { type Prp = crate::primitives::prp::KnuthShufflePRP; type RoKeyBuf = [AesBlock; 256]; } + +/// The 6-bit block width used by [`crate::scheme::bit2_w6`]: six plaintext +/// bits per block, domain 64. Right blocks are 8 bytes (vs 32) and each +/// block costs 64 RO evaluations (vs 256). +#[derive(Debug)] +pub struct Bit6; + +impl BlockWidth for Bit6 { + const BITS: usize = 6; + const DOMAIN: usize = 64; + type RightBlock = RightBlock8; + // Fixed-draw Fisher–Yates, not the rejection-sampled Knuth shuffle: + // Bit6's wire format is not frozen, so it adopts the constant-time, + // ~9×-faster PRP construction. See `LemireFyPrp`. + type Prp = crate::primitives::prp::LemireFyPrp<64>; + type RoKeyBuf = [AesBlock; 64]; +} diff --git a/packages/ore-rs/tests/compat_w6_vectors.rs b/packages/ore-rs/tests/compat_w6_vectors.rs new file mode 100644 index 0000000..7a4c58f --- /dev/null +++ b/packages/ore-rs/tests/compat_w6_vectors.rs @@ -0,0 +1,287 @@ +//! Wire-format vectors for the `OreAes128Bit6` (bit2_w6) scheme — the v2 +//! 6-bit-block scheme with the v2 wire header and the BHKR σ-MMO hash `H` +//! (plan §6 / review brief A1, resolved 2026-06-15). +//! +//! These tests pin the exact serialised bytes produced for fixed keys and +//! plaintexts, plus comparison results over those bytes. They freeze the v2 +//! Bit6 wire format introduced by this PR: once released, any change that +//! alters these bytes is a wire-format break for stored Bit6 ciphertexts and +//! must fail here. (Mirror of `compat_vectors.rs`, which does the same for the +//! legacy Bit8 `OreAes128` scheme.) +//! +//! Left ciphertexts are deterministic given the keys. Full ciphertexts include +//! a random nonce drawn from the cipher's internal RNG, which `OreCipher::init` +//! seeds via `SeedableRng::from_entropy`; [`TestRng`] overrides `from_entropy` +//! to a fixed seed so full-ciphertext bytes are reproducible. +//! +//! To regenerate (only legitimate if the wire format is *deliberately* changed): +//! +//! ```text +//! cargo test --test compat_w6_vectors -- --ignored --nocapture generate +//! ``` + +use ore_rs::{ + scheme::bit2_w6::{OreAes128Bit6, OreAes128Bit6ChaCha20}, + CipherText, OreCipher, OreEncrypt, OreOutput, +}; +use rand::{RngCore, SeedableRng}; +use rand_chacha::ChaCha20Rng; +use std::cmp::Ordering; + +const K1: [u8; 16] = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, +]; +const K2: [u8; 16] = [ + 0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xdb, 0xdc, 0xdd, 0xde, 0xdf, +]; + +/// RNG whose `from_entropy` is deterministic, so that `OreCipher::init` +/// (which calls `from_entropy` internally) produces a reproducible nonce stream. +struct TestRng(ChaCha20Rng); + +impl RngCore for TestRng { + fn next_u32(&mut self) -> u32 { + self.0.next_u32() + } + fn next_u64(&mut self) -> u64 { + self.0.next_u64() + } + fn fill_bytes(&mut self, dest: &mut [u8]) { + self.0.fill_bytes(dest) + } + fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand::Error> { + self.0.try_fill_bytes(dest) + } +} + +impl SeedableRng for TestRng { + type Seed = [u8; 32]; + fn from_seed(seed: Self::Seed) -> Self { + Self(ChaCha20Rng::from_seed(seed)) + } + fn from_entropy() -> Self { + Self::from_seed([SEED; 32]) + } +} + +type OreA = OreAes128Bit6>; +type OreB = OreAes128Bit6>; + +fn cipher_a() -> OreA { + OreCipher::init(&K1, &K2).unwrap() +} + +fn cipher_b() -> OreB { + OreCipher::init(&K1, &K2).unwrap() +} + +fn cipher_left() -> OreAes128Bit6ChaCha20 { + OreCipher::init(&K1, &K2).unwrap() +} + +#[path = "compat_w6_vectors/vectors.rs"] +mod vectors; +use vectors::*; + +// --------------------------------------------------------------------------- +// Left ciphertexts (fully deterministic) +// --------------------------------------------------------------------------- + +#[test] +fn left_u64_456() { + let ore = cipher_left(); + let left = 456u64.encrypt_left(&ore).unwrap(); + assert_eq!(hex::encode(left.to_bytes()), LEFT_U64_456); +} + +#[test] +fn left_u64_zero() { + let ore = cipher_left(); + let left = 0u64.encrypt_left(&ore).unwrap(); + assert_eq!(hex::encode(left.to_bytes()), LEFT_U64_0); +} + +#[test] +fn left_u32_1000() { + let ore = cipher_left(); + let left = 1000u32.encrypt_left(&ore).unwrap(); + assert_eq!(hex::encode(left.to_bytes()), LEFT_U32_1000); +} + +// --------------------------------------------------------------------------- +// Full ciphertexts (deterministic via TestRng nonce stream) +// --------------------------------------------------------------------------- + +#[test] +fn full_u64_vectors() { + for (value, expected) in FULL_U64 { + let ct = value.encrypt(&cipher_a()).unwrap(); + assert_eq!( + hex::encode(ct.to_bytes()), + *expected, + "full ciphertext mismatch for u64 {value}" + ); + } +} + +#[test] +fn full_u32_vectors() { + for (value, expected) in FULL_U32 { + let ct = value.encrypt(&cipher_a()).unwrap(); + assert_eq!( + hex::encode(ct.to_bytes()), + *expected, + "full ciphertext mismatch for u32 {value}" + ); + } +} + +#[test] +fn full_i64_vectors() { + for (value, expected) in FULL_I64 { + let ct = value.encrypt(&cipher_a()).unwrap(); + assert_eq!( + hex::encode(ct.to_bytes()), + *expected, + "full ciphertext mismatch for i64 {value}" + ); + } +} + +#[test] +fn full_f64_vector() { + let ct = 1.5f64.encrypt(&cipher_a()).unwrap(); + assert_eq!(hex::encode(ct.to_bytes()), FULL_F64_1_5); +} + +#[test] +fn full_u64_456_alternate_nonce() { + let ct = 456u64.encrypt(&cipher_b()).unwrap(); + assert_eq!(hex::encode(ct.to_bytes()), FULL_U64_456_SEED_B); +} + +// --------------------------------------------------------------------------- +// Comparison fixtures over pinned bytes (no RNG involved) +// --------------------------------------------------------------------------- + +fn pinned(hex_str: &str) -> Vec { + hex::decode(hex_str).unwrap() +} + +#[test] +fn compare_raw_slices_total_order_u64() { + // FULL_U64 is listed in ascending plaintext order. + for (i, (_, a)) in FULL_U64.iter().enumerate() { + for (j, (_, b)) in FULL_U64.iter().enumerate() { + let expected = i.cmp(&j); + assert_eq!( + OreAes128Bit6ChaCha20::compare_raw_slices(&pinned(a), &pinned(b)), + Some(expected), + "u64 vector order mismatch at ({i}, {j})" + ); + } + } +} + +#[test] +fn compare_raw_slices_total_order_i64() { + // FULL_I64 is listed in ascending plaintext order (incl. negatives). + for (i, (_, a)) in FULL_I64.iter().enumerate() { + for (j, (_, b)) in FULL_I64.iter().enumerate() { + let expected = i.cmp(&j); + assert_eq!( + OreAes128Bit6ChaCha20::compare_raw_slices(&pinned(a), &pinned(b)), + Some(expected), + "i64 vector order mismatch at ({i}, {j})" + ); + } + } +} + +#[test] +fn compare_raw_slices_equality_across_nonces() { + // Same plaintext (456u64) encrypted under two different nonce streams + // must compare equal. + let (_, seed_a) = FULL_U64[2]; + assert_eq!( + OreAes128Bit6ChaCha20::compare_raw_slices(&pinned(seed_a), &pinned(FULL_U64_456_SEED_B)), + Some(Ordering::Equal) + ); +} + +#[test] +fn typed_comparison_of_pinned_bytes() { + // u64 is 11 blocks at 6-bit width. + let a = CipherText::::from_slice(&pinned(FULL_U64[1].1)).unwrap(); + let b = CipherText::::from_slice(&pinned(FULL_U64[3].1)).unwrap(); + assert!(a < b); + assert!(b > a); + assert_eq!(a.cmp(&a), Ordering::Equal); +} + +// --------------------------------------------------------------------------- +// Generator (run manually; see module docs) +// --------------------------------------------------------------------------- + +#[test] +#[ignore = "generator: prints the contents of tests/compat_w6_vectors/vectors.rs"] +fn generate() { + println!("// Contents of tests/compat_w6_vectors/vectors.rs"); + println!( + "// Generated by `cargo test --test compat_w6_vectors -- --ignored --nocapture generate`" + ); + println!("// DO NOT regenerate unless deliberately breaking the wire format (see plan doc)."); + println!(); + + let ore = cipher_left(); + let print_left = |name: &str, bytes: Vec| { + println!("pub const {}: &str = \"{}\";", name, hex::encode(bytes)); + }; + + print_left( + "LEFT_U64_456", + 456u64.encrypt_left(&ore).unwrap().to_bytes(), + ); + print_left("LEFT_U64_0", 0u64.encrypt_left(&ore).unwrap().to_bytes()); + print_left( + "LEFT_U32_1000", + 1000u32.encrypt_left(&ore).unwrap().to_bytes(), + ); + println!(); + + println!("pub const FULL_U64: &[(u64, &str)] = &["); + for value in [0u64, 1, 456, 1 << 32, u64::MAX] { + let ct = value.encrypt(&cipher_a()).unwrap(); + println!(" ({}, \"{}\"),", value, hex::encode(ct.to_bytes())); + } + println!("];"); + println!(); + + println!("pub const FULL_U32: &[(u32, &str)] = &["); + for value in [0u32, 1000, u32::MAX] { + let ct = value.encrypt(&cipher_a()).unwrap(); + println!(" ({}, \"{}\"),", value, hex::encode(ct.to_bytes())); + } + println!("];"); + println!(); + + println!("pub const FULL_I64: &[(i64, &str)] = &["); + for value in [i64::MIN, -1i64, 0, 1, i64::MAX] { + let ct = value.encrypt(&cipher_a()).unwrap(); + println!(" ({}, \"{}\"),", value, hex::encode(ct.to_bytes())); + } + println!("];"); + println!(); + + let f = 1.5f64.encrypt(&cipher_a()).unwrap(); + println!( + "pub const FULL_F64_1_5: &str = \"{}\";", + hex::encode(f.to_bytes()) + ); + + let alt = 456u64.encrypt(&cipher_b()).unwrap(); + println!( + "pub const FULL_U64_456_SEED_B: &str = \"{}\";", + hex::encode(alt.to_bytes()) + ); +} diff --git a/packages/ore-rs/tests/compat_w6_vectors/vectors.rs b/packages/ore-rs/tests/compat_w6_vectors/vectors.rs new file mode 100644 index 0000000..9f39d90 --- /dev/null +++ b/packages/ore-rs/tests/compat_w6_vectors/vectors.rs @@ -0,0 +1,32 @@ +// Contents of tests/compat_w6_vectors/vectors.rs +// Generated by `cargo test --test compat_w6_vectors -- --ignored --nocapture generate` +// DO NOT regenerate unless deliberately breaking the wire format (see plan doc). + +pub const LEFT_U64_456: &str = "0202000b30303030303030303033066da76fd0a92c30514fc8f00ca98a2eb2028fbe301e641f72490894e363ce589e8df6ec476a5ed8796830fbea6ce0624832dae399c351fef149a537f2b8eb08c0cdff4dbcd6c0e38276f78b576550fdd5a0ea9626f42573d61d1ba8372269ef9e6f489c0d208424eddcb113d397481361c3d1ad37b414893fd1bff4b92ba4216833321ae4cc88b8d44b2808a6fd3f8b6469a7a2426c58a491c8399caf81b7c41b23d8b8de7fc61f1cd531a0851aa2ffe2"; +pub const LEFT_U64_0: &str = "0202000b30303030303030303030306da76fd0a92c30514fc8f00ca98a2eb2028fbe301e641f72490894e363ce589e8df6ec476a5ed8796830fbea6ce0624832dae399c351fef149a537f2b8eb08c0cdff4dbcd6c0e38276f78b576550fdd5a0ea9626f42573d61d1ba8372269ef9e6f489c0d208424eddcb113d397481361c3d1ad37b414893fd1bff4b92ba4216833321ae4cc88b8d44b2808a6fd3f8b642190f0d1cfcf729be71b0090e1fbea72829007cdf8c5a5f97d531ff760162a09"; +pub const LEFT_U32_1000: &str = "02020006010101181d3ade4ea5650330e73613b528889d22317305e80fc6fb8b873c039f66bd6452a68b50741e00096f4478a8a0a486a4f875b89b822e0419aefb1379d152350c76be43caaedd9680f4c08792120ec0aa765ef0ca184280fbb109fac7487520d9a920cd"; + +pub const FULL_U64: &[(u64, &str)] = &[ + (0, "0202000b30303030303030303030306da76fd0a92c30514fc8f00ca98a2eb2028fbe301e641f72490894e363ce589e8df6ec476a5ed8796830fbea6ce0624832dae399c351fef149a537f2b8eb08c0cdff4dbcd6c0e38276f78b576550fdd5a0ea9626f42573d61d1ba8372269ef9e6f489c0d208424eddcb113d397481361c3d1ad37b414893fd1bff4b92ba4216833321ae4cc88b8d44b2808a6fd3f8b642190f0d1cfcf729be71b0090e1fbea72829007cdf8c5a5f97d531ff760162a0998191f46e5830216445436978803697a1709ad580000c09f8a67ab89b7e38f00b47a03f4e2ce32a6b10f3a3844133e1e5a64f007c189512be33d0c024336ae384f4573f4a7bdf0d53d0d36054fb7baa78b83614e752c7554396f703222948e6912bfe2f3cc7fd451"), + (1, "0202000b303030303030303030301c6da76fd0a92c30514fc8f00ca98a2eb2028fbe301e641f72490894e363ce589e8df6ec476a5ed8796830fbea6ce0624832dae399c351fef149a537f2b8eb08c0cdff4dbcd6c0e38276f78b576550fdd5a0ea9626f42573d61d1ba8372269ef9e6f489c0d208424eddcb113d397481361c3d1ad37b414893fd1bff4b92ba4216833321ae4cc88b8d44b2808a6fd3f8b642190f0d1cfcf729be71b0090e1fbea72fc5fbd1caacd687846292edaeb697f2298191f46e5830216445436978803697a1709ad580000c09f8a67ab89b7e38f00b47a03f4e2ce32a6b10f3a3844133e1e5a64f007c189512be33d0c024336ae384f4573f4a7bdf0d53d0d36054fb7baa78b83614e752c7554396f703222948e6912afe2e3cc5ed451"), + (456, "0202000b30303030303030303033066da76fd0a92c30514fc8f00ca98a2eb2028fbe301e641f72490894e363ce589e8df6ec476a5ed8796830fbea6ce0624832dae399c351fef149a537f2b8eb08c0cdff4dbcd6c0e38276f78b576550fdd5a0ea9626f42573d61d1ba8372269ef9e6f489c0d208424eddcb113d397481361c3d1ad37b414893fd1bff4b92ba4216833321ae4cc88b8d44b2808a6fd3f8b6469a7a2426c58a491c8399caf81b7c41b23d8b8de7fc61f1cd531a0851aa2ffe298191f46e5830216445436978803697a1709ad580000c09f8a67ab89b7e38f00b47a03f4e2ce32a6b10f3a3844133e1e5a64f007c189512be33d0c024336ae384f4573f4a7bdf0d53d0d36054fb7baa78b83614e752c75548b5b342931f946cf19b1e81aaf869ec8"), + (4294967296, "0202000b3030303030122d2d2d2d2d6da76fd0a92c30514fc8f00ca98a2eb2028fbe301e641f72490894e363ce589e8df6ec476a5ed8796830fbea6ce0624832dae399c351fef149a537f2b8eb08c0cdff4dbcd6c0e38276f78b576550fdd5d88b8766243e3bc0c33b9cd7fb8177fac0bf790914e1c6611f3288661f8d74b037a7e2824f80f249f4f51bb83ba425c81fb3358cda4168948680343b5f4916211ca82a58d8ccdffdc6cab511f23e50477a721591d1b27130df58c8883633d9f798191f46e5830216445436978803697a1709ad580000c09f8a67ab89b7e38f00b47a03f4e2ce32a6b10f3a3844133e1e5a64f007c189512b630d081942532ebab0281c1a21c623371c377fbcfaf9cc1524ec2e4efd3bf561206e95cfc6e6bcdcfdae60c9e4c5d14d"), + (18446744073709551615, "0202000b14131f0e1916352e1427184fc2a5344bd8e4d2267f574f3d125a637f1581b95aabcaa9bf9e75797176b79566143e89a02280f629ab517274541299fc070a178c8e68945d1d8b36ff6ba3affe08ca245c43c210b5a225fa70195653a22759ebe35ae2d27917643f631e2a22646e96893bc6b0fdfbb52eea30f22f9abe59715690cb342e3d4ac8d80876d714c5c09b40480e0080494590f47cf26e6a0dde5d186dc7ac26537622a20bbcae5ac965c3164bb16726d8e5bddbd6edc81598191f46e5830216445436978803697ae8f652a7ffff3e6061b846c45e3c34e8f5e53518be0ddecacfa949ef167231a30706d11d46f0007c03f40f02881963b1eb5e34b7ca7bc1091de83498ba65b8c0873046c35bce173ffef584423cb8b7e3b3fd0fdcca073fa2"), +]; + +pub const FULL_U32: &[(u32, &str)] = &[ + (0, "02020006010101010101de4ea5650330e73613b528889d22317305e80fc6fb8b873c039f66bd6452a68b50741e00096f4478a8a0a486a4f875b814e6fcb930e233b386cbd9dda45497b53b02654308e9ee83954fd229beb70e81d8997d55bed3ce83df28fd023b7d89c898191f46e5830216445436978803697abf6a57a701024b498d595df2d1092c3433343a5107cee8bd1a67d7b140c6550e9d89ce918486c2b7f90852f47463b6b3"), + (1000, "02020006010101181d3ade4ea5650330e73613b528889d22317305e80fc6fb8b873c039f66bd6452a68b50741e00096f4478a8a0a486a4f875b89b822e0419aefb1379d152350c76be43caaedd9680f4c08792120ec0aa765ef0ca184280fbb109fac7487520d9a920cd98191f46e5830216445436978803697abf6a57a701024b498d595df2d1092c3433343a5107cee8bd1e67d7b040d6550e442ee01bef272b62112b2b64dc7df53e"), + (4294967295, "02020006030c291101194562a690d4a461836bc2e78c902fe1e89e465fcf9a24f99375152ead2c7e7111d8ee74340910533979a2eb64c7831b3f5af19be28c4060ede8fe719854dfb35dbd00700a83fe373edf77e4baa9dc92dacf0e4b56c7bd1f9362beec6ae7f862a498191f46e5830216445436978803697a4295a858fefdb4b68318576386da1b05f1d9054cb3bc8418136231333cf4efca5effa190912c5b0ecd4f98680d17dd7d"), +]; + +pub const FULL_I64: &[(i64, &str)] = &[ + (-9223372036854775808, "0202000b30303030303030303030306da76fd0a92c30514fc8f00ca98a2eb2028fbe301e641f72490894e363ce589e8df6ec476a5ed8796830fbea6ce0624832dae399c351fef149a537f2b8eb08c0cdff4dbcd6c0e38276f78b576550fdd5a0ea9626f42573d61d1ba8372269ef9e6f489c0d208424eddcb113d397481361c3d1ad37b414893fd1bff4b92ba4216833321ae4cc88b8d44b2808a6fd3f8b642190f0d1cfcf729be71b0090e1fbea72829007cdf8c5a5f97d531ff760162a0998191f46e5830216445436978803697a1709ad580000c09f8a67ab89b7e38f00b47a03f4e2ce32a6b10f3a3844133e1e5a64f007c189512be33d0c024336ae384f4573f4a7bdf0d53d0d36054fb7baa78b83614e752c7554396f703222948e6912bfe2f3cc7fd451"), + (-1, "0202000b2c2c1c1814213729260f3ea6a46104d8f7d85dd41bb6559481ecc537880628b958b1bd1b7670def64969f54e66bf47b998ea099e036df26be762171ad2556e9702534e1ed7a1d9518876f02764a05137ce3e95e36dfd56bd97c893fa96a0e85f163bb53cbc9c81776ff8e14fa605f2f91e8fc4d8a6d074af62b9437b4b6fc577f046ab20dffb2de8e77a4b1752b6dd47e9e917a9bab7f0fa28fea47df435316fcda9b141d82228470b5c4c6e81021facd21a11ad4131029349531d98191f46e5830216445436978803697aad7de943137d0839d15d4c607487b53a499ab76891cd84076c3d06b3fd508d3ac1887d5a16bed6ca02fd897be402e0410e33e36db414fc02d87209f003771de8b5c97daf75e82155e3c0e4e6ce4f1c7b73707af2841d0ebe"), + (0, "0202000b113c3c3c3c3c3c3c3c3c3cd3da521cdaabfbbf9fcc45c6174a429eda2a3a399c7dc329cb98274b1ab0b39895a9a5fe76973ace13f485d0027a7fcd7c0551155d2d39e1733cb1c16a156d599591638b440006c7d9ffd5e8b05e1d75d2ef94795c749afd728d9210140be472f291216313f526e35c350faaa8e1b84c2d1d6f2e63fec795db2c4e6814a438c875523815387d80a728388e55673f019be16ccd05adce99755f7f0d147ffc5d2d61d2d61c8429df0c5ea84d61ee6bd81b98191f46e5830216445436978803697aad7deb43137d083976351a1289df61856a1283efce71991c35f487702c6d82454715ad108497b7a7c2a18690bce12a4ec81fefc82525b3b14958a5a02ef64f57b6c4b0c0678fc978997015e7cf5184524eb6c61d22c864c7"), + (1, "0202000b113c3c3c3c3c3c3c3c3c1dd3da521cdaabfbbf9fcc45c6174a429eda2a3a399c7dc329cb98274b1ab0b39895a9a5fe76973ace13f485d0027a7fcd7c0551155d2d39e1733cb1c16a156d599591638b440006c7d9ffd5e8b05e1d75d2ef94795c749afd728d9210140be472f291216313f526e35c350faaa8e1b84c2d1d6f2e63fec795db2c4e6814a438c875523815387d80a728388e55673f019be16ccd05adce99755f7f0d147ffc5d2d74c0c308762f6501242d2e466d85b32098191f46e5830216445436978803697aad7deb43137d083976351a1289df61856a1283efce71991c35f487702c6d82454715ad108497b7a7c2a18690bce12a4ec81fefc82525b3b14958a5a02ef64f57b6c4b0c0678fc978997015e7cf51845207b6c63d22c864c7"), + (9223372036854775807, "0202000b14131f0e1916352e1427184fc2a5344bd8e4d2267f574f3d125a637f1581b95aabcaa9bf9e75797176b79566143e89a02280f629ab517274541299fc070a178c8e68945d1d8b36ff6ba3affe08ca245c43c210b5a225fa70195653a22759ebe35ae2d27917643f631e2a22646e96893bc6b0fdfbb52eea30f22f9abe59715690cb342e3d4ac8d80876d714c5c09b40480e0080494590f47cf26e6a0dde5d186dc7ac26537622a20bbcae5ac965c3164bb16726d8e5bddbd6edc81598191f46e5830216445436978803697ae8f652a7ffff3e6061b846c45e3c34e8f5e53518be0ddecacfa949ef167231a30706d11d46f0007c03f40f02881963b1eb5e34b7ca7bc1091de83498ba65b8c0873046c35bce173ffef584423cb8b7e3b3fd0fdcca073fa2"), +]; + +pub const FULL_F64_1_5: &str = "0202000b221e212f2f2f2f2f2f2f2f10177b7f6092ae411ace1de82037f902570e2a33d92a1c8a854e6e82bdfb2bf298aa9f2c5fdce55f7b0f9654cba0f3139f1d9ca41590aa16ebf58d9e5b8f5702d208e17ef3d6c784b86c56f057242a431da5157e3c55339080d6c76b4712472e86d88847df9c4b078697f872b83a044191813b9e4dfe63a130407a4594d7a4ff326f814160865f7bc92c15a62e943f1b10ee79e46a79f6e459ed2cf79411772108bddc07c03d3e390c7299ee38b6073798191f46e5830216445436978803697aa9744ae7977d086064353890312503bd81457c13a6383df0f441a5469cc86eaa10b9da6132823f0f4cf570b4faf4d2a3132fde62df4571cfde9f2e46aa8c8be8dbaa84de3cec940c177e32abeecc8be1bae00b227d0b492c"; +pub const FULL_U64_456_SEED_B: &str = "0202000b30303030303030303033066da76fd0a92c30514fc8f00ca98a2eb2028fbe301e641f72490894e363ce589e8df6ec476a5ed8796830fbea6ce0624832dae399c351fef149a537f2b8eb08c0cdff4dbcd6c0e38276f78b576550fdd5a0ea9626f42573d61d1ba8372269ef9e6f489c0d208424eddcb113d397481361c3d1ad37b414893fd1bff4b92ba4216833321ae4cc88b8d44b2808a6fd3f8b6469a7a2426c58a491c8399caf81b7c41b23d8b8de7fc61f1cd531a0851aa2ffe252f6f5b7a04d1eeff2d0f12880ed826963e803a735bb7e164404b383b468f182908199026bf8e6fb99c720d36978679a1fe2932d5f30672c4d0a74563a7e4ea6da1cbf7ac0b3817221b88df27425b9f702d1b212fd593ea483a3e49a4058c6e22c9e930f84542c8e";