Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions docs/benchmarks/2026-06-15-chained-results.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Chained variable-length scheme — benchmark results

**Date:** 2026-06-15
**Machine:** Apple M1 Max, hardware AES (`aes_armv8`)
**Scheme:** `OreAes128Bit6Chained` (PR 6) — 6-bit blocks, AES-CMAC accumulator,
shape-(ii) PRP (keystream from the accumulator, no per-block key schedule).
**Command:** `cargo bench -p ore-rs --bench chained`

| benchmark | input | blocks | time |
|---|---|---:|---:|
| `chained-encrypt-str-5` | `"alice"` | 7 | **5.05 µs** |
| `chained-encrypt-str-17` | `"alice@example.com"` | 23 | **15.97 µs** |
| `chained-encrypt-str-43` | 43-char sentence | 58 | **40.18 µs** |
| `chained-encrypt-left-str-17` | 17 chars | 23 | 10.09 µs |
| `chained-compare-str-17` | 17 chars | 23 | **402 ns** |

## Reading

- **~0.69 µs/block** for full encryption, scaling linearly with length
(5.05/7, 15.97/23, 40.18/58 all ≈ 0.69).
- This is *below* fixed-N Bit6's **~0.81 µs/block** (≈8.9 µs for an 11-block
u64) — the shape-(ii) payoff: the accumulator is keyed once per ciphertext, so
there is **no per-block AES key schedule**. Per-block work is
~32 (`PRP_STREAM`) + 64 (`RO_KEY`) + 1 (`absorb`) ≈ 97 AES ops + the 64
σ-MMO H evals, all under the already-scheduled accumulator cipher.
- `encrypt_left` is ~63% of full encrypt (it skips the 64 `RO_KEY` finalizes and
the right-block masking per block, keeping only the PRP + the single left tag).
- Comparison (~402 ns at 23 blocks) is a constant-time prefix scan plus one H
eval and one oblivious right-block read.

## Caveats

- CMAC subkey `K1` and the per-block doubling reuse the vectorized
`gf128_double`; the accumulator XORs are `u128` word ops.
- Numbers are from a 3 s criterion measurement on a developer machine (some
thermal variance); treat as indicative, not a regression gate.

## Addendum (2026-10-08) — review hardening cost, and the sort-by-random-key builder

Apple M4, rustc 1.94.1, criterion means, `--release`. Same bench file.

**Review hardening.** Two of the #83 review-response changes were first
written as per-call wipes and cost more than everything else in the scheme:

| commit | str-5 | str-17 | str-43 | left-str-17 |
|---|---:|---:|---:|---:|
| before the review hardening | 4.60 µs | 14.88 µs | 37.00 µs | 11.23 µs |
| + `ro` wiped per block (64 zeroizes, 64 fences, per block) | 5.50 µs | 17.74 µs | 44.87 µs | 11.50 µs |
| + `mixed.zeroize()` per `finalize`/`absorb` (≈97 per block) | 7.89 µs | 25.65 µs | 64.30 µs | 13.94 µs |
| **as merged:** `mixed` encrypted in place; `ro` hoisted, wiped once | **4.76 µs** | **15.09 µs** | **38.13 µs** | **11.43 µs** |

The per-call `zeroize` is a volatile store plus a compiler fence; ~97 of them
per block stop the independent AES ops from pipelining. Encrypting `mixed` in
place leaves no plaintext temporary, so there is nothing to wipe, and `ro` is
fully overwritten by each block, so wiping it once after the loop is
equivalent. Bytes are unchanged by either.

**Sort-by-random-key PRP builder** (rejected; see
`docs/benchmarks/2026-06-13-bit6-prp-results.md` and review brief A4). Measured
against the pre-fix tip above (25.93 µs for str-17), swapped into `prp_at`'s
`from_stream`; the accumulator's 32 `PRP_STREAM` tags (512 bytes) already cover
the sort's 64 words, so the cost is the network alone:

| benchmark | Lemire FY | sort, 56-bit key | sort, 64-bit key |
|---|---:|---:|---:|
| chained-encrypt-str-5 | 7.91 µs | 9.95 µs (+26%) | 11.83 µs (+50%) |
| chained-encrypt-str-17 | 25.93 µs | 32.77 µs (+29%) | 38.27 µs (+49%) |
| chained-encrypt-str-43 | 65.06 µs | 81.27 µs (+26%) | 95.24 µs (+48%) |
| chained-encrypt-left-str-17 | 13.49 µs | 20.71 µs (+52%) | 27.31 µs (+98%) |

The absolute deltas (≈ +0.30 µs and +0.54 µs per block) carry over to the
fixed baseline unchanged. Chained has no known-answer vectors, so a builder
change here would not be caught by a test; only the Bit6 vectors failed.

