From e488ab6120ba91e6376399f4f9121a1aa89606e6 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 13 Jun 2026 00:48:43 +1000 Subject: [PATCH 1/6] test: pin compatibility vectors and bench baseline for ORE v2 refactor Pins serialised ciphertext bytes (left + full via deterministic test RNG) and comparison fixtures for the bit2 scheme, as the byte-identity contract for the v2 refactor. Adds the v2 architecture plan and the criterion baseline numbers on Apple M1 Max. Part of the ORE v2 program (docs/plans/2026-06-12-ore-v2-architecture.md, PR 1). --- docs/benchmarks/2026-06-13-baseline-main.md | 33 ++ docs/plans/2026-06-12-ore-v2-architecture.md | 436 ++++++++++++++++++ packages/ore-rs/tests/compat_vectors.rs | 266 +++++++++++ .../ore-rs/tests/compat_vectors/vectors.rs | 25 + 4 files changed, 760 insertions(+) create mode 100644 docs/benchmarks/2026-06-13-baseline-main.md create mode 100644 docs/plans/2026-06-12-ore-v2-architecture.md create mode 100644 packages/ore-rs/tests/compat_vectors.rs create mode 100644 packages/ore-rs/tests/compat_vectors/vectors.rs diff --git a/docs/benchmarks/2026-06-13-baseline-main.md b/docs/benchmarks/2026-06-13-baseline-main.md new file mode 100644 index 00000000..5d884c6a --- /dev/null +++ b/docs/benchmarks/2026-06-13-baseline-main.md @@ -0,0 +1,33 @@ +# Benchmark baseline — main @ 3f92c45 + +Recorded as the reference point for the ORE v2 performance work +(`docs/plans/2026-06-12-ore-v2-architecture.md`, PR 1). Criterion baseline +name: `main-baseline` (in `target/criterion`, not committed — these numbers +are the durable record). + +**Machine:** Apple M1 Max (aarch64, NEON + ARMv8 crypto extensions), macOS, +rustc 1.87.0, `cargo bench --bench oreaes128`. + +| Benchmark | Median | Notes | +|---|---|---| +| encrypt-8 (u64) | 381.1 µs | full Left+Right, 8 blocks | +| encrypt-left-8 (u64) | 91.1 µs | Left only | +| compare-8 | 738.8 ns | typed `Ord` | +| compare-8-slice | 743.5 ns | `compare_raw_slices` | +| serialize-8 | 472.5 ns | | +| deserialize-8 | 34.0 ns | | +| encrypt-4 (u32) | 187.2 µs | | +| encrypt-left-4 (u32) | 45.1 µs | | +| compare-4 | 671.8 ns | | + +## Observations + +- Per-block costs are ~11 µs for the Left path (PRP setup dominated: Knuth + shuffle with rejection-sampled PRNG, 512-byte permutation buffers zeroized + per drop) and ~36 µs for the Right path on top of that. The raw AES work + per block (~512 batched encryptions) should be well under 1 µs on this + hardware, so the overwhelming majority of encrypt time is construction + overhead around AES, not AES itself — consistent with the v2 plan's cost + model and a large headroom signal for PRs 3–5. +- `serialize-8` at ~472 ns reflects the per-block `Vec` allocations in + `to_bytes`; not a target of this program but cheap to improve in passing. diff --git a/docs/plans/2026-06-12-ore-v2-architecture.md b/docs/plans/2026-06-12-ore-v2-architecture.md new file mode 100644 index 00000000..3b523184 --- /dev/null +++ b/docs/plans/2026-06-12-ore-v2-architecture.md @@ -0,0 +1,436 @@ +# ORE v2: generalised block widths, efficient unary encoding, SIMD, variable-length plaintexts + +**Date:** 2026-06-12 +**Status:** Draft +**Scope:** `packages/ore-rs` (with small additions to `packages/orderable-bytes`) + +## Context + +`ore-rs` implements Lewi-Wu (2016) BlockORE with: + +- **8-bit input blocks only.** Each plaintext byte is one ORE block over a domain of 256. +- **Fixed block counts via const generics.** `CipherText` where `N` is the plaintext + byte length; the Left tag packs `(prefix ‖ xt[i] ‖ block_index)` into one 16-byte AES + input, which hard-caps `N ≤ 15`. +- **A naive iterative right-ciphertext encoder.** Per block: 256 random-oracle keys are + rebuilt byte-by-byte, hashed into a heap-allocated `Vec`, and the indicator bits are + set one at a time through `prp.invert(j)` table lookups (`scheme/bit2.rs:147-177`). +- **No wire-format header.** `compare_raw_slices` infers the block count from the slice + length; ciphertexts of different schemes/sizes are distinguishable only by length. + +This plan covers four goals, structured as a sequence of PRs: + +1. **Efficient unary encoding** — replace the per-bit iterative right-block construction + with bulk, allocation-free encoding. +2. **SIMD implementations** — vectorise the data-parallel inner loops on x86_64 and + aarch64 with a scalar fallback. +3. **6-bit block width** — a second block width with 4× less AES work and a 4× smaller + right block, with the 8-bit scheme retained byte-for-byte for backwards compatibility. +4. **Fixed and variable block counts** — keep the const-generic fast path for numerics; + add a variable-length ciphertext (Vec-backed) for strings, which requires a new + prefix-binding construction to lift the `N ≤ 15` cap. + +## Non-goals + +- No change to the underlying Lewi-Wu construction for the existing 8-bit fixed-N scheme. + `OreAes128ChaCha20` ciphertexts must remain byte-identical and comparable against + existing stored data. +- No serde support, no no_std, no key-management/rotation work. +- No alternative PRP algorithm for the 8-bit scheme (the Knuth shuffle is part of the + ciphertext-compatible key schedule). A cheaper PRP for *new* schemes is an open option. + +## Cost model (why these goals, in this order) + +Per 8-bit block, `encrypt` today costs roughly: + +| Step | Work | +|---|---| +| PRP setup (Knuth shuffle) | ~256 swaps + rejection-sampled AES-CTR PRNG bytes | +| `ro_keys` rebuild | 256 × 16-byte writes (4 KiB) per block, then re-zeroed | +| PRF₁ over `ro_keys` | 256 AES encryptions | +| Random oracle (`hash_all`) | 256 AES encryptions + a 256-byte `Vec` allocation | +| Indicator loop | 256 × (`prp.invert` lookup + branchy bit set) | + +For a `u64` (8 blocks) that is ~4096 AES block encryptions plus ~8 KiB of redundant +buffer traffic and 8 heap allocations. AES is already hardware-accelerated via the `aes` +crate; the headroom is in everything around it. A 6-bit block divides the dominant +AES count by 4 **and** shrinks the stored right block from 32 to 8 bytes — for `u64`, +ciphertexts go from 408 bytes (8 blocks × 49 + 16) to roughly 291 bytes (11 blocks × 25 ++ 16), and encryption does ~700 RO evaluations instead of ~2048. + +## Architecture + +### 1. Core abstractions (the refactor everything else hangs off) + +Introduce three orthogonal axes, today all hard-coded into `scheme/bit2.rs`: + +```text +block width × prefix binding × block count +(Bit8, Bit6) (Packed, Chained) (fixed const N, variable) +``` + +**`BlockWidth` trait** (stable-Rust trait-with-associated-items; `generic_const_exprs` +is not available so we cannot write `[u8; 1 << W]` generically): + +```rust +pub trait BlockWidth: private::Sealed { + /// Bits per input block (8 or 6). + const BITS: usize; + /// Block domain size (256 or 64). + const DOMAIN: usize; + /// Right-block bitvector: 32 bytes for Bit8, 8 bytes for Bit6. + type RightBlock: CipherTextBlock + BitVec; + /// PRP over the block domain. + type Prp: Prp; +} + +pub struct Bit8; +pub struct Bit6; +``` + +**`BlockDecomposer`** — turns canonical plaintext bytes (from `orderable-bytes`) into a +sequence of block values `< DOMAIN`: + +- `Bit8`: identity (one byte per block), exactly today's behaviour. +- `Bit6`: MSB-first bit-packing. `N` bytes become `M = ceil(8N / 6)` blocks; the final + block is zero-padded in its low bits. MSB-first packing preserves lexicographic order, + and for fixed-length inputs trailing zero-padding is order-neutral. The decomposer is + pure bit logic and lives in `ore-rs` (not `orderable-bytes`, which stays about value + canonicalisation). + +Because `M` cannot be computed from `N` at the type level on stable Rust, the +`OreEncrypt` impls name both as consts — the same idiom `encrypt.rs` already uses for +`ENCODED_LEN` (e.g. `u64` → `N = 8`, `M = 11`), with a `const` assertion that +`M == (8 * N).div_ceil(6)`. + +**`IndicatorEncoder`** — the unary-encoding engine (see §2). One scalar implementation, +optionally overridden by SIMD backends (§3). + +**Prefix binding** stays packed (`prefix ‖ xt[i] ‖ i` in one AES block) for all fixed-N +schemes; a chained construction is introduced only for the variable-length scheme (§5). + +The existing `OreAes128` is then re-expressed as the `(Bit8, Packed, fixed-N)` corner +of this space. + +**Seed/tag buffer separation.** Today `left.f` is reused for two cryptographically +different values: the PRF₂ outputs (per-block PRP seeds — key-equivalent material that +must never be serialized) and, after an in-place overwrite, the PRF₁ left tags (the +publishable ciphertext component). The only thing preventing seed exposure is statement +ordering inside `encrypt`. The refactor makes this structural: seeds are computed into a +separate short-lived `ZeroizeOnDrop` buffer, and the `Left` type only ever holds tag +material. Cost is one 16×N-byte stack array and an extra zeroize — noise against the +AES floor (the original reuse was a historical perf choice that measurably buys +nothing). Output bytes are unaffected. The refactor must be **byte-identical** — verified against pinned test +vectors (PR 1) — because the Knuth-shuffle PRP, the PRNG byte order, the `ro_keys` +layout, and the wire format are all observable in stored ciphertexts. + +`OreCipher`'s associated types will need to change shape (e.g. gaining a +`type Width: BlockWidth`). We are pre-1.0, so this is a semver-minor bump under the 0.x +convention, but downstream CipherStash crates should be checked before merge. + +### 2. Efficient unary encoding (goal 1) + +The key observation: `prp.invert(j)` is `permutation[j]`, so the indicator bitvector for +block value `x` is simply + +```text +bit j = (permutation[j] > x) for j in 0..DOMAIN +``` + +i.e. a bulk byte-wise greater-than compare of the permutation table against a broadcast +`x`, packed to a bitmask. The right block is then `indicator_mask XOR hash_mask`, where +`hash_mask` packs the LSB of byte 0 of each RO output. Restructured per block: + +1. **Indicator mask:** one pass over the (already key-derived) permutation table: + `mask[j/8] |= ((perm[j] > x) as u8) << (j%8)`. No `Result` per bit, no `invert` calls, + cache-linear. This is the scalar form of a SIMD `cmpgt + movemask` (§3). +2. **Hash mask:** `hash_all` writes LSBs directly into a stack bitvector + (`[u8; DOMAIN/8]`) instead of returning `Vec`. Zero allocations on the encrypt + path. Also hoist the nonce-keyed hasher out of the block loop — today the AES key + schedule for the nonce is rebuilt once per block (`bit2.rs:169`) when once per + encryption suffices. +3. **Right block:** `right.data[n] = indicator_mask ^ hash_mask`. The bit-at-a-time + `set_bit` loop disappears. +4. **`ro_keys` as template + work buffer:** keep a *template* array whose prefix region + is extended by one byte per block iteration (instead of rebuilt from zero), and + `memcpy` it into a work buffer that PRF₁/the RO encrypt in place. Halves the buffer + traffic and removes the `lazy_static` zero-block dance. Zeroize both buffers once at + the end of `encrypt` (matching the existing "zeroize at the end is fine" decision in + TODO). + +None of this changes ciphertext bytes — it computes the same masks a different way — +so it lands as a pure performance PR against the refactored core, gated by the PR 1 +vectors. Expected win: removal of ~8 allocations and ~8 KiB of redundant writes per +`u64` encrypt, plus a much tighter indicator loop. (Criterion will tell us the real +number; AES remains the floor.) + +### 3. SIMD backends (goal 2) + +**What vectorises** (all data-parallel, no plaintext-dependent control flow): + +| Op | Shape | x86_64 | aarch64 | +|---|---|---|---| +| Indicator mask | bytewise `>` vs broadcast, pack to bitmask | `vpcmpgtb` + `vpmovmskb` (AVX2: 8 iters for 256 lanes; SSE2 fallback: 16) | `cmgt.16b` + bit-narrowing (no movemask; use `ushr`+`addv` or the `vshrn` trick) | +| Hash-LSB mask | strided bit gather from AES output blocks | shifts + `vpmovmskb` on gathered bytes | shifts + narrowing | +| Compare: first-differing-block scan | 16-byte tag equality across blocks | `vpcmpeqb` + movemask, branch-free fold | `cmeq` + fold | + +AES itself already uses AES-NI / ARMv8-CE via the `aes` crate — no work needed there. + +**Mechanism:** + +- A small `backend` module: `trait OreBackend { fn gt_mask(table: &[u8], x: u8, out: &mut [u8]); fn lsb_mask(blocks: &[AesBlock], out: &mut [u8]); ... }` + with `Scalar`, `Avx2` (runtime-detected via `is_x86_feature_detected!` once at + `OreCipher::init`, stored as an enum/fn-table on the cipher), and `Neon` + (compile-time: NEON is baseline on aarch64). SSE2 is baseline on x86_64 and can be the + "scalar" floor there if autovectorisation doesn't already get it. +- `unsafe` confined to `#[target_feature]`-annotated leaf functions in one module; + everything above the trait is safe. +- **No cargo feature flag.** Runtime dispatch + scalar fallback keeps the build matrix + trivial and avoids untested feature combinations. Tests parameterise over backends and + assert bit-identical output vs scalar (and vs PR 1 vectors). +- **Constant-time discipline:** all SIMD ops are fixed-trip-count and branch-free with + respect to plaintext/key data. The comparator keeps its `subtle-ng` conditional-select + structure; SIMD only accelerates the per-block equality tests feeding it, and the fold + stays branch-free. Document this invariant in the module. + +### 4. 6-bit block width (goal 3) + +A new fixed-N scheme instantiating `(Bit6, Packed, fixed-N)`: + +- **PRP:** Knuth shuffle over 64 elements — same algorithm, ~4× cheaper setup, and the + rejection sampling rejects far less (max 63 vs 255 against a uniform byte). +- **Right block:** 8 bytes (64 bits). 64 RO keys per block → 64+64 AES ops per block + vs 256+256. +- **Left tag packing:** block values are stored one-per-byte in the prefix region, so a + `u64` (11 six-bit blocks) packs `11 prefix bytes + xt + index = 13 ≤ 16`. The packed + construction caps Bit6 at **14 blocks** (= 10 plaintext bytes), which covers every + primitive up to 64-bit and dates, but **not** `u128`/`i128` (22 blocks) or `Decimal` + (19 blocks) — those remain on the 8-bit scheme (or wait for the chained prefix, §5). + This constraint must be a compile-time assertion. +- **Naming:** new module `scheme::bit2_w6` exposing `OreAes128Bit6ChaCha20` (exact name + bikesheddable in the PR). The existing `scheme::bit2` module and types are untouched. +- **Wire format v2 with a header** (new schemes only — the legacy scheme keeps its + headerless format forever): + +```text +byte 0 : version (0x02) +byte 1 : scheme id (width | prefix mode | cipher suite, packed enum) +bytes 2..4 : block count (u16, big-endian) +bytes 4.. : left ‖ right (existing layout per block width) +``` + + `compare_raw_slices` on v2 schemes validates version + scheme id and returns `None` on + mismatch — cross-scheme and cross-width comparisons must fail loudly, not silently + mis-order. Legacy v1 slices are only ever handled by the legacy type. (A v1 ciphertext + could in principle collide with a v2 length; since each *type* only parses its own + format, no runtime ambiguity arises.) +- **Domain separation:** the scheme id byte is also mixed into the PRF input layout + decision space — concretely, Bit6 writes `block_count` into the otherwise-unused byte + 15 of the left-tag input so that identical prefixes under Bit6 and Bit8 can never + produce related tags under the same keys. (Cheap insurance; needs a nod from crypto + review.) + +### 5. Variable block counts / string encryption (goal 4) + +The hard part: the packed prefix cannot represent plaintexts longer than ~14 blocks. +Two changes, deliberately isolated in the final PRs: + +**(a) Vec-backed ciphertext types.** `VarCipherText` / `VarLeft` / `VarRight` +mirroring the const-generic types but with `Vec<...>` storage and the v2 header carrying +the block count. The comparator generalises to different lengths: scan for the first +differing block over `min(n_a, n_b)`; if none, the shorter ciphertext sorts first +(correct lexicographic semantics for strings; numerics keep using fixed-N types where +length never varies). The scan stays constant-time *within* the compared range; total +length is public anyway (it's in the header). + +**(b) Chained prefix binding.** The packed prefix cannot exceed one AES block, so the +variable-length scheme needs an *accumulator*: absorb the prefix incrementally, and at +each block position derive the per-block secrets/outputs (PRP seed, left tag, RO keys) +from the accumulated state. Every viable design has this shape; the candidates differ +in **what the chain state is** and **how per-position branch outputs are +domain-separated**. Three candidates go to crypto review: + +**Candidate A — Cascade/GGM (chain through the key slot).** + +```text +t₀ = E_k(CONST) — master key never used as a chain key +tᵢ = E_{t_{i-1}}(0x00 ‖ xᵢ ‖ i) — chain step +prp_seedᵢ = E_{t_{i-1}}(0x01 ‖ 0 ‖ i) — keys the block-i PRP +fᵢ = E_{t_{i-1}}(0x02 ‖ xtᵢ ‖ i) — published left tag +ro_key(i,j) = E_{t_{i-1}}(0x03 ‖ j ‖ i) — RO keys, j ∈ 0..DOMAIN +``` + +(16-byte inputs: tag byte, value byte, u16 index, zero padding — trivially injective.) + +The state after prefix `p` is a GGM *constrained key* for the subtree of plaintexts +extending `p` — exactly the semantic object ORE prefix logic wants. Everything +published at position `i` lives under key `t_{i-1}`; the next chain state is one of +that key's outputs at a tag-separated point, so the "published outputs vs chaining +values" interaction vanishes structurally. Security is a textbook hybrid over chain +depth (`q·n·ε_PRP` plus a `(total blocks)²/2^129` state-collision term), with no +related-key assumptions (keys are PRF outputs; nothing is ever XORed into a key). +Bonus: the `prp_seed` branch **subsumes PRF₂** — single master key — and is +per-`(prefix, i)`, fixing the repeated-PRP-seed TODO (`bit2.rs:78-81`) for new schemes. +Cost: one AES-128 key expansion per block — cheap on x86_64 (`AESKEYGENASSIST`), +software on aarch64 (no key-schedule instruction); estimated 5–20% overhead against +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 +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 +inside; clone-state-then-finalize *is* incremental CMAC). Security then reduces to +CMAC's PRF security plus three auditable claims: (1) the message encoding is injective +across blocks/branches/widths, (2) the incremental implementation is faithful to +one-shot CMAC (tested against the `cmac` crate's vectors), (3) cached secret states +are zeroized. One key schedule total (plus CMAC's two subkeys). PRF₂ remains separate +(or becomes another branch family). + +**Candidate C — XE-style masked single-key chaining.** The original sketch with ad-hoc +tag bytes replaced by Rogaway `2ⁱ3ʲ·L` masks in GF(2^128): `L = E_k(0¹⁶)`, +`tᵢ = E_k(t_{i-1} ⊕ Δᵢ ⊕ enc(xᵢ))` with `Δᵢ = 2ⁱ·L`, branches under `3·Δᵢ` and +`3²·Δᵢ`. One key schedule and one GF doubling per block — strictly fastest. Caveat: +masks and tag bytes are analytically equivalent here (both give within-node +disjointness; cross-node collisions are birthday-bounded either way), and the +PMAC-lineage analyses publish only a final tag — so the many-outputs-per-state +argument remains bespoke. Weakest off-the-shelf story relative to its speed advantage; +the doubling must be constant-time. + +| | A: Cascade/GGM | B: CMAC cached-state | C: XE masked CBC | +|---|---|---|---| +| Chain state | AES key | 128-bit CBC value | 128-bit XOR value | +| Key schedules | 1 per block | 1 (+2 subkeys) | 1 | +| Branch separation | re-key per node + tag bytes | each output a full CMAC tag | GF(2^128) masks | +| Security story | constrained-PRF/GGM hybrid — cleanest | citable, if outputs are genuine tags | PMAC-lineage techniques, bespoke composition | +| Review surface | hybrid writeup only | encoding injectivity + impl faithfulness | many-outputs-per-state argument | +| Perf risk | key expansion on aarch64 | none | none (CT doubling) | +| Bonus | subsumes PRF₂, fixes PRP-seed TODO | standards citation | — | + +**Decision rule:** Candidate A if the NEON key-schedule benchmark comes in under +~10–15% on Bit6-width strings; otherwise Candidate B. Candidate C only if profiling +rules out both. Whichever wins, **internal crypto review happens before PR 6 is +written, not after.** The fixed-N schemes never use the accumulator, so review risk +doesn't block goals 1–3. + +Cost shape is preserved in all candidates: the prefix is absorbed once per block, and +the `DOMAIN` RO keys per block remain a single batched `encrypt_all` under one cipher +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. + +### 6. Random-oracle instantiation (the 1-bit hash H) + +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 +(`bit2.rs:158-168`). The original C implementation used SHA-256 here and was +measurably slower. Because Bit6 (PR 5) is a new scheme with no compatibility +constraint, the H decision must be made **before PR 5** — Bit6 should ship with the +chosen H rather than inherit nonce-as-key. The legacy Bit8 scheme keeps the status quo +forever. + +Hard constraint: H is computed by a **keyless comparator** (e.g. inside Postgres) from +two public ciphertexts — it may use the nonce and published left tag, but no long-term +secret. Any "PRF under a third key" design is therefore out. Candidates (`x` = RO +key / left tag, `r` = nonce): + +| # | Construction | Security model | Cost vs today | Notes | +|---|---|---|---|---| +| 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) | +| 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. + +## PR roadmap + +| PR | Title | Depends on | Size | Risk | +|---|---|---|---|---| +| 1 | **Compatibility vectors + bench baselines.** Pin hex test vectors for `encrypt_left` (deterministic) and `encrypt` (via seeded RNG through the existing `OreAes128` generic) for known keys/plaintexts, plus serialised-format and comparison fixtures. Record criterion baselines. | — | S | none | +| 2 | **Core refactor.** Introduce `BlockWidth`, `BlockDecomposer`, `IndicatorEncoder` (scalar), template-based `ro_keys`; re-express `OreAes128` on top. Byte-identical per PR 1 vectors. | 1 | L | medium (mitigated by vectors) | +| 3 | **Efficient unary encoding.** Permutation-direct indicator mask, bit-packed hash mask, allocation-free encrypt path. Bench deltas in PR description. | 2 | M | low | +| 4 | **SIMD backends.** AVX2 + NEON (+ SSE2 floor) behind runtime dispatch; backend-equivalence tests; CI coverage for both arches. | 3 | M | low | +| 5 | **Bit6 fixed-N scheme + wire format v2.** New scheme type, 64-domain PRP, v2 header, cross-scheme comparison rejection, `OreEncrypt` impls for primitives ≤ 64-bit + chrono. Ships with the H construction chosen in §6. Includes the NEON key-expansion benchmark spike that feeds the §5(b) decision rule. | 2 (parallel with 3/4); §6 H decision | M | medium | +| 6 | **Chained prefix + variable-length ciphertexts + string encryption.** Implements the §5(b) candidate selected by the decision rule. Includes leakage documentation. | 5; §5(b) benchmark gate + crypto review | L | high (crypto) | +| 7 | **Docs, examples, migration guide, README perf table refresh.** | 4, 5, 6 | S | none | + +PRs 3/4 and 5 are independent after PR 2, so they can proceed in parallel. Each PR keeps +`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). + +## Testing strategy + +- **Vectors (PR 1) as the refactor contract:** every later PR runs them unchanged for + the legacy scheme. +- **Property tests:** extend the existing quickcheck suite to Bit6 and variable-length + (order-agreement, equality, serialisation round-trip, cross-length string ordering vs + `str::cmp` on random strings). +- **Backend equivalence:** scalar vs SIMD byte-identical ciphertexts over randomised + inputs, run on both x86_64 and aarch64 CI runners. +- **Negative tests:** cross-scheme/cross-width `compare_raw_slices` returns `None`; + malformed v2 headers return `ParseError`. +- **Benchmarks:** criterion groups per scheme × operation (encrypt, encrypt_left, + compare, serialise); baselines recorded in PR 1 so each PR reports a real delta. + +## Security review checklist (tracked across PRs) + +- [ ] Constant-time invariants preserved in SIMD paths and the variable-length scan + (PR 4, 6). +- [ ] Zeroization coverage of new buffers: PRP-seed buffer, `ro_keys` template/work + buffer, decomposer output, accumulator state (PR 2, 3, 6). +- [ ] 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). +- [ ] 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). + +## Decisions taken (revisit if needed) + +1. **Trait-based `BlockWidth` over const-generic width** — `generic_const_exprs` is + unstable; sealed traits with associated consts/types are the stable idiom. +2. **No SIMD cargo feature** — runtime dispatch with scalar fallback; smaller test + matrix beats opt-in vectorisation. +3. **Legacy scheme stays headerless forever; only new schemes get the v2 header** — + avoids any migration of stored data. +4. **Bit6 fixed-N keeps the packed prefix** (capped at 14 blocks) rather than waiting + for the chained construction — ships goal 3 without the crypto-review dependency; + `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). +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. +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. +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/packages/ore-rs/tests/compat_vectors.rs b/packages/ore-rs/tests/compat_vectors.rs new file mode 100644 index 00000000..d5fd8498 --- /dev/null +++ b/packages/ore-rs/tests/compat_vectors.rs @@ -0,0 +1,266 @@ +//! Compatibility vectors for the `OreAes128` (bit2) scheme. +//! +//! These tests pin the exact serialised bytes produced by the current +//! implementation for fixed keys and plaintexts, plus the comparison results +//! over those pinned bytes. They are the contract for the ORE v2 refactor +//! (see `docs/plans/2026-06-12-ore-v2-architecture.md`): any change that +//! alters these bytes is a wire-format break for data already stored by +//! downstream users, and must fail here. +//! +//! 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. The comparison fixtures parse pinned bytes only, so they +//! exercise the comparator without any RNG involvement. +//! +//! To regenerate (only legitimate if the wire format is *deliberately* +//! changed, which the v2 plan forbids for this scheme): +//! +//! ```text +//! cargo test --test compat_vectors -- --ignored --nocapture generate +//! ``` + +use ore_rs::{ + scheme::bit2::{OreAes128, OreAes128ChaCha20}, + 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 cipher with 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 = OreAes128>; +type OreB = OreAes128>; + +fn cipher_a() -> OreA { + OreCipher::init(&K1, &K2).unwrap() +} + +fn cipher_b() -> OreB { + OreCipher::init(&K1, &K2).unwrap() +} + +fn cipher_left() -> OreAes128ChaCha20 { + OreCipher::init(&K1, &K2).unwrap() +} + +#[path = "compat_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); +} + +#[test] +fn left_plaintext_bytes() { + let ore = cipher_left(); + let left = [0u8, 1, 2, 3, 4, 5, 6, 7].encrypt_left(&ore).unwrap(); + assert_eq!(hex::encode(left.to_bytes()), LEFT_PT8); +} + +// --------------------------------------------------------------------------- +// 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_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!( + OreAes128ChaCha20::compare_raw_slices(&pinned(a), &pinned(b)), + Some(expected), + "u64 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!( + OreAes128ChaCha20::compare_raw_slices(&pinned(seed_a), &pinned(FULL_U64_456_SEED_B)), + Some(Ordering::Equal) + ); +} + +#[test] +fn typed_comparison_of_pinned_bytes() { + 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); +} + +#[test] +fn left_against_pinned_full() { + // A freshly produced Left for 456 must equal the left half of the pinned + // full ciphertext for 456 (left bytes are deterministic). + let ore = cipher_left(); + let left = 456u64.encrypt_left(&ore).unwrap().to_bytes(); + let (_, full_hex) = FULL_U64[2]; + let full = pinned(full_hex); + assert_eq!(left, full[..left.len()], "left half of full ciphertext drifted"); +} + +// --------------------------------------------------------------------------- +// Generator (run manually; see module docs) +// --------------------------------------------------------------------------- + +#[test] +#[ignore = "generator: prints the contents of tests/vectors/mod.rs"] +fn generate() { + println!("// Contents of tests/compat_vectors/vectors.rs"); + println!("// Generated by `cargo test --test compat_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()); + print_left( + "LEFT_PT8", + [0u8, 1, 2, 3, 4, 5, 6, 7].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!(); + + 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_vectors/vectors.rs b/packages/ore-rs/tests/compat_vectors/vectors.rs new file mode 100644 index 00000000..21d43ca9 --- /dev/null +++ b/packages/ore-rs/tests/compat_vectors/vectors.rs @@ -0,0 +1,25 @@ +// Contents of tests/compat_vectors/vectors.rs +// Generated by `cargo test --test compat_vectors -- --ignored --nocapture generate` +// DO NOT regenerate unless deliberately breaking the wire format (see plan doc). + +pub const LEFT_U64_456: &str = "90909090909027b2fb96f7c44c31338394351cb82392a04838afa1140cf2a0b177ea203ff18c2dcec66ae357888badba2d2375bf7646951ad93a16f79b1cd83b1d81d918af0a5669b640b573c4941417538c8d0952fc985061a216bb6f498cebd231bb103aee5d857f6347893f1bb2b4d52ca578343a1a34161f555882bdcf9d80ea15c515d86ed8"; +pub const LEFT_U64_0: &str = "9090909090909090fb96f7c44c31338394351cb82392a04838afa1140cf2a0b177ea203ff18c2dcec66ae357888badba2d2375bf7646951ad93a16f79b1cd83b1d81d918af0a5669b640b573c4941417538c8d0952fc985061a216bb6f498cebd231bb103aee5d85c3e75f249842fa9533e374b167b761faa1ec8cbbc5cfc82b07537cb5774c48a7"; +pub const LEFT_U32_1000: &str = "9090e0c9fb96f7c44c31338394351cb82392a0485867af0b44c8006c935e31d518d87761121f61344819dd9ef2eb2f4876f145fe99ee5334e30dd274fa11de8f8f333001"; +pub const LEFT_PT8: &str = "90279c43e26bd63ffb96f7c44c31338394351cb82392a0481f52cbfea1f98ee034b3cf330292096bf6b2e0d5c850bad025aeb0305c9ec61105dc659b3219a1292ba08b9cf212bf3e8a44d2b9288645ee90a645d7430a20fbd6499786f8fa75002852ead8dbfde42913b814c3f01e1439192061a753b48cb3fb8b5847aefb512fef5939def03fb18f"; + +pub const FULL_U64: &[(u64, &str)] = &[ + (0, "9090909090909090fb96f7c44c31338394351cb82392a04838afa1140cf2a0b177ea203ff18c2dcec66ae357888badba2d2375bf7646951ad93a16f79b1cd83b1d81d918af0a5669b640b573c4941417538c8d0952fc985061a216bb6f498cebd231bb103aee5d85c3e75f249842fa9533e374b167b761faa1ec8cbbc5cfc82b07537cb5774c48a798191f46e5830216445436978803697a0557f46f53515a096efe7a02bbcff8f21655d96e8175e224f1fe1de9886cd11fbf7db70c8fc3ca951579f7369662d48263c68cdd3ea686c4d09d2ace47073ba15aa76e1c89e7bcdb259c8a7ad3053dbce75c4bfe86813f9db8df4a6fe58d5124401dc4ee49da338a208476fb351a67c9e3f21fae3ca39657cc324d4d2d45f90d0a88aeae938347767b9e0e77de2fec11905288d67e60b2b4e1a62c8284740f6d179a497dc885708945047642363bcad8a0108a6a25c53a9954f656996c3e5159158292c04805054dbd717487096879b5a0c96a9dbc85a1730b4506f92ed6878331318f453b3cf6d33aefb11a5dda68c1da067b0e8324618d8eb32db26f762058"), + (1, "9090909090909027fb96f7c44c31338394351cb82392a04838afa1140cf2a0b177ea203ff18c2dcec66ae357888badba2d2375bf7646951ad93a16f79b1cd83b1d81d918af0a5669b640b573c4941417538c8d0952fc985061a216bb6f498cebd231bb103aee5d85c3e75f249842fa9533e374b167b761fad70252dec4c1feb392ff38a4bdfea03298191f46e5830216445436978803697a0557f46f53515a096efe7a02bbcff8f21655d96e8175e224f1fe1de9886cd11fbf7db70c8fc3ca951579f7369662d48263c68cdd3ea686c4d09d2ace47073ba15aa76e1c89e7bcdb259c8a7ad3053dbce75c4bfe86813f9db8df4a6fe58d5124401dc4ee49da338a208476fb351a67c9e3f21fae3ca39657cc324d4d2d45f90d0a88aeae938347767b9e0e77de2fec11905288d67e60b2b4e1a62c8284740f6d179a497dc885708945047642363bcad8a0108a6a25c53a9954f656996c3e5159158292c04805054dbd717487096879b5a0c96a9dbc85a1730b4506f92ed6878331318f45bb3cf6d33aefb11a5dda68c1da067b0e8324618d8eb32db26f762058"), + (456, "90909090909027b2fb96f7c44c31338394351cb82392a04838afa1140cf2a0b177ea203ff18c2dcec66ae357888badba2d2375bf7646951ad93a16f79b1cd83b1d81d918af0a5669b640b573c4941417538c8d0952fc985061a216bb6f498cebd231bb103aee5d857f6347893f1bb2b4d52ca578343a1a34161f555882bdcf9d80ea15c515d86ed898191f46e5830216445436978803697a0557f46f53515a096efe7a02bbcff8f21655d96e8175e224f1fe1de9886cd11fbf7db70c8fc3ca951579f7369662d48263c68cdd3ea686c4d09d2ace47073ba15aa76e1c89e7bcdb259c8a7ad3053dbce75c4bfe86813f9db8df4a6fe58d5124401dc4ee49da338a208476fb351a67c9e3f21fae3ca39657cc324d4d2d45f90d0a88aeae938347767b9e0e77de2fec11905288d67e60b2b4e1a62c8284740f6d179a497dc885708945047642363bcad8a0108a6a25c53a9954f656996c3e5159158292c0c805054dbd717487096879b5a0c96a9dbc85a1730b4506f92ed6878322b5e825ddf0d2a39b1b68d259a4832db35852555b6c728bdc907349136b5acf"), + (4294967296, "90909027d5d5d5d5fb96f7c44c31338394351cb82392a04838afa1140cf2a0b177ea203ff18c2dcec66ae357888badba2d2375bf7646951a092892b4b2de223bed5e76eb7ea7da1baaf31b20cff5b860e5f9d62bd573280a891abf9b4ed3a634d7d10441d41c21ac3813e86ea7092b55367c7d18c063aa7a299bba4ad59237d6258ab252a8cb075598191f46e5830216445436978803697a0557f46f53515a096efe7a02bbcff8f21655d96e8175e224f1fe1de9886cd11fbf7db70c8fc3ca951579f7369662d48263c68cdd3ea686c4d09d2ace47073ba15aa76e1c89e7bcdb259c8a7ad3053dbce75c4bfe86813f9db8df4a6fe58d5124401dc4eec9da338a208476fb351a67c9e3f21fae3ca39657cc324d4d2d45f90d38db7422d98099ada24ab500660c41466b884f038f37099ac9431957cd53c7248740e9a87d17f2199fb09a5daeb6db7fa13fe338fd4eab4b20e5bdfaea5bc7a14825a679e6542906c36bb325f02ad5d4e2d448728794bba2e9c6a4d30b135d896a6c542cbe6fcaedda53f4b85daa19784e41ae9c3314807a1d256879e40e40ef"), + (18446744073709551615, "a21c1a40ad7c1bdc8a8c88ab72d045ffaa566a4af29032bbe6a021ff202caa44397cb4722caa1f5307943b3afd65b7ba40062620087c9cb0da8ff917e8af65c19cd99640d9a2734c4afff593e336cb4916975aa9e21c0726e0d2e851b97368a3f7431fbaba657f36f66e52567ce6cb1659dbc754bc868a8bbb60e71312cd2349237d5ee6a69b11ca98191f46e5830216445436978803697afaa80b90acaea5f6910185fd4430070de9aa27917e8a1ddb0e01e21677932ee05aa8da1ced599fc0048c377ce8feca314b09923d50c15a9e857342ee41d4b05a0520561b0429a476269919fdddaf13804012b2491314104aa13a7e7abdadd4211d5b26ba174c45e302bcf74df3baef26a57f20c41131c2467a17db8a4e3f6d604aff487d19c857a1038d5ea2f0db938c048ea6590bd15d8c5ca7982172aca0fa6fc0f7f7bff62521077b7347413ba99d40c43f7610a6aa087e991ba5b1bba4fbe22257dc452284a4ba0cf8339501b48c11b83482046ddd2b76a00fc01561f2f3698ed7c66e576b02e958bfdf7e5a974a37bb14433e380e3281ed8f0bf642d30c"), +]; + +pub const FULL_U32: &[(u32, &str)] = &[ + (0, "90909090fb96f7c44c31338394351cb82392a0485867af0b44c8006c935e31d518d877610ac41764f2b7ad58e39d2f2177f9a406ff4c9ad92664026428824a0b63afdc1398191f46e5830216445436978803697a0557f46f53515a096efe7a02bbcff8f21655d96e8175e224f1fe1de9886cd11fbfc6480f6865d33fbd79164b17eaa9c8618e35a2eb1e86e346c3d73041be934187657a3c79bb048211ab4eedd9a13da19fc68d41378e84a1475497d42a5757038aa8e448b57b419bda19a52ba2495d866b43d77a1c3e0be0533a5a3c6a1868c7"), + (1000, "9090e0c9fb96f7c44c31338394351cb82392a0485867af0b44c8006c935e31d518d87761121f61344819dd9ef2eb2f4876f145fe99ee5334e30dd274fa11de8f8f33300198191f46e5830216445436978803697a0557f46f53515a096efe7a02bbcff8f21655d96e8175e224f1fe1de9886cd11fbfc6480f6865d33fbd79164b17eaa9c8618e35a2eb1e86e346c3d73041be934187657a3cf9bb048211ab4eedd9a13da19fc6ad41378e84a1475497d42b5757033d9880950d55eea176c8c841a6f75dbdace188fd45834b2cd887ed0f971b4eab"), + (4294967295, "a21c1a408a8c88ab72d045ffaa566a4af29032bb17c413b27510af29d81a206b763efc6d4fe5d9e5b45c15c496eeff00386c754fecd29c615530a683db7795aa2cadf22998191f46e5830216445436978803697afaa80b90acaea5f6910185fd4430070de9aa27917e8a1ddb0e01e21677932ee02b0ef842a5f3fff06ce8db2ace3302d9363926959ca492d41118ffc50eb337f232b289fe46d545ecb616e2b57f7908a8b5c7330e80c2a5c0744bf2ae1bf9ca3fd393ee1b1bb14f91cbc4bcaf4bf691cb4503116eb3bc0a21dc47cf863f17fde9"), +]; + +pub const FULL_F64_1_5: &str = "92526a6a6a6a6a6a39f56739276e4ad29a0ea25440e3f7d2cb874a02f1f9850794256a1770a78286b70758a568801d1070429764c7e239997ba28b2866a84220f50c3b683e2bf781dbb62999ecb9da4b0d7da4f5c09e4ef43df01f8e452cc8052f983cdce1cb149a220dd6c177d59a1a8b3a430c8f9c43096f9480e9a32e3b27c8c3c56449fd27b798191f46e5830216445436978803697aaaac7ba4b4acb7e77104c7ffd430974de4eca7b2f2aa0dcb4e1d438c77955c62ecc7311e38eac44638678f4153f10ea7c120c8ea2a576e6c8b018f5fe44b7b43d0cf1f9c11263660de3948508518264fe574bbf7ccd2818a3e1cf04a3e85b0c3b4d429099484414a0811e2ecf865b4e08fe66607d59f6ae590d78d01b30f2fc8b9783221c4b90d5d44cf1238d2134535f6aff7eb4fddced5db643e87ebc526a28eedf8f16f2ecf4fc6580cf205a112b311a52e3c250f0101b2326519aba8c0f02b6286444ac4d43348c437338e95fd2d591d82e0dc403211d825afc6b10377d2694f26fdb190b19045eed96afbf5792875ac3c31ae56ede26d9797586b75cd04"; +pub const FULL_U64_456_SEED_B: &str = "90909090909027b2fb96f7c44c31338394351cb82392a04838afa1140cf2a0b177ea203ff18c2dcec66ae357888badba2d2375bf7646951ad93a16f79b1cd83b1d81d918af0a5669b640b573c4941417538c8d0952fc985061a216bb6f498cebd231bb103aee5d857f6347893f1bb2b4d52ca578343a1a34161f555882bdcf9d80ea15c515d86ed852f6f5b7a04d1eeff2d0f12880ed826964a769500caf8cb63cd1d10969df5820b743d9a9eeb5001f77ee699a63f60ef156ccafb8c546945fb2b18a3a8bd5e22db644e6b977bcf984fbc2a4e98da7f1848686b28a11a7d9d21cb7e3a53435c510911c188c098aa69d4c77099809694a44ee6601b130fb18b8411584dd15355b1d988d3db4c2a94403c5a12b8412da5f0001631ddc98815295b3b6c157a526c5a84f781c1245980c4262f584ccb811ac405b8c893989347dc0e61cdb512c40f6552bed5b8951ea4d309fc567283a45ecea4822ebc112f26e9be6bf2f743e2ba743ac40cd287453d4e34f7d3ff3d81459905685094e0a28a2b2c7fd33755835ff5a35915168f22c41ec90024fb02b683bf1"; From 5a8c07e075f6afadc287039f70be67e3812f2087 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 13 Jun 2026 01:23:09 +1000 Subject: [PATCH 2/6] style: rustfmt --- packages/ore-rs/tests/compat_vectors.rs | 36 ++++++++++++++++++------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/packages/ore-rs/tests/compat_vectors.rs b/packages/ore-rs/tests/compat_vectors.rs index d5fd8498..af6a43f9 100644 --- a/packages/ore-rs/tests/compat_vectors.rs +++ b/packages/ore-rs/tests/compat_vectors.rs @@ -30,12 +30,10 @@ 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, + 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, + 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` @@ -210,7 +208,11 @@ fn left_against_pinned_full() { let left = 456u64.encrypt_left(&ore).unwrap().to_bytes(); let (_, full_hex) = FULL_U64[2]; let full = pinned(full_hex); - assert_eq!(left, full[..left.len()], "left half of full ciphertext drifted"); + assert_eq!( + left, + full[..left.len()], + "left half of full ciphertext drifted" + ); } // --------------------------------------------------------------------------- @@ -221,7 +223,9 @@ fn left_against_pinned_full() { #[ignore = "generator: prints the contents of tests/vectors/mod.rs"] fn generate() { println!("// Contents of tests/compat_vectors/vectors.rs"); - println!("// Generated by `cargo test --test compat_vectors -- --ignored --nocapture generate`"); + println!( + "// Generated by `cargo test --test compat_vectors -- --ignored --nocapture generate`" + ); println!("// DO NOT regenerate unless deliberately breaking the wire format (see plan doc)."); println!(); @@ -230,12 +234,21 @@ fn generate() { println!("pub const {}: &str = \"{}\";", name, hex::encode(bytes)); }; - print_left("LEFT_U64_456", 456u64.encrypt_left(&ore).unwrap().to_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()); + print_left( + "LEFT_U32_1000", + 1000u32.encrypt_left(&ore).unwrap().to_bytes(), + ); print_left( "LEFT_PT8", - [0u8, 1, 2, 3, 4, 5, 6, 7].encrypt_left(&ore).unwrap().to_bytes(), + [0u8, 1, 2, 3, 4, 5, 6, 7] + .encrypt_left(&ore) + .unwrap() + .to_bytes(), ); println!(); @@ -256,7 +269,10 @@ fn generate() { println!(); let f = 1.5f64.encrypt(&cipher_a()).unwrap(); - println!("pub const FULL_F64_1_5: &str = \"{}\";", hex::encode(f.to_bytes())); + println!( + "pub const FULL_F64_1_5: &str = \"{}\";", + hex::encode(f.to_bytes()) + ); let alt = 456u64.encrypt(&cipher_b()).unwrap(); println!( From 97bf0896c08c6cb52529c6ea9a9e1972fccfb8dd Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 8 Oct 2026 20:48:02 +1100 Subject: [PATCH 3/6] test(compat_vectors): name the generator's real output path The ignore reason said tests/vectors/mod.rs; the generator emits tests/compat_vectors/vectors.rs, and the harness shows the reason when listing ignored tests. --- packages/ore-rs/tests/compat_vectors.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ore-rs/tests/compat_vectors.rs b/packages/ore-rs/tests/compat_vectors.rs index af6a43f9..5113e63a 100644 --- a/packages/ore-rs/tests/compat_vectors.rs +++ b/packages/ore-rs/tests/compat_vectors.rs @@ -220,7 +220,7 @@ fn left_against_pinned_full() { // --------------------------------------------------------------------------- #[test] -#[ignore = "generator: prints the contents of tests/vectors/mod.rs"] +#[ignore = "generator: prints the contents of tests/compat_vectors/vectors.rs"] fn generate() { println!("// Contents of tests/compat_vectors/vectors.rs"); println!( From d82bd5be4be8d9368e4b403c233d28668208b0b8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 8 Oct 2026 20:48:02 +1100 Subject: [PATCH 4/6] docs(plan): specify unsigned byte compares for the SIMD indicator mask The table named vpcmpgtb and cmgt, both signed compares, but permutation entries and x are unsigned bytes: 0xff > 0x01 is false under a signed compare, which would change the indicator mask and break ciphertext compatibility. The shipped kernels are already correct (AVX2 biases both operands by 0x80 before vpcmpgtb; NEON uses vcgtq_u8, i.e. cmhi); the plan now says so. --- docs/plans/2026-06-12-ore-v2-architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-06-12-ore-v2-architecture.md b/docs/plans/2026-06-12-ore-v2-architecture.md index 3b523184..aceb03c1 100644 --- a/docs/plans/2026-06-12-ore-v2-architecture.md +++ b/docs/plans/2026-06-12-ore-v2-architecture.md @@ -170,7 +170,7 @@ number; AES remains the floor.) | Op | Shape | x86_64 | aarch64 | |---|---|---|---| -| Indicator mask | bytewise `>` vs broadcast, pack to bitmask | `vpcmpgtb` + `vpmovmskb` (AVX2: 8 iters for 256 lanes; SSE2 fallback: 16) | `cmgt.16b` + bit-narrowing (no movemask; use `ushr`+`addv` or the `vshrn` trick) | +| Indicator mask | bytewise **unsigned** `>` vs broadcast, pack to bitmask | `vpcmpgtb` + `vpmovmskb` on operands biased by `0x80` (AVX2 has only signed byte compares; XOR-ing `0x80` into both sides maps unsigned `>` onto signed `>`) (AVX2: 8 iters for 256 lanes; SSE2 fallback: 16) | `cmhi.16b` (`vcgtq_u8`, unsigned) + bit-narrowing (no movemask; use `ushr`+`addv` or the `vshrn` trick) | | Hash-LSB mask | strided bit gather from AES output blocks | shifts + `vpmovmskb` on gathered bytes | shifts + narrowing | | Compare: first-differing-block scan | 16-byte tag equality across blocks | `vpcmpeqb` + movemask, branch-free fold | `cmeq` + fold | From aadae590c4b8792cff139562471cd6343e73a617 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 8 Oct 2026 20:48:03 +1100 Subject: [PATCH 5/6] bench: black-box every result and input, and record the effect The helpers returned (), so no result reached Criterion's black_box, and the inputs were literal constants, which lets the optimiser elide work whose result is unused. They now return their results, and the inputs are black-boxed. The baseline machine (M1 Max) is not available, so the baseline doc gets an addendum with a same-machine A/B on an M4: encrypt, compare and serialize move 0-4%, the cost of producing and dropping the result, and are not materially affected; deserialize-8 was under-measured by about 1.8x. --- docs/benchmarks/2026-06-13-baseline-main.md | 33 ++++++++++++++++ packages/ore-rs/benches/oreaes128.rs | 42 ++++++++++----------- 2 files changed, 54 insertions(+), 21 deletions(-) diff --git a/docs/benchmarks/2026-06-13-baseline-main.md b/docs/benchmarks/2026-06-13-baseline-main.md index 5d884c6a..402cf147 100644 --- a/docs/benchmarks/2026-06-13-baseline-main.md +++ b/docs/benchmarks/2026-06-13-baseline-main.md @@ -31,3 +31,36 @@ rustc 1.87.0, `cargo bench --bench oreaes128`. model and a large headroom signal for PRs 3–5. - `serialize-8` at ~472 ns reflects the per-block `Vec` allocations in `to_bytes`; not a target of this program but cheap to improve in passing. + +## Addendum (2026-10-08): result elimination check + +The benchmark helpers in `benches/oreaes128.rs` returned `()`, so no result +reached Criterion's `black_box` and the inputs were literal constants. That +permits the optimiser to elide work whose result is unused. The helpers now +return their results and the inputs are black-boxed. + +The M1 Max above is not available to re-record on, so the effect was measured +as an A/B on one machine instead (Apple M4, same commit, `--warm-up-time 2 +--measurement-time 5`, old helpers saved as a Criterion baseline, then the +fixed helpers compared against it): + +| Benchmark | Old helpers | Fixed helpers | Change | +|---|---|---|---| +| encrypt-8 | 475.3 µs | 479.8 µs | +0.9% | +| encrypt-left-8 | 107.2 µs | 111.3 µs | +3.3% | +| compare-8 | 1.023 µs | 1.023 µs | no change (p = 0.33) | +| compare-8-slice | 1.028 µs | 1.030 µs | +0.4% | +| serialize-8 | 658.4 ns | 656.9 ns | +0.5% | +| deserialize-8 | 72.2 ns | 133.6 ns | **+82.8%** | +| encrypt-4 | 236.2 µs | 238.3 µs | +0.8% | +| encrypt-left-4 | 53.6 µs | 55.8 µs | +4.0% | +| compare-4 | 922.3 ns | 931.6 ns | no change (p = 0.35) | + +The encrypt, compare and serialize figures above are not materially affected: +the 0–4% moves are the cost of producing and dropping each result inside the +timed loop. **`deserialize-8` was under-measured by about 1.8×**: scaled by the +ratio measured here, roughly 60 ns on the M1 Max rather than the 34 ns +recorded above. Later PRs' benchmark results +were taken with the old helpers; the same caveat applies to their +deserialization figures only. + diff --git a/packages/ore-rs/benches/oreaes128.rs b/packages/ore-rs/benches/oreaes128.rs index a5394246..c0da75ae 100644 --- a/packages/ore-rs/benches/oreaes128.rs +++ b/packages/ore-rs/benches/oreaes128.rs @@ -1,48 +1,48 @@ use criterion::{black_box, criterion_group, criterion_main, Criterion}; use hex_literal::hex; -use ore_rs::{scheme::bit2::OreAes128ChaCha20, CipherText, OreCipher, OreEncrypt, OreOutput}; +use ore_rs::{scheme::bit2::OreAes128ChaCha20, CipherText, Left, OreCipher, OreEncrypt, OreOutput}; #[inline] -fn do_encrypt_64(input: u64, ore: &mut OreAes128ChaCha20) { - input.encrypt(ore).unwrap(); +fn do_encrypt_64(input: u64, ore: &mut OreAes128ChaCha20) -> CipherText { + input.encrypt(ore).unwrap() } #[inline] -fn do_encrypt_left_64(input: u64, ore: &mut OreAes128ChaCha20) { - input.encrypt_left(ore).unwrap(); +fn do_encrypt_left_64(input: u64, ore: &mut OreAes128ChaCha20) -> Left { + input.encrypt_left(ore).unwrap() } #[inline] fn do_compare( a: &CipherText, b: &CipherText, -) { - let _ret = a.partial_cmp(b); +) -> Option { + a.partial_cmp(b) } #[inline] -fn do_compare_slice(a: &[u8], b: &[u8]) { - let _ret = OreAes128ChaCha20::compare_raw_slices(a, b); +fn do_compare_slice(a: &[u8], b: &[u8]) -> Option { + OreAes128ChaCha20::compare_raw_slices(a, b) } #[inline] -fn do_serialize(a: &CipherText) { - let _ret = a.to_bytes(); +fn do_serialize(a: &CipherText) -> Vec { + a.to_bytes() } #[inline] -fn do_deserialize(bytes: &[u8]) { - let _ret = CipherText::::from_slice(bytes).unwrap(); +fn do_deserialize(bytes: &[u8]) -> CipherText { + CipherText::::from_slice(bytes).unwrap() } #[inline] -fn do_encrypt_32(input: u32, ore: &mut OreAes128ChaCha20) { - input.encrypt(ore).unwrap(); +fn do_encrypt_32(input: u32, ore: &mut OreAes128ChaCha20) -> CipherText { + input.encrypt(ore).unwrap() } #[inline] -fn do_encrypt_left_32(input: u32, ore: &mut OreAes128ChaCha20) { - input.encrypt_left(ore).unwrap(); +fn do_encrypt_left_32(input: u32, ore: &mut OreAes128ChaCha20) -> Left { + input.encrypt_left(ore).unwrap() } fn criterion_benchmark(c: &mut Criterion) { @@ -60,10 +60,10 @@ fn criterion_benchmark(c: &mut Criterion) { let y_u32 = 10098393_u32.encrypt(&ore).unwrap(); c.bench_function("encrypt-8", |b| { - b.iter(|| do_encrypt_64(25u64, black_box(&mut ore))) + b.iter(|| do_encrypt_64(black_box(25u64), black_box(&mut ore))) }); c.bench_function("encrypt-left-8", |b| { - b.iter(|| do_encrypt_left_64(25u64, black_box(&mut ore))) + b.iter(|| do_encrypt_left_64(black_box(25u64), black_box(&mut ore))) }); c.bench_function("compare-8", |b| { b.iter(|| do_compare(black_box(&x_u64), black_box(&y_u64))) @@ -79,10 +79,10 @@ fn criterion_benchmark(c: &mut Criterion) { }); c.bench_function("encrypt-4", |b| { - b.iter(|| do_encrypt_32(25u32, black_box(&mut ore))) + b.iter(|| do_encrypt_32(black_box(25u32), black_box(&mut ore))) }); c.bench_function("encrypt-left-4", |b| { - b.iter(|| do_encrypt_left_32(25u32, black_box(&mut ore))) + b.iter(|| do_encrypt_left_32(black_box(25u32), black_box(&mut ore))) }); c.bench_function("compare-4", |b| { b.iter(|| do_compare(black_box(&x_u32), black_box(&y_u32))) From cad46bbe3cdf5e8fbf061b01cd948f5df3509c43 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 12 Oct 2026 10:46:29 +1100 Subject: [PATCH 6/6] test(compat): pin cross-nonce comparison fixtures Every unequal pair in FULL_U64 shares one nonce, because each cipher_a() call reseeds the test RNG. The pinned fixtures therefore never checked which operand supplies the hash nonce: reading it from `a` instead of `b` still passed every compat test. FULL_U64_ONE_CIPHER encrypts four values under one cipher, so each has its own nonce. Pairs first differ at block 0, block 3 and block 7, and every ordered pair is checked through compare_raw_slices and the typed Ord. Reading the nonce from `a` now fails the test. --- packages/ore-rs/tests/compat_vectors.rs | 70 +++++++++++++++++++ .../ore-rs/tests/compat_vectors/vectors.rs | 7 ++ 2 files changed, 77 insertions(+) diff --git a/packages/ore-rs/tests/compat_vectors.rs b/packages/ore-rs/tests/compat_vectors.rs index 5113e63a..c0ab944e 100644 --- a/packages/ore-rs/tests/compat_vectors.rs +++ b/packages/ore-rs/tests/compat_vectors.rs @@ -68,6 +68,7 @@ impl SeedableRng for TestRng { type OreA = OreAes128>; type OreB = OreAes128>; +type OreC = OreAes128>; fn cipher_a() -> OreA { OreCipher::init(&K1, &K2).unwrap() @@ -77,6 +78,21 @@ fn cipher_b() -> OreB { OreCipher::init(&K1, &K2).unwrap() } +fn cipher_c() -> OreC { + OreCipher::init(&K1, &K2).unwrap() +} + +/// Plaintexts for [`FULL_U64_ONE_CIPHER`], in ascending order. Blocks are the +/// big-endian bytes, so pairs first differ at the last block (`BASE` and +/// `BASE + 1`), a middle block (`+ (1 << 32)`, block 3) and block 0 +/// (`+ (1 << 56)`). +const ONE_CIPHER_U64: [u64; 4] = [ + 0x1020_3040_5060_7080, + 0x1020_3040_5060_7081, + 0x1020_3041_5060_7080, + 0x1120_3040_5060_7080, +]; + fn cipher_left() -> OreAes128ChaCha20 { OreCipher::init(&K1, &K2).unwrap() } @@ -151,6 +167,22 @@ fn full_f64_vector() { assert_eq!(hex::encode(ct.to_bytes()), FULL_F64_1_5); } +#[test] +fn full_u64_one_cipher_vectors() { + // One cipher for every value, so its nonce stream advances and each + // ciphertext has a different nonce. + let ore = cipher_c(); + for (value, (pinned_value, expected)) in ONE_CIPHER_U64.iter().zip(FULL_U64_ONE_CIPHER) { + assert_eq!(value, pinned_value); + let ct = value.encrypt(&ore).unwrap(); + assert_eq!( + hex::encode(ct.to_bytes()), + *expected, + "full ciphertext mismatch for u64 {value}" + ); + } +} + #[test] fn full_u64_456_alternate_nonce() { let ct = 456u64.encrypt(&cipher_b()).unwrap(); @@ -191,6 +223,35 @@ fn compare_raw_slices_equality_across_nonces() { ); } +#[test] +fn compare_raw_slices_across_nonces() { + // Every unequal pair here has two different nonces, and the comparator + // must hash under the nonce of its second argument. FULL_U64 cannot pin + // this: its unequal pairs share one nonce. + // The nonce opens the right half, after 8 left blocks of 17 bytes. + let nonce = |ct: &str| pinned(ct)[8 * 17..8 * 17 + 16].to_vec(); + for (i, (_, a)) in FULL_U64_ONE_CIPHER.iter().enumerate() { + for (j, (_, b)) in FULL_U64_ONE_CIPHER.iter().enumerate() { + if i != j { + assert_ne!(nonce(a), nonce(b), "pair ({i}, {j}) shares a nonce"); + } + let expected = i.cmp(&j); + assert_eq!( + OreAes128ChaCha20::compare_raw_slices(&pinned(a), &pinned(b)), + Some(expected), + "raw order mismatch at ({i}, {j})" + ); + let ct_a = CipherText::::from_slice(&pinned(a)).unwrap(); + let ct_b = CipherText::::from_slice(&pinned(b)).unwrap(); + assert_eq!( + ct_a.cmp(&ct_b), + expected, + "typed order mismatch at ({i}, {j})" + ); + } + } +} + #[test] fn typed_comparison_of_pinned_bytes() { let a = CipherText::::from_slice(&pinned(FULL_U64[1].1)).unwrap(); @@ -279,4 +340,13 @@ fn generate() { "pub const FULL_U64_456_SEED_B: &str = \"{}\";", hex::encode(alt.to_bytes()) ); + println!(); + + let ore = cipher_c(); + println!("pub const FULL_U64_ONE_CIPHER: &[(u64, &str)] = &["); + for value in ONE_CIPHER_U64 { + let ct = value.encrypt(&ore).unwrap(); + println!(" ({}, \"{}\"),", value, hex::encode(ct.to_bytes())); + } + println!("];"); } diff --git a/packages/ore-rs/tests/compat_vectors/vectors.rs b/packages/ore-rs/tests/compat_vectors/vectors.rs index 21d43ca9..9a838ba9 100644 --- a/packages/ore-rs/tests/compat_vectors/vectors.rs +++ b/packages/ore-rs/tests/compat_vectors/vectors.rs @@ -23,3 +23,10 @@ pub const FULL_U32: &[(u32, &str)] = &[ pub const FULL_F64_1_5: &str = "92526a6a6a6a6a6a39f56739276e4ad29a0ea25440e3f7d2cb874a02f1f9850794256a1770a78286b70758a568801d1070429764c7e239997ba28b2866a84220f50c3b683e2bf781dbb62999ecb9da4b0d7da4f5c09e4ef43df01f8e452cc8052f983cdce1cb149a220dd6c177d59a1a8b3a430c8f9c43096f9480e9a32e3b27c8c3c56449fd27b798191f46e5830216445436978803697aaaac7ba4b4acb7e77104c7ffd430974de4eca7b2f2aa0dcb4e1d438c77955c62ecc7311e38eac44638678f4153f10ea7c120c8ea2a576e6c8b018f5fe44b7b43d0cf1f9c11263660de3948508518264fe574bbf7ccd2818a3e1cf04a3e85b0c3b4d429099484414a0811e2ecf865b4e08fe66607d59f6ae590d78d01b30f2fc8b9783221c4b90d5d44cf1238d2134535f6aff7eb4fddced5db643e87ebc526a28eedf8f16f2ecf4fc6580cf205a112b311a52e3c250f0101b2326519aba8c0f02b6286444ac4d43348c437338e95fd2d591d82e0dc403211d825afc6b10377d2694f26fdb190b19045eed96afbf5792875ac3c31ae56ede26d9797586b75cd04"; pub const FULL_U64_456_SEED_B: &str = "90909090909027b2fb96f7c44c31338394351cb82392a04838afa1140cf2a0b177ea203ff18c2dcec66ae357888badba2d2375bf7646951ad93a16f79b1cd83b1d81d918af0a5669b640b573c4941417538c8d0952fc985061a216bb6f498cebd231bb103aee5d857f6347893f1bb2b4d52ca578343a1a34161f555882bdcf9d80ea15c515d86ed852f6f5b7a04d1eeff2d0f12880ed826964a769500caf8cb63cd1d10969df5820b743d9a9eeb5001f77ee699a63f60ef156ccafb8c546945fb2b18a3a8bd5e22db644e6b977bcf984fbc2a4e98da7f1848686b28a11a7d9d21cb7e3a53435c510911c188c098aa69d4c77099809694a44ee6601b130fb18b8411584dd15355b1d988d3db4c2a94403c5a12b8412da5f0001631ddc98815295b3b6c157a526c5a84f781c1245980c4262f584ccb811ac405b8c893989347dc0e61cdb512c40f6552bed5b8951ea4d309fc567283a45ecea4822ebc112f26e9be6bf2f743e2ba743ac40cd287453d4e34f7d3ff3d81459905685094e0a28a2b2c7fd33755835ff5a35915168f22c41ec90024fb02b683bf1"; + +pub const FULL_U64_ONE_CIPHER: &[(u64, &str)] = &[ + (1161981756646125696, "cd46c736eb765766e9081cbc5fee7c6a0fe388fc3d1f90278ae182f61786921e7e64a189063b62dc4ff74ca0d7381dc785844f7a18291e48f6a24bc507fd78077c746de0c00ab6179361d7eff78cef171dc4b7f07862714ec15f6b25eafa67a9430edb2ed2c81edc691e697243c46f5c30e15101f63bb404580f02d22e9e8c37ab75b288366131abb0da5d7f2e11289ffc66e452f8a554a25dffb6ea791a2d5a018947c6007bc032aa7bae57f0d906e125eafcc131c10924efb75ec002187586391d8ba2c7973716b6fcaaf7ed3cf213521a043c38f23724e3489e6602b2f298f9da446a21c9533971565a2519c709304ee29c197bb37997899867f6317b0e239880b69cfe99258f439a786cb492ec3f8b8085ffae832666f5a3af94e04f4beb1592454e626d077706342aae5925f289fbe1b2c3ed42d3d235ced2bf41480bafbb30088b34216e39fd34368f1aca4e5c82c7f5126853861d3a4b92324f9bfcd13d8167d4f6133d138928a3e40c067c0911053100576fb189d0057387e72f69d898103bbf9e20b8a9da5758521e59c2eff012f531428613de"), + (1161981756646125697, "cd46c736eb7657ade9081cbc5fee7c6a0fe388fc3d1f90278ae182f61786921e7e64a189063b62dc4ff74ca0d7381dc785844f7a18291e48f6a24bc507fd78077c746de0c00ab6179361d7eff78cef171dc4b7f07862714ec15f6b25eafa67a9430edb2ed2c81edc691e697243c46f5c30e15101f63bb404b931b447a3372cab71f9b3b625096a870c172dbb05ba9f2f06af649820e3217f82a9f2b1d0f69acc1b3f7971be68a6301ac0411818689cc918077116daa83ef7e256b61e8caf6d1e683ac1804e6e98fdbd783082de2d5385c066a02eb656015fed2f3457fc16437391a048407d584a0ffa836780fcf65faeae4b887315cc6e50cae05191004f8a68e4669597418f0b59490ed0161ba0489f7c22fb16af9918ef5f9b227a7b3e3bfc25594ec3516663bc6d033dbe529731485a1c82a3fa94f14e088f4c60cf7d6be817d86a15fc7bac894610999d96b53f294c0824d92a3a5714f4d6f0cb9e889015bde6bd34e7da40055e64e96466addc9bf99389087d4cd4d377af8b8674e32a8ec9e6d23dc2e79093aa17edc5ab999568c52340041022aad0"), + (1161981760941092992, "cd46c700d138fc92e9081cbc5fee7c6a0fe388fc3d1f90278ae182f61786921e7e64a189063b62dc4ff74ca0d7381dc785844f7a18291e48e9ce5ac3b201f925d7192f8995b40545245d4b75e4041b079610602ab634829bae3030b882c264fb9ac750be1eaa95f357fd7123817ff90a70283260459be12eda077721d4e679e1c704adb8bc9581bb84418b90551c8d2c2f15737a3938daec14882f2279dc763bc89505a553b942e24941f9aa04814e32e556089fba5b6ad433984c283e4e8d91418e42e547007f02b4a61f1eafbb9694c9ae731d61fe597ecf62617a0a5ec0a046e62f73966281b4c5b3b5732280d7b6ab6d5da418fc1a2d957ad2033a84f8b7213cef48a939fbc2f9c58c62bc33b1cb142f5b02a97f1bab7edc3461b40d8dd7b84251c95ae3b6495d1854f81410a912d5ceddfc52c313c73c346f834b17448a1697869f9f74c9ec33c352bb3820140b3259937e14995267e989616450cc0db48c6de0ec2e229c0f72cb308b8e76fb7fd5efc1b20bceb45b3d049f90fdfc3365adf69b4a493ab13ddd43174f4b85c592024b023f4d2b36c1"), + (1234039350684053632, "3f0fa527f3e002e359b37fe3938acd3627132d745be8da6d60d5d5c249f93ebc1feb7471143d0ae91e5b137685b264217df3cadf40cfccd982b45b22a3c94a4974ff78ce5fb9dad5d08849d4c3a4b9110bec0f6e86f7310065e5e5908b589440ab4826f8075d842d373c4987666b28a099b1e4c42a5c387ad400c09549d0b19e21a290ad0c609475b38a3220da7270dfb56e24b5a1892ee048b479465c838e4e2f06eccdbbab1586b93b4471b26e7ff47ff430222764ff3d0612bfc38e07223c0c8aecee8fe5cfd9bfc3e2cdf677f6eefcfdfa4791623b344359f5900d178ee1beeb9d718c096fc5b3dd1b0f0f50812ab480901d7c0bf5ab4cf0bdbb2c0f03cdce1b2bfd7bd1aae8a188015d0b3c25c54c9f0fd2690428663f1665b4f066662f9027b8f4b7714be1dd4d2c6912cbff93b37fdb180f7fa0f08e15e8946a41e90c23b2d3aae8d0dc8b36f075f0f5d0a62a37a3e384a9b9d43d2dd598ff94c080a8bba12292d17581e269e90221a3fd9ae8e9ec7a260165b2b505f878ad7a0bf3fd380743c881c0cbda9c81d360801a94aeb2da258fdf8f46b5"), +];