diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 65d4617c..25dff0d2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,8 +15,19 @@ defaults: jobs: test: - runs-on: ubuntu-latest - name: "πŸ§ͺ Test" + # Both architectures: aarch64 runs hardware AES (`--cfg aes_armv8`), the + # NEON kernels and the NEON PRP builder, which x86_64 never reaches, against + # the same pinned vectors. + runs-on: ${{ matrix.os }} + name: "πŸ§ͺ Test (${{ matrix.arch }})" + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + arch: x86_64 + - os: ubuntu-24.04-arm + arch: aarch64 steps: - uses: actions/checkout@v4 @@ -29,7 +40,7 @@ jobs: ~/.cargo/registry/cache/ ~/.cargo/git/db/ target/ - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }} - name: setup rust 1.78 uses: dtolnay/rust-toolchain@1.78.0 @@ -45,6 +56,11 @@ jobs: - name: test run: cargo test --all-features + # `ct_bench` and its tests compile only with this feature; the clippy + # step above builds them but never runs them. + - name: test (ct-bench) + run: cargo test -p ore-rs --features ct-bench --lib ct_bench + bench: runs-on: ubuntu-latest name: "⏱ Benchmark" diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 00000000..d816e4ad --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,120 @@ +name: "Verify" +# Dynamic and formal evidence for the v2 ORE implementation, beyond the unit +# and property tests in `test.yml`: +# +# * ct-taint: the ctgrind-style Valgrind run (packages/ct-taint) over the v2 +# encryptors; the legacy OreAes128 encryptor is out of scope (its PRP +# swaps at secret indices by design, and its wire format is frozen). Key +# and plaintext are marked undefined; every secret-dependent branch or +# secret-indexed access memcheck reports fails the job unless it is one of +# the documented exceptions in packages/ct-taint/valgrind.supp. The run +# fails if the taint never reaches a mode's output, and a canary mode +# that branches on a tainted byte must fail. +# * fuzz: the libFuzzer targets (packages/ore-rs/fuzz) for the ciphertext +# parsers and raw comparators, time-boxed per pull request. +# * kani: the bounded model-checking harnesses (`#[cfg(kani)]`) for the pure +# building blocks. The two heaviest, behind the `kani-full` feature, are +# not run here: the exhaustive-length `ct_select_byte` harness (about 9 +# minutes) and the SWAR-builder equivalence harness (about 15 minutes on +# an M4, and cancelled on GitHub's runners); run +# `cargo kani -p ore-rs --features kani-full` by hand for the full set. +# +# Like test.yml, this runs on every pull request regardless of base branch, so +# the stacked PRs get it too. +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +defaults: + run: + shell: bash -l {0} + +jobs: + ct-taint: + # Both legs matter: `oblivious::build` dispatches to the SSSE3 builder on + # x86_64 and the NEON builder on aarch64, so each target's shipping + # builder is only taint-checked on its own runner. + runs-on: ${{ matrix.os }} + name: "πŸ” Valgrind taint (${{ matrix.arch }})" + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + arch: x86_64 + - os: ubuntu-24.04-arm + arch: aarch64 + steps: + - uses: actions/checkout@v4 + + - name: setup rust + uses: dtolnay/rust-toolchain@stable + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + packages/ct-taint/target/ + key: ${{ runner.os }}-${{ runner.arch }}-ct-taint-${{ hashFiles('packages/ct-taint/Cargo.lock', 'packages/ore-rs/src/**') }} + + # crabgrind binds the Valgrind client-request header at build time. + - name: install valgrind + run: sudo apt-get update -qq && sudo apt-get install -y -qq valgrind clang libclang-dev + + - name: taint run + run: scripts/ct-taint.sh + + fuzz: + runs-on: ubuntu-latest + name: "🧬 fuzz (${{ matrix.target }})" + strategy: + fail-fast: false + matrix: + target: [chained_parse, chained_compare, bit6_parse, bit8_parse] + steps: + - uses: actions/checkout@v4 + + - name: setup nightly (cargo-fuzz requires it) + uses: dtolnay/rust-toolchain@nightly + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + ~/.cargo/bin/cargo-fuzz + packages/ore-rs/fuzz/target/ + key: ${{ runner.os }}-fuzz-${{ hashFiles('packages/ore-rs/fuzz/Cargo.lock', 'packages/ore-rs/src/**') }} + + - name: install cargo-fuzz + run: command -v cargo-fuzz >/dev/null || cargo install cargo-fuzz --locked + + - name: fuzz + run: scripts/fuzz.sh 120 ${{ matrix.target }} + + - name: keep any crashing input + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fuzz-artifacts-${{ matrix.target }} + path: packages/ore-rs/fuzz/artifacts/ + if-no-files-found: ignore + + kani: + runs-on: ubuntu-latest + name: "βœ… Kani" + # A hung or runaway CBMC run should fail the job rather than hold a + # runner for GitHub's six-hour default. + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - uses: model-checking/kani-github-action@v1.1 + with: + args: -p ore-rs diff --git a/.gitignore b/.gitignore index b1b15cef..660d1999 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,11 @@ .DS_Store .env *.pem + +# Detached verification crates (packages/ct-taint, packages/ct-dudect, +# packages/ore-rs/fuzz) build into their own target dirs; the taint harness +# also builds into target-linux from a container on a macOS host. libFuzzer +# crash artefacts are per-run output, not seeds (the seeds are in corpus/). +target/ +packages/ct-taint/target-linux/ +packages/ore-rs/fuzz/artifacts/ diff --git a/docs/benchmarks/2026-10-09-oblivious-prp-results.md b/docs/benchmarks/2026-10-09-oblivious-prp-results.md new file mode 100644 index 00000000..ffb2403b --- /dev/null +++ b/docs/benchmarks/2026-10-09-oblivious-prp-results.md @@ -0,0 +1,256 @@ +# Oblivious PRP builder β€” NEON, SSSE3 and SWAR + +The dynamic verification (`docs/reviews/2026-10-09-ore-v2-dynamic-verification.md`, +Β§4.2) found that the Bit6 PRP builder's time depended on which permutation +it built. This note records which part of the builder carried that signal, +the oblivious builders that replaced it, and their cost and timing. + +All the oblivious builders produce the same `permutation` and `inverse` +tables as the old one for the same 512-byte stream, so every ciphertext is +unchanged. The old builder survives as the test-only *reference* builder. + +## Machine and method + +- Apple M4 (4 performance, 6 efficiency cores), macOS, on mains power with + Low Power Mode off; rustc 1.94.1, `--release`, hardware AES + (`--cfg aes_armv8`). Nothing else heavy ran during a measurement. +- A first pass was taken in Low Power Mode on battery (about half the + clock). Every figure here is from the rerun on mains power; the ratios + between builders and the dudect picture matched the first pass. +- Timings are criterion medians. The builder benchmark + (`benches/prp_build.rs`, `--features ct-bench`) cycles through 256 + precomputed random streams, because the reference builder's time depends + on the stream. Each call goes through a `ct_bench` hook that also builds + the struct, does one oblivious `permute` and wipes the struct on drop; + that fixed cost is in every builder figure. +- End-to-end figures come from `benches/bit6.rs` and `benches/chained.rs`, + all built with `--features ct-bench`, with the dispatcher pointed at each + builder in turn by a temporary, uncommitted edit to + `oblivious::build`. The NEON build was benchmarked twice, before and after + the others; the two runs agree within 3%, and the first is reported. +- dudect (`packages/ct-dudect`): "batch" is one run of 100,000 samples with a + freshly drawn pair of inputs; "continuous" runs for 150 s, redrawing the + pair every 100,000 samples and pooling the statistics. The figure is + dudect's `max t`. + +## 1. Which half of the old builder carries the signal + +The old builder does two things with secret addresses: the Fisher–Yates +swap at the draw-derived index `j`, and the `inverse[perm[k]] = k` fill. +Each half was timed alone on two fixed streams (fixed A against fixed B), +next to a control doing the same work at public addresses (the swap at +`i, i-1` with the draw still computed; the fill at `k`). + +| bench | batch t, 3 runs | +|---|---| +| `iso_swap_secret` (swap loop, secret index) | βˆ’41.0, βˆ’17.6, βˆ’49.9 | +| `iso_swap_public` (control) | +1.1, βˆ’0.8, βˆ’1.5 | +| `iso_fill_secret` (inverse fill, secret offsets) | βˆ’0.8, βˆ’0.3, +0.5 | +| `iso_fill_public` (control) | +3.0, βˆ’0.7, βˆ’1.9 | +| whole old builder, same session (`prp_build_reference_fixed_a_vs_fixed_b`, 5 runs) | +57.7, +17.2, βˆ’26.7, +83.4, βˆ’38.8 | + +**The swap loop carries the signal; the inverse fill carries none.** The +secret-index swap reaches |t| = 18 to 50 against at most 1.5 for its +control, and the fill sits with its control. + +Each swap loads `perm[j]` and `perm[i]` just after the previous step stored +to `perm[i+1]` and `perm[j']`. Whether a load overlaps a store still in +flight depends on the draws, and so does whether the core forwards the +stored byte, waits for it, or speculates past it and has to replay. The fill +only stores, to 64 distinct offsets, with no load depending on those stores, +so it has nothing to forward or mispredict. The measurement is consistent +with that mechanism (store-to-load forwarding and memory disambiguation on +byte-granular accesses at secret offsets within one L1-resident line); it +does not prove it. + +## 2. The builders + +All of them run the same 63 Lemire-reduced draws in the same order and apply +the same swaps. Code: `packages/ore-rs/src/primitives/prp/oblivious.rs`. + +- **NEON (aarch64, dispatched there).** Both tables live in eight 16-byte + vector registers for the whole build. `perm[j]` is fetched with a `tbl` + table lookup on a broadcast `j`; the swapped entries are written back with + compare-against-a-constant-index-vector and bitwise select. The only memory + traffic is the stream reads (at public offsets) and the two final 64-byte + stores. NEON is part of the aarch64 baseline, so there is no runtime check. +- **SSSE3 (x86_64, runtime detected).** The same shape with `pshufb` on each + 16-byte chunk, the right chunk picked by a mask. +- **SWAR (every other target, and under Kani).** Each table is eight `u64` + words. Byte equality against a broadcast value is exact zero-byte + detection, and every secret-indexed read or write is a masked pass over + the words. +- **Byte-at-a-time with `subtle_ng` (tests and benches only).** The most + literal form: a full-scan select for `perm[j]`, masked passes over all 64 + bytes for both writes and both inverse updates. +- **Reference (tests, Kani and benches only).** The old textbook builder, + which the others are pinned to. + +Two things make the vector and SWAR forms cheaper than the literal one, and +neither depends on a secret: + +1. **The inverse is updated on the value side.** Swapping positions `i` and + `j` changes the inverse by exchanging the values `i` and `j` wherever they + appear in it. That needs neither swapped entry, so it runs alongside the + permutation rather than after it, and there is no separate fill. +2. **Only the part of the table at or below `i` is searched.** Step `i` is + public and `j <= i`, so the builders search and rewrite only the 16-byte + chunks (or 8-byte words) covering `0..=i`. In the NEON form the fetch + becomes a 1-, 2-, 3- or 4-register `tbl`. + +In the first pass, a NEON version with full 64-byte passes and the inverse +updated through the swapped values was about 13% slower than the shipped one; +one applying the swap as a lane permutation (four 4-register `tbl`s per step) +was slower again, as was one computing the inverse after the loop. + +`from_stream` calls the dispatched builder, so Bit6 (through `Prp::new`) and +the chained scheme (through its `PRP_STREAM` branch) both use it with no +change at the call sites. + +### Byte-identical tables + +- Property tests (quickcheck, random streams) assert that the dispatched, + SWAR and `subtle_ng` builders produce exactly the reference builder's + `permutation` and `inverse`; edge streams (all `0x00`, all `0xff` and + others) are tested as well. +- Kani proves the SWAR builder equal to the reference for every stream whose + first 6 draws are symbolic (the rest fixed), and the reference a + permutation with a correct inverse over the same streams. +- The Bit6 (`compat_w6_vectors`) and chained (`compat_chained_vectors`) + known-answer vectors pass unchanged, which shows identical ciphertexts end + to end. +- `cargo test -p ore-rs --all-features` passes on aarch64, and under + Rosetta 2 for `--target x86_64-apple-darwin`, where SSSE3 is reported and + the dispatched test therefore exercises the SSSE3 builder. + +## 3. Cost + +### The builder alone (per 512-byte stream) + +| builder | median | vs reference | +|---|---:|---:| +| reference (old, secret-indexed) | 248 ns | β€” | +| **NEON (dispatched on aarch64)** | **199 ns** | **βˆ’20%** | +| SWAR (portable fallback) | 710 ns | +186% | +| byte-at-a-time, `subtle_ng` | 12.0 Β΅s | 48Γ— | + +Both the reference and the new builders wipe their 8-byte copy of each draw +with `zeroize`; in the first pass that wipe was about half the reference +builder's time and about a tenth of the NEON builder's. So against the +reference *without* the wipe the NEON builder would be slower; as shipped, +with the wipe in both, it is 20% faster. + +The `subtle_ng` form is slow because every `Choice` passes through a volatile +read, about 16,000 of them per build. It is kept only as a second reference +in the tests. + +### End to end + +| benchmark | reference | NEON | SWAR | +|---|---:|---:|---:| +| bit6-encrypt-u64 | 8.59 Β΅s | **7.89 Β΅s** (βˆ’8%) | 13.55 Β΅s (+58%) | +| bit6-encrypt-left-u64 | 6.33 Β΅s | **5.77 Β΅s** (βˆ’9%) | 11.32 Β΅s (+79%) | +| bit6-encrypt-u32 | 4.75 Β΅s | **4.44 Β΅s** (βˆ’7%) | 7.67 Β΅s (+61%) | +| bit6-compare-u64 | 151 ns | 149 ns | β€” | +| 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 | β€” | + +For Bit6 u64 the saving is the builder saving times the block count +(11 Γ— ~50 ns β‰ˆ 0.55 Β΅s predicted, 0.70 Β΅s measured). Left-only encryption +gains or loses most because the builder is a larger share of it. +Comparison is untouched (it builds no PRP). + +## 4. Timing after + +Same session, one binary; the reference builder's benches give the before. + +| bench | before: reference builder | after: NEON (dispatched) | +|---|---|---| +| `prp_build_*_fixed_a_vs_fixed_b`, batch, 5 runs | +57.7, +17.2, βˆ’26.7, +83.4, βˆ’38.8 | +1.9, βˆ’1.8, βˆ’1.1, +1.3, +1.5 | +| same, continuous 150 s | +8.6 at 281 M (tau +0.0005) | **βˆ’0.8 at 92 M (tau βˆ’0.0001)** | +| `bit6_encrypt_left_fixed_a_vs_fixed_b`, batch | +21.6, +11.5 (verification doc) | βˆ’2.4, βˆ’0.9, +1.2 | +| `chained_encrypt_fixed_vs_random`, batch | βˆ’5.5, βˆ’5.3 (verification doc) | βˆ’1.8, +2.3 | +| `prp_build_const_vs_random`, continuous 150 s | (batch: βˆ’4.8, βˆ’7.8, verification doc) | +7.7 at 239 M (tau +0.0005) | + +The portable builders, from the same binary: + +| builder | `fixed_a_vs_fixed_b` batch | +|---|---| +| SWAR, 5 runs | βˆ’1.5, βˆ’0.8, βˆ’1.8, βˆ’1.7, +1.6 | +| `subtle_ng`, 3 runs | βˆ’2.1, +2.0, +2.4 | + +**The dependence on which permutation is built is gone.** Fixed A against +fixed B, which reached |t| = 83 for the reference builder in a single pair, +stays under 2 for the NEON builder in every run and pools to βˆ’0.8 over +92 M samples. The end-to-end left-only encryption test, which showed +21.6 +and +11.5 before, shows nothing. + +The pooled continuous figure is weak evidence for the reference builder +(+8.6): each pair has its own sign, so pooling over re-drawn pairs dilutes +it. In the Low Power Mode pass the same test gave βˆ’45 at 135 M. The batch +runs are the stronger evidence in both directions. + +`prp_build_const_vs_random` showed a small difference (t = +7.7, +tau 0.0005). It came from the bench, not the builder: the classes' inputs +sat in separate heap allocations, and dudect's percentile cropping, on a +24 MHz timer that gives a one-build sample only a handful of values, +exaggerates the difference that makes. With both classes in one pool, +slots assigned at random, and 16 builds per sample, it is gone (uncropped +t = βˆ’0.04, M1 Max); details in the verification doc, Β§4.2. On the M4 it is +clean too: uncropped |t| ≀ 1.23 in six 150 s runs, DIT off and on (macOS +26.5.2, mains power, Low Power Mode off). The rest of +this paragraph is the record of how that was found. An earlier version of this paragraph +said the classes differ in where their input comes from, one repeated input +against a 256 KB pool. They do not: the `Left` pool is 512 copies of the +fixed stream, indexed at random like the `Right` pool of 512 random streams, +so the classes have the same footprint and access pattern and differ only +in content. (Shrinking the pool to 4, which in the Low Power Mode pass cut +the residual by more than half, shrinks both classes.) The NEON builder has +no secret address or branch for it to come from, so the candidate is +content-dependent microarchitecture such as the data memory-dependent +prefetcher. DIT disables that on M3 and later, but the harness did not apply +DIT to the builder benches until the verification review. It has now been +tested, at `daf0344`: continuous, 150 s per run, DIT off and on alternately, +on the same M4 (macOS 26.5.2, mains power, Low Power Mode off). + +| run, in order | DIT | n | max t | max tau | +|---|---|---|---|---| +| off-1 | off | 240 M | +20.4 | +0.0013 | +| on-1 | on | 189 M | +21.6 | +0.0016 | +| off-2 | off | 251 M | +19.6 | +0.0012 | +| on-2 | on | 191 M | +21.8 | +0.0016 | + +DIT does not remove or reduce it, so on four runs it is not the prefetcher. The figures are larger than the +7.7 above, but +the commit that measured +7.7 (`6760707`), rerun in the same session, gave ++31.4 at 215 M, so the change is in the session, not the code. Fixed A +against fixed B stays under 2.5 both ways (five batch runs each). Details: +verification doc, Β§4.2. + +## Not measured + +- **x86_64 timing.** The SSSE3 builder compiles, passes clippy (stable and + 1.78) and passes the tests under Rosetta 2, which translates it to Arm + code; its speed and its timing behaviour on a real x86 core (with and + without SMT) are unknown. +- **Graviton or other Arm cores.** +- **The Valgrind taint run** without the swap suppression was not run in + this pass. It has since been run, clean, for both the NEON and the SSSE3 + builders; see the verification doc, Β§1. +- **AVX2.** An AVX2 form (two 32-byte registers per table) is a natural next + step for x86_64 and was not tried. + +## Outcome + +The NEON builder is the default on aarch64: it removes the measured +dependence on the permutation and is faster than the builder it replaced, by +20% for the builder and 7 to 10% end to end. On x86_64 the SSSE3 builder, +present on every x86_64 CPU of the last fifteen years, has the same +register-resident shape; benchmark it and rerun the dudect tests on real x86 +hardware, with and without SMT, before relying on either its speed or its +timing. The SWAR builder is the portable fallback for every other target: it +is oblivious and showed no timing dependence, at about 2.9 times the builder +cost and 58 to 95% end to end. The `subtle_ng` byte-at-a-time form ships +nowhere. diff --git a/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md new file mode 100644 index 00000000..4b77c600 --- /dev/null +++ b/docs/reviews/2026-10-09-ore-v2-dynamic-verification.md @@ -0,0 +1,456 @@ +# ORE v2 β€” dynamic and formal verification results + +**Date:** 2026-10-09 +**Scope:** the v2 schemes (`OreAes128Bit6`, `OreAes128Bit6Chained`), their +primitives, and the legacy `OreAes128` parsers and comparator. +**Machine for timing work:** Apple M4, macOS; Valgrind in an arm64 Linux +container (valgrind 3.19); rustc 1.94.1. + +This complements the static work (the crypto review brief A1–A4, the +constant-time analysis, the zeroize audits) with four kinds of evidence, +each reproducible from the repository: + +| what | where | gate | +|---|---|---| +| Valgrind taint (ctgrind technique) | `packages/ct-taint`, `scripts/ct-taint.sh` | CI (`verify.yml`), fails on any undocumented report | +| libFuzzer targets | `packages/ore-rs/fuzz`, `scripts/fuzz.sh` | CI, 120 s per target per PR | +| Kani proof harnesses | `#[cfg(kani)]` modules in `ore-rs` | CI (`cargo kani -p ore-rs`) | +| dudect timing tests | `packages/ct-dudect` | by hand; results below | + +## 1. Valgrind taint β€” secret-dependent branches and addresses + +The harness marks the key and the plaintext as undefined memory and runs each +v2 encryptor. Memcheck then reports every conditional jump and every memory +address that depends on them. The modes cover: + +- Bit6 through `OreEncrypt`, full and left-only for `u64`, and full for `i64`, + `f64` and `u32`. Each type runs its own `to_orderable_bytes` transform on + the secret (the float's `-0.0` canonicalisation compiles to a select, not a + branch), and `u32` has a different block count (6, not 11). +- Bit6's raw-block entry points, `OreCipher::encrypt` and `encrypt_left`. +- The chained scheme, full and left-only, through `encrypt_str` and through + the raw-symbol `encrypt_var` and `encrypt_left_var`. + +The legacy `OreAes128` encryptor is out of scope. Its Knuth-shuffle PRP +rejection-samples and swaps at secret indices by design, and its wire format +is frozen. + +The typed modes are clean. The raw-block modes report one branch, in +`Blocks6::check`, which is the one entry in `packages/ct-taint/valgrind.supp`. +It ORs the symbols' high bits together and branches once on the result, so the +branch reveals only whether some symbol was out of the domain, and it goes the +same way for every well-formed input. + +**The gate cannot pass vacuously.** `scripts/ct-taint.sh` sets +`CT_TAINT_GATE`, under which every taint request must succeed (exit 3 +otherwise), and each mode checks, before it untaints its output, that some of +the output is still undefined (exit 4 otherwise). That proves on every run +that the taint flowed from the key and the plaintext into the ciphertext. A +`canary` mode branches on a tainted byte and indexes a table with it; the +script requires memcheck to fail it. With crabgrind built without its +`valgrind` feature, so that every request is a no-op, the gate fails each mode +with exit 4 and the canary check fails as well. + +Two report classes came up along the way: + +1. **`oblivious_lookup`'s range check** (`index >= table.len()`) and its + callers' test of the returned `Result`. A comparison on the plaintext + symbol against a public length, whose direction is the same for every + in-domain symbol, so it carries no information; the static analysis had + not listed it. It was suppressed in the first runs, which needed a glob + over every `?` on a `Result`. It is now removed at the + root: `permute` and `invert` take a `Symbol`, a byte that is in the + domain by type, so they are infallible and the lookup has no range + branch. The encryptors take their symbols from the 6-bit decomposition, + which masks rather than checks. Callers that pass raw symbols + (`OreCipher::encrypt` for Bit6, chained `encrypt_var`) are checked once at + entry by `Blocks6::check`, still returning `OreError::PrpError`; that is the + suppressed branch above. +2. (Harness only, fixed.) `str::from_utf8`'s validation of the tainted + string; the harness now builds the `&str` unchecked. + +Nothing is reported from the 6-bit decomposition, the CMAC accumulator, the +Οƒ-MMO hash, the SIMD indicator kernels, `ct_select_byte`, or AES. The gate +was first shown live by hand (the commit before the `Symbol` change fails on +exactly the range check); the canary and the output check above now show it +on every run. + +A third class, the Fisher–Yates swap at a keystream-derived index in +`LemireFyPrp::from_stream` (a pointer-sized use of an undefined value), was +suppressed when this run was first made; it was the secret-indexed write the +brief's A4 accepted under the one-cache-line argument. Β§4.2 is why it went: +the PRP builder is now oblivious, and its suppression has been removed. The +oblivious builders use the secret draws only as register operands (`tbl` / +`pshufb` indices, compare-and-select masks, SWAR arithmetic), which memcheck +propagates as undefined data without reporting, and touch memory only at +public offsets (the stream reads and the two final 64-byte stores). The run +is clean without it on both shipping builders: NEON in an arm64 container, +and SSSE3 in an amd64 container under emulation (the emulated CPU reports +SSSE3, so the dispatcher picks it). The left-only ciphertexts from the two +runs are byte-identical, as they must be. The `verify.yml` ct-taint job now +runs on native x86_64 and aarch64 runners, so each builder is checked on its +own target. + +The comparators are not tainted: their inputs are ciphertexts, which are +public. Their timing question is Β§4's. + +## 2. Fuzzing β€” parsers and raw comparators on untrusted bytes + +Four targets, each asserting "never panics; an accepted ciphertext re-encodes +to the same bytes" (and for the comparators, "answers any pair"). First runs, +45 s each on the M4: + +| target | runs | result | +|---|---:|---| +| `chained_parse` (`VarCipherText::from_slice`) | 43.0 M | clean | +| `chained_compare` (`compare_raw_slices`, `compare_left_to_full`) | 37.6 M | clean | +| `bit6_parse` (`CipherText`/`Left::from_slice` at N = 11 and 6, `compare_raw_slices`) | 23.0 M | clean | +| `bit8_parse` (legacy, N = 8 and 4) | β€” | **panic on the first input** | + +The legacy `OreAes128::compare_raw_slices` inferred the block count from the +length without checking it: two empty slices underflowed the nonce +subtraction, and a partial block sliced past the end. Fixed (`None` for any +length that is not `nΒ·(1 + 16 + 32) + 16`), with a regression test; 16.5 M +runs clean afterwards. `n = 0` is included: a nonce-only input is the valid +ciphertext of a zero-byte plaintext, which the parser also accepts, and both +comparators return `Equal` on it. This is a pre-existing bug in the frozen legacy +scheme, not a v2 regression, and it reaches any caller that compares stored +bytes without parsing them first. + +The corpora from these runs are committed as seeds. + +`bit6_parse` and `bit8_parse` now also parse every input as a right-only +ciphertext (`Right::from_slice`), which no target covered before; 20 s per +target in an arm64 container, all four clean (5.7 M to 13.1 M runs). The +chained left-only query has no parser: `compare_left_to_full` reads it as +raw bytes, and `chained_compare` fuzzes that. + +## 3. Kani β€” proofs for the pure building blocks + +Seventeen harnesses, all verified. Fifteen run in CI; the two heaviest run +only with the `kani-full` feature, by hand: the exhaustive-length +`ct_select_byte` harness (about 9 minutes) and the SWAR-builder equivalence +harness (872 s on the M4). The latter is out of CI because it never fit: +on GitHub's `ubuntu-latest` runners it was cancelled 26 to 32 minutes in on +five of six runs, and the sixth took 57 minutes. The default set takes +8 m 50 s on an M1 Max (about 15 minutes expected in CI, which runs about +1.7 times slower); the job has a 30-minute timeout. With `Symbol`, the +out-of-range `oblivious_lookup` cases no longer exist to prove, so the +any-length lookup harness was dropped and a `Symbol` harness added; the +`from_stream` length check, a 335 s harness (552 s in CI) for 504 cases, is +now an exhaustive unit test. Kani cannot execute AES symbolically, so nothing keyed +is in scope; these are the pieces where a wrong implementation would hide +behind matching known-answer vectors. + +| property | domain | time | +|---|---|---:| +| `ct_select_byte(block, i) == block[i]` | every length ≀ 256, every `i` (`kani-full`); the 8- and 32-byte right blocks | 519 s; 1 s | +| `ct_bit(byte, pos) == (byte >> pos) & 1`, and 0 for `pos β‰₯ 8` | exhaustive | < 1 s | +| `oblivious_lookup(table, i) == table[i]` for every symbol `i` | every 64-entry table of in-domain values; every 256-entry table | 10 s; 29 s (M1 Max) | +| `Symbol::<64>::from_low_bits(b)` is `< 64`, and is `b` for `b < 64` | every `u8` | < 1 s | +| `oblivious::lemire_draw` itself reads step `i`'s draw from bytes `(63βˆ’i)Β·8 ..+ 8` (little-endian) and returns `(xΒ·(i+1)) >> 64 ≀ i` | every 504-byte stream, every step `1..64` | 113 s (M1 Max) | +| the reference (textbook Fisher–Yates) builder yields a permutation of 0..64 with a correct inverse | first 6 of 63 draws symbolic, the rest fixed (all 63 symbolic did not finish in 30 min) | 200 s | +| the SWAR oblivious builder's `permutation` and `inverse` equal the reference builder's (under Kani `from_stream` dispatches to SWAR: Kani cannot model the NEON or SSSE3 intrinsics) | same stream shape (`kani-full`) | 872 s | +| `num_blocks_6bit(n)` is the least `b` with `6b β‰₯ 8n` | every `n ≀ usize::MAX / 8` | < 1 s | +| `decompose_6bit`: symbols < 64, bit `5βˆ’t` of block `i` is plaintext bit `6i+t`; injective; order-preserving for any two lengths (a proper prefix sorts first, the chained scheme's string order) | inputs ≀ 16 bytes | 5 s; 8 s; 31 s (M1 Max; equal lengths only, 9 s, before) | +| `final_block` and `prefix_block` injective, and disjoint from each other | full domains | < 1 s each | +| `gf128_double` matches the byte-wise NIST SP 800-38B `dbl` | every 128-bit input | < 1 s | + +No harness found a bug. + +The NEON and SSSE3 builders, the ones that ship, are outside Kani: it cannot +model their intrinsics, so under `cargo kani` the dispatcher picks SWAR, +which is what the equivalence harness proves. Their evidence is tests against +the reference builder: random and edge streams, and every step `i` with every +swap index `j ≀ i` (2 079 forced draws, each inside a full build). Those run +on x86_64 in `test.yml` (SSSE3) and on an aarch64 runner in `verify.yml` +(NEON). Until the `lemire_draw` harness above, the draw was proved only as a +restated formula. + +## 4. dudect β€” timing leakage + +Welch's t-test over two interleaved input classes; `|t| < 5` after millions +of samples means no distinguishable timing. `max tau` is the effect size in +units of the timing spread. Quiet machine, release build, hardware AES. + +### 4.1 Comparators: does the time reveal where the inputs first differ? + +Class `Left`: pairs differing in the first block. Class `Right`: pairs +sharing every block but the last. + +Before the fix described below: + +| bench | samples | t | tau | +|---|---:|---:|---:| +| `bit6_compare_first_vs_last_block` (u64, 11 blocks) | 151 M | +10.2 | +0.0008 | +| `chained_compare_first_vs_last_byte` (17 chars, 23 blocks) | 154 M | **βˆ’248** | **βˆ’0.020** | +| same, inputs held in L1 (`CT_DUDECT_POOL=4`) | 72 M | +25 | +0.003 | + +After it (same machine, same binary apart from the comparators): + +| bench | samples | t | tau | +|---|---:|---:|---:| +| `bit6_compare_first_vs_last_block` | 91 M | βˆ’7.1 | βˆ’0.0008 | +| `chained_compare_first_vs_last_byte` | 70 M | +12.5 | +0.0015 | + +**Finding.** The chained comparator's time depended on the position of the +first differing block. The prefix scan is constant-time, but the resolution +step after it loaded `f[l]`, `xt[l]` and the right block `right[l]` by the +secret-derived index `l`. The right block is the one region the scan never +touched, so whether its cache line was already present depended on `l`: with +inputs arriving from memory the effect was 2 % of the timing spread (t = βˆ’248 +over 154 M samples); with every input held in L1 it all but vanished +(tau 0.003). Bit6 was barely affected because its whole ciphertext spans five +cache lines, which the hardware prefetcher covers. + +What it leaked is `l`, the first differing block. In the Lewi–Wu model that +position is already revealed to whoever holds both ciphertexts (the left +tags are deterministic), so this did not widen the scheme's leakage +profile; it moved part of it from "the ciphertext holder" to "anyone who can +time the comparison", which the constant-time scan was written to prevent. + +**Fix (applied to the Bit6 raw and chained comparators in their scheme +commits; in this PR to the Bit6 typed `Ord` and to both legacy `OreAes128` +comparators, raw and typed).** The scan now +latches `xt[l]`, `f[l]` and `right[l]` as it runs, with +`width::ct_assign_bytes` under the choice "this is the first differing +block" (set for exactly one `n`), and the resolution step hashes and +bit-selects from the latched copies. Every load is now indexed by the public +loop counter. Every comparator (legacy and Bit6, raw and typed, and +chained) shares the latch as `width::FirstDiff`, which zeroizes the copies +on drop. The added cost is one 8-byte right-block read and about 25 +masked byte copies per block, against a scan already comparing 17 bytes per +block; the chained benchmarks in `docs/benchmarks/` were not re-run, since +the comparator is not on the encrypt path they time. The legacy comparator +had the same post-scan `l`-indexed loads and takes the same fix, which +leaves its frozen wire format unchanged; it has no dudect bench, so the +legacy fix rests on the same reasoning, not a measurement. + +The chained effect size fell from 0.020 to 0.0015, below the L1-resident +control measured before the fix, so the cache component is gone. What +remains (t β‰ˆ 12 at 70 M samples) is the same order as the Bit6 figure, which +the fix did not change (0.0008 before and after, sign flipped): a floor the +test resolves at these sample counts but whose source is not the +`l`-indexed loads. Both comparators keep the two branches that reveal only +the result the caller receives anyway: the early return on equality and the +final `test == 1`. + +### 4.2 Encryptors: does the time depend on the plaintext? + +As first measured, with the PRP builder that swapped at secret indices: + +| bench | classes | runs (100 k samples each) | t | +|---|---|---|---:| +| `bit6_encrypt_zero_vs_random` | 0 vs random u64 | 2 | +2.7, βˆ’3.0 | +| `bit6_encrypt_const_vs_random` | one random-looking constant vs random | 2 | +14.7, βˆ’7.0 | +| `bit6_encrypt_left_const_vs_random` | same, left-only | 2 | +5.6, +8.0 | +| `bit6_encrypt_left_fixed_a_vs_fixed_b` | two fixed values (fresh pair per run) | 2 | +21.6, +11.5 | +| `chained_encrypt_fixed_vs_random` | 17 Γ— `a` vs random lowercase | 2 | βˆ’5.5, βˆ’5.3 | +| `prp_build_const_vs_random` | one fixed 512-byte stream vs random | 2 | βˆ’4.8, βˆ’7.8 | +| `prp_build_fixed_a_vs_fixed_b` | two fixed streams (fresh pair per run) | 2 | +65.5, βˆ’3.6 | +| `prp_build_fixed_a_vs_fixed_b`, continuous, pairs re-drawn | | 138 M | βˆ’16.4 (tau βˆ’0.0014) | + +**Finding (fixed).** The PRP builder's time depended on *which* permutation +it builds: for some pairs of draw streams the two were clearly +distinguishable (t = +65 at 100 k samples), for others not, and at population +level the effect was real but very small (tau βˆ’0.0014). The only +secret-dependent memory traffic in the builder was the Fisher–Yates swap at +the draw-derived index and the inverse-table fill, so this was the A4 access +pattern showing up as a within-process timing signature, on a core with no +SMT and no co-tenant. Setting the ARM DIT bit was taken to rule out the +data memory-dependent prefetcher, but the harness only applied DIT through +the cipher constructors, so the `prp_build_*` and `iso_*` benches ran +without it whatever the environment said; for the builder-only measurements +the prefetcher was not ruled out. The harness now applies DIT in every bench +and fails if the bit does not stick. The one-cache-line argument addresses +line-granularity cache leakage and says nothing about this. + +**Mechanism isolation.** Each half of the old builder, timed alone on two +fixed streams, next to a control doing the same work at public addresses +(the swap at `i, iβˆ’1` with the draw still computed; the fill at `k`). Apple +M4 on mains power, release build, three runs of 100 k samples, a fresh pair +per run: + +| bench | t, 3 runs | +|---|---| +| `iso_swap_secret` (swap loop, secret index) | βˆ’41.0, βˆ’17.6, βˆ’49.9 | +| `iso_swap_public` (control) | +1.1, βˆ’0.8, βˆ’1.5 | +| `iso_fill_secret` (inverse fill, secret offsets) | βˆ’0.8, βˆ’0.3, +0.5 | +| `iso_fill_public` (control) | +3.0, βˆ’0.7, βˆ’1.9 | + +The swap loop carries the whole signal; the fill carries none. Each swap +loads `perm[j]` and `perm[i]` just after the previous step stored to +`perm[i+1]` and `perm[j']`, so whether a load overlaps a store still in +flight depends on the draws, and with it whether the core forwards the +stored byte, waits, or speculates and replays. The fill only stores, to 64 +distinct offsets, with no load depending on those stores. Store-to-load +forwarding and memory disambiguation on byte-granular accesses at secret +offsets inside one L1-resident line is consistent with that; the +measurement does not prove it. + +**Fix.** The builder is now oblivious (`primitives/prp/oblivious.rs`): both +tables stay in registers, `perm[j]` is fetched by a table lookup on a +broadcast `j` and written back by compare-and-select, and the inverse is +kept by exchanging the values `i` and `j` in it at each step instead of a +fill. Same draws and swaps, byte-identical tables, no vector change. NEON on +aarch64, SSSE3 on x86_64 when present, SWAR elsewhere; on the M4 the NEON +builder is 20% faster than the one it replaced (199 ns against 248 ns). +Details and costs: `docs/benchmarks/2026-10-09-oblivious-prp-results.md`. + +**After**, same session and binary (`prp_build_reference_*` is the old +builder, kept as a test-only reference): + +| bench | before (old builder) | after | +|---|---|---| +| `prp_build_fixed_a_vs_fixed_b`, 5 runs | +57.7, +17.2, βˆ’26.7, +83.4, βˆ’38.8 | +1.9, βˆ’1.8, βˆ’1.1, +1.3, +1.5 | +| same, continuous 150 s, pairs re-drawn | +8.6 at 281 M (tau +0.0005) | βˆ’0.8 at 92 M (tau βˆ’0.0001) | +| `prp_build_swar_obl_fixed_a_vs_fixed_b`, 5 runs | β€” | βˆ’1.5, βˆ’0.8, βˆ’1.8, βˆ’1.7, +1.6 | +| `bit6_encrypt_left_fixed_a_vs_fixed_b`, 3 runs | +21.6, +11.5 (table above) | βˆ’2.4, βˆ’0.9, +1.2 | +| `chained_encrypt_fixed_vs_random`, 2 runs | βˆ’5.5, βˆ’5.3 (table above) | βˆ’1.8, +2.3 | +| `prp_build_const_vs_random`, continuous 150 s | β€” | +7.7 at 239 M (tau +0.0005) | + +The pooled continuous figure is weak evidence either way: each 100 k-sample +pair has its own sign, so pooling dilutes it (the old builder's +8.6 here, +against |t| up to 83 in a single pair). The per-pair runs are the test, and +there the dependence is gone. + +`prp_build_const_vs_random` kept a small residual (tau 0.0005 in the table +above, 0.0012–0.0016 in the DIT runs below). It turned out to come from the +bench, not the builder; the account below keeps the steps that got there. +An earlier version of this paragraph put it down to the +classes' footprints, one repeated input against a 256 KB pool. That is +wrong: the `Left` pool is 512 copies of the fixed stream, indexed at random +exactly as the `Right` pool of 512 random streams is, so the two classes +have the same footprint and access pattern and differ only in content. The +NEON builder has no secret-dependent branch or address (Β§1), so the +candidates are content-dependent microarchitecture, the data +memory-dependent prefetcher first. + +**DIT test: not the prefetcher.** DIT disables the prefetcher on M3 and +later, and this bench did not get DIT before (above), so it was run +continuously at `daf0344`, 150 s per run, DIT off and on alternately. Apple +M4 (`FEAT_DIT` = 1), macOS 26.5.2 (25F84), mains power with the battery +charged, Low Power Mode off; one other Claude Code session was open but +idle. With `CT_DUDECT_DIT=1` the harness reads the bit back and panics if it +did not stick; both DIT runs completed. + +| run, in order | DIT | n | max t | max tau | +|---|---|---|---|---| +| off-1 | off | 240 M | +20.4 | +0.0013 | +| on-1 | on | 189 M | +21.6 | +0.0016 | +| off-2 | off | 251 M | +19.6 | +0.0012 | +| on-2 | on | 191 M | +21.8 | +0.0016 | + +The residual is there both ways and DIT does not reduce it; tau is a +little higher with DIT on, which also runs about 20% slower (fewer samples +in the same 150 s). So on this evidence the data memory-dependent +prefetcher does not explain it. Four runs are evidence, not proof, and the +residual stays open. tau holds steady through each run from 50 M samples +on (0.0013–0.0014 off, 0.0015–0.0017 on), so t grows with √n: a constant +offset, not drift. The signal is larger than the first measurement (+7.7 at +239 M, tau 0.0005), but the builder and harness at the commit of that +measurement (`6760707`), run in the same session with DIT off, gave +31.4 +at 215 M (tau 0.0021). The larger figures therefore come from this +session's conditions, not from the code changes since `6760707`. + +**Cause: the harness.** Two things in the bench, not the builder, produce +the residual. Measured first on an M1 Max, then on the M4 at the commit "docs(dudect): the const-vs-random residual was the harness" +(conditions below), 150 s continuous runs, one build per sample unless +stated, DIT off unless stated. (The M1 has a data memory-dependent +prefetcher that DIT cannot switch off; the controls below change only the +bench's layout and statistics, with the prefetcher the same throughout.) + +| variant | M1 Max: dudect max t | M1 Max: uncropped t | M4: dudect max t | M4: uncropped t | +|---|---:|---:|---:|---:| +| as written: a separate pool per class, `Left`'s allocated first | +54.7; +27.7 | +1.10 (14.7 M per class) | +30.4 | +0.52 (22.1 M) | +| allocation order swapped | +40.1 | β€” | β€” | β€” | +| one pool, interleaved, fixed stream at even slots | βˆ’12.6 | β€” | βˆ’47.3 | βˆ’2.43 (21.8 M) | +| same, fixed stream at odd slots | +23.5 | β€” | +47.3 | +4.14 (21.9 M) | +| one pool, slots assigned to classes at random each batch | +18.4; +6.4 | +1.34 (15.0 M) | β€” | β€” | +| the same, 16 builds per sample (the bench as it now is) | βˆ’1.8 | **βˆ’0.04** (7.3 M samples, 117 M builds, per class) | βˆ’2.3; βˆ’2.2; +3.7 | **+1.10; βˆ’1.23; +0.18** (9.9–10.3 M) | +| the same, DIT on | β€” | β€” | +4.4; +8.1; +9.3 | **βˆ’0.61; βˆ’0.27; +0.47** (9.4–9.5 M) | +| `prp_build_fixed_a_vs_fixed_b`, 16 builds per sample | +2.7 | βˆ’0.12 (8.6 M samples) | +1.9 | +0.59 (11.9 M) | + +1. *Placement.* The classes' inputs sat in two separate heap allocations, so + they differed in where they were as well as what they held. Moving the + fixed stream from the even to the odd slots of one shared pool flips the + sign (βˆ’12.6 to +23.5), and the size of the effect changes from session + to session with the heap layout, which is why the M4 sessions gave +7.7, + +20 and +31 for the same code. +2. *What dudect reports.* `max t` is the largest |t| over 101 tests, 100 of + which first drop every sample above a percentile of the runtime + distribution. Apple's timer is a 24 MHz counter, so a one-build sample + (about 200 ns) takes only a handful of values (208, 250, 291, 333 ns), + and the percentiles fall exactly on them. Where the classes differ in + spread (sd 333 against 501 ns as written), cropping removes more of one + class's tail than the other's, and the cropped tests report a large t + with almost no difference in mean: +27.7 reported, +1.10 uncropped. + +The bench now draws both classes from one pool with the slots assigned at +random each batch, and times 16 builds per sample (about 80 ticks). There +it shows nothing, cropped or uncropped. `scripts/dudect-uncropped.sh` +records every sample and prints the uncropped t beside dudect's figure; +the old bench is kept as `prp_build_const_vs_random_separate_pools`, and +the interleaved controls as `…_interleaved_even` and `…_odd`. The +encryptor `const_vs_random` benches in the first table use the same +separate-pool layout; a call there is microseconds, so the timer is not a +factor, but placement and cropping may be, and they have not been +rerun. + +**On the M4.** Apple M4, macOS 26.5.2 (25F84), mains power with the +battery charged, Low Power Mode off. Other Claude Code sessions were open, +and a Chrome renderer was at 180% CPU when the first DIT-off run started, so +that run was repeated; the table gives both. The corrected bench is clean +uncropped both ways: |t| ≀ 1.23 in six runs, DIT off and on alternately. +The controls behave as on the M1. Separate pools give +30.4 cropped against ++0.52 uncropped. With the fixed stream at even slots, then at odd, the +cropped figures are βˆ’47.3 and +47.3 and the uncropped βˆ’2.43 and +4.14, and +the tick-bin shares move in mirror image (|z| β‰ˆ 30): whichever class sits +at the even slots has more samples at 166–167 ns and fewer at +208–209 ns. Placement is a real timing difference, and cropping inflates +it. Fixed A against fixed B shows nothing. + +One thing is left over, and only with DIT on. dudect's cropped figure is ++4.4, +8.1 and +9.3 in the three DIT-on runs, against βˆ’2.3, βˆ’2.2 and +3.7 +with DIT off. In all three DIT-on runs, `Left` (the fixed stream) has +slightly fewer samples than `Right` at 2875–2917 ns and slightly more at +3000 ns, a share difference of 0.0005–0.001 per bin with |z| up to 6.4. +The DIT-off runs show no such shift (|z| ≀ 3.5, with no consistent sign). +The shift moves the mean by under a nanosecond (Left βˆ’ Right βˆ’0.31, βˆ’0.19, ++0.39 ns, against an sd of 800–1900 ns), below what the uncropped test can +resolve, so by the |t| < 5 rule nothing is detected. But it repeated three +times and only with DIT on, and its cause is not known. + +Fixed A against fixed B, where both classes repeat one input, shows nothing +either way: five batch runs each, DIT off βˆ’0.9, +2.3, βˆ’2.0, +1.5, +2.2, DIT +on βˆ’2.4, βˆ’1.6, βˆ’1.7, +0.4, +1.6. The first pass of these measurements, in Low +Power Mode on battery, gave the same per-pair picture (its pooled old-builder +figure was larger, βˆ’45 at 135 M). + +Encryption time does not depend on the plaintext *value* in any way the test +could see once the inputs are symmetric (`zero_vs_random`: t β‰ˆ 3); the +`const_vs_random` signals in the first table were the builder effect above, +seen through a fixed versus varying permutation sequence. + +## 5. What is not covered + +- The cryptographic argument (Lewi–Wu leakage, the BHKR bound for H, CMAC + branch separation) is reviewed by reading the brief, not by any tool here. +- Kani does not reach anything keyed; the end-to-end comparator and + encryptor correctness rest on the property tests and known-answer vectors. +- The taint run uses memcheck's definedness tracking, which does not follow + taint through a load at a tainted address (the loaded byte is "defined"). + Secret-indexed *reads* after the first one are therefore invisible to it; + that is exactly why the comparators are measured with dudect instead. +- dudect was run on one Apple M4. x86 (with and without SMT, where the + SSSE3 builder runs) and Graviton would be worth a run; the SSSE3 + builder's timing has not been measured on real x86 hardware. The harness + now builds on aarch64 Linux (the DIT write uses the generic system + register encoding), so Graviton needs no changes. +- The `prp_build_const_vs_random` residual (Β§4.2) came from the bench + (input placement and dudect's cropping on a coarse timer), not the + builder. The corrected bench is clean on the M1 Max and on the M4 + (uncropped |t| ≀ 1.23 in six runs, DIT off and on). With DIT on, the M4 + shows a small tick-bin shift that repeated in three runs, below what the + uncropped test resolves (Β§4.2); its cause is not known. The encryptor + `const_vs_random` benches share the old layout and have not been rerun. diff --git a/packages/ct-dudect/Cargo.lock b/packages/ct-dudect/Cargo.lock new file mode 100644 index 00000000..45f634da --- /dev/null +++ b/packages/ct-dudect/Cargo.lock @@ -0,0 +1,631 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", + "zeroize", +] + +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-modes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2211b0817f061502a8dd9f11a37e879e79763e3c698d2418cf824d8cb2f21e" + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "2.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +dependencies = [ + "ansi_term", + "atty", + "bitflags 1.3.2", + "strsim", + "textwrap", + "unicode-width", + "vec_map", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ct-dudect" +version = "0.0.0" +dependencies = [ + "dudect-bencher", + "ore-rs", +] + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "dudect-bencher" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6174adfb35811a6845001876823965032406784c36e795edb84a74353455e1" +dependencies = [ + "clap", + "ctrlc", + "rand 0.10.3", + "rand_chacha 0.10.0", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core 0.10.1", +] + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "orderable-bytes" +version = "0.1.1" + +[[package]] +name = "ore-rs" +version = "0.8.3" +dependencies = [ + "aes", + "block-modes", + "byteorder", + "hex", + "num", + "orderable-bytes", + "rand 0.8.8", + "rand_chacha 0.3.1", + "subtle-ng", + "thiserror", + "zeroize", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" +dependencies = [ + "ppv-lite86", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + +[[package]] +name = "subtle-ng" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "734676eb262c623cec13c3155096e08d1f8f29adce39ba17948b18dad1e54142" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/packages/ct-dudect/Cargo.toml b/packages/ct-dudect/Cargo.toml new file mode 100644 index 00000000..8f8f90d3 --- /dev/null +++ b/packages/ct-dudect/Cargo.toml @@ -0,0 +1,25 @@ +# dudect timing-leakage benches for the v2 ORE comparators and encryptors. +# +# DETACHED crate (own `[workspace]` table): it is run by hand on a quiet +# machine and its results are recorded in the docs, not gated in CI, because a +# t-test over wall-clock timings is too noisy on shared runners. Run with +# cargo run --release -- --continuous +# or a single bench by name; see src/main.rs for the classes each bench +# compares. +[package] +name = "ct-dudect" +version = "0.0.0" +publish = false +edition = "2021" + +[dependencies] +dudect-bencher = "0.7" + + +[dependencies.ore-rs] +path = "../ore-rs" +# `ct-bench` exposes the private PRP builder for the isolated bench. +features = ["ct-bench"] + +[workspace] +resolver = "2" diff --git a/packages/ct-dudect/src/main.rs b/packages/ct-dudect/src/main.rs new file mode 100644 index 00000000..48034ba8 --- /dev/null +++ b/packages/ct-dudect/src/main.rs @@ -0,0 +1,563 @@ +//! dudect timing-leakage benches for the v2 ORE comparators and encryptors. +//! +//! dudect feeds two input classes to the same function, interleaved, and +//! runs Welch's t-test on the timing distributions. `|t| < 5` after a few +//! million samples means no leakage was detected between the classes. +//! +//! The comparator benches ask the question the constant-time prefix scan is +//! meant to answer: does the time to compare two ciphertexts depend on +//! *where* they first differ? Class `Left` pairs differ in the first block; +//! class `Right` pairs share every block but the last. The encryptor benches +//! ask whether encryption time depends on the plaintext: `Left` is the +//! all-zero value, `Right` a random one. +//! +//! Run on a quiet machine: `cargo run --release -- --continuous` (all +//! benches, until interrupted) or `cargo run --release -- `. + +use dudect_bencher::rand::RngExt; +use dudect_bencher::{ctbench_main, BenchRng, Class, CtRunner}; +use ore_rs::ct_bench::{self, Line}; +use ore_rs::scheme::bit2_w6::OreAes128Bit6ChaCha20; +use ore_rs::scheme::chained::{OreAes128Bit6Chained, OreAes128Bit6ChainedChaCha20}; +use ore_rs::{OreCipher, OreEncrypt, OreOutput}; +use std::hint::black_box; + +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, +]; + +/// Inputs per class, precomputed so the measured region is the call alone. +/// `CT_DUDECT_POOL` overrides it: a pool of a few entries keeps every input +/// in L1, which separates cache effects from arithmetic ones. +fn pool_size() -> usize { + std::env::var("CT_DUDECT_POOL") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(512) +} +/// Measurements per invocation of a bench (dudect's `--continuous` loops). +const SAMPLES: usize = 100_000; + +/// With `CT_DUDECT_DIT=1`, set the ARMv8.4 DIT (data-independent timing) +/// bit for this thread before measuring, and fail if it does not stick. On +/// Apple M3 and later this also disables the data memory-dependent +/// prefetcher, whose behaviour depends on whether loaded *values* look like +/// pointers; a difference that disappears under DIT is that +/// microarchitectural effect, not a data-dependent code path. (On M1 and M2 +/// DIT does not affect the prefetcher.) +/// +/// Every bench calls this first, so the setting covers all of them. +fn data_independent_timing() { + if std::env::var_os("CT_DUDECT_DIT").is_none() { + return; + } + #[cfg(target_arch = "aarch64")] + { + assert!( + std::arch::is_aarch64_feature_detected!("dit"), + "CT_DUDECT_DIT is set but this CPU does not implement FEAT_DIT" + ); + // PSTATE.DIT is bit 24 of system register S3_3_C4_C2_5. The generic + // encoding assembles without the ARMv8.4 `dit` target feature, which + // aarch64 Linux targets do not enable (only Apple's baseline does). + const DIT: u64 = 1 << 24; + let mut v: u64; + // SAFETY: reads and sets the per-thread timing control bit only; it + // touches no memory and has no other architectural effect. FEAT_DIT + // is checked above, so the register exists. + unsafe { + std::arch::asm!("mrs {v}, s3_3_c4_c2_5", v = out(reg) v, options(nomem, nostack)); + std::arch::asm!("msr s3_3_c4_c2_5, {v}", v = in(reg) v | DIT, options(nomem, nostack)); + std::arch::asm!("mrs {v}, s3_3_c4_c2_5", v = out(reg) v, options(nomem, nostack)); + } + assert!(v & DIT != 0, "CT_DUDECT_DIT: the DIT bit did not stick"); + } + #[cfg(not(target_arch = "aarch64"))] + panic!("CT_DUDECT_DIT is set, but DIT is an aarch64 feature"); +} + +fn bit6() -> OreAes128Bit6ChaCha20 { + data_independent_timing(); + OreCipher::init(&K1, &K2).unwrap() +} + +fn chained() -> OreAes128Bit6ChainedChaCha20 { + data_independent_timing(); + OreAes128Bit6Chained::init(&K1).unwrap() +} + +/// Random class assignment, so the two classes interleave. +fn class(rng: &mut BenchRng) -> Class { + if rng.random::() { + Class::Left + } else { + Class::Right + } +} + +/// Bit6 u64 comparator: first block differs (`Left`) versus only the last +/// 6-bit block differs (`Right`). +fn bit6_compare_first_vs_last_block(runner: &mut CtRunner, rng: &mut BenchRng) { + let ore = bit6(); + let mut left = Vec::with_capacity(pool_size()); + let mut right = Vec::with_capacity(pool_size()); + for _ in 0..pool_size() { + let x: u64 = rng.random::(); + // Flip the top bit: block 0 differs. + let y0 = x ^ (1 << 63); + // Flip the bottom bit: only the last block differs. + let y1 = x ^ 1; + left.push(( + x.encrypt(&ore).unwrap().to_bytes(), + y0.encrypt(&ore).unwrap().to_bytes(), + )); + right.push(( + x.encrypt(&ore).unwrap().to_bytes(), + y1.encrypt(&ore).unwrap().to_bytes(), + )); + } + for _ in 0..SAMPLES { + let c = class(rng); + let pool = if matches!(c, Class::Left) { + &left + } else { + &right + }; + let (a, b) = &pool[rng.random_range(0..pool_size())]; + runner.run_one(c, || { + black_box(OreAes128Bit6ChaCha20::compare_raw_slices( + black_box(a), + black_box(b), + )) + }); + } +} + +/// Chained string comparator over 17-character strings: first byte differs +/// (`Left`) versus only the last byte differs (`Right`). +fn chained_compare_first_vs_last_byte(runner: &mut CtRunner, rng: &mut BenchRng) { + let ore = chained(); + let mut left = Vec::with_capacity(pool_size()); + let mut right = Vec::with_capacity(pool_size()); + for _ in 0..pool_size() { + let mut s = [0u8; 17]; + for b in s.iter_mut() { + *b = rng.random_range(b'a'..=b'z'); + } + let mut first = s; + first[0] ^= 0x01; + let mut last = s; + last[16] ^= 0x01; + let enc = |bytes: &[u8]| { + ore.encrypt_str(std::str::from_utf8(bytes).unwrap()) + .unwrap() + .to_bytes() + }; + left.push((enc(&s), enc(&first))); + right.push((enc(&s), enc(&last))); + } + for _ in 0..SAMPLES { + let c = class(rng); + let pool = if matches!(c, Class::Left) { + &left + } else { + &right + }; + let (a, b) = &pool[rng.random_range(0..pool_size())]; + runner.run_one(c, || { + black_box(OreAes128Bit6ChainedChaCha20::compare_raw_slices( + black_box(a), + black_box(b), + )) + }); + } +} + +/// Bit6 u64 encryption: all-zero plaintext (`Left`) versus random (`Right`). +/// Both classes draw from a precomputed pool, so the work between two +/// measurements is the same whichever class comes next. +fn bit6_encrypt_zero_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { + let ore = bit6(); + let left = vec![0u64; pool_size()]; + let right: Vec = (0..pool_size()).map(|_| rng.random::()).collect(); + for _ in 0..SAMPLES { + let c = class(rng); + let pool = if matches!(c, Class::Left) { + &left + } else { + &right + }; + let x = pool[rng.random_range(0..pool_size())]; + runner.run_one(c, || black_box(black_box(x).encrypt(&ore).unwrap())); + } +} + +/// Bit6 u64 encryption: one fixed random-looking plaintext (`Left`) versus +/// fresh random ones (`Right`). Separates "the value is constant" from "the +/// value is zero": a constant with the shape of a random word isolates the +/// zero class's other property, that it never looks like a pointer. +fn bit6_encrypt_const_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { + let ore = bit6(); + let left = vec![0x1234_5678_9abc_def0u64; pool_size()]; + let right: Vec = (0..pool_size()).map(|_| rng.random::()).collect(); + for _ in 0..SAMPLES { + let c = class(rng); + let pool = if matches!(c, Class::Left) { + &left + } else { + &right + }; + let x = pool[rng.random_range(0..pool_size())]; + runner.run_one(c, || black_box(black_box(x).encrypt(&ore).unwrap())); + } +} + +/// Bit6 u64 *left-only* encryption, constant versus random plaintext. The +/// left path is the prefix PRF, the PRP build and the oblivious `permute`; +/// no random-oracle keys, hashing or right blocks. +fn bit6_encrypt_left_const_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { + let ore = bit6(); + let left = vec![0x1234_5678_9abc_def0u64; pool_size()]; + let right: Vec = (0..pool_size()).map(|_| rng.random::()).collect(); + for _ in 0..SAMPLES { + let c = class(rng); + let pool = if matches!(c, Class::Left) { + &left + } else { + &right + }; + let x = pool[rng.random_range(0..pool_size())]; + runner.run_one(c, || black_box(black_box(x).encrypt_left(&ore).unwrap())); + } +} + +/// Builds timed per sample by `prp_build_const_vs_random` and +/// `prp_build_fixed_a_vs_fixed_b`. One build takes about 200 ns, five ticks +/// of Apple's 24 MHz counter, so a one-build sample takes only a handful of +/// values and a class difference is a sliver of samples crossing a tick +/// boundary, which dudect's percentile cropping (cut exactly at those tick +/// values) exaggerates. Sixteen builds per sample span about eighty ticks. +const BUILDS_PER_SAMPLE: usize = 16; + +/// A fresh random 512-byte draw stream. +fn random_stream(rng: &mut BenchRng) -> [u8; 512] { + let mut s = [0u8; 512]; + for b in s.iter_mut() { + *b = rng.random::(); + } + s +} + +/// The PRP builder alone (`LemireFyPrp::from_stream`, the oblivious builder +/// the schemes use, through the `ct-bench` hook): one fixed 512-byte draw +/// stream (`Left`) versus fresh random streams (`Right`). The draw stream +/// decides every Fisher–Yates swap, so this isolates the builder from +/// everything else in the encryptor. +/// +/// Both classes share one pool of `2 * pool` streams, and a random half of +/// its slots, redrawn every batch, holds the fixed stream, so the classes +/// differ in content only. With a separate pool per class they also differ +/// in where their inputs sit, and that alone moved t from -13 to +55 with +/// the layout (`prp_build_const_vs_random_separate_pools` and the +/// interleaved controls below). Each sample times `BUILDS_PER_SAMPLE` +/// builds over streams from the class's slots. +fn prp_build_const_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { + data_independent_timing(); + let fixed = random_stream(rng); + let n = 2 * pool_size(); + // Fisher–Yates over slot indices: the first half are `Left`. + let mut order: Vec = (0..n).collect(); + for i in (1..n).rev() { + order.swap(i, rng.random_range(0..=i)); + } + let (left_idx, right_idx) = order.split_at(pool_size()); + let mut pool = vec![[0u8; 512]; n]; + for &k in left_idx { + pool[k] = fixed; + } + for &k in right_idx { + pool[k] = random_stream(rng); + } + for _ in 0..SAMPLES { + let c = class(rng); + let idx = if matches!(c, Class::Left) { + left_idx + } else { + right_idx + }; + let picks: [&[u8; 512]; BUILDS_PER_SAMPLE] = + std::array::from_fn(|_| &pool[idx[rng.random_range(0..pool_size())]]); + runner.run_one(c, || { + let mut acc = 0u8; + for s in picks { + acc ^= ore_rs::ct_bench::lemire_fy_prp_from_stream(black_box(s)); + } + black_box(acc) + }); + } +} + +/// Artifact control: `prp_build_const_vs_random` as first written, one +/// build per sample and a separate pool per class (`Left`'s, 512 copies of +/// the fixed stream, allocated first). This is the bench behind the +7.7 to +/// +31 residuals; it is kept to show that they come from the harness. +fn prp_build_const_vs_random_separate_pools(runner: &mut CtRunner, rng: &mut BenchRng) { + data_independent_timing(); + let fixed = random_stream(rng); + let left = vec![fixed; pool_size()]; + let right: Vec<[u8; 512]> = (0..pool_size()).map(|_| random_stream(rng)).collect(); + for _ in 0..SAMPLES { + let c = class(rng); + let pool = if matches!(c, Class::Left) { + &left + } else { + &right + }; + let s = &pool[rng.random_range(0..pool_size())]; + runner.run_one(c, || { + black_box(ore_rs::ct_bench::lemire_fy_prp_from_stream(black_box(s))) + }); + } +} + +/// Artifact control: one build per sample, both classes in one pool, +/// interleaved, `Left` at the slots of parity `left_parity`. Placement is +/// systematic but differs only by a 512-byte offset; the result's sign +/// follows the parity. +fn interleaved(runner: &mut CtRunner, rng: &mut BenchRng, left_parity: usize) { + data_independent_timing(); + let fixed = random_stream(rng); + let mut pool = vec![[0u8; 512]; 2 * pool_size()]; + for (k, s) in pool.iter_mut().enumerate() { + *s = if k % 2 == left_parity { + fixed + } else { + random_stream(rng) + }; + } + for _ in 0..SAMPLES { + let c = class(rng); + let parity = if matches!(c, Class::Left) { + left_parity + } else { + 1 - left_parity + }; + let s = &pool[2 * rng.random_range(0..pool_size()) + parity]; + runner.run_one(c, || { + black_box(ore_rs::ct_bench::lemire_fy_prp_from_stream(black_box(s))) + }); + } +} + +/// [`interleaved`] with the fixed stream at even slots. +fn prp_build_const_vs_random_interleaved_even(runner: &mut CtRunner, rng: &mut BenchRng) { + interleaved(runner, rng, 0); +} + +/// [`interleaved`] with the fixed stream at odd slots. +fn prp_build_const_vs_random_interleaved_odd(runner: &mut CtRunner, rng: &mut BenchRng) { + interleaved(runner, rng, 1); +} + +/// The PRP builder on two *different fixed* streams, `A` (`Left`) and `B` +/// (`Right`). Both classes repeat their input, so predictor training is the +/// same for each; a difference here means the time depends on *which* +/// permutation is built, not merely on whether the input repeats. Each +/// sample times `BUILDS_PER_SAMPLE` builds. +fn prp_build_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) { + data_independent_timing(); + let (a, b) = (random_stream(rng), random_stream(rng)); + for _ in 0..SAMPLES { + let c = class(rng); + let s = if matches!(c, Class::Left) { &a } else { &b }; + runner.run_one(c, || { + let mut acc = 0u8; + for _ in 0..BUILDS_PER_SAMPLE { + acc ^= ore_rs::ct_bench::lemire_fy_prp_from_stream(black_box(s)); + } + black_box(acc) + }); + } +} + +/// Bit6 u64 left-only encryption of two different fixed plaintexts: the +/// same question as `prp_build_fixed_a_vs_fixed_b`, end to end. +fn bit6_encrypt_left_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) { + let ore = bit6(); + let a: u64 = rng.random(); + let b: u64 = rng.random(); + for _ in 0..SAMPLES { + let c = class(rng); + let x = if matches!(c, Class::Left) { a } else { b }; + runner.run_one(c, || black_box(black_box(x).encrypt_left(&ore).unwrap())); + } +} + +/// Chained encryption of 17 bytes: all-`a` plaintext (`Left`) versus random +/// lowercase (`Right`), both from precomputed pools. +fn chained_encrypt_fixed_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { + let ore = chained(); + let left: Vec = vec!["a".repeat(17); pool_size()]; + let right: Vec = (0..pool_size()) + .map(|_| { + (0..17) + .map(|_| char::from(rng.random_range(b'a'..=b'z'))) + .collect() + }) + .collect(); + for _ in 0..SAMPLES { + let c = class(rng); + let pool = if matches!(c, Class::Left) { + &left + } else { + &right + }; + let s = pool[rng.random_range(0..pool_size())].as_str(); + runner.run_one(c, || black_box(ore.encrypt_str(black_box(s)).unwrap())); + } +} + +/// Two fixed random 512-byte draw streams for the isolation benches. +fn stream_pair(rng: &mut BenchRng) -> ([u8; 512], [u8; 512]) { + let mut a = [0u8; 512]; + let mut b = [0u8; 512]; + for x in a.iter_mut().chain(b.iter_mut()) { + *x = rng.random::(); + } + (a, b) +} + +/// Mechanism isolation, swap half: the Fisher–Yates swap loop alone, at +/// the secret draw-derived index, on two fixed streams. +fn iso_swap_secret_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) { + data_independent_timing(); + let (a, b) = stream_pair(rng); + for _ in 0..SAMPLES { + let c = class(rng); + let s = if matches!(c, Class::Left) { &a } else { &b }; + runner.run_one(c, || { + let mut out = Line([0u8; 64]); + ct_bench::fy_swaps_only(black_box(s), &mut out); + black_box(out.0[0]) + }); + } +} + +/// Control for the swap half: the same draws and swaps at a public index. +fn iso_swap_public_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) { + data_independent_timing(); + let (a, b) = stream_pair(rng); + for _ in 0..SAMPLES { + let c = class(rng); + let s = if matches!(c, Class::Left) { &a } else { &b }; + runner.run_one(c, || { + let mut out = Line([0u8; 64]); + ct_bench::fy_swaps_public(black_box(s), &mut out); + black_box(out.0[0]) + }); + } +} + +/// Mechanism isolation, inverse half: the inverse fill alone, from the two +/// permutations two fixed streams build (computed outside the measurement). +fn iso_fill_secret_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) { + data_independent_timing(); + let (a, b) = stream_pair(rng); + let (pa, pb) = (ct_bench::permutation_for(&a), ct_bench::permutation_for(&b)); + for _ in 0..SAMPLES { + let c = class(rng); + let p = if matches!(c, Class::Left) { &pa } else { &pb }; + runner.run_one(c, || { + let mut out = Line([0u8; 64]); + ct_bench::inverse_fill_only(black_box(p), &mut out); + black_box(out.0[0]) + }); + } +} + +/// Control for the inverse half: the same loads and stores at public offsets. +fn iso_fill_public_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) { + data_independent_timing(); + let (a, b) = stream_pair(rng); + let (pa, pb) = (ct_bench::permutation_for(&a), ct_bench::permutation_for(&b)); + for _ in 0..SAMPLES { + let c = class(rng); + let p = if matches!(c, Class::Left) { &pa } else { &pb }; + runner.run_one(c, || { + let mut out = Line([0u8; 64]); + ct_bench::inverse_fill_public(black_box(p), &mut out); + black_box(out.0[0]) + }); + } +} + +/// `prp_build_fixed_a_vs_fixed_b` through the reference builder (textbook +/// Fisher–Yates, secret-indexed), for before/after comparison in one binary. +fn prp_build_reference_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) { + data_independent_timing(); + let (a, b) = stream_pair(rng); + for _ in 0..SAMPLES { + let c = class(rng); + let s = if matches!(c, Class::Left) { &a } else { &b }; + runner.run_one(c, || { + black_box(ct_bench::lemire_fy_prp_from_stream_reference(black_box(s))) + }); + } +} + +/// `prp_build_fixed_a_vs_fixed_b` through the portable scalar oblivious +/// builder. +fn prp_build_scalar_obl_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) { + data_independent_timing(); + let (a, b) = stream_pair(rng); + for _ in 0..SAMPLES { + let c = class(rng); + let s = if matches!(c, Class::Left) { &a } else { &b }; + runner.run_one(c, || { + black_box(ct_bench::lemire_fy_prp_from_stream_scalar_oblivious( + black_box(s), + )) + }); + } +} + +/// `prp_build_fixed_a_vs_fixed_b` through the portable SWAR oblivious +/// builder (the fallback on targets with neither NEON nor SSSE3). +fn prp_build_swar_obl_fixed_a_vs_fixed_b(runner: &mut CtRunner, rng: &mut BenchRng) { + data_independent_timing(); + let (a, b) = stream_pair(rng); + for _ in 0..SAMPLES { + let c = class(rng); + let s = if matches!(c, Class::Left) { &a } else { &b }; + runner.run_one(c, || { + black_box(ct_bench::lemire_fy_prp_from_stream_swar_oblivious( + black_box(s), + )) + }); + } +} + +ctbench_main!( + bit6_compare_first_vs_last_block, + chained_compare_first_vs_last_byte, + bit6_encrypt_zero_vs_random, + bit6_encrypt_const_vs_random, + bit6_encrypt_left_const_vs_random, + bit6_encrypt_left_fixed_a_vs_fixed_b, + prp_build_const_vs_random, + prp_build_const_vs_random_separate_pools, + prp_build_const_vs_random_interleaved_even, + prp_build_const_vs_random_interleaved_odd, + prp_build_fixed_a_vs_fixed_b, + chained_encrypt_fixed_vs_random, + iso_swap_secret_fixed_a_vs_fixed_b, + iso_swap_public_fixed_a_vs_fixed_b, + iso_fill_secret_fixed_a_vs_fixed_b, + iso_fill_public_fixed_a_vs_fixed_b, + prp_build_reference_fixed_a_vs_fixed_b, + prp_build_scalar_obl_fixed_a_vs_fixed_b, + prp_build_swar_obl_fixed_a_vs_fixed_b +); diff --git a/packages/ct-taint/Cargo.lock b/packages/ct-taint/Cargo.lock new file mode 100644 index 00000000..29677e4d --- /dev/null +++ b/packages/ct-taint/Cargo.lock @@ -0,0 +1,582 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", + "zeroize", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bindgen" +version = "0.73.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "787ef8ef523575546b106a58213d6e6b06198a05c2f757258c68a74273670cfa" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 3.0.6", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-modes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2211b0817f061502a8dd9f11a37e879e79763e3c698d2418cf824d8cb2f21e" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crabgrind" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3f4bc9b199b6cfdf87ef7960ac7be99306a1ad4d6c442b1c3701cddb22ea47d" +dependencies = [ + "bindgen", + "cc", + "pkg-config", + "rustversion", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ct-taint" +version = "0.0.0" +dependencies = [ + "crabgrind", + "hex-literal", + "ore-rs", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "orderable-bytes" +version = "0.2.0" + +[[package]] +name = "ore-rs" +version = "0.8.4" +dependencies = [ + "aes", + "block-modes", + "byteorder", + "hex", + "num", + "orderable-bytes", + "rand", + "rand_chacha", + "subtle-ng", + "thiserror", + "zeroize", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn 3.0.6", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "subtle-ng" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "734676eb262c623cec13c3155096e08d1f8f29adce39ba17948b18dad1e54142" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/packages/ct-taint/Cargo.toml b/packages/ct-taint/Cargo.toml new file mode 100644 index 00000000..ba6ffa95 --- /dev/null +++ b/packages/ct-taint/Cargo.toml @@ -0,0 +1,36 @@ +# Valgrind taint harness for ore-rs's secret-dependent code paths. +# +# This is a DETACHED crate (own `[workspace]` table, not a member of the root +# workspace): it links Valgrind client requests through `crabgrind`, which the +# normal build and test of ore-rs must never depend on. Run it with +# `scripts/ct-taint.sh`, under `valgrind --tool=memcheck`; outside Valgrind the +# marks are no-ops and the binary just exercises the paths. +# +# The technique is ctgrind's: mark the key and the plaintext as *undefined* +# memory, run the operation, and let memcheck report every conditional jump +# and every memory address that depends on them. Each report is a +# secret-dependent branch or a secret-indexed access. The expected reports β€” +# the documented exceptions β€” live in `valgrind.supp`; anything else fails +# the run. +[package] +name = "ct-taint" +version = "0.0.0" +publish = false +edition = "2021" + +[dependencies] +crabgrind = "0.4" +hex-literal = "0.4" + +[dependencies.ore-rs] +path = "../ore-rs" + +# Frames must resolve for the suppressions to match by function name, so keep +# debug info in the optimised build. Optimisation is required: the question is +# whether the *shipped* code is branch-free, and the debug build is not the +# shipped code. +[profile.release] +debug = 1 + +[workspace] +resolver = "2" diff --git a/packages/ct-taint/src/main.rs b/packages/ct-taint/src/main.rs new file mode 100644 index 00000000..0dd3665a --- /dev/null +++ b/packages/ct-taint/src/main.rs @@ -0,0 +1,300 @@ +//! Valgrind taint harness for the v2 ORE encryptors (ctgrind technique). +//! +//! Each mode marks the key material and the plaintext as *undefined* memory +//! (Valgrind client request), runs one operation, marks the output defined, +//! and prints a checksum. Under `valgrind --tool=memcheck`, every conditional +//! jump and every memory address that depends on the undefined bytes is +//! reported: those are the secret-dependent branches and secret-indexed +//! accesses. The expected ones are listed, with their reasons, in +//! `valgrind.supp`; `scripts/ct-taint.sh` fails on anything else. +//! +//! Only the encryptors are tainted. The comparators take ciphertexts, which are +//! public, so there is nothing to mark on that path; the comparator's timing +//! behaviour on *equal-prefix versus differing* inputs is a statistical +//! question (dudect), not a taint one. +//! +//! Outside Valgrind the marks are no-ops and the modes just run. +//! +//! **Gate mode** (`CT_TAINT_GATE=1`, set by `scripts/ct-taint.sh`) refuses a +//! vacuous pass. Every taint request must succeed, and each mode checks, +//! before untainting, that its output is still partly undefined: the taint +//! reached the ciphertext, so a clean run means the path really is free of +//! secret-dependent branches and addresses. The `canary` mode branches on a +//! tainted byte and indexes memory with it, and the script requires memcheck +//! to fail it. +//! +//! Scope: the v2 encryptors only. The legacy `OreAes128` encryptor is not +//! run: its Knuth-shuffle PRP rejection-samples and swaps at secret indices +//! by design, and its wire format is frozen (v2 plan, Non-goals). + +use std::env; +use std::process::exit; + +use crabgrind::memcheck::{mark_memory, vbits, MemState}; +use hex_literal::hex; +use ore_rs::scheme::bit2_w6::OreAes128Bit6ChaCha20; +use ore_rs::scheme::chained::{OreAes128Bit6Chained, OreAes128Bit6ChainedChaCha20}; +use ore_rs::{OreCipher, OreEncrypt, OreOutput}; + +/// Whether this run is the gate (`CT_TAINT_GATE` set), which must run under +/// memcheck and must not pass vacuously. +fn gate() -> bool { + env::var_os("CT_TAINT_GATE").is_some() +} + +/// Mark `bytes` undefined: from here on, anything computed from them is +/// tracked as tainted by memcheck. +fn taint(bytes: &mut [u8]) { + let marked = mark_memory( + bytes.as_mut_ptr() as *const _, + bytes.len(), + MemState::Undefined, + ); + // Outside Valgrind the request fails and the run is a plain exercise of + // the path. In the gate that would make every mode pass while checking + // nothing, so it is fatal there. + if gate() && marked.is_err() { + eprintln!("ct-taint: mark_memory failed; the gate must run under memcheck"); + exit(3); + } +} + +/// Fail the gate if every byte of `out` is defined. The ciphertext is +/// computed from the tainted key and plaintext, so some of it must be +/// undefined; if none is, the taint was lost (a request that did nothing, or +/// a key the optimiser kept in a register past the mark) and a clean run +/// proves nothing. +fn assert_tainted(out: &[u8]) { + if !gate() { + return; + } + // GET_VBITS wants 4-byte-aligned buffers whose length is a multiple of 4. + let words = out.len() / 4; + let mut src = vec![0u32; words]; + let mut bits = vec![0u32; words]; + // SAFETY: both buffers hold `words` u32s; the byte views cover them exactly. + let (src_bytes, bits_bytes) = unsafe { + ( + std::slice::from_raw_parts_mut(src.as_mut_ptr() as *mut u8, words * 4), + std::slice::from_raw_parts_mut(bits.as_mut_ptr() as *mut u8, words * 4), + ) + }; + // The copy carries the V bits with it. + src_bytes.copy_from_slice(&out[..words * 4]); + if let Err(e) = vbits(src_bytes.as_ptr() as *const _, bits_bytes) { + eprintln!("ct-taint: reading V bits failed ({e:?}); the gate must run under memcheck"); + exit(3); + } + if bits_bytes.iter().all(|&b| b == 0) { + eprintln!("ct-taint: the output is fully defined, so the taint never reached it"); + exit(4); + } + // Let the copy be dropped without memcheck tracking it as tainted. + untaint(src_bytes); +} + +/// Mark `bytes` defined again, so that using the *output* of an operation +/// (printing it, hashing it) is not itself reported. Checks first, in the +/// gate, that the taint reached it. +fn untaint_output(bytes: &mut [u8]) { + assert_tainted(bytes); + untaint(bytes); +} + +fn untaint(bytes: &mut [u8]) { + let _ = mark_memory( + bytes.as_mut_ptr() as *const _, + bytes.len(), + MemState::DefinedIfAddressable, + ); +} + +/// FNV-1a over the output, so the result is observably used and the +/// optimiser cannot drop the computation. +fn checksum(bytes: &[u8]) -> u64 { + bytes.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &b| { + (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3) + }) +} + +fn keys() -> ([u8; 16], [u8; 16]) { + ( + hex!("000102030405060708090a0b0c0d0e0f"), + hex!("d0d1d2d3d4d5d6d7d8d9dadbdcdddedf"), + ) +} + +/// `x` with its bytes marked undefined. +fn tainted(mut x: T) -> T { + // SAFETY: `T` is plain `Copy` data; this views its bytes to mark them. + let bytes = unsafe { + std::slice::from_raw_parts_mut(&mut x as *mut T as *mut u8, std::mem::size_of::()) + }; + taint(bytes); + x +} + +fn bit6() -> OreAes128Bit6ChaCha20 { + let (mut k1, mut k2) = keys(); + taint(&mut k1); + taint(&mut k2); + OreCipher::init(&k1, &k2).expect("init") +} + +/// Full Bit6 encryption of a tainted `x` through `OreEncrypt`, which runs the +/// type's own `to_orderable_bytes` transform on the secret. +fn bit6_typed>(x: T) -> Vec { + let ore = bit6(); + let mut out = tainted(x).encrypt(&ore).expect("encrypt").to_bytes(); + untaint_output(&mut out); + out +} + +fn bit6_full() -> Vec { + bit6_typed(0x1234_5678_9abc_def0u64) +} + +fn bit6_left() -> Vec { + let ore = bit6(); + let x = tainted(0x1234_5678_9abc_def0u64); + let mut out = x.encrypt_left(&ore).expect("encrypt_left").to_bytes(); + untaint_output(&mut out); + out +} + +fn bit6_i64() -> Vec { + bit6_typed(-0x1234_5678_9abc_def0i64) +} + +fn bit6_f64() -> Vec { + bit6_typed(-1234.5678f64) +} + +/// `u32` is 6 blocks, a different `N` from the 64-bit types' 11. +fn bit6_u32() -> Vec { + bit6_typed(0x89ab_cdefu32) +} + +/// The 11 six-bit blocks of a u64, as a caller of the raw-block entry points +/// would pass them. +const RAW_BLOCKS: [u8; 11] = [4, 35, 5, 22, 15, 9, 43, 60, 55, 59, 48]; + +fn bit6_raw() -> Vec { + let ore = bit6(); + let x = tainted(RAW_BLOCKS); + let mut out = OreCipher::encrypt(&ore, &x).expect("encrypt").to_bytes(); + untaint_output(&mut out); + out +} + +fn bit6_raw_left() -> Vec { + let ore = bit6(); + let x = tainted(RAW_BLOCKS); + let mut out = OreCipher::encrypt_left(&ore, &x) + .expect("encrypt_left") + .to_bytes(); + untaint_output(&mut out); + out +} + +/// The plaintext string, tainted. Built with `from_utf8_unchecked` because +/// `from_utf8`'s validation branches on every byte, which would report the +/// harness rather than the encryptor; the bytes are ASCII. +fn tainted_str(buf: &mut [u8; 17]) -> &str { + *buf = *b"alice@example.com"; + taint(buf); + // SAFETY: the buffer was just filled with ASCII. + unsafe { std::str::from_utf8_unchecked(buf) } +} + +fn chained() -> OreAes128Bit6ChainedChaCha20 { + let (mut k1, _) = keys(); + taint(&mut k1); + OreAes128Bit6Chained::init(&k1).expect("init") +} + +fn chained_full() -> Vec { + let ore = chained(); + let mut buf = [0u8; 17]; + let s = tainted_str(&mut buf); + let mut out = ore.encrypt_str(s).expect("encrypt_str").to_bytes(); + untaint_output(&mut out); + out +} + +fn chained_left() -> Vec { + let ore = chained(); + let mut buf = [0u8; 17]; + let s = tainted_str(&mut buf); + let mut out = ore + .encrypt_left_str(s) + .expect("encrypt_left_str") + .to_bytes(); + untaint_output(&mut out); + out +} + +fn chained_raw() -> Vec { + let ore = chained(); + let x = tainted(RAW_BLOCKS); + let mut out = ore.encrypt_var(&x).expect("encrypt_var").to_bytes(); + untaint_output(&mut out); + out +} + +fn chained_raw_left() -> Vec { + let ore = chained(); + let x = tainted(RAW_BLOCKS); + let mut out = ore + .encrypt_left_var(&x) + .expect("encrypt_left_var") + .to_bytes(); + untaint_output(&mut out); + out +} + +/// Positive control: branches on a tainted byte and indexes memory with it. +/// Memcheck must report both, so `scripts/ct-taint.sh` requires this mode to +/// fail. If it passes, the gate cannot see a secret-dependent branch. +fn canary() -> Vec { + let secret = tainted(std::hint::black_box(0x5au8)); + let table = std::hint::black_box([0u8; 256]); + let mut out = vec![table[usize::from(secret)]]; + if secret & 1 == 0 { + out.push(std::hint::black_box(1)); + } + untaint(&mut out); + out +} + +const MODES: &[(&str, fn() -> Vec)] = &[ + ("bit6-encrypt", bit6_full), + ("bit6-encrypt-left", bit6_left), + ("bit6-encrypt-i64", bit6_i64), + ("bit6-encrypt-f64", bit6_f64), + ("bit6-encrypt-u32", bit6_u32), + ("bit6-encrypt-raw", bit6_raw), + ("bit6-encrypt-left-raw", bit6_raw_left), + ("chained-encrypt", chained_full), + ("chained-encrypt-left", chained_left), + ("chained-encrypt-raw", chained_raw), + ("chained-encrypt-left-raw", chained_raw_left), + ("canary", canary), +]; + +fn main() { + let mode = env::args().nth(1).unwrap_or_default(); + let Some((name, run)) = MODES.iter().find(|(n, _)| *n == mode) else { + eprintln!("usage: ct-taint \nmodes:"); + for (n, _) in MODES { + eprintln!(" {n}"); + } + exit(2); + }; + let out = run(); + println!( + "{name}: {} bytes, checksum {:016x}", + out.len(), + checksum(&out) + ); +} diff --git a/packages/ct-taint/valgrind.supp b/packages/ct-taint/valgrind.supp new file mode 100644 index 00000000..493378d1 --- /dev/null +++ b/packages/ct-taint/valgrind.supp @@ -0,0 +1,25 @@ +# Accepted memcheck reports for the taint harness. Each entry is a +# secret-dependent access or branch that the design documents and accepts; +# scripts/ct-taint.sh fails on anything not listed here. Add an entry only +# with a reason. Frame names are demangled Rust symbols, matched as globs. +# +# The typed entry points report nothing: `permute` takes an in-domain +# `Symbol`, and `OreEncrypt` and the string encryptors get their symbols from +# the 6-bit decomposition, which masks rather than checks. +# +# The one accepted report is `Blocks6::check`, on the raw-block entry points +# (`OreCipher::encrypt`/`encrypt_left` for Bit6, `encrypt_var`/ +# `encrypt_left_var` for the chained scheme). A caller of those passes symbols +# that may be out of the 6-bit domain, and the function must refuse them. It +# ORs every symbol's high bits together without branching and branches once +# on the result, so the branch reveals only whether some symbol was out of +# domain. For a well-formed input it always goes the same way. `check` is +# `#[inline(never)]`, so the branch is always in its own frame, whose name is +# `ore_rs::scheme::decompose::Blocks6::check` (or its v0-mangled form, which +# also contains `Blocks6` and `check`). + +{ + blocks6-check: one branch on whether any raw symbol is out of domain + Memcheck:Cond + fun:*Blocks6*check* +} diff --git a/packages/ore-rs/Cargo.toml b/packages/ore-rs/Cargo.toml index c57ba418..501b6aed 100644 --- a/packages/ore-rs/Cargo.toml +++ b/packages/ore-rs/Cargo.toml @@ -31,6 +31,16 @@ chrono = ["dep:chrono", "orderable-bytes/chrono"] # order-preserving encoding fed into the existing fixed-N ORE machinery. # The encoder lives in the sibling `orderable-bytes` crate. decimal = ["dep:rust_decimal", "orderable-bytes/decimal"] +# Exposes `ct_bench`, timing-measurement entry points to otherwise private +# primitives, for the detached `packages/ct-dudect` crate. Not part of the +# public API: a feature rather than a `cfg` so it never trips +# `unexpected_cfgs`, and never enabled by a dependant. +ct-bench = [] +# Adds the two heaviest Kani harnesses: exhaustive-length `ct_select_byte` +# (about 9 minutes) and SWAR-builder equivalence (about 15 minutes on an M4, +# too heavy for GitHub's runners). CI runs `cargo kani` without it; run +# `cargo kani -p ore-rs --features kani-full` for the complete set. +kani-full = [] [dependencies] aes = { version = "0.8.2", features = ["zeroize"] } @@ -47,6 +57,10 @@ subtle-ng = "2.5.0" zeroize = { version = "1.5.7", features = ["zeroize_derive"] } thiserror = "1.0.38" +# `cfg(kani)` gates the Kani proof harnesses (compiled only by `cargo kani`). +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(kani)'] } + [[bench]] name = "oreaes128" harness = false @@ -69,5 +83,10 @@ required-features = ["chrono"] name = "chained" harness = false +[[bench]] +name = "prp_build" +harness = false +required-features = ["ct-bench"] + [[example]] name = "encrypt" diff --git a/packages/ore-rs/benches/prp_build.rs b/packages/ore-rs/benches/prp_build.rs new file mode 100644 index 00000000..db62cb85 --- /dev/null +++ b/packages/ore-rs/benches/prp_build.rs @@ -0,0 +1,69 @@ +//! The Bit6 PRP builder alone, per 512-byte draw stream: the reference +//! builder (textbook Fisher–Yates, secret-indexed; not used by any scheme), +//! the byte-at-a-time `subtle_ng` and SWAR oblivious builders, and the +//! target's dispatched oblivious builder (NEON on aarch64, SSSE3 or SWAR on +//! x86_64, SWAR elsewhere), which is what the schemes call. Each goes +//! through a `ct_bench` hook that also builds the struct, does one oblivious +//! `permute` and wipes the struct on drop, a fixed cost common to all. +//! +//! Each iteration takes the next of 256 precomputed random streams, so the +//! figure is an average over swap sequences rather than one sequence's +//! (the reference builder's time depends on the sequence). +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use ore_rs::ct_bench; + +const STREAMS: usize = 256; + +/// A `ct_bench` builder hook: one stream in, one permuted value out. +type Builder = fn(&[u8; 512]) -> u8; + +fn streams() -> Vec<[u8; 512]> { + let mut x = 0x9e37_79b9_7f4a_7c15u64; + (0..STREAMS) + .map(|_| { + let mut s = [0u8; 512]; + for b in s.iter_mut() { + // xorshift64: fixed, random-looking streams. + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + *b = (x >> 24) as u8; + } + s + }) + .collect() +} + +fn criterion_benchmark(c: &mut Criterion) { + let s = streams(); + let builders: [(&str, Builder); 4] = [ + ( + "prp-build-reference", + ct_bench::lemire_fy_prp_from_stream_reference, + ), + ( + "prp-build-scalar-oblivious", + ct_bench::lemire_fy_prp_from_stream_scalar_oblivious, + ), + ( + "prp-build-swar-oblivious", + ct_bench::lemire_fy_prp_from_stream_swar_oblivious, + ), + ( + "prp-build-dispatched-oblivious", + ct_bench::lemire_fy_prp_from_stream, + ), + ]; + for (name, build) in builders { + c.bench_function(name, |b| { + let mut n = 0usize; + b.iter(|| { + n = (n + 1) % STREAMS; + build(black_box(&s[n])) + }) + }); + } +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/packages/ore-rs/fuzz/Cargo.lock b/packages/ore-rs/fuzz/Cargo.lock new file mode 100644 index 00000000..0ce1a0b1 --- /dev/null +++ b/packages/ore-rs/fuzz/Cargo.lock @@ -0,0 +1,436 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", + "zeroize", +] + +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "block-modes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2211b0817f061502a8dd9f11a37e879e79763e3c698d2418cf824d8cb2f21e" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "orderable-bytes" +version = "0.2.0" + +[[package]] +name = "ore-rs" +version = "0.8.4" +dependencies = [ + "aes", + "block-modes", + "byteorder", + "hex", + "num", + "orderable-bytes", + "rand", + "rand_chacha", + "subtle-ng", + "thiserror", + "zeroize", +] + +[[package]] +name = "ore-rs-fuzz" +version = "0.0.0" +dependencies = [ + "libfuzzer-sys", + "ore-rs", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "subtle-ng" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "734676eb262c623cec13c3155096e08d1f8f29adce39ba17948b18dad1e54142" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/packages/ore-rs/fuzz/Cargo.toml b/packages/ore-rs/fuzz/Cargo.toml new file mode 100644 index 00000000..f48077d0 --- /dev/null +++ b/packages/ore-rs/fuzz/Cargo.toml @@ -0,0 +1,52 @@ +# Fuzz crate for ore-rs's ciphertext parsers and raw-byte comparators. +# +# This is a DETACHED crate: the `[workspace]` table at the bottom makes it its +# own workspace root, so the libfuzzer-sys dependency and the nightly-only +# build never touch the main workspace. It is not a member of the root +# `Cargo.toml`. Run with `cargo +nightly fuzz run ` from this +# directory, or via `scripts/fuzz.sh`. +[package] +name = "ore-rs-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" + +[dependencies.ore-rs] +path = ".." + +[[bin]] +name = "chained_parse" +path = "fuzz_targets/chained_parse.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "chained_compare" +path = "fuzz_targets/chained_compare.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "bit6_parse" +path = "fuzz_targets/bit6_parse.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "bit8_parse" +path = "fuzz_targets/bit8_parse.rs" +test = false +doc = false +bench = false + +[workspace] +resolver = "2" diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/000d7507a7b6bed879041f4ce25a3d529e28e623 b/packages/ore-rs/fuzz/corpus/bit6_parse/000d7507a7b6bed879041f4ce25a3d529e28e623 new file mode 100644 index 00000000..5ea0e75f Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/000d7507a7b6bed879041f4ce25a3d529e28e623 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/00ce8985418ed391c629aa567ef15cbb260732e7 b/packages/ore-rs/fuzz/corpus/bit6_parse/00ce8985418ed391c629aa567ef15cbb260732e7 new file mode 100644 index 00000000..42ab5f5d Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/00ce8985418ed391c629aa567ef15cbb260732e7 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/12aebf75e86d5d4f05aa3aad23349ec2aeef98fd b/packages/ore-rs/fuzz/corpus/bit6_parse/12aebf75e86d5d4f05aa3aad23349ec2aeef98fd new file mode 100644 index 00000000..1dbd9403 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/12aebf75e86d5d4f05aa3aad23349ec2aeef98fd differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/157f50e1321d659080887d9fd370c1dc589b6e72 b/packages/ore-rs/fuzz/corpus/bit6_parse/157f50e1321d659080887d9fd370c1dc589b6e72 new file mode 100644 index 00000000..bc51a3af Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/157f50e1321d659080887d9fd370c1dc589b6e72 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/183665a90573ea56efa1cc2cb3ef78006a36b2b7 b/packages/ore-rs/fuzz/corpus/bit6_parse/183665a90573ea56efa1cc2cb3ef78006a36b2b7 new file mode 100644 index 00000000..b2df4ff9 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/183665a90573ea56efa1cc2cb3ef78006a36b2b7 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/23aa46d9452afbbd7e3ab119be67326c9a0fd4f6 b/packages/ore-rs/fuzz/corpus/bit6_parse/23aa46d9452afbbd7e3ab119be67326c9a0fd4f6 new file mode 100644 index 00000000..1008a1f6 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/23aa46d9452afbbd7e3ab119be67326c9a0fd4f6 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/2e6844ff250c6e5a1c3913796828e70c8b5a5366 b/packages/ore-rs/fuzz/corpus/bit6_parse/2e6844ff250c6e5a1c3913796828e70c8b5a5366 new file mode 100644 index 00000000..e20621b4 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/2e6844ff250c6e5a1c3913796828e70c8b5a5366 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/3085b983de17cea555ce3567bcb6df1b1cf24fae b/packages/ore-rs/fuzz/corpus/bit6_parse/3085b983de17cea555ce3567bcb6df1b1cf24fae new file mode 100644 index 00000000..592f06ce Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/3085b983de17cea555ce3567bcb6df1b1cf24fae differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/30b556270801fd397cd4a9592a6326488af77be4 b/packages/ore-rs/fuzz/corpus/bit6_parse/30b556270801fd397cd4a9592a6326488af77be4 new file mode 100644 index 00000000..7c75e7bb Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/30b556270801fd397cd4a9592a6326488af77be4 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/3562e03960b028eff5d572b72e290ec9d0a657fe b/packages/ore-rs/fuzz/corpus/bit6_parse/3562e03960b028eff5d572b72e290ec9d0a657fe new file mode 100644 index 00000000..e9b5fdd0 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/3562e03960b028eff5d572b72e290ec9d0a657fe differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/3659dc5c4e97fa96417cc67ad753ef2fce37a59b b/packages/ore-rs/fuzz/corpus/bit6_parse/3659dc5c4e97fa96417cc67ad753ef2fce37a59b new file mode 100644 index 00000000..d36e17b4 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/3659dc5c4e97fa96417cc67ad753ef2fce37a59b differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/3f7be39df0bb715a7b4745d57e4b79fc7c6aecd0 b/packages/ore-rs/fuzz/corpus/bit6_parse/3f7be39df0bb715a7b4745d57e4b79fc7c6aecd0 new file mode 100644 index 00000000..7899b4f9 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/3f7be39df0bb715a7b4745d57e4b79fc7c6aecd0 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/49c99d11dada646352a48cd45da81108d28ccd0e b/packages/ore-rs/fuzz/corpus/bit6_parse/49c99d11dada646352a48cd45da81108d28ccd0e new file mode 100644 index 00000000..ea69dc38 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/49c99d11dada646352a48cd45da81108d28ccd0e differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/4fa94c6fd9ba98f8939114f8660ec114a5d8e556 b/packages/ore-rs/fuzz/corpus/bit6_parse/4fa94c6fd9ba98f8939114f8660ec114a5d8e556 new file mode 100644 index 00000000..053f4cea Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/4fa94c6fd9ba98f8939114f8660ec114a5d8e556 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/500083da360656944aa064d1b2c3892f5ce76927 b/packages/ore-rs/fuzz/corpus/bit6_parse/500083da360656944aa064d1b2c3892f5ce76927 new file mode 100644 index 00000000..0a88a742 --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit6_parse/500083da360656944aa064d1b2c3892f5ce76927 @@ -0,0 +1 @@ +��;;����Ι \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/50414e57b481be32d7a5a35052d511a1c468fa42 b/packages/ore-rs/fuzz/corpus/bit6_parse/50414e57b481be32d7a5a35052d511a1c468fa42 new file mode 100644 index 00000000..0ecbc2e3 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/50414e57b481be32d7a5a35052d511a1c468fa42 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/505b7ce5f3630a15f521fc2a6e3419f52e9101f0 b/packages/ore-rs/fuzz/corpus/bit6_parse/505b7ce5f3630a15f521fc2a6e3419f52e9101f0 new file mode 100644 index 00000000..19055f98 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/505b7ce5f3630a15f521fc2a6e3419f52e9101f0 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/54b20345484cd24ae7186b035119e50496531cb4 b/packages/ore-rs/fuzz/corpus/bit6_parse/54b20345484cd24ae7186b035119e50496531cb4 new file mode 100644 index 00000000..0af93a30 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/54b20345484cd24ae7186b035119e50496531cb4 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/5e6c1c9650f82fb531e908b5c5fddf2d68719c3a b/packages/ore-rs/fuzz/corpus/bit6_parse/5e6c1c9650f82fb531e908b5c5fddf2d68719c3a new file mode 100644 index 00000000..069273dd Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/5e6c1c9650f82fb531e908b5c5fddf2d68719c3a differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/5f16e95069a76408a8fe334e71e2178bd8c20875 b/packages/ore-rs/fuzz/corpus/bit6_parse/5f16e95069a76408a8fe334e71e2178bd8c20875 new file mode 100644 index 00000000..7df03581 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/5f16e95069a76408a8fe334e71e2178bd8c20875 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/66a0ba18cdc3f9e4134daa8651a55e6da00e5492 b/packages/ore-rs/fuzz/corpus/bit6_parse/66a0ba18cdc3f9e4134daa8651a55e6da00e5492 new file mode 100644 index 00000000..73376839 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/66a0ba18cdc3f9e4134daa8651a55e6da00e5492 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/6fdcd6e786ddeaa27a88dd9bd01eea8899efc2a9 b/packages/ore-rs/fuzz/corpus/bit6_parse/6fdcd6e786ddeaa27a88dd9bd01eea8899efc2a9 new file mode 100644 index 00000000..8cd0a8ee Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/6fdcd6e786ddeaa27a88dd9bd01eea8899efc2a9 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/6fee54294ecbd170c1d99d69cbe0748dd3e689ec b/packages/ore-rs/fuzz/corpus/bit6_parse/6fee54294ecbd170c1d99d69cbe0748dd3e689ec new file mode 100644 index 00000000..d853e81e Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/6fee54294ecbd170c1d99d69cbe0748dd3e689ec differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/773ba1a9c4879736ff180d3eb548f429842cc5c9 b/packages/ore-rs/fuzz/corpus/bit6_parse/773ba1a9c4879736ff180d3eb548f429842cc5c9 new file mode 100644 index 00000000..d62ab072 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/773ba1a9c4879736ff180d3eb548f429842cc5c9 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/786a871ce842feefd10f6b4fc075b4a8d8fad8e7 b/packages/ore-rs/fuzz/corpus/bit6_parse/786a871ce842feefd10f6b4fc075b4a8d8fad8e7 new file mode 100644 index 00000000..2b16b554 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/786a871ce842feefd10f6b4fc075b4a8d8fad8e7 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/7d75cc8bc1fd82131e2230305a01f8436f68f5b9 b/packages/ore-rs/fuzz/corpus/bit6_parse/7d75cc8bc1fd82131e2230305a01f8436f68f5b9 new file mode 100644 index 00000000..bbb63ae4 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/7d75cc8bc1fd82131e2230305a01f8436f68f5b9 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/84ef5b36e3cdd63709b71371113fa57ab95d9e01 b/packages/ore-rs/fuzz/corpus/bit6_parse/84ef5b36e3cdd63709b71371113fa57ab95d9e01 new file mode 100644 index 00000000..bc4ef803 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/84ef5b36e3cdd63709b71371113fa57ab95d9e01 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/855155dccb9ab04e6954ed4e2711e244baa6ba14 b/packages/ore-rs/fuzz/corpus/bit6_parse/855155dccb9ab04e6954ed4e2711e244baa6ba14 new file mode 100644 index 00000000..9942c82a Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/855155dccb9ab04e6954ed4e2711e244baa6ba14 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/874cada632e8aa3982ff17f30dbdbe6a8d74eed4 b/packages/ore-rs/fuzz/corpus/bit6_parse/874cada632e8aa3982ff17f30dbdbe6a8d74eed4 new file mode 100644 index 00000000..e3a114ef Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/874cada632e8aa3982ff17f30dbdbe6a8d74eed4 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/89096f0b81069aba25c5349783bb4861c03c20c2 b/packages/ore-rs/fuzz/corpus/bit6_parse/89096f0b81069aba25c5349783bb4861c03c20c2 new file mode 100644 index 00000000..2aa437d0 --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit6_parse/89096f0b81069aba25c5349783bb4861c03c20c2 @@ -0,0 +1,2 @@ + +� \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/90899bbb8bc55398246823622d09833d1d9d9499 b/packages/ore-rs/fuzz/corpus/bit6_parse/90899bbb8bc55398246823622d09833d1d9d9499 new file mode 100644 index 00000000..f0307868 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/90899bbb8bc55398246823622d09833d1d9d9499 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/97aa93acfca61e454d4cf767d59ee8891b6264aa b/packages/ore-rs/fuzz/corpus/bit6_parse/97aa93acfca61e454d4cf767d59ee8891b6264aa new file mode 100644 index 00000000..d6025142 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/97aa93acfca61e454d4cf767d59ee8891b6264aa differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/99f7fab666c9333055210ee362273969764f029f b/packages/ore-rs/fuzz/corpus/bit6_parse/99f7fab666c9333055210ee362273969764f029f new file mode 100644 index 00000000..23b1f01b Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/99f7fab666c9333055210ee362273969764f029f differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/9cc6b74a646822c92ea1fd2e5443a489704fbb21 b/packages/ore-rs/fuzz/corpus/bit6_parse/9cc6b74a646822c92ea1fd2e5443a489704fbb21 new file mode 100644 index 00000000..5d1a5f23 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/9cc6b74a646822c92ea1fd2e5443a489704fbb21 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/a0b29d245b7b93f3ba2a10cf9786969a880f749a b/packages/ore-rs/fuzz/corpus/bit6_parse/a0b29d245b7b93f3ba2a10cf9786969a880f749a new file mode 100644 index 00000000..8e7c1cf8 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/a0b29d245b7b93f3ba2a10cf9786969a880f749a differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/a23de43548e425170ab14d347c9abe7db9d572eb b/packages/ore-rs/fuzz/corpus/bit6_parse/a23de43548e425170ab14d347c9abe7db9d572eb new file mode 100644 index 00000000..4896aeae Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/a23de43548e425170ab14d347c9abe7db9d572eb differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/a3cad4149521dd2f7ef2b1e98df8f15a214f8c6a b/packages/ore-rs/fuzz/corpus/bit6_parse/a3cad4149521dd2f7ef2b1e98df8f15a214f8c6a new file mode 100644 index 00000000..5469d61e Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/a3cad4149521dd2f7ef2b1e98df8f15a214f8c6a differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/a820b93c65d2350da0135e0006c226e58c33399b b/packages/ore-rs/fuzz/corpus/bit6_parse/a820b93c65d2350da0135e0006c226e58c33399b new file mode 100644 index 00000000..6402e7ed Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/a820b93c65d2350da0135e0006c226e58c33399b differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/ac99fd37c875754cdece6c8b0f88ba3bc83f8e74 b/packages/ore-rs/fuzz/corpus/bit6_parse/ac99fd37c875754cdece6c8b0f88ba3bc83f8e74 new file mode 100644 index 00000000..33f276ff Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/ac99fd37c875754cdece6c8b0f88ba3bc83f8e74 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/b634594820aa893076dc03c130b7d019576d1243 b/packages/ore-rs/fuzz/corpus/bit6_parse/b634594820aa893076dc03c130b7d019576d1243 new file mode 100644 index 00000000..e2303ee6 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/b634594820aa893076dc03c130b7d019576d1243 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/c5b6ccdb4df2eba10378960b334b0406d63f63ce b/packages/ore-rs/fuzz/corpus/bit6_parse/c5b6ccdb4df2eba10378960b334b0406d63f63ce new file mode 100644 index 00000000..494d1562 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/c5b6ccdb4df2eba10378960b334b0406d63f63ce differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/c967cd940ba7cd1d6901e03f848e643a7d601c87 b/packages/ore-rs/fuzz/corpus/bit6_parse/c967cd940ba7cd1d6901e03f848e643a7d601c87 new file mode 100644 index 00000000..a6203da1 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/c967cd940ba7cd1d6901e03f848e643a7d601c87 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/ca5b02785cca7a3641a216d93ddaf6b96804b2a4 b/packages/ore-rs/fuzz/corpus/bit6_parse/ca5b02785cca7a3641a216d93ddaf6b96804b2a4 new file mode 100644 index 00000000..72f1ed56 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/ca5b02785cca7a3641a216d93ddaf6b96804b2a4 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/cea59c2bd0bb96386146a3776b40f9b8cd7b497d b/packages/ore-rs/fuzz/corpus/bit6_parse/cea59c2bd0bb96386146a3776b40f9b8cd7b497d new file mode 100644 index 00000000..fe307359 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/cea59c2bd0bb96386146a3776b40f9b8cd7b497d differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/d913fde08ecaff77eb3349757c5eff50d0a590ae b/packages/ore-rs/fuzz/corpus/bit6_parse/d913fde08ecaff77eb3349757c5eff50d0a590ae new file mode 100644 index 00000000..c470a204 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/d913fde08ecaff77eb3349757c5eff50d0a590ae differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/dac13f6b4c0db911dc5ba50a183408924bfe8519 b/packages/ore-rs/fuzz/corpus/bit6_parse/dac13f6b4c0db911dc5ba50a183408924bfe8519 new file mode 100644 index 00000000..81edacec Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/dac13f6b4c0db911dc5ba50a183408924bfe8519 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/e0ed746a4207aadce54ee1ee5716e9299839f263 b/packages/ore-rs/fuzz/corpus/bit6_parse/e0ed746a4207aadce54ee1ee5716e9299839f263 new file mode 100644 index 00000000..4db899a8 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/e0ed746a4207aadce54ee1ee5716e9299839f263 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/eb53cb42f3a3bbf0fd55906fd4eaa73dbf6c8491 b/packages/ore-rs/fuzz/corpus/bit6_parse/eb53cb42f3a3bbf0fd55906fd4eaa73dbf6c8491 new file mode 100644 index 00000000..131123ef Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/eb53cb42f3a3bbf0fd55906fd4eaa73dbf6c8491 differ diff --git a/packages/ore-rs/fuzz/corpus/bit6_parse/f92c5d64634b29aa544bef2f7c2ba26abe059d09 b/packages/ore-rs/fuzz/corpus/bit6_parse/f92c5d64634b29aa544bef2f7c2ba26abe059d09 new file mode 100644 index 00000000..86ea8a88 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit6_parse/f92c5d64634b29aa544bef2f7c2ba26abe059d09 differ diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/1a1169b85b4da2d2e7d6a40d0a92ea304871c898 b/packages/ore-rs/fuzz/corpus/bit8_parse/1a1169b85b4da2d2e7d6a40d0a92ea304871c898 new file mode 100644 index 00000000..b1ca2b73 --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit8_parse/1a1169b85b4da2d2e7d6a40d0a92ea304871c898 @@ -0,0 +1,2 @@ + +&ββββββββββββββββββββββββββββββββββββςββββββββββwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwββββΰβββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/1bc7c470785e9b20b5e6b476a2cab9876a16117b b/packages/ore-rs/fuzz/corpus/bit8_parse/1bc7c470785e9b20b5e6b476a2cab9876a16117b new file mode 100644 index 00000000..247d375d --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit8_parse/1bc7c470785e9b20b5e6b476a2cab9876a16117b @@ -0,0 +1,2 @@ + +WWWWWWWWWWWW����WW¦WWWWWWWWββββββββββββββββββββββββββββββββWWWW©¨§ϊ���WWόόόόόόόόόόόόύόόόόόόόόόόόόόόόόόόόόόόόόόόόόόˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆ \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/1d2945904afe31ca4e11beace7fa80b26ea7d76d b/packages/ore-rs/fuzz/corpus/bit8_parse/1d2945904afe31ca4e11beace7fa80b26ea7d76d new file mode 100644 index 00000000..8892481d Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit8_parse/1d2945904afe31ca4e11beace7fa80b26ea7d76d differ diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/4ec9aa556c92fb471e8a47817b673a9a3d1f278f b/packages/ore-rs/fuzz/corpus/bit8_parse/4ec9aa556c92fb471e8a47817b673a9a3d1f278f new file mode 100644 index 00000000..1c81276a --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit8_parse/4ec9aa556c92fb471e8a47817b673a9a3d1f278f @@ -0,0 +1,3 @@ + +«««««««««««««««]««« +«΅ \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/57b635ecfa2d621d1059ddf560e330fcef2e1ab4 b/packages/ore-rs/fuzz/corpus/bit8_parse/57b635ecfa2d621d1059ddf560e330fcef2e1ab4 new file mode 100644 index 00000000..0b4d2e3b --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit8_parse/57b635ecfa2d621d1059ddf560e330fcef2e1ab4 @@ -0,0 +1,2 @@ + +&ββββββββββββββββββββββββββββββββββββςββββββββββwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwββββΰβββββββτββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/5b55ec37559bb110228a6591713076b7fa2ce5e8 b/packages/ore-rs/fuzz/corpus/bit8_parse/5b55ec37559bb110228a6591713076b7fa2ce5e8 new file mode 100644 index 00000000..5e2ed41f --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit8_parse/5b55ec37559bb110228a6591713076b7fa2ce5e8 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/5d3671d6fd79c556777376b9db9eeb1238286cbe b/packages/ore-rs/fuzz/corpus/bit8_parse/5d3671d6fd79c556777376b9db9eeb1238286cbe new file mode 100644 index 00000000..a74b3e2e --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit8_parse/5d3671d6fd79c556777376b9db9eeb1238286cbe @@ -0,0 +1,3 @@ + +WWWWWWWWWWWW����WW¦WWWWWWWWββββββββββββββββββββββββββββββββW +WWWWWWWWWWWW����WW¦WWWόόόόόόόόόόόόόόόόόόόόόόόόόόόˆˆˆˆˆˆˆˆˆˆˆA \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/79a4d9f793276a51378df429e0ebf837c8aadc07 b/packages/ore-rs/fuzz/corpus/bit8_parse/79a4d9f793276a51378df429e0ebf837c8aadc07 new file mode 100644 index 00000000..1e793758 --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit8_parse/79a4d9f793276a51378df429e0ebf837c8aadc07 @@ -0,0 +1,3 @@ + +&βββββββββββββββββββββββββββββββββKβββςββββββββββwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww +WWWWWWWWWWWW����WW¦WWWWWWWWββββββββββββββββββββββββββββββββWWWW©¨§ϊ���WWόόόόόόόόόόόόύόόόόόόόόόόόόόόόόόόόόόόόόόόόόόˆˆˆˆˆˆˆˆˆˆˆˆˆwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwβ∈ββˆβˆβˆβ \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/867f9d66dcbce6ae39342dd2fc04ac219dbb2a16 b/packages/ore-rs/fuzz/corpus/bit8_parse/867f9d66dcbce6ae39342dd2fc04ac219dbb2a16 new file mode 100644 index 00000000..3800a21c --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit8_parse/867f9d66dcbce6ae39342dd2fc04ac219dbb2a16 @@ -0,0 +1,3 @@ + +«W«««««««««««««««]««« +«W \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/bfcf553891e3ba2c34e5e63225b51bc0a0332eb2 b/packages/ore-rs/fuzz/corpus/bit8_parse/bfcf553891e3ba2c34e5e63225b51bc0a0332eb2 new file mode 100644 index 00000000..0ed8f212 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit8_parse/bfcf553891e3ba2c34e5e63225b51bc0a0332eb2 differ diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/c6b504a181054b9c9bff07e9461d1a6ea2dafc1b b/packages/ore-rs/fuzz/corpus/bit8_parse/c6b504a181054b9c9bff07e9461d1a6ea2dafc1b new file mode 100644 index 00000000..039e75de --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit8_parse/c6b504a181054b9c9bff07e9461d1a6ea2dafc1b @@ -0,0 +1,2 @@ + +WWWWWWWWWWWW����WW¦WWWWWWWWββββββββββββββββββββββββββββββββWWWW©¨§ϊ���WWόόόόόόόόόόόόύόόόόόόόόόόόόόόόόόόόόόόόόόόόόόˆˆˆˆˆˆˆˆˆˆˆA \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/c77f05e9e9648c99adb66235e1b87715da99309f b/packages/ore-rs/fuzz/corpus/bit8_parse/c77f05e9e9648c99adb66235e1b87715da99309f new file mode 100644 index 00000000..ddda8a2c --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit8_parse/c77f05e9e9648c99adb66235e1b87715da99309f @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/ccaa9768cc85a13e46df3dea0e02952e010b3852 b/packages/ore-rs/fuzz/corpus/bit8_parse/ccaa9768cc85a13e46df3dea0e02952e010b3852 new file mode 100644 index 00000000..26ea6a27 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/bit8_parse/ccaa9768cc85a13e46df3dea0e02952e010b3852 differ diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/e7dd041512c660ccd0f4f63daf0414497b8b7be2 b/packages/ore-rs/fuzz/corpus/bit8_parse/e7dd041512c660ccd0f4f63daf0414497b8b7be2 new file mode 100644 index 00000000..6c997655 --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit8_parse/e7dd041512c660ccd0f4f63daf0414497b8b7be2 @@ -0,0 +1,3 @@ + + +&ββββββββββββββββββββββββββββββββββββςβββwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwββββΰβββββββwwwwβββββββββββββββββββββ \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/eaf3021dbdc5feeaba63175d7006d9523d8fc465 b/packages/ore-rs/fuzz/corpus/bit8_parse/eaf3021dbdc5feeaba63175d7006d9523d8fc465 new file mode 100644 index 00000000..c12424f3 --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit8_parse/eaf3021dbdc5feeaba63175d7006d9523d8fc465 @@ -0,0 +1,4 @@ + +&ββββββββββββββββββββββββββββββββββββββββββςβββwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwββββββββββββββββββββ + +&ββββββββββββββββββββββββββββββββββββςβββwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwβββββββwwwwwwwwwwwwwwwwwwwwwwwβββςββββββββββwwwwwwwwwwwwwwwwwwwwwwwwwww \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/f11684a34515cd7d5c708ae44def7a4163aa1074 b/packages/ore-rs/fuzz/corpus/bit8_parse/f11684a34515cd7d5c708ae44def7a4163aa1074 new file mode 100644 index 00000000..14bdae15 --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit8_parse/f11684a34515cd7d5c708ae44def7a4163aa1074 @@ -0,0 +1,2 @@ + +&ββ=βββββββββββββββββββββββ~βββββββββββββββββββββΪΪΪββΪΪΪΪΪΪΪΪΪΪΪΪΪΪΪΪΪΪΨ \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/bit8_parse/f683fb5ae87e6044c802b30a3fb58c0bdcb350be b/packages/ore-rs/fuzz/corpus/bit8_parse/f683fb5ae87e6044c802b30a3fb58c0bdcb350be new file mode 100644 index 00000000..bb11cc55 --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/bit8_parse/f683fb5ae87e6044c802b30a3fb58c0bdcb350be @@ -0,0 +1,3 @@ + +�����������« +��������������������������������������������������� \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/1498b91d9601baa5b4a8506dcf35f35084aefeb8 b/packages/ore-rs/fuzz/corpus/chained_compare/1498b91d9601baa5b4a8506dcf35f35084aefeb8 new file mode 100644 index 00000000..32a12c7c Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/chained_compare/1498b91d9601baa5b4a8506dcf35f35084aefeb8 differ diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/1a162e129193f8179d677a82bd7f25c830e292b5 b/packages/ore-rs/fuzz/corpus/chained_compare/1a162e129193f8179d677a82bd7f25c830e292b5 new file mode 100644 index 00000000..66f1d7e5 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/chained_compare/1a162e129193f8179d677a82bd7f25c830e292b5 differ diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/1b77c4fe26629c6716dbd50cc7a3a549e68fd9e1 b/packages/ore-rs/fuzz/corpus/chained_compare/1b77c4fe26629c6716dbd50cc7a3a549e68fd9e1 new file mode 100644 index 00000000..1aa79e48 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/chained_compare/1b77c4fe26629c6716dbd50cc7a3a549e68fd9e1 differ diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/1fd2e9fcf01c1736b15e7226e7676b19c04616c1 b/packages/ore-rs/fuzz/corpus/chained_compare/1fd2e9fcf01c1736b15e7226e7676b19c04616c1 new file mode 100644 index 00000000..9d711897 --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/chained_compare/1fd2e9fcf01c1736b15e7226e7676b19c04616c1 @@ -0,0 +1,3 @@ + ++ ++ diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/245f6e3cdedbf18b1a839a9c43c109b1711c9d50 b/packages/ore-rs/fuzz/corpus/chained_compare/245f6e3cdedbf18b1a839a9c43c109b1711c9d50 new file mode 100644 index 00000000..4da3d060 --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/chained_compare/245f6e3cdedbf18b1a839a9c43c109b1711c9d50 @@ -0,0 +1,4 @@ +½λλλ��������������������������������������������������������������������������������������������������������������� +�������������λλλλλλλλλλλλλλλλλλλλλλ2λλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλλ +λλλλλλλ]λλλλλλλλλλλλλλλλλλλλλ����� +���������������+ \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/2886832a394afa27969905c40503dbd60834e1f6 b/packages/ore-rs/fuzz/corpus/chained_compare/2886832a394afa27969905c40503dbd60834e1f6 new file mode 100644 index 00000000..41ea4293 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/chained_compare/2886832a394afa27969905c40503dbd60834e1f6 differ diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/4766af07390d4fb62682fd6658fdf3caaeab97f2 b/packages/ore-rs/fuzz/corpus/chained_compare/4766af07390d4fb62682fd6658fdf3caaeab97f2 new file mode 100644 index 00000000..ea417bad --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/chained_compare/4766af07390d4fb62682fd6658fdf3caaeab97f2 @@ -0,0 +1 @@ +��,��, \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/50c99221f6bbd7f615f47fde80c726514578aa06 b/packages/ore-rs/fuzz/corpus/chained_compare/50c99221f6bbd7f615f47fde80c726514578aa06 new file mode 100644 index 00000000..2101cd33 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/chained_compare/50c99221f6bbd7f615f47fde80c726514578aa06 differ diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/66a01f825f6546b851c597318bd307a73a089fe5 b/packages/ore-rs/fuzz/corpus/chained_compare/66a01f825f6546b851c597318bd307a73a089fe5 new file mode 100644 index 00000000..30ee0c06 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/chained_compare/66a01f825f6546b851c597318bd307a73a089fe5 differ diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/69db7413a72a01817a55236ccc943dae5b75a382 b/packages/ore-rs/fuzz/corpus/chained_compare/69db7413a72a01817a55236ccc943dae5b75a382 new file mode 100644 index 00000000..3ccd5898 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/chained_compare/69db7413a72a01817a55236ccc943dae5b75a382 differ diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/6f1e8d7fa8c2dc39d5df3248c0fce695b170fb5e b/packages/ore-rs/fuzz/corpus/chained_compare/6f1e8d7fa8c2dc39d5df3248c0fce695b170fb5e new file mode 100644 index 00000000..b42f82e6 --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/chained_compare/6f1e8d7fa8c2dc39d5df3248c0fce695b170fb5e @@ -0,0 +1 @@ +�,�3 \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/712e27f64b93cf7fd2b7c09e7354a3fcf22d4158 b/packages/ore-rs/fuzz/corpus/chained_compare/712e27f64b93cf7fd2b7c09e7354a3fcf22d4158 new file mode 100644 index 00000000..91348809 --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/chained_compare/712e27f64b93cf7fd2b7c09e7354a3fcf22d4158 @@ -0,0 +1,2 @@ + +�ϊ������� \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/749e715bf58ab2b3a134353ef1f123611c5ec337 b/packages/ore-rs/fuzz/corpus/chained_compare/749e715bf58ab2b3a134353ef1f123611c5ec337 new file mode 100644 index 00000000..06df6fda --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/chained_compare/749e715bf58ab2b3a134353ef1f123611c5ec337 @@ -0,0 +1 @@ +�,�,7 \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/8643cda4e2e84d8255f9decd445a05f22a821793 b/packages/ore-rs/fuzz/corpus/chained_compare/8643cda4e2e84d8255f9decd445a05f22a821793 new file mode 100644 index 00000000..ad20df4c Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/chained_compare/8643cda4e2e84d8255f9decd445a05f22a821793 differ diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/ae0b7e44403af4ef918f37a4a254c47bc69195b1 b/packages/ore-rs/fuzz/corpus/chained_compare/ae0b7e44403af4ef918f37a4a254c47bc69195b1 new file mode 100644 index 00000000..af91ad9e --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/chained_compare/ae0b7e44403af4ef918f37a4a254c47bc69195b1 @@ -0,0 +1,2 @@ + +)))�ϊ������� \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/b9d1f333f27c1376371782661a6ff0301522fed7 b/packages/ore-rs/fuzz/corpus/chained_compare/b9d1f333f27c1376371782661a6ff0301522fed7 new file mode 100644 index 00000000..9f425c71 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/chained_compare/b9d1f333f27c1376371782661a6ff0301522fed7 differ diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/ba921fc92e3bdb9757825fc618ba41f3741fefa8 b/packages/ore-rs/fuzz/corpus/chained_compare/ba921fc92e3bdb9757825fc618ba41f3741fefa8 new file mode 100644 index 00000000..c20c5ff9 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/chained_compare/ba921fc92e3bdb9757825fc618ba41f3741fefa8 differ diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/bb3953c2f738264b2d17d9d8f1301c1a3567443a b/packages/ore-rs/fuzz/corpus/chained_compare/bb3953c2f738264b2d17d9d8f1301c1a3567443a new file mode 100644 index 00000000..2ec8cbb6 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/chained_compare/bb3953c2f738264b2d17d9d8f1301c1a3567443a differ diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/c190bddafec30d2016a482c9ae390ac59e1ab36b b/packages/ore-rs/fuzz/corpus/chained_compare/c190bddafec30d2016a482c9ae390ac59e1ab36b new file mode 100644 index 00000000..16473aad --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/chained_compare/c190bddafec30d2016a482c9ae390ac59e1ab36b @@ -0,0 +1 @@ +�����«( \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/c7c8ad4d6eb862e28ce0b192f58d80bfc1dda323 b/packages/ore-rs/fuzz/corpus/chained_compare/c7c8ad4d6eb862e28ce0b192f58d80bfc1dda323 new file mode 100644 index 00000000..64d002d8 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/chained_compare/c7c8ad4d6eb862e28ce0b192f58d80bfc1dda323 differ diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/d304cab0bc67acafc9ff86ef1000551d731e8521 b/packages/ore-rs/fuzz/corpus/chained_compare/d304cab0bc67acafc9ff86ef1000551d731e8521 new file mode 100644 index 00000000..7f7d6b1b Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/chained_compare/d304cab0bc67acafc9ff86ef1000551d731e8521 differ diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/d70a31566afd8362d9fc4d9f221dced7c77277aa b/packages/ore-rs/fuzz/corpus/chained_compare/d70a31566afd8362d9fc4d9f221dced7c77277aa new file mode 100644 index 00000000..b6cb8292 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/chained_compare/d70a31566afd8362d9fc4d9f221dced7c77277aa differ diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/d870ec0cc1724a182044c97ba3c7870a2f28da8e b/packages/ore-rs/fuzz/corpus/chained_compare/d870ec0cc1724a182044c97ba3c7870a2f28da8e new file mode 100644 index 00000000..d1d1abf7 --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/chained_compare/d870ec0cc1724a182044c97ba3c7870a2f28da8e @@ -0,0 +1,2 @@ + ++ \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/de6b6d078e2249a744cb5e103557d22cd9c75dd9 b/packages/ore-rs/fuzz/corpus/chained_compare/de6b6d078e2249a744cb5e103557d22cd9c75dd9 new file mode 100644 index 00000000..18697ce1 --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/chained_compare/de6b6d078e2249a744cb5e103557d22cd9c75dd9 @@ -0,0 +1,2 @@ +½����� ++ \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/chained_compare/f4855d9e4dd28214677e20d52b5969d88be8c692 b/packages/ore-rs/fuzz/corpus/chained_compare/f4855d9e4dd28214677e20d52b5969d88be8c692 new file mode 100644 index 00000000..b111549e --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/chained_compare/f4855d9e4dd28214677e20d52b5969d88be8c692 @@ -0,0 +1 @@ +�,#� \ No newline at end of file diff --git a/packages/ore-rs/fuzz/corpus/chained_parse/11efd772c1b4e6f667ecbfeb4873ff777f3c2a09 b/packages/ore-rs/fuzz/corpus/chained_parse/11efd772c1b4e6f667ecbfeb4873ff777f3c2a09 new file mode 100644 index 00000000..007ff774 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/chained_parse/11efd772c1b4e6f667ecbfeb4873ff777f3c2a09 differ diff --git a/packages/ore-rs/fuzz/corpus/chained_parse/2ed8c13d341e8e12aa6f6320c11259b87fcffdae b/packages/ore-rs/fuzz/corpus/chained_parse/2ed8c13d341e8e12aa6f6320c11259b87fcffdae new file mode 100644 index 00000000..047088a1 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/chained_parse/2ed8c13d341e8e12aa6f6320c11259b87fcffdae differ diff --git a/packages/ore-rs/fuzz/corpus/chained_parse/4450d4ef86aa3af4aa06080ba8b724ee1617de07 b/packages/ore-rs/fuzz/corpus/chained_parse/4450d4ef86aa3af4aa06080ba8b724ee1617de07 new file mode 100644 index 00000000..c7e7b78a Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/chained_parse/4450d4ef86aa3af4aa06080ba8b724ee1617de07 differ diff --git a/packages/ore-rs/fuzz/corpus/chained_parse/4a7ac40e72557bbb47625347bc253bf59592ebcc b/packages/ore-rs/fuzz/corpus/chained_parse/4a7ac40e72557bbb47625347bc253bf59592ebcc new file mode 100644 index 00000000..247b0123 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/chained_parse/4a7ac40e72557bbb47625347bc253bf59592ebcc differ diff --git a/packages/ore-rs/fuzz/corpus/chained_parse/5a0d51d6406dff707363da7baed6cbf85c862965 b/packages/ore-rs/fuzz/corpus/chained_parse/5a0d51d6406dff707363da7baed6cbf85c862965 new file mode 100644 index 00000000..e832e94d Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/chained_parse/5a0d51d6406dff707363da7baed6cbf85c862965 differ diff --git a/packages/ore-rs/fuzz/corpus/chained_parse/5d18fd9726255aef7acb14cacb6c1f74ab748661 b/packages/ore-rs/fuzz/corpus/chained_parse/5d18fd9726255aef7acb14cacb6c1f74ab748661 new file mode 100644 index 00000000..6d27bed8 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/chained_parse/5d18fd9726255aef7acb14cacb6c1f74ab748661 differ diff --git a/packages/ore-rs/fuzz/corpus/chained_parse/6dc20172dc2ab8b09c45e12ab5d5836223566e5e b/packages/ore-rs/fuzz/corpus/chained_parse/6dc20172dc2ab8b09c45e12ab5d5836223566e5e new file mode 100644 index 00000000..db2866e1 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/chained_parse/6dc20172dc2ab8b09c45e12ab5d5836223566e5e differ diff --git a/packages/ore-rs/fuzz/corpus/chained_parse/a4fe776497787732b7e01a7e5c051ed91f6bb8b7 b/packages/ore-rs/fuzz/corpus/chained_parse/a4fe776497787732b7e01a7e5c051ed91f6bb8b7 new file mode 100644 index 00000000..68abf304 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/chained_parse/a4fe776497787732b7e01a7e5c051ed91f6bb8b7 differ diff --git a/packages/ore-rs/fuzz/corpus/chained_parse/ae3597212aeaf69e8c6932e9a26d9aeeea3c80a6 b/packages/ore-rs/fuzz/corpus/chained_parse/ae3597212aeaf69e8c6932e9a26d9aeeea3c80a6 new file mode 100644 index 00000000..84fe154e Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/chained_parse/ae3597212aeaf69e8c6932e9a26d9aeeea3c80a6 differ diff --git a/packages/ore-rs/fuzz/corpus/chained_parse/b72e19e1a60e66a97d4f55e4ef410a84c1d06c26 b/packages/ore-rs/fuzz/corpus/chained_parse/b72e19e1a60e66a97d4f55e4ef410a84c1d06c26 new file mode 100644 index 00000000..2e947f1b Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/chained_parse/b72e19e1a60e66a97d4f55e4ef410a84c1d06c26 differ diff --git a/packages/ore-rs/fuzz/corpus/chained_parse/d623c63a4d62802dd790ca8c575034d350d2e4d4 b/packages/ore-rs/fuzz/corpus/chained_parse/d623c63a4d62802dd790ca8c575034d350d2e4d4 new file mode 100644 index 00000000..c3fe03d0 Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/chained_parse/d623c63a4d62802dd790ca8c575034d350d2e4d4 differ diff --git a/packages/ore-rs/fuzz/corpus/chained_parse/d663b1e3643fe6e5e851067d5eabc6aee524e64f b/packages/ore-rs/fuzz/corpus/chained_parse/d663b1e3643fe6e5e851067d5eabc6aee524e64f new file mode 100644 index 00000000..25e26eeb Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/chained_parse/d663b1e3643fe6e5e851067d5eabc6aee524e64f differ diff --git a/packages/ore-rs/fuzz/corpus/chained_parse/d9e816fb2b510edc9434e954a29a1f7a9bfab1b7 b/packages/ore-rs/fuzz/corpus/chained_parse/d9e816fb2b510edc9434e954a29a1f7a9bfab1b7 new file mode 100644 index 00000000..e8f22aeb --- /dev/null +++ b/packages/ore-rs/fuzz/corpus/chained_parse/d9e816fb2b510edc9434e954a29a1f7a9bfab1b7 @@ -0,0 +1,2 @@ +* +* diff --git a/packages/ore-rs/fuzz/corpus/chained_parse/ea6969cd6fdea04b397aec0b3be7dfc7ef7c14cc b/packages/ore-rs/fuzz/corpus/chained_parse/ea6969cd6fdea04b397aec0b3be7dfc7ef7c14cc new file mode 100644 index 00000000..b6344f9c Binary files /dev/null and b/packages/ore-rs/fuzz/corpus/chained_parse/ea6969cd6fdea04b397aec0b3be7dfc7ef7c14cc differ diff --git a/packages/ore-rs/fuzz/fuzz_targets/bit6_parse.rs b/packages/ore-rs/fuzz/fuzz_targets/bit6_parse.rs new file mode 100644 index 00000000..641c842c --- /dev/null +++ b/packages/ore-rs/fuzz/fuzz_targets/bit6_parse.rs @@ -0,0 +1,76 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use ore_rs::scheme::bit2_w6::OreAes128Bit6ChaCha20; +use ore_rs::{CipherText, Left, OreCipher, OreOutput, Right}; + +// The fixed-width v2 scheme at its two shipped widths (11 blocks for 64-bit +// values, 6 for 32-bit). Parsing untrusted bytes as a full, left-only or +// right-only ciphertext must never panic, anything accepted must re-encode +// to itself, and the raw comparator must answer arbitrary pairs without +// panicking and agree with the typed `Ord` on pairs that parse. +fn check(data: &[u8]) { + if let Ok(ct) = CipherText::::from_slice(data) { + assert_eq!( + ct.to_bytes(), + data, + "accepted full ciphertext did not round-trip" + ); + } + if let Ok(left) = Left::::from_slice(data) { + assert_eq!( + left.to_bytes(), + data, + "accepted left ciphertext did not round-trip" + ); + } + if let Ok(right) = Right::::from_slice(data) { + assert_eq!( + right.to_bytes(), + data, + "accepted right ciphertext did not round-trip" + ); + } +} + +/// When both inputs parse as full ciphertexts, the raw comparator and the +/// typed `Ord` must give the same order: a database index (raw bytes) and an +/// application (parsed values) must not order the same rows differently. +/// Antisymmetry is not asserted; it holds only for genuine ciphertexts. +fn agree(a: &[u8], b: &[u8]) { + let (Ok(ct_a), Ok(ct_b)) = ( + CipherText::::from_slice(a), + CipherText::::from_slice(b), + ) else { + return; + }; + assert_eq!( + OreAes128Bit6ChaCha20::compare_raw_slices(a, b), + Some(ct_a.cmp(&ct_b)), + "raw and typed comparators disagree" + ); +} + +/// `a` with its first `header` bytes kept and `b` written over the rest. +/// Random pairs almost never both parse; this pair has `a`'s length and +/// header, so when `a` parses it usually does too. +fn overlay(a: &[u8], b: &[u8], header: usize) -> Vec { + let mut out = a.to_vec(); + for (o, &x) in out.iter_mut().skip(header).zip(b) { + *o = x; + } + out +} + +fuzz_target!(|pair: (&[u8], &[u8])| { + let (a, b) = pair; + check::<11>(a); + check::<6>(a); + let b2 = overlay(a, b, 4); + for (x, y) in [(a, b), (a, &b2[..]), (&b2[..], a)] { + agree::<11>(x, y); + agree::<6>(x, y); + } + let _ = OreAes128Bit6ChaCha20::compare_raw_slices(a, b); + let _ = OreAes128Bit6ChaCha20::compare_raw_slices(b, a); +}); diff --git a/packages/ore-rs/fuzz/fuzz_targets/bit8_parse.rs b/packages/ore-rs/fuzz/fuzz_targets/bit8_parse.rs new file mode 100644 index 00000000..d18601ff --- /dev/null +++ b/packages/ore-rs/fuzz/fuzz_targets/bit8_parse.rs @@ -0,0 +1,76 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use ore_rs::scheme::bit2::OreAes128ChaCha20; +use ore_rs::{CipherText, Left, OreCipher, OreOutput, Right}; + +// The legacy 8-bit-block scheme, whose wire format is frozen and whose rows +// already sit in customer databases. Same contract as the v2 targets, for +// the full, left-only and right-only parsers: +// never panic on untrusted bytes, accepted bytes round-trip, the raw +// comparator answers any pair and agrees with the typed `Ord`. +fn check(data: &[u8]) { + if let Ok(ct) = CipherText::::from_slice(data) { + assert_eq!( + ct.to_bytes(), + data, + "accepted full ciphertext did not round-trip" + ); + } + if let Ok(left) = Left::::from_slice(data) { + assert_eq!( + left.to_bytes(), + data, + "accepted left ciphertext did not round-trip" + ); + } + if let Ok(right) = Right::::from_slice(data) { + assert_eq!( + right.to_bytes(), + data, + "accepted right ciphertext did not round-trip" + ); + } +} + +/// When both inputs parse as full ciphertexts, the raw comparator and the +/// typed `Ord` must give the same order: a database index (raw bytes) and an +/// application (parsed values) must not order the same rows differently. +/// Antisymmetry is not asserted; it holds only for genuine ciphertexts. +fn agree(a: &[u8], b: &[u8]) { + let (Ok(ct_a), Ok(ct_b)) = ( + CipherText::::from_slice(a), + CipherText::::from_slice(b), + ) else { + return; + }; + assert_eq!( + OreAes128ChaCha20::compare_raw_slices(a, b), + Some(ct_a.cmp(&ct_b)), + "raw and typed comparators disagree" + ); +} + +/// `a` with its first `header` bytes kept and `b` written over the rest. +/// Random pairs almost never both parse; this pair has `a`'s length and +/// header, so when `a` parses it usually does too. +fn overlay(a: &[u8], b: &[u8], header: usize) -> Vec { + let mut out = a.to_vec(); + for (o, &x) in out.iter_mut().skip(header).zip(b) { + *o = x; + } + out +} + +fuzz_target!(|pair: (&[u8], &[u8])| { + let (a, b) = pair; + check::<8>(a); + check::<4>(a); + let b2 = overlay(a, b, 0); + for (x, y) in [(a, b), (a, &b2[..]), (&b2[..], a)] { + agree::<8>(x, y); + agree::<4>(x, y); + } + let _ = OreAes128ChaCha20::compare_raw_slices(a, b); + let _ = OreAes128ChaCha20::compare_raw_slices(b, a); +}); diff --git a/packages/ore-rs/fuzz/fuzz_targets/chained_compare.rs b/packages/ore-rs/fuzz/fuzz_targets/chained_compare.rs new file mode 100644 index 00000000..9bcbd001 --- /dev/null +++ b/packages/ore-rs/fuzz/fuzz_targets/chained_compare.rs @@ -0,0 +1,17 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use ore_rs::scheme::chained::OreAes128Bit6ChainedChaCha20; + +// Both comparators take raw stored bytes and index into them by the block +// count each header declares. Arbitrary byte pairs must never panic: a +// malformed side is answered with `None`. No ordering property is asserted +// here, because antisymmetry only holds for genuine ciphertexts, not for +// arbitrary right halves. +fuzz_target!(|pair: (&[u8], &[u8])| { + let (a, b) = pair; + let _ = OreAes128Bit6ChainedChaCha20::compare_raw_slices(a, b); + let _ = OreAes128Bit6ChainedChaCha20::compare_raw_slices(b, a); + let _ = OreAes128Bit6ChainedChaCha20::compare_left_to_full(a, b); + let _ = OreAes128Bit6ChainedChaCha20::compare_left_to_full(b, a); +}); diff --git a/packages/ore-rs/fuzz/fuzz_targets/chained_parse.rs b/packages/ore-rs/fuzz/fuzz_targets/chained_parse.rs new file mode 100644 index 00000000..928745f3 --- /dev/null +++ b/packages/ore-rs/fuzz/fuzz_targets/chained_parse.rs @@ -0,0 +1,18 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use ore_rs::scheme::chained::VarCipherText; + +// Ciphertexts are read back from storage, so the parser sees untrusted bytes: +// it must never panic (the block count is read from the header and drives +// every slice index), and anything it accepts must re-encode to the same +// bytes, or the stored form and the parsed form would disagree. +fuzz_target!(|data: &[u8]| { + if let Ok(ct) = VarCipherText::from_slice(data) { + assert_eq!( + ct.to_bytes(), + data, + "accepted ciphertext did not round-trip" + ); + } +}); diff --git a/packages/ore-rs/src/lib.rs b/packages/ore-rs/src/lib.rs index a9d86410..c5dfdd1d 100644 --- a/packages/ore-rs/src/lib.rs +++ b/packages/ore-rs/src/lib.rs @@ -144,6 +144,154 @@ mod encrypt; mod primitives; pub mod scheme; pub use crate::ciphertext::*; + +/// Timing-measurement entry points to private primitives, for the detached +/// `ct-dudect` crate. Enabled only by the `ct-bench` feature; not public API. +#[cfg(feature = "ct-bench")] +#[doc(hidden)] +pub mod ct_bench { + use crate::primitives::prp::{oblivious, LemireFyPrp}; + use crate::primitives::{Prp, Symbol}; + + /// Build the 64-element Bit6 PRP from a 512-byte draw stream and return + /// one permuted value, so the construction is observably used. This is + /// the builder the schemes use (the target's oblivious builder). + pub fn lemire_fy_prp_from_stream(stream: &[u8; 512]) -> u8 { + LemireFyPrp::<64>::from_stream(stream) + .expect("512 bytes is a full stream") + .permute(Symbol::from_low_bits(0)) + .get() + } + + fn from_stream_with(stream: &[u8; 512], build: fn(&[u8], &mut [u8; 64], &mut [u8; 64])) -> u8 { + LemireFyPrp::<64>::from_stream_with(stream, build) + .expect("512 bytes is a full stream") + .permute(Symbol::from_low_bits(0)) + .get() + } + + /// As [`lemire_fy_prp_from_stream`], through the reference builder: + /// textbook Fisher–Yates with secret-indexed swaps and inverse fill. + /// Not used by any scheme; here for before/after timing. + pub fn lemire_fy_prp_from_stream_reference(stream: &[u8; 512]) -> u8 { + from_stream_with(stream, oblivious::reference::build) + } + + /// As [`lemire_fy_prp_from_stream`], through the byte-at-a-time + /// `subtle_ng` oblivious builder whatever the target. + pub fn lemire_fy_prp_from_stream_scalar_oblivious(stream: &[u8; 512]) -> u8 { + from_stream_with(stream, oblivious::scalar::build) + } + + /// As [`lemire_fy_prp_from_stream`], through the portable SWAR + /// oblivious builder whatever the target. + pub fn lemire_fy_prp_from_stream_swar_oblivious(stream: &[u8; 512]) -> u8 { + from_stream_with(stream, oblivious::swar::build) + } + + // ----------------------------------------------------------------- + // Mechanism isolation: the two halves of the reference builder, and a + // control for each with the same work at public addresses. Each keeps + // its table in one 64-byte-aligned line, as `LemireFyPrp<64>` does. + // ----------------------------------------------------------------- + + /// One 64-byte, line-aligned table. + #[repr(C, align(64))] + pub struct Line(pub [u8; 64]); + + /// Lemire draw for step `i`, reduced to `0..=i`: the builders' own + /// [`oblivious::lemire_draw`], so the benches cannot drift from it. + #[inline(always)] + fn draw(stream: &[u8; 512], i: usize) -> usize { + usize::from(oblivious::lemire_draw(stream, i)) + } + + /// The reference builder's Fisher–Yates swap loop alone (no inverse + /// fill): swaps at the secret draw-derived index. The loop is + /// [`oblivious::reference::build`]'s, restated so it can be timed + /// without the fill; a test pins it to that builder's permutation. + #[inline(never)] + pub fn fy_swaps_only(stream: &[u8; 512], out: &mut Line) { + for (k, p) in out.0.iter_mut().enumerate() { + *p = k as u8; + } + for i in (1..64).rev() { + let j = draw(stream, i); + out.0.swap(i, j); + } + } + + /// Control for [`fy_swaps_only`]: the same draws and the same swaps, + /// but at the public index `i - 1` (the draw is computed and kept live, + /// then not used as an address). + #[inline(never)] + pub fn fy_swaps_public(stream: &[u8; 512], out: &mut Line) { + for (k, p) in out.0.iter_mut().enumerate() { + *p = k as u8; + } + for i in (1..64).rev() { + core::hint::black_box(draw(stream, i)); + out.0.swap(i, i - 1); + } + } + + /// The permutation the builders build for `stream`, computed outside + /// any measured region by the reference builder itself, to feed + /// [`inverse_fill_only`]. + pub fn permutation_for(stream: &[u8; 512]) -> Line { + let mut l = Line([0u8; 64]); + let mut inverse = [0u8; 64]; + oblivious::reference::build(stream, &mut l.0, &mut inverse); + l + } + + /// The reference builder's inverse fill alone: `inverse[perm[k]] = k`, + /// stores at secret offsets. + #[inline(never)] + pub fn inverse_fill_only(perm: &Line, out: &mut Line) { + for (index, val) in perm.0.iter().enumerate() { + out.0[*val as usize & 63] = index as u8; + } + } + + /// Control for [`inverse_fill_only`]: the same loads and stores, at the + /// public offset `index` (the loaded value is kept live, not used as an + /// address). + #[inline(never)] + pub fn inverse_fill_public(perm: &Line, out: &mut Line) { + for (index, val) in perm.0.iter().enumerate() { + core::hint::black_box(*val); + out.0[index] = index as u8; + } + } + + #[cfg(test)] + mod tests { + use super::*; + + /// The isolated swap loop and inverse fill build exactly what the + /// reference builder builds, so the isolation benches time the real + /// construction. + #[test] + fn isolation_halves_match_the_reference_builder() { + for seed in 0u8..16 { + let stream: [u8; 512] = + core::array::from_fn(|k| (k as u8).wrapping_mul(167) ^ seed.wrapping_mul(29)); + let mut want_perm = [0u8; 64]; + let mut want_inverse = [0u8; 64]; + oblivious::reference::build(&stream, &mut want_perm, &mut want_inverse); + + let mut swaps = Line([0u8; 64]); + fy_swaps_only(&stream, &mut swaps); + assert_eq!(swaps.0, want_perm); + + let mut inverse = Line([0u8; 64]); + inverse_fill_only(&permutation_for(&stream), &mut inverse); + assert_eq!(inverse.0, want_inverse); + } + } + } +} pub use crate::encrypt::OreEncrypt; use primitives::PrpError; use std::cmp::Ordering; diff --git a/packages/ore-rs/src/primitives.rs b/packages/ore-rs/src/primitives.rs index 0928ae66..b45ffeb7 100644 --- a/packages/ore-rs/src/primitives.rs +++ b/packages/ore-rs/src/primitives.rs @@ -32,14 +32,59 @@ pub trait Hash { pub struct PrpError; pub type PrpResult = Result; -pub trait Prp: Sized { +/// A value of a `D`-element PRP domain: a byte known to be `< D`, for a +/// power-of-two `D ≀ 256`. +/// +/// The type carries the domain check, so [`Prp::permute`] and +/// [`Prp::invert`] are infallible and the oblivious table lookup behind them +/// has no range branch on the (secret) symbol. A symbol is made by +/// [`Self::from_low_bits`], which masks and never branches, or (for the +/// 256-element domain, where every byte is a symbol) by `From`. Callers +/// that take symbols from outside validate them first; see +/// `scheme::decompose::Blocks6`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Symbol(u8); + +impl Symbol { + const DOMAIN_OK: () = assert!( + D.is_power_of_two() && D <= 256, + "Symbol: the domain must be a power of two no larger than 256" + ); + + /// The low `log2(D)` bits of `b`: `b mod D`, by masking. Branch-free, and + /// the identity on any `b < D`. + #[inline] + #[allow(clippy::let_unit_value)] + pub fn from_low_bits(b: u8) -> Self { + let () = Self::DOMAIN_OK; + // `D - 1` is at most 255, so the mask fits a byte. + Self(b & (D - 1) as u8) + } + + #[inline] + pub fn get(self) -> u8 { + self.0 + } +} + +/// Every byte is a symbol of the 256-element domain. +impl From for Symbol<256> { + #[inline] + fn from(b: u8) -> Self { + Self(b) + } +} + +/// A pseudo-random permutation over the domain whose values are `S` (a +/// [`Symbol`]). Only key setup can fail; a symbol is in the domain by type. +pub trait Prp: Sized { fn new(key: &[u8]) -> PrpResult; - fn permute(&self, data: T) -> PrpResult; + fn permute(&self, data: S) -> S; /// Inverse of [`Self::permute`]. The encrypt path uses the bulk /// [`Self::indicator_mask_xor`] instead; this remains the per-value /// reference (used by the mask equivalence tests). #[allow(dead_code)] - fn invert(&self, data: T) -> PrpResult; + fn invert(&self, data: S) -> S; /// XOR the indicator mask for `data` into `out`: bit `j` of the mask is /// `1` iff `invert(j) > data`. Bit order matches @@ -49,5 +94,5 @@ pub trait Prp: Sized { /// This is the bulk form of the per-`j` `invert`-and-compare loop the /// right-ciphertext encoder needs; implementations walk their inverse /// table linearly instead of doing `DOMAIN` indexed lookups. - fn indicator_mask_xor(&self, data: T, out: &mut [u8]); + fn indicator_mask_xor(&self, data: S, out: &mut [u8]); } diff --git a/packages/ore-rs/src/primitives/cmac.rs b/packages/ore-rs/src/primitives/cmac.rs index 35fec7a2..d4c89ceb 100644 --- a/packages/ore-rs/src/primitives/cmac.rs +++ b/packages/ore-rs/src/primitives/cmac.rs @@ -272,3 +272,46 @@ mod tests { } } } + +#[cfg(kani)] +mod kani_proofs { + use super::*; + + fn any_branch() -> Branch { + if kani::any() { + Branch::RoKey + } else { + Branch::PrpStream + } + } + + /// `final_block` is injective over its full domain: for all + /// `(branch, n, s, width)` pairs, equal blocks imply equal tuples. + #[kani::proof] + fn final_block_injective() { + let (b1, n1, s1, w1) = (any_branch(), kani::any(), kani::any(), kani::any()); + let (b2, n2, s2, w2) = (any_branch(), kani::any(), kani::any(), kani::any()); + if final_block(b1, n1, s1, w1) == final_block(b2, n2, s2, w2) { + assert!(b1 == b2 && n1 == n2 && s1 == s2 && w1 == w2); + } + } + + /// `prefix_block` is injective over its full domain: for all + /// `(pos, sym)` pairs, equal blocks imply equal pairs. + #[kani::proof] + fn prefix_block_injective() { + let (p1, y1): (u16, u8) = (kani::any(), kani::any()); + let (p2, y2): (u16, u8) = (kani::any(), kani::any()); + if prefix_block(p1, y1) == prefix_block(p2, y2) { + assert!(p1 == p2 && y1 == y2); + } + } + + /// No prefix block equals any final block, over both full domains. + #[kani::proof] + fn prefix_and_final_blocks_disjoint() { + let pb = prefix_block(kani::any(), kani::any()); + let fb = final_block(any_branch(), kani::any(), kani::any(), kani::any()); + assert!(pb != fb); + } +} diff --git a/packages/ore-rs/src/primitives/hash.rs b/packages/ore-rs/src/primitives/hash.rs index 173f64c7..898c6765 100644 --- a/packages/ore-rs/src/primitives/hash.rs +++ b/packages/ore-rs/src/primitives/hash.rs @@ -275,3 +275,39 @@ mod tests { assert_eq!(b, want); } } + +#[cfg(kani)] +mod kani_proofs { + use super::*; + + /// Reference GF(2^128) doubling on a big-endian byte string (NIST SP + /// 800-38B `dbl`): shift left one bit across bytes, then XOR `0x87` + /// into the last byte iff the dropped MSB was set. + fn dbl_reference(b: [u8; 16]) -> [u8; 16] { + let mut out = [0u8; 16]; + for k in 0..16 { + let next_msb = if k + 1 < 16 { b[k + 1] >> 7 } else { 0 }; + out[k] = (b[k] << 1) | next_msb; + } + if b[0] & 0x80 != 0 { + out[15] ^= 0x87; + } + out + } + + /// `gf128_double_u128` and the in-place `gf128_double` both equal the + /// bytewise reference `dbl` for every 128-bit input (full domain). + #[kani::proof] + #[kani::unwind(17)] + fn gf128_double_matches_reference() { + let b: [u8; 16] = kani::any(); + let expected = dbl_reference(b); + assert_eq!( + gf128_double_u128(u128::from_be_bytes(b)).to_be_bytes(), + expected + ); + let mut in_place = b; + gf128_double(&mut in_place); + assert_eq!(in_place, expected); + } +} diff --git a/packages/ore-rs/src/primitives/prp.rs b/packages/ore-rs/src/primitives/prp.rs index b203785a..ed269d6e 100644 --- a/packages/ore-rs/src/primitives/prp.rs +++ b/packages/ore-rs/src/primitives/prp.rs @@ -1,7 +1,7 @@ pub(crate) mod oblivious; pub mod prng; use crate::primitives::prp::prng::Aes128Prng; -use crate::primitives::{AesBlock, Prp, PrpError, PrpResult}; +use crate::primitives::{AesBlock, Prp, PrpError, PrpResult, Symbol}; use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit}; use aes::Aes128Enc; use zeroize::{Zeroize, ZeroizeOnDrop}; @@ -12,16 +12,14 @@ use zeroize::{Zeroize, ZeroizeOnDrop}; /// table only defends cache-line granularity, and sub-line timing would /// otherwise reveal which part of the table was read. /// -/// Out-of-range `index` is an error, decided by a comparison against the -/// public table length. +/// `index` is in range by type, so there is no range check to branch on. +/// The tables are permutations of `0..D`, so every entry is itself a symbol; +/// `from_low_bits` is the identity on it. #[inline] -fn oblivious_lookup(table: &[u8], index: u8) -> PrpResult { - if usize::from(index) >= table.len() { - return Err(PrpError); - } - Ok(crate::scheme::width::ct_select_byte( +fn oblivious_lookup(table: &[u8; D], index: Symbol) -> Symbol { + Symbol::from_low_bits(crate::scheme::width::ct_select_byte( table, - usize::from(index), + usize::from(index.get()), )) } @@ -49,7 +47,7 @@ impl ZeroizeOnDrop for KnuthShufflePRP {} /// schemes only. macro_rules! impl_knuth_shuffle_prp { ($domain:literal, $gt_mask:path) => { - impl Prp for KnuthShufflePRP { + impl Prp> for KnuthShufflePRP { /* * Initialize a ($domain element) PRP using a KnuthShuffle * seeded from a 16-byte key @@ -90,26 +88,26 @@ macro_rules! impl_knuth_shuffle_prp { * * Forward permutations are only used once in the ORE scheme so this is OK */ - fn permute(&self, input: u8) -> PrpResult { + fn permute(&self, input: Symbol<$domain>) -> Symbol<$domain> { oblivious_lookup(&self.inverse, input) } /* * Performs the inverse permutation in constant time. */ - fn invert(&self, input: u8) -> PrpResult { + fn invert(&self, input: Symbol<$domain>) -> Symbol<$domain> { // Forward and inverse permutations are reversed for historical reasons oblivious_lookup(&self.permutation, input) } - fn indicator_mask_xor(&self, data: u8, out: &mut [u8]) { + fn indicator_mask_xor(&self, data: Symbol<$domain>, 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); + $gt_mask(&self.permutation, data.get(), out); } } }; @@ -197,27 +195,38 @@ macro_rules! impl_lemire_fy_prp { /// tables come from the oblivious builder ([`oblivious::build`]), /// so no address depends on the stream. pub(crate) fn from_stream(stream: &[u8]) -> PrpResult { - if stream.len() < ($domain - 1) * 8 { - return Err(PrpError); - } - // Fisher–Yates with Lemire-reduced wide draws: draw `d` // (8 bytes) drives step `$domain - 1 - d`. Fixed trip count, // branch-free index reduction, and no secret-dependent // address: the oblivious builder keeps both tables in // registers (or masked words) and maintains the inverse // alongside the swaps. It wipes its copies of each draw. + Self::from_stream_with(stream, oblivious::build) + } + + /// [`Self::from_stream`] through a given builder. The schemes + /// only ever use [`oblivious::build`]; the `ct-bench` timing + /// hooks pass the reference builder or a portable oblivious one, + /// so they time this same function. `build` is a generic + /// parameter, not a function pointer, so the scheme path is + /// monomorphised and the builder still inlines. + pub(crate) fn from_stream_with(stream: &[u8], build: F) -> PrpResult + where + F: FnOnce(&[u8], &mut [u8; $domain], &mut [u8; $domain]), + { + if stream.len() < ($domain - 1) * 8 { + return Err(PrpError); + } let mut perm = Self { permutation: [0u8; $domain], inverse: [0u8; $domain], }; - oblivious::build(stream, &mut perm.permutation, &mut perm.inverse); - + build(stream, &mut perm.permutation, &mut perm.inverse); Ok(perm) } } - impl Prp for LemireFyPrp<$domain> { + impl Prp> for LemireFyPrp<$domain> { fn new(key: &[u8]) -> PrpResult { if key.len() < 16 { return Err(PrpError); @@ -251,17 +260,17 @@ macro_rules! impl_lemire_fy_prp { Ok(perm) } - fn permute(&self, input: u8) -> PrpResult { + fn permute(&self, input: Symbol<$domain>) -> Symbol<$domain> { oblivious_lookup(&self.inverse, input) } - fn invert(&self, input: u8) -> PrpResult { + fn invert(&self, input: Symbol<$domain>) -> Symbol<$domain> { oblivious_lookup(&self.permutation, input) } - fn indicator_mask_xor(&self, data: u8, out: &mut [u8]) { + fn indicator_mask_xor(&self, data: Symbol<$domain>, out: &mut [u8]) { debug_assert_eq!(out.len() * 8, $domain); - $gt_mask(&self.permutation, data, out); + $gt_mask(&self.permutation, data.get(), out); } } }; @@ -290,11 +299,11 @@ mod tests { let prp: KnuthShufflePRP = Prp::new(&key[0..16]).unwrap(); let mut mask = [0u8; 32]; - prp.indicator_mask_xor(x, &mut mask); + prp.indicator_mask_xor(Symbol::from(x), &mut mask); let mut reference = [0u8; 32]; for j in 0..=255u8 { - let indicator = u8::from(prp.invert(j).unwrap() > x); + let indicator = u8::from(prp.invert(Symbol::from(j)).get() > x); reference[(j / 8) as usize] |= indicator << (j % 8); } @@ -306,10 +315,11 @@ mod tests { fn test_invert() -> Result<(), PrpError> { let prp = init_prp()?; - for i in 0..=255 { + for i in 0..=255u8 { + let i = Symbol::from(i); assert_eq!( i, - prp.invert(prp.permute(i)?)?, + prp.invert(prp.permute(i)), "permutation round-trip failed" ); } @@ -321,6 +331,11 @@ mod tests { // LemireFyPrp (Bit6 PRP) // ----------------------------------------------------------------- + fn sym64(v: u8) -> Symbol<64> { + assert!(v < 64, "test symbol in 0..64"); + Symbol::from_low_bits(v) + } + fn init_fy(seed_byte: u8) -> LemireFyPrp<64> { Prp::new(&[seed_byte; 16]).unwrap() } @@ -332,7 +347,7 @@ mod tests { // 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(); + let mapped = prp.invert(sym64(v)).get(); assert!(mapped < 64); assert!( !seen[mapped as usize], @@ -343,8 +358,9 @@ mod tests { } // 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()); + let v = sym64(v); + assert_eq!(v, prp.invert(prp.permute(v))); + assert_eq!(v, prp.permute(prp.invert(v))); } } } @@ -354,16 +370,32 @@ mod tests { 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()); + assert_eq!(a.permute(sym64(v)), b.permute(sym64(v))); } // 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())); + assert!((0..64u8).any(|v| a.permute(sym64(v)) != c.permute(sym64(v)))); + } + + /// `from_stream` rejects every stream shorter than 63 * 8 bytes and + /// accepts the first full length. Exhaustive over the 504 short lengths; + /// this was a Kani harness, which took 335 s to prove the same thing. + #[test] + fn fy_from_stream_rejects_every_short_stream() { + let buf = [0u8; 63 * 8]; + for len in 0..buf.len() { + assert!( + LemireFyPrp::<64>::from_stream(&buf[..len]).is_err(), + "len {}", + len + ); + } + assert!(LemireFyPrp::<64>::from_stream(&buf).is_ok()); } #[test] fn fy_rejects_short_key() { - assert!( as Prp>::new(&[0u8; 8]).is_err()); + assert!( as Prp>>::new(&[0u8; 8]).is_err()); } quickcheck! { @@ -374,13 +406,14 @@ mod tests { return quickcheck::TestResult::discard(); } let prp: LemireFyPrp<64> = Prp::new(&key[0..16]).unwrap(); + let x = Symbol::<64>::from_low_bits(x); 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); + let indicator = u8::from(prp.invert(sym64(j)).get() > x.get()); reference[(j / 8) as usize] |= indicator << (j % 8); } @@ -388,3 +421,139 @@ mod tests { } } } + +#[cfg(kani)] +mod kani_proofs { + use super::*; + + /// `oblivious_lookup` over a 64-entry table returns `table[i]` for every + /// symbol `i` (all tables whose entries are in the domain, as a + /// permutation's are). + #[kani::proof] + #[kani::unwind(65)] + fn oblivious_lookup_64_matches_index() { + let table: [u8; 64] = kani::any(); + kani::assume(table.iter().all(|&v| v < 64)); + let index = Symbol::<64>::from_low_bits(kani::any()); + let got = oblivious_lookup(&table, index); + assert_eq!(got.get(), table[usize::from(index.get())]); + } + + /// `oblivious_lookup` over a 256-entry table returns `table[i]` for every + /// symbol `i` (all tables). + #[kani::proof] + #[kani::unwind(257)] + fn oblivious_lookup_256_matches_index() { + let table: [u8; 256] = kani::any(); + let index = Symbol::<256>::from(kani::any::()); + let got = oblivious_lookup(&table, index); + assert_eq!(got.get(), table[usize::from(index.get())]); + } + + /// `Symbol::<64>::from_low_bits` always yields a value in `0..64`, and + /// is the identity on `0..64` (all `u8`). + #[kani::proof] + fn symbol_64_from_low_bits_in_domain() { + let b: u8 = kani::any(); + let s = Symbol::<64>::from_low_bits(b); + assert!(s.get() < 64); + if b < 64 { + assert_eq!(s.get(), b); + } + } + + /// `oblivious::lemire_draw`, the draw every builder uses, reads step + /// `i`'s draw from bytes `(63 - i) * 8 ..+ 8` as a little-endian `u64` + /// and reduces it to `0..=i` by multiply-high: for every stream and every + /// step `1..64` it is `(x * (i + 1)) >> 64` of that draw, and so never + /// exceeds `i`. Proved on the function itself, not a restatement of its + /// formula. The steps are a loop rather than a symbolic `i`, so every + /// slice offset is concrete once unwound. + #[kani::proof] + #[kani::unwind(64)] + fn lemire_draw_in_range() { + let stream: [u8; 63 * 8] = kani::any(); + for i in 1..64 { + let d = 63 - 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 = oblivious::lemire_draw(&stream, i); + assert_eq!(u128::from(j), (u128::from(x) * (i as u128 + 1)) >> 64); + assert!(usize::from(j) <= i); + } + } + + /// Asserts `perm` is a permutation of `0..64` with `inverse` its + /// two-sided inverse. Checking `perm[i] < 64` and `inverse[perm[i]] == i` + /// for every `i` suffices: it makes `perm` injective on a 64-element + /// domain into `0..64`, hence a bijection, and `inverse` then agrees with + /// `perm⁻¹` at every point of `0..64`. + fn assert_is_permutation(perm: &[u8; 64], inverse: &[u8; 64]) { + for (i, p) in perm.iter().enumerate() { + assert!(*p < 64); + assert_eq!(inverse[*p as usize] as usize, i); + } + } + + /// A fixed non-trivial keystream, used for the concrete draws of the + /// bounded harness below. + const FIXED_STREAM: [u8; 512] = { + let mut s = [0u8; 512]; + let mut n = 0; + while n < 512 { + s[n] = (n as u8).wrapping_mul(167).wrapping_add(13); + n += 1; + } + s + }; + + /// Number of leading symbolic draws in the bounded harness below. + const SYMBOLIC_DRAWS: usize = 6; + + /// The reference builder (textbook Fisher–Yates, which the oblivious + /// builders are proved equal to below) yields a permutation of `0..64` + /// and its inverse for every stream whose first `SYMBOLIC_DRAWS` draws + /// (driving steps 63 down to `64 - SYMBOLIC_DRAWS`) are arbitrary and + /// whose remaining draws come from `FIXED_STREAM`. Proved on the + /// reference rather than through `from_stream` (the SWAR builder under + /// Kani) because that takes under half the time; the harness below + /// carries the property over to the SWAR builder. + /// With all 63 draws symbolic CBMC does not finish in 30 minutes; 8 + /// symbolic draws does not finish in 20. + #[kani::proof] + #[kani::unwind(65)] + fn lemire_fy_reference_is_permutation_first_draws_symbolic() { + let mut stream = FIXED_STREAM; + let head: [u8; SYMBOLIC_DRAWS * 8] = kani::any(); + stream[..SYMBOLIC_DRAWS * 8].copy_from_slice(&head); + let (mut perm, mut inverse) = ([0u8; 64], [0u8; 64]); + oblivious::reference::build(&stream, &mut perm, &mut inverse); + assert_is_permutation(&perm, &inverse); + } + + /// The SWAR oblivious builder (what `from_stream` dispatches to under + /// Kani, and on targets with neither NEON nor SSSE3) builds exactly the + /// reference builder's `permutation` and `inverse` for every stream whose + /// first `SYMBOLIC_DRAWS` draws are arbitrary and whose remaining draws + /// come from `FIXED_STREAM`: same shape as the harness above. + // 872 s on an M4, and on GitHub's ubuntu-latest runners it has been + // cancelled 26 to 32 minutes in on every run but one, so it runs only + // with `kani-full`. Every CI run still tests SWAR against the reference + // on every swap (`oblivious::tests`). + #[cfg(feature = "kani-full")] + #[kani::proof] + #[kani::unwind(65)] + fn oblivious_swar_matches_reference_first_draws_symbolic() { + let mut stream = FIXED_STREAM; + let head: [u8; SYMBOLIC_DRAWS * 8] = kani::any(); + stream[..SYMBOLIC_DRAWS * 8].copy_from_slice(&head); + let (mut perm, mut inverse) = ([0u8; 64], [0u8; 64]); + oblivious::swar::build(&stream, &mut perm, &mut inverse); + let (mut want_perm, mut want_inverse) = ([0u8; 64], [0u8; 64]); + oblivious::reference::build(&stream, &mut want_perm, &mut want_inverse); + assert!(perm == want_perm); + assert!(inverse == want_inverse); + } +} diff --git a/packages/ore-rs/src/primitives/prp/oblivious.rs b/packages/ore-rs/src/primitives/prp/oblivious.rs index 986974a2..fdfc744f 100644 --- a/packages/ore-rs/src/primitives/prp/oblivious.rs +++ b/packages/ore-rs/src/primitives/prp/oblivious.rs @@ -16,8 +16,8 @@ //! - `swar` holds each table as eight `u64` words and does every //! secret-indexed read and write as a masked pass over the words, with //! exact SWAR byte-equality masks. Portable fallback. -//! - `scalar` (tests only) is the same construction a byte at a time with -//! `subtle_ng` choices. +//! - `scalar` (tests and benches only) is the same construction a byte at +//! a time with `subtle_ng` choices. //! - `neon` (aarch64) holds both tables in eight 16-byte registers for the //! whole build: `tbl` fetches `perm[j]` by a broadcast index, and //! compare-and-select against a constant index vector writes back. @@ -59,7 +59,7 @@ pub(crate) const STREAM_BYTES: usize = (DOMAIN - 1) * 8; /// `63 - i`, as a little-endian u64, mapped to `0..=i` by multiply-high. /// Identical to the indexed builder's reduction. #[inline(always)] -fn lemire_draw(stream: &[u8], i: usize) -> u8 { +pub(crate) fn lemire_draw(stream: &[u8], i: usize) -> u8 { let d = DOMAIN - 1 - i; let mut draw = [0u8; 8]; draw.copy_from_slice(&stream[d * 8..d * 8 + 8]); @@ -79,15 +79,18 @@ fn lemire_draw(stream: &[u8], i: usize) -> u8 { pub(crate) fn build(stream: &[u8], perm: &mut [u8; DOMAIN], inverse: &mut [u8; DOMAIN]) { assert!(stream.len() >= STREAM_BYTES); - #[cfg(target_arch = "aarch64")] + // Kani cannot model the NEON and SSSE3 intrinsics, so under `cargo kani` + // every target dispatches to the SWAR builder, which the harnesses prove + // equal to the reference builder. + #[cfg(all(target_arch = "aarch64", not(kani)))] // SAFETY: NEON is baseline on aarch64; `stream` length asserted above. unsafe { neon::build(stream, perm, inverse) } - #[cfg(not(target_arch = "aarch64"))] + #[cfg(any(not(target_arch = "aarch64"), kani))] { - #[cfg(target_arch = "x86_64")] + #[cfg(all(target_arch = "x86_64", not(kani)))] if is_x86_feature_detected!("ssse3") { // SAFETY: SSSE3 presence just checked; `stream` length asserted. unsafe { ssse3::build(stream, perm, inverse) }; @@ -101,9 +104,10 @@ pub(crate) fn build(stream: &[u8], perm: &mut [u8; DOMAIN], inverse: &mut [u8; D /// The reference builder: textbook Fisher–Yates, swapping at the /// draw-derived index, then `inverse[perm[k]] = k`. Both use a secret value /// as an address, which is the timing channel the oblivious builders close, -/// so it is never compiled into the library: it exists only as the -/// specification the oblivious builders are tested against. -#[cfg(test)] +/// so it is never compiled into a shipped build: it exists only as the +/// specification the oblivious builders are tested and proved (Kani) +/// against, and for the `ct-bench` before/after timing hooks. +#[cfg(any(test, kani, feature = "ct-bench"))] pub(crate) mod reference { use super::*; @@ -124,9 +128,10 @@ pub(crate) mod reference { /// Byte-at-a-time oblivious builder with `subtle_ng` choices: full-scan /// reads and masked full-table writes, the inverse maintained alongside. /// The most direct form of the construction, kept as a second reference -/// for the tests; `subtle_ng`'s per-choice optimisation barrier makes it -/// about 17x slower than [`swar`], which the dispatcher uses instead. -#[cfg(test)] +/// for the tests and the timing benches; `subtle_ng`'s per-choice +/// optimisation barrier makes it about 17x slower than [`swar`], which the +/// dispatcher uses instead. +#[cfg(any(test, feature = "ct-bench"))] pub(crate) mod scalar { use super::*; use subtle_ng::{ConditionallySelectable, ConstantTimeEq}; @@ -171,7 +176,7 @@ pub(crate) mod scalar { /// a public bound). The inverse is updated on the value side, as in the /// NEON builder. Pure integer arithmetic with no `subtle_ng` optimisation barrier /// per byte, so the compiler can keep the tables in registers. -#[cfg(any(not(target_arch = "aarch64"), test))] +#[cfg(any(not(target_arch = "aarch64"), kani, test, feature = "ct-bench"))] pub(crate) mod swar { use super::*; @@ -252,7 +257,7 @@ pub(crate) mod swar { } } -#[cfg(target_arch = "aarch64")] +#[cfg(all(target_arch = "aarch64", not(kani)))] mod neon { use super::{lemire_draw, DOMAIN}; use core::arch::aarch64::*; @@ -349,7 +354,7 @@ mod neon { } } -#[cfg(target_arch = "x86_64")] +#[cfg(all(target_arch = "x86_64", not(kani)))] mod ssse3 { use super::{lemire_draw, DOMAIN}; use core::arch::x86_64::*; @@ -501,6 +506,35 @@ mod tests { } } + /// The smallest draw that `lemire_draw` reduces to `j` at step `i`: + /// `ceil(j * 2^64 / (i + 1))`. + fn draw_for(i: usize, j: usize) -> u64 { + let n = i as u128 + 1; + (((j as u128) << 64).div_ceil(n)) as u64 + } + + /// Every step `i` with every swap index `j` in `0..=i`, through the + /// dispatched builder (NEON on aarch64, SSSE3 or SWAR on x86_64) and the + /// portable ones. Kani cannot model the NEON and SSSE3 intrinsics, so + /// this is the coverage those builders get: each of the 2 079 `(i, j)` + /// swaps is exercised at least once, inside a full build whose other + /// draws vary, and the tables must equal the reference builder's. + #[test] + fn builders_match_reference_for_every_swap() { + for i in 1..DOMAIN { + for j in 0..=i { + let mut s = stream_from(&[i as u8, j as u8]); + let d = DOMAIN - 1 - i; + s[d * 8..d * 8 + 8].copy_from_slice(&draw_for(i, j).to_le_bytes()); + assert_eq!(usize::from(lemire_draw(&s, i)), j, "draw_for({i}, {j})"); + + let want = run(reference::build, &s); + assert_eq!(run(build, &s), want, "dispatched, i {i} j {j}"); + assert_eq!(run(swar::build, &s), want, "swar, i {i} j {j}"); + } + } + } + /// The reference tables are a permutation of `0..64` and its inverse, /// so matching them means every builder builds one. #[test] diff --git a/packages/ore-rs/src/scheme/bit2.rs b/packages/ore-rs/src/scheme/bit2.rs index a0419f3d..42e0abd4 100644 --- a/packages/ore-rs/src/scheme/bit2.rs +++ b/packages/ore-rs/src/scheme/bit2.rs @@ -7,9 +7,9 @@ use crate::{ ciphertext::*, primitives::{ - hash::Aes128Z2Hash, prf::Aes128Prf, AesBlock, Hash, HashKey, Prf, Prp, NONCE_SIZE, + hash::Aes128Z2Hash, prf::Aes128Prf, AesBlock, Hash, HashKey, Prf, Prp, Symbol, NONCE_SIZE, }, - scheme::width::{AesBlockBuf, Bit8, BlockWidth, RightBitVec}, + scheme::width::{AesBlockBuf, Bit8, BlockWidth, FirstDiff, RightBitVec}, OreCipher, OreError, PlainText, }; @@ -97,7 +97,7 @@ fn derive_prp_seeds(prf2: &Aes128Prf, x: &PlainText) -> SeedB pub(crate) fn encode_right_block( block: &mut W::RightBlock, prp: &W::Prp, - x: u8, + x: W::Symbol, hasher: &H, ro_blocks: &mut [AesBlock], ) { @@ -121,7 +121,7 @@ impl OreAes128 { ) -> Result<(), OreError> { for n in 0..N { let prp: ::Prp = Prp::new(&seeds.0[n])?; - left.xt[n] = prp.permute(x[n])?; + left.xt[n] = prp.permute(Symbol::from(x[n])).get(); left.f[n][0..n].clone_from_slice(&x[0..n]); left.f[n][n] = left.xt[n]; @@ -192,7 +192,7 @@ impl OreCipher for OreAes128 { for n in 0..N { let prp: ::Prp = Prp::new(&seeds.0[n])?; - left.xt[n] = prp.permute(x[n])?; + left.xt[n] = prp.permute(Symbol::from(x[n])).get(); left.f[n][0..n].clone_from_slice(&x[0..n]); left.f[n][n] = left.xt[n]; @@ -212,7 +212,13 @@ 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, + Symbol::from(x[n]), + &hasher, + &mut work, + ); } self.prf1.encrypt_all(&mut left.f); @@ -234,41 +240,60 @@ impl OreCipher for OreAes128 { let left_size = Self::LeftBlockType::BLOCK_SIZE; let right_size = Self::RightBlockType::BLOCK_SIZE; - // TODO: This calculation slows things down a bit - maybe store the number of blocks in the - // first byte? - let num_blocks = (a.len() - NONCE_SIZE) / (left_size + right_size + 1); + // The legacy wire has no header: the block count is implied by the + // length, `nΒ·(1 + left + right) + nonce`. Anything that does not fit + // that shape exactly (too short for a nonce, or a partial block) is + // not a ciphertext of this scheme, and the slicing below would panic + // on it. A nonce-only input is the valid `N = 0` ciphertext, which + // `CipherText::<_, 0>::from_slice` also accepts; it compares Equal. + let body = a.len().checked_sub(NONCE_SIZE)?; + let per_block = left_size + right_size + 1; + if body % per_block != 0 { + return None; + } + let num_blocks = body / per_block; let mut is_equal = Choice::from(1); - let mut l: u64 = 0; // Unequal block - - // Slices for the PRF ("f") blocks + // What the resolution step needs from the first differing block `l`: + // `a`'s permuted symbol and PRF tag, and `b`'s right bitvector. All + // three are latched *inside* the scan, under the choice "this is the + // first difference", so no load after the loop is indexed by `l`. + // The right blocks are the one region the scan would otherwise never + // touch, so a direct `right[l]` afterwards hits or misses the cache + // according to `l`; measured as a timing signal, see `docs/reviews/`. + // The wire format is unchanged. + let mut diff = FirstDiff::<{ RightBlock32::BLOCK_SIZE }>::new(); + + // Slices for the PRF ("f") blocks and the right half. let a_f = &a[num_blocks..]; let b_f = &b[num_blocks..]; + let b_right = &b[num_blocks * (left_size + 1)..]; + let b_right_blocks = &b_right[NONCE_SIZE..]; 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); + // Set for exactly one `n`: the first differing block. + let first = is_equal & condition; + + diff.latch( + a[n], + left_block(a_f, n), + right_block(b_right_blocks, n), + first, + ); + is_equal.conditional_assign(&Choice::from(0), first); } - 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_key = HashKey::from_slice(&b_right[0..NONCE_SIZE]); let hash: Aes128Z2Hash = Hash::new(hash_key); - 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 { + if diff.resolve(&hash) == 1 { return Some(Ordering::Greater); } @@ -289,16 +314,6 @@ fn right_block(input: &[u8], n: usize) -> &[u8] { &input[f_pos..(f_pos + RightBlock32::BLOCK_SIZE)] } -#[inline] -fn get_bit(block: &[u8], bit: usize) -> u8 { - debug_assert!(block.len() == RightBlock32::BLOCK_SIZE); - debug_assert!(bit < 256); - // `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) @@ -308,27 +323,32 @@ impl PartialEq for CipherText { impl Ord for CipherText { fn cmp(&self, b: &Self) -> Ordering { let mut is_equal = Choice::from(1); - let mut l: u64 = 0; // Unequal block + // Latch the first differing block during the scan, as in + // `compare_raw_slices`: no load after the loop is indexed by its + // position. + let mut diff = FirstDiff::<{ RightBlock32::BLOCK_SIZE }>::new(); 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); + // Set for exactly one `n`: the first differing block. + let first = is_equal & condition; + + diff.latch( + self.left.xt[n], + &self.left.f[n], + b.right.data[n].bytes(), + first, + ); + is_equal.conditional_assign(&Choice::from(0), first); } - let l: usize = l as usize; - if bool::from(is_equal) { return Ordering::Equal; } let hash: Aes128Z2Hash = Hash::new(AesBlock::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 { + if diff.resolve(&hash) == 1 { return Ordering::Greater; } @@ -368,6 +388,44 @@ mod tests { OreCipher::init(&k1, &k2).unwrap() } + /// The legacy wire has no header, so the comparator infers the block + /// count from the length. Lengths that do not fit `nΒ·(1 + 16 + 32) + 16` + /// must be refused, not indexed: two empty slices used to underflow the + /// nonce subtraction, and a partial block to slice past the end. + #[test] + fn compare_raw_slices_refuses_malformed_lengths() { + let ore = init_ore(); + let good = 42u64.encrypt(&ore).unwrap().to_bytes(); + assert!(Ore::compare_raw_slices(&good, &good).is_some()); + + assert_eq!(Ore::compare_raw_slices(&[], &[]), None); + let short = vec![0u8; NONCE_SIZE - 1]; + assert_eq!(Ore::compare_raw_slices(&short, &short), None); + let partial = &good[..good.len() - 1]; + assert_eq!(Ore::compare_raw_slices(partial, partial), None); + let mut longer = good.clone(); + longer.push(0); + assert_eq!(Ore::compare_raw_slices(&longer, &longer), None); + } + + /// A zero-block plaintext encrypts to a nonce-only ciphertext. The raw + /// comparator must agree with the parsed one on it, not refuse it. + #[test] + fn compare_raw_slices_accepts_zero_blocks() { + let ore = init_ore(); + let a = [0u8; 0].encrypt(&ore).unwrap(); + let b = [0u8; 0].encrypt(&ore).unwrap(); + let (a_bytes, b_bytes) = (a.to_bytes(), b.to_bytes()); + assert_eq!(a_bytes.len(), NONCE_SIZE); + + let parsed = CipherText::::from_slice(&a_bytes).unwrap(); + assert_eq!(parsed.cmp(&b), Ordering::Equal); + assert_eq!( + Ore::compare_raw_slices(&a_bytes, &b_bytes), + Some(Ordering::Equal) + ); + } + quickcheck! { fn compare_u64(x: u64, y: u64) -> bool { let ore = init_ore(); diff --git a/packages/ore-rs/src/scheme/bit2/block_types.rs b/packages/ore-rs/src/scheme/bit2/block_types.rs index 6012f452..4de40211 100644 --- a/packages/ore-rs/src/scheme/bit2/block_types.rs +++ b/packages/ore-rs/src/scheme/bit2/block_types.rs @@ -31,6 +31,12 @@ impl RightBlock32 { /// The raw bitvector bytes, LSB-first within each byte β€” the bit order /// used by [`Self::set_bit`] / [`Self::get_bit`]. #[inline] + pub(crate) fn bytes(&self) -> &[u8] { + &self.data + } + + /// Mutable form of [`Self::bytes`]. + #[inline] pub(crate) fn bytes_mut(&mut self) -> &mut [u8] { &mut self.data } diff --git a/packages/ore-rs/src/scheme/bit2_w6.rs b/packages/ore-rs/src/scheme/bit2_w6.rs index 80b8207d..868e56e9 100644 --- a/packages/ore-rs/src/scheme/bit2_w6.rs +++ b/packages/ore-rs/src/scheme/bit2_w6.rs @@ -30,7 +30,8 @@ use crate::{ primitives::{ hash::FixedPiZ2Hash, prf::Aes128Prf, stack, AesBlock, Hash, HashKey, Prf, Prp, NONCE_SIZE, }, - scheme::width::{ct_assign_bytes, AesBlockBuf, Bit6, BlockWidth}, + scheme::decompose::Blocks6, + scheme::width::{AesBlockBuf, Bit6, BlockWidth, FirstDiff}, OreCipher, OreError, PlainText, }; @@ -141,29 +142,25 @@ impl OreAes128Bit6 { self.prf2.encrypt_all(&mut seeds); SeedBuf(seeds) } -} -/// The encrypt paths without the stack scrub. Every caller runs them inside -/// [`stack::scrubbed`]: building a block's PRP leaves its seed and tables in -/// dead stack (see `primitives::stack`). -impl OreAes128Bit6 { - pub(crate) fn encrypt_left_unscrubbed( + /// [`OreCipher::encrypt_left`] over blocks already known to be in the + /// 6-bit domain, without the stack scrub. Every caller runs it inside + /// [`stack::scrubbed`]: building a block's PRP leaves its seed and tables + /// in dead stack (see `primitives::stack`). + pub(crate) fn encrypt_left_blocks( &self, - x: &PlainText, + x: &Blocks6>, ) -> 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); + let seeds = self.derive_prp_seeds(x.inner()); for n in 0..N { let prp: ::Prp = Prp::new(&seeds.0[n])?; - output.xt[n] = prp.permute(x[n])?; + output.xt[n] = prp.permute(x.symbol(n)).get(); - output.f[n][0..n].clone_from_slice(&x[0..n]); + output.f[n][0..n].clone_from_slice(&x.as_bytes()[0..n]); output.f[n][n] = output.xt[n]; output.f[n][N] = n as u8; output.f[n][15] = N as u8; @@ -173,21 +170,20 @@ impl OreAes128Bit6 { Ok(output) } - pub(crate) fn encrypt_unscrubbed( + /// [`OreCipher::encrypt`] over blocks already known to be in the 6-bit + /// domain, without the stack scrub; see [`Self::encrypt_left_blocks`]. + pub(crate) fn encrypt_blocks( &self, - x: &PlainText, + x: &Blocks6>, ) -> 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 seeds = self.derive_prp_seeds(x.inner()); let hasher: Z2Hash = Hash::new(HashKey::from_slice(&right.nonce)); // RO key template, maintained incrementally (see the bit2 sibling). @@ -201,16 +197,16 @@ impl OreAes128Bit6 { for n in 0..N { let prp: ::Prp = Prp::new(&seeds.0[n])?; - left.xt[n] = prp.permute(x[n])?; + left.xt[n] = prp.permute(x.symbol(n)).get(); - left.f[n][0..n].clone_from_slice(&x[0..n]); + left.f[n][0..n].clone_from_slice(&x.as_bytes()[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 - 1] = x.as_bytes()[n - 1]; entry[n] = j as u8; entry[N] = n as u8; } @@ -222,7 +218,7 @@ impl OreAes128Bit6 { crate::scheme::bit2::encode_right_block::( &mut right.data[n], &prp, - x[n], + x.symbol(n), &hasher, &mut work, ); @@ -261,16 +257,18 @@ impl OreCipher for OreAes128Bit6 { }) } - /// Encrypt `x`, whose entries are **6-bit block values** (`< 64`, - /// produced by [`crate::scheme::decompose`]); `N` is the block count + /// Encrypt `x`, whose entries are **6-bit block values** (`< 64`, as + /// [`crate::scheme::decompose`] produces); `N` is the block count /// (≀ [`MAX_BLOCKS`]). The [`crate::OreEncrypt`] impls handle the - /// byteβ†’block decomposition for primitive types. + /// byteβ†’block decomposition for primitive types and skip the check + /// below. A value `>= 64` returns [`OreError::PrpError`]. fn encrypt_left(&self, x: &PlainText) -> EncryptLeftResult { - stack::scrubbed(|| self.encrypt_left_unscrubbed(x)) + stack::scrubbed(|| self.encrypt_left_blocks(&Blocks6::check(*x)?)) } + /// As [`Self::encrypt_left`], for the full ciphertext. fn encrypt(&self, x: &PlainText) -> EncryptResult { - stack::scrubbed(|| self.encrypt_unscrubbed(x)) + stack::scrubbed(|| self.encrypt_blocks(&Blocks6::check(*x)?)) } fn compare_raw_slices(a: &[u8], b: &[u8]) -> Option { @@ -314,9 +312,7 @@ impl OreCipher for OreAes128Bit6 { // The right blocks are the one region the scan would otherwise never // touch, so a direct `right[l]` afterwards hits or misses the cache // according to `l`; measured as a timing signal, see `docs/reviews/`. - let mut sel_xt = 0u8; - let mut sel_f = [0u8; LeftBlock16::BLOCK_SIZE]; - let mut sel_right = [0u8; RightBlock8::BLOCK_SIZE]; + let mut diff = FirstDiff::<{ RightBlock8::BLOCK_SIZE }>::new(); // Slices for the PRF ("f") blocks and the right half. let a_f = &a[num_blocks..]; @@ -331,9 +327,12 @@ impl OreCipher for OreAes128Bit6 { // Set for exactly one `n`: the first differing block. let first = is_equal & condition; - sel_xt.conditional_assign(&a[n], first); - ct_assign_bytes(&mut sel_f, left_block(a_f, n), first); - ct_assign_bytes(&mut sel_right, right_block(b_right_blocks, n), first); + diff.latch( + a[n], + left_block(a_f, n), + right_block(b_right_blocks, n), + first, + ); is_equal.conditional_assign(&Choice::from(0), first); } @@ -342,10 +341,8 @@ impl OreCipher for OreAes128Bit6 { } let hash: Z2Hash = Hash::new(HashKey::from_slice(&b_right[0..NONCE_SIZE])); - let h = hash.hash(&sel_f); - let test = get_bit(&sel_right, sel_xt as usize) ^ h; - if test == 1 { + if diff.resolve(&hash) == 1 { return Some(Ordering::Greater); } @@ -365,16 +362,6 @@ fn right_block(input: &[u8], n: usize) -> &[u8] { &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) @@ -384,27 +371,32 @@ impl PartialEq for CipherText { impl Ord for CipherText { fn cmp(&self, b: &Self) -> Ordering { let mut is_equal = Choice::from(1); - let mut l: u64 = 0; // Unequal block + // Latch the first differing block during the scan, as in + // `compare_raw_slices`: no load after the loop is indexed by its + // position. + let mut diff = FirstDiff::<{ RightBlock8::BLOCK_SIZE }>::new(); 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])); + // Set for exactly one `n`: the first differing block. + let first = is_equal & condition; - l.conditional_assign(&(n as u64), is_equal & condition); - is_equal.conditional_assign(&Choice::from(0), is_equal & condition); + diff.latch( + self.left.xt[n], + &self.left.f[n], + b.right.data[n].bytes(), + first, + ); + is_equal.conditional_assign(&Choice::from(0), first); } - 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 { + if diff.resolve(&hash) == 1 { return Ordering::Greater; } @@ -427,7 +419,7 @@ impl Eq for CipherText {} mod encrypt_impls { use super::{OreAes128Bit6, MAX_BLOCKS}; use crate::primitives::stack; - use crate::scheme::decompose::{decompose_6bit, num_blocks_6bit}; + use crate::scheme::decompose::{num_blocks_6bit, Blocks6}; use crate::{CipherText, Left, OreEncrypt, OreError}; use orderable_bytes::{FixedOrderableBytes, OrderableBytes}; use rand::{Rng, SeedableRng}; @@ -453,18 +445,16 @@ mod encrypt_impls { // frame, so they go inside the scrub too. stack::scrubbed(|| { let bytes = self.to_orderable_bytes(); - let mut blocks = [0u8; $blocks_const]; - decompose_6bit(&bytes, &mut blocks); - cipher.encrypt_left_unscrubbed(&blocks) + let blocks = Blocks6::decompose(&bytes, [0u8; $blocks_const]); + cipher.encrypt_left_blocks(&blocks) }) } fn encrypt(&self, cipher: &OreAes128Bit6) -> Result { stack::scrubbed(|| { let bytes = self.to_orderable_bytes(); - let mut blocks = [0u8; $blocks_const]; - decompose_6bit(&bytes, &mut blocks); - cipher.encrypt_unscrubbed(&blocks) + let blocks = Blocks6::decompose(&bytes, [0u8; $blocks_const]); + cipher.encrypt_blocks(&blocks) }) } } @@ -512,6 +502,29 @@ mod tests { /// input `xt[0] β€– 0Γ—10 β€– 0@11 β€– 0Γ—3 β€– 11@15`; legacy block 11 of /// `(xt[0], 0Γ—10, c, 0Γ—3)` has input `xt[0] β€– 0Γ—10 β€– xt'[11] β€– 0Γ—3 β€– /// 11@15`, which matches whenever `c` permutes to 0. Search every `c`. + /// Blocks passed straight to `OreCipher` are checked: a value outside + /// the 6-bit domain is refused, and an in-domain array encrypts exactly + /// as the decomposing `OreEncrypt` path does. + #[test] + fn raw_blocks_are_checked() { + let ore = init_ore(); + let mut blocks = [0u8; 11]; + blocks[3] = 64; + assert!(matches!( + OreCipher::encrypt(&ore, &blocks), + Err(OreError::PrpError(_)) + )); + assert!(matches!( + OreCipher::encrypt_left(&ore, &blocks), + Err(OreError::PrpError(_)) + )); + + let x = 0x0123_4567_89ab_cdefu64; + let decomposed = crate::scheme::decompose::Blocks6::decompose(&x.to_be_bytes(), [0u8; 11]); + let raw = OreCipher::encrypt_left(&ore, decomposed.inner()).unwrap(); + assert_eq!(raw.to_bytes(), x.encrypt_left(&ore).unwrap().to_bytes()); + } + #[test] fn legacy_ciphertexts_never_publish_bit6_tags() { use crate::scheme::bit2::OreAes128ChaCha20; @@ -723,8 +736,9 @@ mod tests { for seed in seeds.0.iter() { let prp: ::Prp = Prp::new(seed).unwrap(); needles.push(seed.to_vec()); - needles.push((0..64).map(|i| prp.permute(i).unwrap()).collect()); - needles.push((0..64).map(|i| prp.invert(i).unwrap()).collect()); + let symbols = (0..64).map(crate::primitives::Symbol::<64>::from_low_bits); + needles.push(symbols.clone().map(|i| prp.permute(i).get()).collect()); + needles.push(symbols.map(|i| prp.invert(i).get()).collect()); } needles } @@ -734,7 +748,7 @@ mod tests { let k1 = [0x3cu8; 16]; let k2 = [0xc3u8; 16]; let x = 0x0123_4567_89ab_cdefu64; - let needles = block_secrets(&Ore::init(&k1, &k2).unwrap(), &u64_blocks(x)); + let needles = block_secrets(&Ore::init(&k1, &k2).unwrap(), u64_blocks(x).inner()); // Positive control and depth: without the scrub the secrets are // there, and the scrub must reach deeper than the encryption did. @@ -761,10 +775,8 @@ mod tests { } } - fn u64_blocks(x: u64) -> [u8; 11] { - let mut blocks = [0u8; 11]; - crate::scheme::decompose::decompose_6bit(&x.to_be_bytes(), &mut blocks); - blocks + fn u64_blocks(x: u64) -> Blocks6<[u8; 11]> { + Blocks6::decompose(&x.to_be_bytes(), [0u8; 11]) } #[test] @@ -774,7 +786,7 @@ mod tests { core::hint::black_box(x.encrypt(ore).unwrap()); }, |ore, x| { - core::hint::black_box(ore.encrypt_unscrubbed(&u64_blocks(x)).unwrap()); + core::hint::black_box(ore.encrypt_blocks(&u64_blocks(x)).unwrap()); }, ); } @@ -786,7 +798,7 @@ mod tests { core::hint::black_box(x.encrypt_left(ore).unwrap()); }, |ore, x| { - core::hint::black_box(ore.encrypt_left_unscrubbed(&u64_blocks(x)).unwrap()); + core::hint::black_box(ore.encrypt_left_blocks(&u64_blocks(x)).unwrap()); }, ); } diff --git a/packages/ore-rs/src/scheme/bit2_w6/block_types.rs b/packages/ore-rs/src/scheme/bit2_w6/block_types.rs index 97ced6cf..7ae81dbb 100644 --- a/packages/ore-rs/src/scheme/bit2_w6/block_types.rs +++ b/packages/ore-rs/src/scheme/bit2_w6/block_types.rs @@ -23,6 +23,13 @@ impl RightBlock8 { let byte = crate::scheme::width::ct_select_byte(&self.data, bit / 8); crate::scheme::width::ct_bit(byte, (bit % 8) as u8) } + + /// The raw bitvector bytes, LSB-first within each byte β€” the bit order + /// used by [`Self::get_bit`]. + #[inline] + pub(crate) fn bytes(&self) -> &[u8] { + &self.data + } } impl RightBitVec for RightBlock8 { diff --git a/packages/ore-rs/src/scheme/chained.rs b/packages/ore-rs/src/scheme/chained.rs index 786809be..ec5278b4 100644 --- a/packages/ore-rs/src/scheme/chained.rs +++ b/packages/ore-rs/src/scheme/chained.rs @@ -51,8 +51,8 @@ use crate::primitives::cmac::{final_block, prefix_block, Branch, CmacAccumulator use crate::primitives::hash::FixedPiZ2Hash; use crate::primitives::prp::LemireFyPrp; use crate::primitives::{stack, AesBlock, Hash, HashKey, Prp}; -use crate::scheme::decompose::{decompose_6bit, num_blocks_6bit}; -use crate::scheme::width::{ct_assign_bytes, ct_bit, ct_select_byte}; +use crate::scheme::decompose::{num_blocks_6bit, Blocks6}; +use crate::scheme::width::FirstDiff; use crate::OreError; const VERSION: u8 = 0x02; @@ -234,23 +234,19 @@ impl OreAes128Bit6Chained { Ok(prp) } - /// Full Left+Right ciphertext for `x` (one 6-bit symbol per element). + /// Full Left+Right ciphertext for `x` (one 6-bit symbol per element). A + /// value `>= 64` returns [`OreError::PrpError`]. pub fn encrypt_var(&self, x: &[u8]) -> Result { - stack::scrubbed(|| self.encrypt_var_unscrubbed(x)) + stack::scrubbed(|| self.encrypt_blocks(&check_symbols(x)?)) } - /// Left-only ciphertext (smaller; for query plaintexts). - pub fn encrypt_left_var(&self, x: &[u8]) -> Result { - stack::scrubbed(|| self.encrypt_left_var_unscrubbed(x)) - } - - /// [`Self::encrypt_var`] without the stack scrub. Every caller runs it - /// inside [`stack::scrubbed`]: building a block's PRP and running the + /// [`Self::encrypt_var`] over blocks already known to be in the 6-bit + /// domain, without the stack scrub. Every caller runs it inside + /// [`stack::scrubbed`]: building a block's PRP and running the /// accumulator leave their secrets in dead stack (see /// `primitives::stack`). - fn encrypt_var_unscrubbed(&self, x: &[u8]) -> Result { + fn encrypt_blocks>(&self, x: &Blocks6) -> Result { let count = x.len(); - debug_assert!(x.iter().all(|&b| (b as usize) < DOMAIN)); if count > u16::MAX as usize { return Err(OreError::TooManyBlocks); } @@ -269,10 +265,11 @@ impl OreAes128Bit6Chained { // loop rather than once per block. let mut ro = [AesBlock::default(); DOMAIN]; - for (n, &sym) in x.iter().enumerate() { + for n in 0..count { + let sym = x.symbol(n); let n16 = n as u16; let prp = self.prp_at(&acc, n16)?; - let permuted = prp.permute(sym)?; + let permuted = prp.permute(sym).get(); xt.push(permuted); // ro_key for every domain value; f[n] = ro(n, xt[n]). @@ -300,7 +297,7 @@ impl OreAes128Bit6Chained { prp.indicator_mask_xor(sym, &mut rb); blocks.push(rb); - acc.absorb(&prefix_block(n16, sym)); + acc.absorb(&prefix_block(n16, sym.get())); } // `ro` held key-derived RO_KEY tags (then H outputs) for the last block. @@ -314,11 +311,16 @@ impl OreAes128Bit6Chained { }) } - /// [`Self::encrypt_left_var`] without the stack scrub; see - /// [`Self::encrypt_var_unscrubbed`]. - fn encrypt_left_var_unscrubbed(&self, x: &[u8]) -> Result { + /// Left-only ciphertext (smaller; for query plaintexts). A value `>= 64` + /// returns [`OreError::PrpError`]. + pub fn encrypt_left_var(&self, x: &[u8]) -> Result { + stack::scrubbed(|| self.encrypt_left_blocks(&check_symbols(x)?)) + } + + /// [`Self::encrypt_left_var`] over blocks already known to be in the + /// 6-bit domain, without the stack scrub; see [`Self::encrypt_blocks`]. + fn encrypt_left_blocks>(&self, x: &Blocks6) -> Result { let count = x.len(); - debug_assert!(x.iter().all(|&b| (b as usize) < DOMAIN)); if count > u16::MAX as usize { return Err(OreError::TooManyBlocks); } @@ -327,10 +329,11 @@ impl OreAes128Bit6Chained { let mut xt = Vec::with_capacity(count); let mut f = Vec::with_capacity(count); - for (n, &sym) in x.iter().enumerate() { + for n in 0..count { + let sym = x.symbol(n); let n16 = n as u16; let prp = self.prp_at(&acc, n16)?; - let permuted = prp.permute(sym)?; + let permuted = prp.permute(sym).get(); xt.push(permuted); f.push(acc.finalize(&final_block( Branch::RoKey, @@ -338,7 +341,7 @@ impl OreAes128Bit6Chained { permuted as u16, WIDTH_BIT6, ))); - acc.absorb(&prefix_block(n16, sym)); + acc.absorb(&prefix_block(n16, sym.get())); } Ok(VarLeft { xt, f }) } @@ -350,14 +353,14 @@ impl OreAes128Bit6Chained { /// as the order and common-prefix length that comparisons reveal; see /// the module docs. pub fn encrypt_str(&self, s: &str) -> Result { - stack::scrubbed(|| self.encrypt_var_unscrubbed(&str_to_blocks(s)?)) + stack::scrubbed(|| self.encrypt_blocks(&str_to_blocks(s)?)) } /// Left-only string ciphertext. Strings longer than [`MAX_STR_BYTES`] /// return [`OreError::TooManyBlocks`]. Like [`Self::encrypt_str`], it /// reveals the string's exact length. pub fn encrypt_left_str(&self, s: &str) -> Result { - stack::scrubbed(|| self.encrypt_left_var_unscrubbed(&str_to_blocks(s)?)) + stack::scrubbed(|| self.encrypt_left_blocks(&str_to_blocks(s)?)) } /// Compare two serialised full ciphertexts (lexicographic; shorter prefix @@ -423,16 +426,12 @@ impl OreAes128Bit6Chained { fn compare_views(a: &[u8], ca: usize, b: &[u8], cb: usize) -> Ordering { let min_count = ca.min(cb); let mut is_equal = Choice::from(1u8); - let mut sel_xt = 0u8; - let mut sel_f = [0u8; F_LEN]; - let mut sel_right = [0u8; RIGHT_LEN]; + let mut diff = FirstDiff::::new(); for n in 0..min_count { let differs = !xt_at(a, n).ct_eq(&xt_at(b, n)) | !f_at(a, ca, n).ct_eq(f_at(b, cb, n)); // Set for exactly one `n`: the first differing block. let first = is_equal & differs; - sel_xt.conditional_assign(&xt_at(a, n), first); - ct_assign_bytes(&mut sel_f, f_at(a, ca, n), first); - ct_assign_bytes(&mut sel_right, right_at(b, cb, n), first); + diff.latch(xt_at(a, n), f_at(a, ca, n), right_at(b, cb, n), first); is_equal.conditional_assign(&Choice::from(0u8), first); } @@ -442,10 +441,7 @@ impl OreAes128Bit6Chained { } let hasher = FixedPiZ2Hash::new(HashKey::from_slice(nonce_at(b, cb))); - let h = hasher.hash(&sel_f); - let byte = ct_select_byte(&sel_right, (sel_xt / 8) as usize); - let test = ct_bit(byte, sel_xt % 8) ^ h; - if test == 1 { + if diff.resolve(&hasher) == 1 { Ordering::Greater } else { Ordering::Less @@ -474,20 +470,28 @@ pub const MAX_STR_BYTES: usize = u16::MAX as usize * 6 / 8; const _: () = assert!(num_blocks_6bit(MAX_STR_BYTES) <= u16::MAX as usize); const _: () = assert!(num_blocks_6bit(MAX_STR_BYTES + 1) > u16::MAX as usize); +/// Check raw symbols for [`OreAes128Bit6Chained::encrypt_var`] and +/// [`OreAes128Bit6Chained::encrypt_left_var`], refusing more blocks than the +/// wire format can number *before* scanning them. +fn check_symbols(x: &[u8]) -> Result, OreError> { + if x.len() > u16::MAX as usize { + return Err(OreError::TooManyBlocks); + } + Blocks6::check(x) +} + /// Decompose `s` into 6-bit blocks, refusing a string too long for the wire /// format *before* allocating or decomposing it. The decomposition is the /// whole plaintext (it inverts), so the buffer is wiped when dropped. -fn str_to_blocks(s: &str) -> Result>, OreError> { +fn str_to_blocks(s: &str) -> Result>>, OreError> { let bytes = s.as_bytes(); if bytes.len() > MAX_STR_BYTES { return Err(OreError::TooManyBlocks); } - let nb = num_blocks_6bit(bytes.len()); - let mut blocks = Zeroizing::new(vec![0u8; nb]); - if nb > 0 { - decompose_6bit(bytes, &mut blocks); - } - Ok(blocks) + Ok(Blocks6::decompose( + bytes, + Zeroizing::new(vec![0u8; num_blocks_6bit(bytes.len())]), + )) } #[cfg(test)] @@ -498,6 +502,28 @@ mod tests { OreAes128Bit6Chained::init(&[0x11; 16]).unwrap() } + /// Symbols passed straight to `encrypt_var` / `encrypt_left_var` are + /// checked: a value outside the 6-bit domain is refused, and in-domain + /// symbols encrypt exactly as the string path's decomposition does. + #[test] + fn raw_symbols_are_checked() { + let c = ore(); + assert!(matches!( + c.encrypt_var(&[1, 64, 2]), + Err(OreError::PrpError(_)) + )); + assert!(matches!( + c.encrypt_left_var(&[1, 64, 2]), + Err(OreError::PrpError(_)) + )); + + let blocks = str_to_blocks("alice").unwrap(); + assert_eq!( + c.encrypt_left_var(blocks.as_bytes()).unwrap().to_bytes(), + c.encrypt_left_str("alice").unwrap().to_bytes() + ); + } + #[test] fn strings_over_the_wire_limit_are_rejected() { let c = ore(); @@ -512,6 +538,23 @@ mod tests { )); } + /// Raw symbols over the wire limit are refused on their length, before + /// any symbol is checked: out-of-domain symbols give `TooManyBlocks`, + /// not `PrpError`. + #[test] + fn raw_symbols_over_the_wire_limit_are_rejected() { + let c = ore(); + let too_many = vec![64u8; u16::MAX as usize + 1]; + assert!(matches!( + c.encrypt_var(&too_many), + Err(OreError::TooManyBlocks) + )); + assert!(matches!( + c.encrypt_left_var(&too_many), + Err(OreError::TooManyBlocks) + )); + } + #[test] fn comparators_reject_out_of_domain_symbols() { let c = ore(); @@ -752,10 +795,11 @@ mod tests { let blocks = str_to_blocks(s).unwrap(); let mut acc = CmacAccumulator::new(&ore.k_acc); let mut needles = Vec::new(); - for (n, &sym) in blocks.iter().enumerate() { + for (n, &sym) in blocks.as_bytes().iter().enumerate() { let prp = ore.prp_at(&acc, n as u16).unwrap(); - needles.push((0..64).map(|i| prp.permute(i).unwrap()).collect()); - needles.push((0..64).map(|i| prp.invert(i).unwrap()).collect()); + let symbols = (0..64).map(crate::primitives::Symbol::<64>::from_low_bits); + needles.push(symbols.clone().map(|i| prp.permute(i).get()).collect()); + needles.push(symbols.map(|i| prp.invert(i).get()).collect()); acc.absorb(&prefix_block(n as u16, sym)); } needles @@ -799,7 +843,7 @@ mod tests { }, |ore, s| { let blocks = str_to_blocks(s).unwrap(); - core::hint::black_box(ore.encrypt_var_unscrubbed(&blocks).unwrap()); + core::hint::black_box(ore.encrypt_blocks(&blocks).unwrap()); }, ); } @@ -812,7 +856,7 @@ mod tests { }, |ore, s| { let blocks = str_to_blocks(s).unwrap(); - core::hint::black_box(ore.encrypt_left_var_unscrubbed(&blocks).unwrap()); + core::hint::black_box(ore.encrypt_left_blocks(&blocks).unwrap()); }, ); } diff --git a/packages/ore-rs/src/scheme/decompose.rs b/packages/ore-rs/src/scheme/decompose.rs index f49b8af4..c99896b5 100644 --- a/packages/ore-rs/src/scheme/decompose.rs +++ b/packages/ore-rs/src/scheme/decompose.rs @@ -14,15 +14,14 @@ //! The 6-bit packing ships ahead of the Bit6 scheme (v2 plan, PR 5) because //! it is pure bit logic that can be pinned by property tests now. -// The 6-bit functions are consumed by the Bit6 scheme (v2 plan, PR 5); they -// land early so the packing is pinned by tests independent of that scheme. -#[allow(dead_code)] +use crate::primitives::{PrpError, Symbol}; +use crate::OreError; + /// Number of 6-bit blocks needed for `n` plaintext bytes. pub(crate) const fn num_blocks_6bit(n: usize) -> usize { (8 * n).div_ceil(6) } -#[allow(dead_code)] /// Decompose `bytes` MSB-first into 6-bit block values, writing them to /// `out`. `out.len()` must be exactly `num_blocks_6bit(bytes.len())`. /// @@ -47,6 +46,71 @@ pub(crate) fn decompose_6bit(bytes: &[u8], out: &mut [u8]) { } } +/// 6-bit block values, each `< 64` by construction, in any byte storage +/// (`[u8; N]` for Bit6, `Vec` for the chained scheme). +/// +/// There are two ways to make one. [`Self::decompose`] masks every value to +/// six bits as it packs, so it never branches on the plaintext; the +/// `OreEncrypt` and string paths use it. [`Self::check`] validates blocks a +/// caller passes in directly and branches once on the result. The encryptors +/// take a `Blocks6`, so the PRP gets an in-domain [`Symbol`] without a +/// per-block range check on the secret. +pub(crate) struct Blocks6(B); + +impl> Blocks6 { + /// Accept `blocks` if every value is `< 64`, else + /// [`OreError::PrpError`] (the error an out-of-domain symbol has always + /// produced). The values are folded together branch-free and the one + /// branch is on the folded result, so for well-formed input the branch + /// always goes the same way. + /// + /// Kept out of line so that this branch, the one the taint harness + /// accepts, has a frame of its own whatever the caller inlines + /// (`packages/ct-taint/valgrind.supp`). + #[inline(never)] + pub(crate) fn check(blocks: B) -> Result { + let high = blocks.as_ref().iter().fold(0u8, |acc, &b| acc | (b >> 6)); + if high != 0 { + return Err(OreError::PrpError(PrpError)); + } + Ok(Self(blocks)) + } + + /// The block values as bytes. + #[inline] + pub(crate) fn as_bytes(&self) -> &[u8] { + self.0.as_ref() + } + + /// Block `i` as a PRP symbol. `from_low_bits` is the identity here (the + /// value is `< 64`), and does not branch. + #[inline] + pub(crate) fn symbol(&self, i: usize) -> Symbol<64> { + Symbol::from_low_bits(self.0.as_ref()[i]) + } + + /// The number of blocks. + #[inline] + pub(crate) fn len(&self) -> usize { + self.0.as_ref().len() + } + + /// The underlying storage. + #[inline] + pub(crate) fn inner(&self) -> &B { + &self.0 + } +} + +impl + AsMut<[u8]>> Blocks6 { + /// Decompose `bytes` into `out` (see [`decompose_6bit`]). + /// `out.as_ref().len()` must be `num_blocks_6bit(bytes.len())`. + pub(crate) fn decompose(bytes: &[u8], mut out: B) -> Self { + decompose_6bit(bytes, out.as_mut()); + Self(out) + } +} + #[cfg(test)] mod tests { use super::*; @@ -65,6 +129,29 @@ mod tests { xb.cmp(&yb) == xq.cmp(&yq) } + /// Order is preserved across different lengths too, with a proper + /// prefix sorting first: the chained scheme's string order. + fn order_preserved_any_lengths(a: Vec, b: Vec) -> bool { + let mut aq = vec![0u8; num_blocks_6bit(a.len())]; + let mut bq = vec![0u8; num_blocks_6bit(b.len())]; + decompose_6bit(&a, &mut aq); + decompose_6bit(&b, &mut bq); + a.cmp(&b) == aq.cmp(&bq) + } + + /// The case padding could break: `b` extends `a`, so the two share + /// `a`'s bits and `a`'s zero-padded last block meets `b`'s real + /// bits. Random pairs almost never share a prefix, hence this case + /// on its own. + fn order_preserved_proper_prefix(a: Vec, tail: Vec) -> bool { + let b: Vec = a.iter().chain(&tail).copied().collect(); + let mut aq = vec![0u8; num_blocks_6bit(a.len())]; + let mut bq = vec![0u8; num_blocks_6bit(b.len())]; + decompose_6bit(&a, &mut aq); + decompose_6bit(&b, &mut bq); + a.cmp(&b) == aq.cmp(&bq) + } + /// Every block value is in the 6-bit domain. fn blocks_in_domain(bytes: Vec) -> bool { let mut out = vec![0u8; num_blocks_6bit(bytes.len())]; @@ -102,6 +189,17 @@ mod tests { assert_eq!(num_blocks_6bit(0), 0); } + #[test] + fn blocks6_check_accepts_exactly_the_domain() { + assert!(Blocks6::check([0u8, 63, 17]).is_ok()); + assert!(matches!( + Blocks6::check([0u8, 64, 17]), + Err(OreError::PrpError(_)) + )); + assert!(Blocks6::check([255u8]).is_err()); + assert!(Blocks6::check(Vec::::new()).is_ok()); + } + #[test] fn known_packing() { // Bit stream 11111100 00001111 -> 111111 | 000000 | 1111 + 2 pad bits @@ -110,3 +208,110 @@ mod tests { assert_eq!(out, [0b111111, 0b000000, 0b111100]); } } + +#[cfg(kani)] +mod kani_proofs { + use super::*; + + /// Inputs up to this many bytes are covered by the bounded harnesses. + const MAX_LEN: usize = 16; + /// `num_blocks_6bit(MAX_LEN)`. + const MAX_BLOCKS: usize = 22; + + /// Bit `k` (MSB-first) of `bytes`, or 0 past the end. + fn input_bit(bytes: &[u8], k: usize) -> u8 { + if k / 8 < bytes.len() { + (bytes[k / 8] >> (7 - k % 8)) & 1 + } else { + 0 + } + } + + /// `num_blocks_6bit(n)` is the least `b` with `6b >= 8n`, for every `n` + /// with `8n` representable in `usize` (full domain of non-overflowing `n`). + #[kani::proof] + fn num_blocks_6bit_is_ceil_8n_over_6() { + let n: usize = kani::any(); + kani::assume(n <= usize::MAX / 8); + let b = num_blocks_6bit(n); + assert!(6 * (b as u128) >= 8 * (n as u128)); + if b > 0 { + assert!(6 * ((b - 1) as u128) < 8 * (n as u128)); + } + } + + /// For every input of length `0..=16`: every output symbol is `< 64`, and + /// bit `5 - t` of block `i` is plaintext bit `6i + t` (MSB-first, zero + /// past the end) β€” the exact packing spec, which implies round-trip. + #[kani::proof] + #[kani::unwind(23)] + fn decompose_6bit_matches_bit_spec() { + let buf: [u8; MAX_LEN] = kani::any(); + let len: usize = kani::any(); + kani::assume(len <= MAX_LEN); + let bytes = &buf[..len]; + let nb = num_blocks_6bit(len); + let mut out_buf = [0u8; MAX_BLOCKS]; + let out = &mut out_buf[..nb]; + decompose_6bit(bytes, out); + + let i: usize = kani::any(); + kani::assume(i < nb); + assert!(out[i] < 64); + let t: usize = kani::any(); + kani::assume(t < 6); + assert_eq!((out[i] >> (5 - t)) & 1, input_bit(bytes, 6 * i + t)); + } + + /// For any two inputs of lengths `0..=16`, equal decompositions (same + /// block count and same symbols) imply equal inputs: decomposition is + /// injective on that domain. + #[kani::proof] + #[kani::unwind(23)] + fn decompose_6bit_injective() { + let a_buf: [u8; MAX_LEN] = kani::any(); + let b_buf: [u8; MAX_LEN] = kani::any(); + let a_len: usize = kani::any(); + let b_len: usize = kani::any(); + kani::assume(a_len <= MAX_LEN && b_len <= MAX_LEN); + let a = &a_buf[..a_len]; + let b = &b_buf[..b_len]; + + let mut a_out = [0u8; MAX_BLOCKS]; + let mut b_out = [0u8; MAX_BLOCKS]; + let a_nb = num_blocks_6bit(a_len); + let b_nb = num_blocks_6bit(b_len); + decompose_6bit(a, &mut a_out[..a_nb]); + decompose_6bit(b, &mut b_out[..b_nb]); + + if a_out[..a_nb] == b_out[..b_nb] { + assert!(a == b); + } + } + + /// For any two inputs of lengths `0..=16`, equal or not, lexicographic + /// order of the block values (a proper prefix sorting first) equals + /// lexicographic order of the bytes. This is the order the chained + /// comparator computes for strings of different lengths: the first + /// differing block decides, else the shorter sorts first. The zero + /// padding of a shorter input's last block is what could break it, and + /// does not: it is never greater than the longer input's real bits. + #[kani::proof] + #[kani::unwind(23)] + fn decompose_6bit_preserves_order() { + let a_buf: [u8; MAX_LEN] = kani::any(); + let b_buf: [u8; MAX_LEN] = kani::any(); + let a_len: usize = kani::any(); + let b_len: usize = kani::any(); + kani::assume(a_len <= MAX_LEN && b_len <= MAX_LEN); + let a = &a_buf[..a_len]; + let b = &b_buf[..b_len]; + let a_nb = num_blocks_6bit(a_len); + let b_nb = num_blocks_6bit(b_len); + let mut a_out = [0u8; MAX_BLOCKS]; + let mut b_out = [0u8; MAX_BLOCKS]; + decompose_6bit(a, &mut a_out[..a_nb]); + decompose_6bit(b, &mut b_out[..b_nb]); + assert!(a.cmp(b) == a_out[..a_nb].cmp(&b_out[..b_nb])); + } +} diff --git a/packages/ore-rs/src/scheme/width.rs b/packages/ore-rs/src/scheme/width.rs index 1dd841a0..3cff3cbf 100644 --- a/packages/ore-rs/src/scheme/width.rs +++ b/packages/ore-rs/src/scheme/width.rs @@ -11,9 +11,10 @@ //! as associated types instead. use crate::ciphertext::CipherTextBlock; -use crate::primitives::{AesBlock, Prp}; +use crate::primitives::{AesBlock, Hash, Prp, Symbol}; use crate::scheme::bit2::block_types::RightBlock32; use crate::scheme::bit2_w6::block_types::RightBlock8; +use zeroize::{Zeroize, ZeroizeOnDrop}; mod sealed { pub trait Sealed {} @@ -129,6 +130,52 @@ pub(crate) fn ct_bit(byte: u8, pos: u8) -> u8 { out } +/// The first differing block of a comparator's prefix scan: `a`'s permuted +/// symbol `xt` and PRF tag `f`, and `b`'s `R`-byte right bitvector. +/// +/// The scan calls [`Self::latch`] for every block, under the choice "this is +/// the first difference", so nothing after the scan is loaded at an address +/// derived from that position (see [`ct_assign_bytes`]). The copies are +/// zeroized on drop, on every return path, so they do not outlive the +/// comparison on the stack. +#[derive(Zeroize, ZeroizeOnDrop)] +pub(crate) struct FirstDiff { + xt: u8, + f: [u8; 16], + right: [u8; R], +} + +impl FirstDiff { + pub(crate) fn new() -> Self { + Self { + xt: 0, + f: [0; 16], + right: [0; R], + } + } + + /// Copy one block's values in when `first` is set; every byte of the + /// latch and the inputs is touched either way. + #[inline] + pub(crate) fn latch(&mut self, xt: u8, f: &[u8], right: &[u8], first: subtle_ng::Choice) { + use subtle_ng::ConditionallySelectable; + self.xt.conditional_assign(&xt, first); + ct_assign_bytes(&mut self.f, f, first); + ct_assign_bytes(&mut self.right, right, first); + } + + /// The resolution step: bit `xt` of the right bitvector XOR the random + /// oracle on `f`. `1` means `a > b`. The bit is read with + /// [`ct_select_byte`] and [`ct_bit`], so its address does not depend on + /// `xt` either. + #[inline] + pub(crate) fn resolve(&self, hasher: &H) -> u8 { + debug_assert!(usize::from(self.xt) < R * 8); + let byte = ct_select_byte(&self.right, usize::from(self.xt / 8)); + ct_bit(byte, self.xt % 8) ^ hasher.hash(&self.f) + } +} + /// Per-block bitvector operations on a Right ciphertext block, one bit per /// value in the block domain. pub trait RightBitVec { @@ -160,8 +207,10 @@ pub trait BlockWidth: sealed::Sealed + 'static { const DOMAIN: usize = 1 << Self::BITS; /// Right-ciphertext block: a `DOMAIN`-bit masked truth-table row. type RightBlock: CipherTextBlock + RightBitVec; + /// A block value: a [`Symbol`] of the `DOMAIN`-element domain. + type Symbol: Copy; /// PRP over the block domain. - type Prp: Prp; + type Prp: Prp; /// Buffer holding `DOMAIN` random-oracle keys. type RoKeyBuf: AesBlockBuf; } @@ -174,6 +223,7 @@ pub struct Bit8; impl BlockWidth for Bit8 { const BITS: usize = 8; type RightBlock = RightBlock32; + type Symbol = Symbol<256>; type Prp = crate::primitives::prp::KnuthShufflePRP; type RoKeyBuf = [AesBlock; 256]; } @@ -191,5 +241,58 @@ impl BlockWidth for Bit6 { // 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 Symbol = Symbol<64>; type RoKeyBuf = [AesBlock; 64]; } + +#[cfg(kani)] +mod kani_proofs { + use super::*; + + /// `ct_select_byte(block, idx) == block[idx]` for every block of length + /// 1..=256 (the stated domain) and every in-range `idx`. + // Every length 1..=256 takes about nine minutes; the shipped lengths are + // proved by the harness below, so this runs only with `kani-full`. + #[cfg(feature = "kani-full")] + #[kani::proof] + #[kani::unwind(257)] + fn ct_select_byte_equals_index_up_to_256() { + let buf: [u8; 256] = kani::any(); + let len: usize = kani::any(); + kani::assume(len >= 1 && len <= 256); + let idx: usize = kani::any(); + kani::assume(idx < len); + let block = &buf[..len]; + assert_eq!(ct_select_byte(block, idx), block[idx]); + } + + /// `ct_select_byte(block, idx) == block[idx]` for the two right-block + /// sizes the comparators use (8 and 32 bytes), all contents, all + /// in-range `idx`. + #[kani::proof] + #[kani::unwind(33)] + fn ct_select_byte_equals_index_right_blocks() { + let b8: [u8; 8] = kani::any(); + let i8: usize = kani::any(); + kani::assume(i8 < 8); + assert_eq!(ct_select_byte(&b8, i8), b8[i8]); + + let b32: [u8; 32] = kani::any(); + let i32: usize = kani::any(); + kani::assume(i32 < 32); + assert_eq!(ct_select_byte(&b32, i32), b32[i32]); + } + + /// `ct_bit(byte, pos) == (byte >> pos) & 1` for every byte and every + /// `pos` in `0..8`, and is 0 for every `pos >= 8` (exhaustive). + #[kani::proof] + fn ct_bit_equals_shift() { + let byte: u8 = kani::any(); + let pos: u8 = kani::any(); + if pos < 8 { + assert_eq!(ct_bit(byte, pos), (byte >> pos) & 1); + } else { + assert_eq!(ct_bit(byte, pos), 0); + } + } +} diff --git a/scripts/ct-taint.sh b/scripts/ct-taint.sh new file mode 100755 index 00000000..d2579527 --- /dev/null +++ b/scripts/ct-taint.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Run the Valgrind taint harness (packages/ct-taint) over the v2 encryptors. +# +# scripts/ct-taint.sh # gate: fail on any report not in valgrind.supp +# scripts/ct-taint.sh --gen # print every report as a suppression, for triage +# +# Needs Linux and valgrind on PATH. On macOS run it inside an arm64 container: +# docker run --rm --platform linux/arm64 -v "$PWD":/src -w /src rust:bookworm \ +# bash -c 'apt-get update -qq && apt-get install -y -qq valgrind && scripts/ct-taint.sh' +# +# Each mode marks the key and plaintext undefined and runs one encryptor; every +# memcheck report is a secret-dependent branch ("Cond") or a secret-indexed +# address ("Value*"). The reports that are accepted, and why, are the entries +# of packages/ct-taint/valgrind.supp. +# +# The gate refuses to pass vacuously. CT_TAINT_GATE makes every mode fail if a +# taint request does nothing or if its output comes out fully defined (the +# taint never reached it), and the `canary` mode, which branches on a tainted +# byte, must fail. +set -euo pipefail + +cd "$(dirname "$0")/../packages/ct-taint" + +cargo build --release --quiet +bin="${CARGO_TARGET_DIR:-target}/release/ct-taint" +modes=( + bit6-encrypt bit6-encrypt-left bit6-encrypt-i64 bit6-encrypt-f64 bit6-encrypt-u32 + bit6-encrypt-raw bit6-encrypt-left-raw + chained-encrypt chained-encrypt-left chained-encrypt-raw chained-encrypt-left-raw +) +status=0 +export CT_TAINT_GATE=1 + +if [ "${1:-}" != "--gen" ]; then + echo "== canary (must fail)" + canary=0 + valgrind -q --tool=memcheck --leak-check=no --error-exitcode=99 \ + --suppressions=valgrind.supp "$bin" canary >/dev/null 2>&1 || canary=$? + if [ "$canary" -eq 99 ]; then + echo " ok: memcheck reported the tainted branch" + else + echo " FAIL: canary exited $canary, not 99; the gate cannot see a tainted branch" + status=1 + fi +fi + +for mode in "${modes[@]}"; do + echo "== $mode" + if [ "${1:-}" = "--gen" ]; then + valgrind -q --tool=memcheck --leak-check=no --gen-suppressions=all \ + "$bin" "$mode" || true + elif valgrind -q --tool=memcheck --leak-check=no --error-exitcode=99 \ + --suppressions=valgrind.supp "$bin" "$mode"; then + echo " ok" + else + echo " FAIL: unexpected secret-dependent branch or access (see above)" + status=1 + fi +done + +exit $status diff --git a/scripts/dudect-uncropped.sh b/scripts/dudect-uncropped.sh new file mode 100755 index 00000000..3aaea9de --- /dev/null +++ b/scripts/dudect-uncropped.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Run one ct-dudect bench continuously and report the *uncropped* Welch t +# next to dudect's own figure. +# +# scripts/dudect-uncropped.sh prp_build_const_vs_random # 150 s +# CT_DUDECT_DIT=1 scripts/dudect-uncropped.sh 300 +# +# dudect reports the largest |t| over 101 tests, 100 of which first drop +# every sample above a percentile of the runtime distribution. When the two +# classes differ in spread, dropping the slow tail removes more of one class +# than the other, and the cropped tests can report a large t with no +# difference in mean. On Apple Silicon the timer is a 24 MHz counter (41.67 +# ns ticks), so the percentiles also fall exactly on tick values. This +# script records every sample (streamed through a FIFO into per-class +# histograms, so a long run stays small) and prints the plain Welch t, the +# per-class means and spreads, and the share of samples in each tick bin. +# +# Needs python3. dudect-bencher writes every sample with class 0; the classes +# are recovered from the row order (it writes Left, Right pairs). +set -euo pipefail + +bench="${1:?usage: dudect-uncropped.sh [seconds]}" +seconds="${2:-150}" +cd "$(dirname "$0")/../packages/ct-dudect" + +cargo +stable build --release --locked --quiet +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +mkfifo "$tmp/samples" + +awk -F, 'NR > 1 && NF == 3 && $3 ~ /^[0-9]+$/ { + k++; h[(k % 2 == 1 ? "L" : "R") " " $3]++ + } + END { for (x in h) print x, h[x] }' "$tmp/samples" >"$tmp/hist" & +timeout "$seconds" ./target/release/ct-dudect --continuous "$bench" \ + --out "$tmp/samples" >"$tmp/dudect.log" 2>&1 || true +wait + +echo "dudect: $(grep 'max t' "$tmp/dudect.log" | tail -1 | sed -E 's/.*: (n ==.*)/\1/')" +python3 - "$tmp/hist" <<'EOF' +import math, sys +from collections import defaultdict + +h = {"L": defaultdict(int), "R": defaultdict(int)} +for line in open(sys.argv[1]): + c, v, k = line.split() + h[c][float(v)] += int(k) + +def moments(d): + n = sum(d.values()) + m = sum(v * c for v, c in d.items()) / n + var = sum(c * (v - m) ** 2 for v, c in d.items()) / (n - 1) + return n, m, var + +(nl, ml, vl), (nr, mr, vr) = moments(h["L"]), moments(h["R"]) +t = (ml - mr) / math.sqrt(vl / nl + vr / nr) +print(f"uncropped: n = {nl / 1e6:.1f} M + {nr / 1e6:.1f} M, " + f"t = {t:+.2f}, tau = {t / math.sqrt(nl + nr):+.5f}") +print(f"mean Left {ml:.2f}, Right {mr:.2f} (diff {ml - mr:+.3f}); " + f"sd Left {math.sqrt(vl):.1f}, Right {math.sqrt(vr):.1f}") +print("bin Left share Right share z") +top = sorted(set(h["L"]) | set(h["R"]), key=lambda v: -(h["L"][v] + h["R"][v]))[:8] +for b in sorted(top): + pl, pr = h["L"][b] / nl, h["R"][b] / nr + p = (h["L"][b] + h["R"][b]) / (nl + nr) + se = math.sqrt(p * (1 - p) * (1 / nl + 1 / nr)) or 1.0 + print(f"{b:7.0f} {pl:10.5f} {pr:11.5f} {(pl - pr) / se:+6.1f}") +EOF diff --git a/scripts/fuzz.sh b/scripts/fuzz.sh new file mode 100755 index 00000000..7a0f74a6 --- /dev/null +++ b/scripts/fuzz.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Build and run the ore-rs fuzz targets (packages/ore-rs/fuzz, a detached +# cargo-fuzz crate; needs the nightly toolchain and cargo-fuzz). +# +# scripts/fuzz.sh # every target, 60 s each +# scripts/fuzz.sh 300 # every target, 300 s each +# scripts/fuzz.sh 60 bit6_parse # one target +# +# The targets assert that the ciphertext parsers and raw comparators never +# panic on arbitrary bytes and that accepted bytes re-encode to themselves. +# A crash leaves its input under packages/ore-rs/fuzz/artifacts//. +# +# Each target is built and run on its own, so a single-target run (one CI +# matrix job) compiles only that target, and a crash in one target does not +# stop the others: every target runs, and the script fails at the end if +# any of them did. +set -euo pipefail + +seconds="${1:-60}" +cd "$(dirname "$0")/../packages/ore-rs/fuzz" + +if [ $# -ge 2 ]; then + targets=("$2") +else + # Every target cargo-fuzz knows, so a new target needs no edit here. A + # plain assignment, so `set -e` stops the script if the listing fails. + list="$(cargo +nightly fuzz list)" + targets=() + while IFS= read -r t; do + [ -n "$t" ] && targets+=("$t") + done <<<"$list" +fi +if [ ${#targets[@]} -eq 0 ]; then + echo "no fuzz targets found" >&2 + exit 1 +fi + +failed=() +for target in "${targets[@]}"; do + echo "== $target (${seconds}s)" + if ! cargo +nightly fuzz build "$target" || + ! cargo +nightly fuzz run "$target" -- -max_total_time="$seconds"; then + echo " FAIL: $target" + failed+=("$target") + fi +done + +if [ ${#failed[@]} -gt 0 ]; then + echo "failed: ${failed[*]}" + exit 1 +fi