## Addendum (2026-10-09) — oblivious PRP builder

`from_stream` now builds through the oblivious builder (review brief A4,
"Status"; NEON on aarch64, SSSE3 on x86_64 when present, SWAR elsewhere),
with byte-identical tables, so the chained vectors are unchanged. Apple M4 on
mains power, rustc 1.94.1, criterion medians, `--release`; the same build
with the dispatcher pointed at each builder in turn (the indexed column by a
temporary, uncommitted edit). A second run of the NEON build agreed within 3%.

| benchmark | indexed | NEON | SWAR |
|---|---:|---:|---:|
| chained-encrypt-str-5 | 4.67 µs | **4.37 µs** (−7%) | 7.86 µs (+68%) |
| chained-encrypt-str-17 | 15.20 µs | **13.70 µs** (−10%) | 25.18 µs (+66%) |
| chained-encrypt-str-43 | 36.88 µs | **34.12 µs** (−7%) | 64.47 µs (+75%) |
| chained-encrypt-left-str-17 | 11.29 µs | **10.21 µs** (−10%) | 22.05 µs (+95%) |
| chained-compare-str-17 | 318 ns | 316 ns | — (builds no PRP) |

The indexed column agrees with the "as merged" row above (15.09 µs for
str-17, 15.20 µs here). The saving is roughly the builder saving times the
block count: about 50 ns per block (248 ns indexed, 199 ns NEON, per
512-byte stream) × 23 blocks ≈ 1.1 µs predicted for str-17, 1.5 µs
measured.
45 changes: 38 additions & 7 deletions docs/plans/2026-06-12-ore-v2-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,18 +374,36 @@ length of the common prefix**, which is materially more revealing than for fixed
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:
reveals nothing about order. It does reveal **length**, below.

**Every ciphertext reveals the exact plaintext length** (review of #83). The header
stores the block count, the ciphertext size is `4 + 25*count + 16` bytes (`4 + 17*count`
for a left), and for a string of `n` UTF-8 bytes `count = ⌈8n/6⌉`, strictly increasing
in `n`. So one ciphertext, full, left or right alone (`count * 8` bytes), gives `n`
exactly, with no comparison or query. Short, low-entropy columns (country codes,
statuses, first names) are often unique or nearly unique by length, and length plus
common-prefix length is a strong frequency-analysis signal. Hiding it needs padding to
a length bucket (Padmé, or a fixed maximum per column). Padding must keep order, so the
pad symbol must sort below every real symbol: a 65-value domain, which changes the PRP
domain and the wire format. Tracked in
[#104](https://github.com/cipherstash/ore.rs/issues/104); decide it before ore-v2 freezes
the chained wire format, or it needs a new scheme id. Until then, the string leakage
profile is **exact length, plus order and common-prefix length**. Three threat tiers
follow for the order and prefix leakage (length leaks in all of them):

- **Offline / at rest:** right ciphertexts are semantically secure, so right-only
storage would make this tier clean: an attacker holding only rights recovers
nothing. **But right-only storage is not supported today**
([#99](https://github.com/cipherstash/ore.rs/issues/99)). Every scheme here, legacy
Bit8 included, stores full ciphertexts, whose deterministic left half (`xt`, `f`)
sits next to the right half, and the only comparators (`compare_raw_slices`, `Ord`
on `CipherText`) take two full ciphertexts. So until #99 lands, an attacker who
sits next to the right half, and the right half is not usable on its own: the only
comparators (`compare_raw_slices`, `Ord` on `CipherText`) take two full ciphertexts,
and `VarRight` has no serialise/compare API. So until #99 lands, an attacker who
exfiltrates the database can compare any two stored rows, with no query, and
recovers their **order** and, for strings, their **common-prefix length**. This is
the same exposure as legacy Bit8 today, not a regression.
the same exposure as legacy Bit8 today, not a regression. Right-only storage needs a
left-versus-right comparator, likely a trinary indicator, which is a format change
(#99).
- **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.
Expand Down Expand Up @@ -559,6 +577,16 @@ PRs 3/4 and 5 are independent after PR 2, so they can proceed in parallel. Each
`main` releasable; release-plz handles version bumps (all semver-minor under 0.x except
PR 2's trait change, which should be called out in the changelog).

> **PR 6 status (2026-06-15): IMPLEMENTED** on `feat/ore-v2-chained` against the A2
> design (crypto sign-off of A2 and A3 given 2026-10-09). Ships the
> AES-CMAC accumulator (`primitives/cmac.rs`, validated vs NIST SP 800-38B),
> `LemireFyPrp::from_stream` (shape ii), and `OreAes128Bit6Chained` (`scheme/chained.rs`)
> with variable-length `Var*` types and string encryption. Lexicographic cross-length
> comparison, equality-across-nonces, and >14-block plaintexts are tested; perf
> `docs/benchmarks/2026-06-15-chained-results.md` (~0.69 µs/block, below fixed-N's ~0.81).
> Not yet in: `u128`/`Decimal` via the const-N + accumulator path. Wire vectors are
> pinned (`tests/compat_chained_vectors.rs`).

## Testing strategy

- **Vectors (PR 1) as the refactor contract:** every later PR runs them unchanged for
Expand All @@ -583,14 +611,17 @@ PR 2's trait change, which should be called out in the changelog).
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).
Bit8/Bit6: done by key derivation (§4), with a cross-scheme regression test.
Chained: to confirm in PR 6.
Chained: separated, its only key is `AES_{k1}("ORE.v2.chain.acc")`, whose last
byte is outside every legacy PRF input and whose label differs from Bit6's.
- [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).
- [x] Selected §5(b) accumulator candidate reviewed and signed off (2026-10-09; review
brief A2 and A3).
- [ ] 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. Stored
- [ ] String leakage profile documented and acknowledged at product level. Every
ciphertext reveals the exact plaintext length (padding tracked in #104). Stored
full ciphertexts compare offline, revealing order and common-prefix length, as
legacy does, until right-only storage is supported (#99); see §5(b) (PR 6).
- [ ] Legacy zero-run seed leakage (Context, "Known leakage of the legacy scheme")
Expand Down
13 changes: 7 additions & 6 deletions docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ Four crypto decisions gate the v2 work. Two block a PR that is already open
| # | 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 |
| **A2** | Chained-prefix accumulator = AES-CMAC cached-state | CMAC PRF (standard) + 3 auditable claims | ✅ **SIGNED OFF** 2026-10-09; implemented as `OreAes128Bit6Chained` | — |
| **A3** | PRP keystream from the accumulator (shape ii) | statistical (≤2⁻⁵⁵) + branch-family soundness | ✅ **SIGNED OFF** 2026-10-09; shape (ii) ships in the chained scheme (`from_stream`), shape (i) in Bit6 | — |
| **A4** | Secret-indexed swap in PRP key-gen — ratify alignment + MemJam posture? | ~~constant-time / cache-line (sub-line = oblivious tier)~~ **oblivious key generation, every target** (2026-10-09) | oblivious builder (NEON / SSSE3 / SWAR) is the default, byte-identical tables; oblivious table reads and compare read; `N = 64` guard | nothing — fixes are byte-stable; A1 alone gates vectors |

**Recommended sequencing:** **A1 is resolved** (BHKR σ-MMO) — Bit6 vectors can now
Expand All @@ -26,7 +26,7 @@ applied (none of which alter ciphertexts); the MemJam posture call it carried
was overtaken on 2026-10-09, when a measured timing dependence made the
oblivious builder the default rather than a tier (§5, "Status"). Then
**A2 + A3 as one pass** (they gate PR 6, and A3 only exists inside A2's
accumulator).
accumulator). A2 and A3 were signed off together on 2026-10-09.

**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
Expand Down Expand Up @@ -611,10 +611,11 @@ None of (a)–(d) changes ciphertexts, so Bit6 vectors are gated only by A1.
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
- [x] **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.
zeroization test plan agreed. Signed off 2026-10-09 (Dan Draper).
- [x] **A3** PRP-stream-as-CMAC-branch-family domain separation approved.
Signed off 2026-10-09 (Dan Draper).

---

Expand Down
16 changes: 10 additions & 6 deletions docs/reviews/2026-06-16-zeroize-audit-pr83.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@
> `CmacAccumulator::absorb` and `finalize` each build an owned `mixed` value
> from the chain state (and, in `finalize`, `K1`) and returned without
> wiping it; and `from_stream`'s per-draw keystream copy (see the #82 audit)
> applies here too. Both are fixed: on #83 in "harden(cmac): wipe the mixed
> state temporaries in absorb and finalize", and on #82. The wipes remove the
> source-level copies; they cannot rule out copies the compiler keeps in
> registers or spills, and no IR evidence was gathered for them.
> applies here too. Both are fixed: on #83 in "harden(cmac): encrypt the
> mixed state in place in absorb and finalize", and on #82. In `absorb` and
> `finalize` the mixed input is now encrypted in place, so the one buffer
> that held it holds the new state or the tag afterwards and there is no
> plaintext temporary to wipe (a per-call `zeroize` was measured at +45% on
> chained encryption and dropped). These fixes remove the source-level
> copies; they cannot rule out copies the compiler keeps in registers or
> spills, and no IR evidence was gathered for them.

## Scope

Expand All @@ -27,12 +31,12 @@ wiped, on the struct Drop and in the per-block local buffers.
| Item | Status |
|---|---|
| `CmacAccumulator` Drop (`k1`, `state`) | ✅ wiped; `cipher` via aes `ZeroizeOnDrop` |
| **`CmacAccumulator::absorb` / `finalize` `mixed` temporaries** | ❌→✅ **was a gap** (missed by this audit) — state/`K1`-derived values left on the stack. **Fixed** on #83 (see the note above). |
| **`CmacAccumulator::absorb` / `finalize` `mixed` temporaries** | ❌→✅ **was a gap** (missed by this audit) — state/`K1`-derived values left on the stack. **Fixed** on #83: encrypted in place, the tag overwrites the input (see the note above). |
| chained `k_acc` Drop | ✅ wiped |
| chained `stream` (PRP keystream, `prp_at`) | ✅ `stream.zeroize()` |
| `from_stream` consumed stream | ✅ caller-owned; both callers wipe. **Amended:** the per-draw `draw`/`x` copies inside `from_stream` were not wiped; fixed on #82. |
| **`L = E_k(0)` in `CmacAccumulator::new`** | ❌→✅ **was a gap** — `K1 = dbl(L)`'s source, left on the stack. **Fixed:** `l.zeroize()` after deriving `k1`. |
| **chained `ro` RO-tag buffer (`encrypt_var`)** | ❌→✅ **was a gap** — 64 key-derived CMAC tags, never wiped (the sibling `bit2_w6` wipes its `template`/`work`). **Fixed:** per-block `ro` wipe after the right block is built. |
| **chained `ro` RO-tag buffer (`encrypt_var`)** | ❌→✅ **was a gap** — 64 key-derived CMAC tags, never wiped (the sibling `bit2_w6` wipes its `template`/`work`). **Fixed:** `ro` is one scratch buffer for the whole ciphertext, wiped once after the block loop; every entry is overwritten by each block's tags, so a per-block wipe (measured at +19%) protected nothing between blocks. |

Dangerous-API scan: 0 hits (`mem::forget`/`ManuallyDrop`/`Box::leak`/`transmute`/
`write_bytes`/`mem::take`/secret-across-`.await`), grep-confirmed. No `Copy` on
Expand Down
4 changes: 4 additions & 0 deletions packages/ore-rs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,9 @@ name = "chrono"
harness = false
required-features = ["chrono"]

[[bench]]
name = "chained"
harness = false

[[example]]
name = "encrypt"
41 changes: 41 additions & 0 deletions packages/ore-rs/benches/chained.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use hex_literal::hex;
use ore_rs::scheme::chained::{OreAes128Bit6Chained, OreAes128Bit6ChainedChaCha20};

fn init_ore() -> OreAes128Bit6ChainedChaCha20 {
let k1: [u8; 16] = hex!("00010203 04050607 08090a0b 0c0d0e0f");
OreAes128Bit6Chained::init(&k1).unwrap()
}

fn criterion_benchmark(c: &mut Criterion) {
let ore = init_ore();

// Short (~8 chars ≈ 11 blocks) and long (~40 chars ≈ 54 blocks) strings.
let short = "alice";
let medium = "alice@example.com";
let long = "the quick brown fox jumps over the lazy dog";

c.bench_function("chained-encrypt-str-5", |b| {
b.iter(|| ore.encrypt_str(black_box(short)).unwrap())
});
c.bench_function("chained-encrypt-str-17", |b| {
b.iter(|| ore.encrypt_str(black_box(medium)).unwrap())
});
c.bench_function("chained-encrypt-str-43", |b| {
b.iter(|| ore.encrypt_str(black_box(long)).unwrap())
});
c.bench_function("chained-encrypt-left-str-17", |b| {
b.iter(|| ore.encrypt_left_str(black_box(medium)).unwrap())
});

let a = ore.encrypt_str(medium).unwrap().to_bytes();
let b_ct = ore.encrypt_str("alice@example.org").unwrap().to_bytes();
c.bench_function("chained-compare-str-17", |bench| {
bench.iter(|| {
OreAes128Bit6ChainedChaCha20::compare_raw_slices(black_box(&a), black_box(&b_ct))
})
});
}

criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);
4 changes: 4 additions & 0 deletions packages/ore-rs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,10 @@ pub enum OreError {
/// per-ciphertext nonce).
#[error("Randomness Error")]
RandError(#[from] rand::Error),
/// The plaintext exceeds the maximum block count the wire format can
/// represent (the chained scheme encodes the block count as a `u16`).
#[error("Too many blocks")]
TooManyBlocks,
}

/// A BlockORE cipher: a key-bound object that can encrypt fixed-N plaintexts
Expand Down
1 change: 1 addition & 0 deletions packages/ore-rs/src/primitives.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod cmac;
pub mod hash;
pub mod prf;
pub mod prp;
Expand Down
Loading
Loading