Skip to content

ORE v2 (5/n): 6-bit block scheme + v2 wire format - #82

Merged
coderdan merged 33 commits into
feat/ore-v2-simdfrom
feat/ore-v2-bit6
Oct 12, 2026
Merged

coderdan merged 33 commits into
feat/ore-v2-simdfrom
feat/ore-v2-bit6

Conversation

@coderdan

@coderdan coderdan commented Jun 12, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #81. Plan §4 + §6 (docs/plans/2026-06-12-ore-v2-architecture.md); crypto review brief docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md.

Crypto review status ✅

The §6 H-construction gate (A1) is resolved (2026-06-15): the scheme uses the BHKR fixed-key-AES σ-MMO H(x, r) = LSB(π(σ(x) ⊕ r) ⊕ σ(x) ⊕ r), with π = AES-128_{K₀} (public K₀) and σ(x) = 2x in GF(2¹²⁸).

  • The known fixed-key-MMO attacks (GKWY; the half-gates multi-instance attack, eprint 2019/1168) require known, Free-XOR-correlated hash inputs and a recoverable global offset. ORE has independent secret PRF inputs and no global offset, so they do not port.
  • The tight tweak-as-key variant (2019/1168 Thm 2) is declined: rekeying per evaluation breaks the keyless-comparator / performance requirement and fixes a degradation ORE doesn't suffer.
  • The AES-hashing cryptanalysis (eprint 2025/792) targets collision/preimage/one-wayness — not the 1-bit correlation-robustness we rely on — and reaches only round-reduced AES (7/10 collision).
  • The orthomorphism σ is cheap defense-in-depth (security by matching the named BHKR/Zahur construction, not a usage argument). Full write-up: review brief A1.

Wire format is now frozen — Bit6 vectors pinned in tests/compat_w6_vectors (mirrors the Bit8 compat_vectors contract: pinned left + full bytes, comparison/order fixtures, plus a signed i64 order vector).

Also lands the constant-time PRP hardening (A4): LemireFyPrp (fixed-draw Fisher–Yates + Lemire reduction, replacing the rejection-sampled shuffle — closes a plaintext-dependent timing channel and a modulo bias), #[repr(C, align(64))] + an N ≤ 64 compile guard for the secret-indexed key-gen writes, and an oblivious comparator block read (ct_select_byte, closing the cache-line / MemJam channel). See review brief A4.

Numbers (Apple M1 Max, u64)

Bit8 (after #80) Bit6 (this PR)
encrypt ~25 µs ~8.9 µs
compare ~234 ns ~198 ns
ciphertext 408 B 295 B (incl. 4 B v2 header)

The σ-MMO's GF(2¹²⁸) doubling is vectorized to a single u128 word op (fused with the nonce XOR in the ~704-eval/u64 hot loop), so encrypt is back to the pre-orthomorphism shape-i level (~8.9 µs vs ~12.3 µs for the scalar byte-loop). Byte-identical, still constant-time.

What

  • OreAes128Bit6 / OreAes128Bit6ChaCha20 — 64-element domain, 64 RO evals/block, 8-byte right blocks, MSB-first 6-bit decomposition (order-preservation quickchecked).
  • v2 wire header (version ‖ scheme_id ‖ count) via OreCipher::WIRE_HEADER (None for legacy — Bit8 bytes unchanged, its vectors still pass). Cross-scheme / cross-shape comparisons return None; corrupt/truncated headers fail parsing.
  • H = BHKR σ-MMO (FixedPiZ2Hash) — A1 resolved.
  • PRP = LemireFyPrp (fixed-draw FY + Lemire reduction), constant-time hardened (A4).
  • Domain separation: block count bound into the PRF inputs; fixes the repeated-PRP-seed TODO for this scheme.
  • OreEncrypt for primitives ≤ 64 bits with compile-time block-count assertions; u128/i128/Decimal exceed the 14-block packed-prefix cap (documented; they arrive with the chained prefix, PR 6).
  • Wire vectors pinned (tests/compat_w6_vectors).
  • API change: OreEncrypt blanket impls (T: OreCipher) became scheme-specific — coherence requires it once block count ≠ byte count. Downstream code generic over OreCipher must name a scheme.

Not in this PR

  • chrono/decimal Bit6 impls (chrono fits trivially; Decimal can't).
  • The chained-prefix / variable-length scheme + string encryption (PR 6; behind the A2/A3 crypto gate).

@coderdan coderdan changed the title ORE v2 (5/n): 6-bit block scheme + v2 wire format — DO NOT MERGE before H review ORE v2 (5/n): 6-bit block scheme + v2 wire format Jun 15, 2026
coderdan added a commit that referenced this pull request Jun 15, 2026
Make explicit that the chained scheme has exactly one secret key (k),
producing both branches via the branch tag; branch-tag domain separation
under a good PRF is equivalent to independent per-branch keys but cheaper
(one key schedule + one subkey pair). Note H's pi is public (not secret) and
the nonce is not a key; contrast with fixed-N (#82, two keys) and the
init(k1,k2) API (k2 redundant here); flag the single-key choice for explicit
sign-off.
coderdan added a commit that referenced this pull request Jun 16, 2026
…ebug (#82 review)

Code-review follow-ups on the Bit6 / v2-wire PR:

- compare_raw_slices: reject a degenerate count=0 header. No OreEncrypt path
  produces zero blocks; without this, two crafted 0-block ciphertexts (header
  + nonce only) compare Equal because the scan loop never runs. (C2)
- Deduplicate encode_right_block: make the bit2 helper generic over the hash
  (encode_right_block<W: BlockWidth, H: Hash>) and have the Bit6 scheme call it
  instead of keeping a near-verbatim copy. (D1)
- Add width::ct_bit and route all four get_bit sites through it: extract the
  target bit with constant shift amounts + a constant-time select, instead of
   (a shift by a secret amount — constant-time on x86_64/
  aarch64 but not guaranteed on every target). Pairs with ct_select_byte for a
  fully oblivious, data-independent bit read. (D3 mitigation)
- Replace #[derive(Debug)] on OreAes128 / OreAes128Bit6 with an explicit opaque
  Debug impl (finish_non_exhaustive) so key material can never be rendered.
  (Used a manual impl rather than vitaminc::OpaqueDebug — vitaminc is a 0.2.0
  pre-release and ore-rs is a published crate; see PR discussion.)

No wire-format change: bit2 and bit6 compat vectors remain byte-identical.
coderdan added a commit that referenced this pull request Jun 16, 2026
…ebug (#82 review)

Code-review follow-ups on the Bit6 / v2-wire PR:

- compare_raw_slices: reject a degenerate count=0 header. No OreEncrypt path
  produces zero blocks; without this, two crafted 0-block ciphertexts (header
  + nonce only) compare Equal because the scan loop never runs. (C2)
- Deduplicate encode_right_block: make the bit2 helper generic over the hash
  (encode_right_block<W: BlockWidth, H: Hash>) and have the Bit6 scheme call it
  instead of keeping a near-verbatim copy. (D1)
- Add width::ct_bit and route all four get_bit sites through it: extract the
  target bit with constant shift amounts + a constant-time select, instead of
  "byte >> (bit % 8)" (a shift by a secret amount — constant-time on
  x86_64/aarch64 but not guaranteed on every target). Pairs with ct_select_byte
  for a fully oblivious, data-independent bit read. (D3 mitigation)
- Replace #[derive(Debug)] on OreAes128 / OreAes128Bit6 with an explicit opaque
  Debug impl (finish_non_exhaustive) so key material can never be rendered.
  (Used a manual impl rather than vitaminc::OpaqueDebug — vitaminc is a 0.2.0
  pre-release and ore-rs is a published crate; see PR discussion.)

No wire-format change: bit2 and bit6 compat vectors remain byte-identical.
@coderdan

Copy link
Copy Markdown
Contributor Author

Review: constant-time & zeroization

Ran the Trail of Bits constant-time-testing and zeroize-audit skills (plus a multi-agent code review) over this PR. Summary of the two security dimensions below; the code-review fixes are in 7458510.

Constant-time

The Bit6 path delivers the intended hardening (verified against the legacy bit2 baseline):

  • CT-1 (encrypt-timing via rejection sampling): fixed — LemireFyPrp is fixed-draw (Lemire multiply-high, no rejection loop, no %).
  • CT-4 (compare-side cache leak): fixed — all four get_bit sites read the right-block byte through the oblivious ct_select_byte.
  • CT-2 / CT-3 (secret-indexed permute lookup / FY swap): mitigated to a MemJam sub-cache-line residual by #[repr(C, align(64))] + N = 64 (one cache line) + const assert!(N <= 64); alignment confirmed empirically (size 128 / align 64).
  • New (this PR): the post-ct_select_byte bit extract used byte >> (bit % 8) — a shift by a secret amount, constant-time on x86_64/aarch64 but not guaranteed on every target. Now routed through a new width::ct_bit (constant shift amounts + constant-time select), so the bit read is data-independent on all targets.

No new timing channels introduced; the σ-MMO hash is branch-free over constant-time AES.

Zeroization

Clean — no new gaps. All new key-derived material zeroizes on drop, and the compiler/IR phase confirms the wipes survive -O2 (crate-wide store volatile rises 2→6→384 across O0/O1/O2; zero OPTIMIZED_AWAY):

  • LemireFyPrp — Zeroize + manual Drop + ZeroizeOnDrop; keystream wiped after the permutation is built; AES key schedule via aes's own ZeroizeOnDrop (survives O2 under --cfg aes_armv8).
  • OreAes128Bit6 — derive(ZeroizeOnDrop) over the PRF keys; SeedBuf + per-block template/work scratch all wiped.
  • FixedPiZ2Hash — correctly not flagged (PI_KEY is a fixed public key; the new param is a nonce).

The only finding is the pre-existing ZA-0001 (legacy Aes128Prng lacks ZeroizeOnDrop) — not introduced here; fixed on #84 and inherited on rebase. The benign #[derive(Debug)] on the cipher was replaced with an explicit opaque Debug impl in this PR.

Full write-ups: docs/reviews/2026-06-16-zeroize-audit-pr82.md and docs/reviews/2026-06-16-constant-time-analysis-pr79.md.

@coderdan

coderdan commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

Added 4be9e6b, which restores the #81 self-review hardening (951c7cb) that e164730 overwrote when this branch was rebased over #81. That change predates today's rebases; it was already in the previously pushed 92833dd.

  • lsb_mask_256 uses real assert_eq! again. The NEON path gathers blocks through raw pointers assuming exactly 256 of them, so with only a debug_assert a shorter slice reads out of bounds in release, from a safe fn. Current callers check the length first, so nothing triggered it.
  • The scalar fallbacks sit under cfg(not(target_arch = "aarch64")) again instead of #[allow(unreachable_code)], which Rust 1.78 does not honour on a statement (clippy failed on aarch64). The new gt_mask_xor_64 gets the same shape.

Also on #79: 08a308e derives BlockWidth::DOMAIN from BITS (stable clippy flagged BITS as never read), so this PR's Bit6 drops its explicit DOMAIN. #80, #81 and #83 were rebased over both with no conflicts. fmt, clippy (stable and 1.78, aarch64 and x86_64) and tests (stable and 1.78) pass at every tip.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

PRF input collisions, malformed-symbol acceptance, and inaccurate security premises remain unresolved.

6 open findings
What changed in this PR

Adds an opt-in 6-bit ORE scheme with smaller ciphertexts and less per-block AES work, while preserving legacy Bit8 wire bytes.

Changes:

  • Introduces Bit6 encryption, v2 headers, and pinned compatibility vectors.
  • Adds σ-MMO hashing, fixed-draw permutations, and oblivious comparator reads.
  • Documents security decisions, benchmarks, and the future CMAC accumulator.
File Description
packages/​ore-rs/​tests/​compat_w6_vectors/​vectors.rs Pins Bit6 ciphertext bytes.
packages/​ore-rs/​tests/​compat_w6_vectors.rs Tests wire compatibility and ordering.
packages/​ore-rs/​src/​scheme/​width.rs Adds Bit6 types and oblivious reads.
packages/​ore-rs/​src/​scheme/​bit2/​block_types.rs Hardens legacy bit access.
packages/​ore-rs/​src/​scheme/​bit2.rs Shares encoding and hardens comparisons.
packages/​ore-rs/​src/​scheme/​bit2_w6/​block_types.rs Defines eight-byte right blocks.
packages/​ore-rs/​src/​scheme/​bit2_w6.rs Implements Bit6 encryption and comparison.
packages/​ore-rs/​src/​scheme.rs Exposes the Bit6 module.
packages/​ore-rs/​src/​primitives/​simd.rs Adds 64-lane indicator processing.
packages/​ore-rs/​src/​primitives/​prp.rs Adds fixed-draw Fisher–Yates permutations.
packages/​ore-rs/​src/​primitives/​hash.rs Implements fixed-key σ-MMO hashing.
packages/​ore-rs/​src/​lib.rs Adds scheme-specific wire-header support.
packages/​ore-rs/​src/​encrypt.rs Makes legacy encryption implementations scheme-specific.
packages/​ore-rs/​src/​decimal.rs Keeps Decimal encryption on Bit8.
packages/​ore-rs/​src/​ciphertext.rs Adds header-aware serialization and parsing.
packages/​ore-rs/​src/​chrono.rs Keeps date encryption on Bit8.
packages/​ore-rs/​Cargo.toml Registers Bit6 benchmarks.
packages/​ore-rs/​benches/​bit6.rs Benchmarks Bit6 encryption and comparison.
docs/​reviews/​2026-06-14-ore-v2-crypto-review-brief.md Records cryptographic decisions and review gates.
docs/​plans/​2026-06-15-ore-v2-cmac-accumulator-spec.md Specifies the future chained-prefix design.
docs/​plans/​2026-06-12-ore-v2-architecture.md Updates architecture and leakage guidance.
docs/​benchmarks/​2026-06-13-bit6-prp-results.md Records permutation benchmark results.

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

Comment thread packages/ore-rs/src/scheme/bit2_w6.rs
Comment thread packages/ore-rs/src/scheme/bit2_w6.rs Outdated
Comment thread packages/ore-rs/src/ciphertext.rs
Comment thread packages/ore-rs/src/scheme/bit2_w6.rs
Comment thread docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md Outdated
Comment thread docs/reviews/2026-06-14-ore-v2-crypto-review-brief.md Outdated
@coderdan
coderdan requested a review from freshtonic October 8, 2026 23:41
coderdan added a commit that referenced this pull request Oct 9, 2026
…ator's block

Review of #82 (A4): permute reads inverse[x[n]] with the plaintext
symbol, and invert reads permutation[j]. The tables' alignment defends
only cache-line granularity; sub-line timing can reveal which part of
the table was read, which for permute is plaintext bits. Both PRPs now
read every entry and select the wanted one in constant time
(ct_select_byte). Encryption calls permute once per block, so the cost
is up to 256 selects per block against ~10 µs of PRP setup.

The Bit6 comparator had the compare-side twin of this. Its prefix scan
is constant-time, but the resolution step then loaded a[l], f[l] and
right[l] by the latched index l. The left blocks are cache-resident
after the scan whatever l is; the right blocks are never touched by it,
so the right[l] load hits or misses according to l, and a dudect run
measured that as a timing signal (2 % of the spread on a 23-block
chained ciphertext, at the edge of detection on Bit6's five lines).
The scan now latches all three under the choice "this is the first
differing block" (ct_assign_bytes), so no load after it is indexed by
l. One extra 8-byte read and about 25 masked byte copies per block.
The brief's compare-side section records the measurement and the fix.

Same output, so no ciphertext byte changes; the legacy and Bit6
vectors pass unchanged.
coderdan added a commit that referenced this pull request Oct 9, 2026
Review of #82, two findings that change Bit6 bytes, done together so the
vectors are regenerated once (Bit6 is not frozen; legacy bytes are
unchanged):

- Bit6 ran PRF1/PRF2 under the caller's raw keys, which the legacy
  scheme also uses, and the count in byte 15 did not separate the
  inputs: a legacy N=15 left ciphertext can reproduce the PRF1 input of
  any Bit6 masking key, so under shared keys a legacy query acts as a
  Bit6 query token. Bit6 now keys PRF1/PRF2 with AES_k1("ORE.v2.bit6.prf1")
  and AES_k2("ORE.v2.bit6.prf2"). The labels' last byte (ASCII '1'/'2')
  is outside every legacy PRF input (byte 15 is 0..=14 for PRF1, 0 for
  PRF2), so no legacy ciphertext publishes a derived key.
- Seeds bound only the block count, so a zero-padded prefix collided with
  a shorter one and every position of an all-zero value shared one
  permutation (the pinned zero vector had eleven identical xt bytes).
  Seeds now carry the block index in byte 14, free because N <= 14.

Regression tests: a search over legacy N=15 encryptions for a Bit6 tag
under shared keys, and distinct symbols across an all-zero value's
positions. Both fail on the previous construction. The plan's section 4
claim that byte 15 separates the schemes is corrected.
coderdan added a commit that referenced this pull request Oct 9, 2026
…ad paths

Review of #82:
- A1 justified the σ-MMO hash by saying H's inputs are secret and never
  learnt. They are not: every left tag f[n] is the RO key at xt[n], and
  the comparator hashes it. Restated in the brief, hash.rs and the plan:
  unrevealed RO keys are secrets shared across ciphertexts with public
  nonces as offsets, so GKWY's multi-instance shape applies, and security
  rests on the BHKR bound (about p*C/2^128) rather than on input secrecy.
  The conclusion holds for realistic parameters. Marked pending sign-off.
- A4 said permute/invert reads were already constant-time. permute read
  inverse[x[n]] by plaintext symbol; both are now oblivious scans (the
  previous commit), and A4 lists the reads with the writes for the
  high-assurance tier.
@coderdan

coderdan commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

Force-pushed (rebase, with lease): the Bit6 comparator now latches the first differing block inside its constant-time scan.

A dudect run on the stacked verification PR (#100) found that the resolution step after the scan loaded a[l], f[l] and right[l] by the latched index l. The scan never touches the right blocks, so the right[l] load hit or missed the cache according to l, which a timing-only attacker can read. On Bit6 the signal was at the edge of detection (t ≈ 10 over 151 M samples, tau 0.0008, the whole ciphertext spans five cache lines); on the chained scheme it was 2 % of the timing spread.

The scan now copies the three values under the choice "this is the first differing block" (width::ct_assign_bytes, set for exactly one block), and the resolution step works from the copies, so no load after the scan is indexed by l. Folded into e033aad (harden(prp): read permute/invert tables obliviously; latch the comparator's block) as the compare-side twin of that commit's oblivious table reads. The brief's compare-side section, which had accepted the direct index on the grounds that l is leaked to the ciphertext holder anyway, now records the measurement and the fix.

No ciphertext bytes change; the Bit6 vectors pass unchanged. Clippy clean on stable, 1.78, aarch64 and x86_64-apple-darwin.

Freeze the OreAes128Bit6 (bit2_w6) wire format now that A1 (the H
construction) is resolved — BHKR σ-MMO. Mirrors compat_vectors.rs (Bit8):
deterministic TestRng nonce, pinned left + full ciphertext bytes, and
comparison/order fixtures over the pinned bytes. Adds a signed-int (i64)
order vector since Bit6 is a new scheme; cross-checks confirm the v2 header
(0x02/0x02 + u16 block count) and the orderable sign-flip equivalence
(i64::MIN ↔ 0u64, i64::MAX ↔ u64::MAX).

Regeneratable via:
  cargo test --test compat_w6_vectors -- --ignored --nocapture generate
Replace the per-byte carry loop in the BHKR sigma-MMO with a single u128
word op: 2x = (x << 1) ^ ((x >> 127) * 0x87) over the big-endian field
element. In the bulk encrypt path, fold the doubling and the nonce XOR into
one u128 pass per block (the ~704-evals/u64 hot loop).

Byte-identical output (sigma is unchanged), so the pinned Bit6 vectors and
the scalar<->bulk consistency test still pass; still constant-time
(branch-free, no secret-dependent control flow).

Recovers the A1 sigma-MMO regression: Bit6 u64 encrypt ~12.3us -> ~8.9us
(Apple M1 Max), back to the pre-orthomorphism shape-i level.
The detailed, review-ready spec for the PR 6 accumulator (the A2 gate),
replacing the sketch in the review brief. Covers:

- Construction: CMAC (NIST SP 800-38B) over an injective prefix-block +
  final-block encoding; cached CBC state = incremental CMAC. Subkeys reuse
  the (vectorized) gf128_double.
- Injective message encoding (byte layouts) + injectivity argument.
- Per-block algorithm; the left tag f[n] = ro(n, xt[n]) is the RO_KEY branch
  at the permuted symbol (not a separate output), preserving mask
  cancellation at compare time.
- NO total-length binding -- required so prefix-sharing strings of different
  lengths compare correctly; length comparability enforced at the comparator.
- One dedicated KDF'd key unifies the old prf1/prf2 via branch tags
  (RO_KEY/PRP_STREAM); PRF2 subsumed.
- Shape-(ii) PRP (A3): keystream derived as CMAC tags, no per-block key
  schedule (needs a LemireFyPrp::from_stream ctor).
- Security: reduction to CMAC PRF + the three auditable claims (injectivity,
  incremental faithfulness, zeroization) + birthday budget; the chain state
  is never published, so the cascade/GGM interaction does not arise.
- Test plan + open questions for the reviewer.

Linked from review brief A2 and plan section 5(b).
The final-block signature F(branch, n, s) and section 3's 'branch
separation' referenced 'branch' before it was named. Add an explicit
definition at the top of section 4 (RO_KEY / PRP_STREAM output families,
carried as the byte-0 branch tag) and point section 3 at it.
Make explicit that the chained scheme has exactly one secret key (k),
producing both branches via the branch tag; branch-tag domain separation
under a good PRF is equivalent to independent per-branch keys but cheaper
(one key schedule + one subkey pair). Note H's pi is public (not secret) and
the nonce is not a key; contrast with fixed-N (#82, two keys) and the
init(k1,k2) API (k2 redundant here); flag the single-key choice for explicit
sign-off.
…ebug (#82 review)

Code-review follow-ups on the Bit6 / v2-wire PR:

- compare_raw_slices: reject a degenerate count=0 header. No OreEncrypt path
  produces zero blocks; without this, two crafted 0-block ciphertexts (header
  + nonce only) compare Equal because the scan loop never runs. (C2)
- Deduplicate encode_right_block: make the bit2 helper generic over the hash
  (encode_right_block<W: BlockWidth, H: Hash>) and have the Bit6 scheme call it
  instead of keeping a near-verbatim copy. (D1)
- Add width::ct_bit and route all four get_bit sites through it: extract the
  target bit with constant shift amounts + a constant-time select, instead of
  "byte >> (bit % 8)" (a shift by a secret amount — constant-time on
  x86_64/aarch64 but not guaranteed on every target). Pairs with ct_select_byte
  for a fully oblivious, data-independent bit read. (D3 mitigation)
- Replace #[derive(Debug)] on OreAes128 / OreAes128Bit6 with an explicit opaque
  Debug impl (finish_non_exhaustive) so key material can never be rendered.
  (Used a manual impl rather than vitaminc::OpaqueDebug — vitaminc is a 0.2.0
  pre-release and ore-rs is a published crate; see PR discussion.)

No wire-format change: bit2 and bit6 compat vectors remain byte-identical.
e164730 (13 June) predates the #81 self-review commit 951c7cb (16
June). When this branch was rebased over it, e164730's version of
simd.rs won, silently undoing two of 951c7cb's fixes:

- lsb_mask_256's length checks went back to debug_assert. The NEON path
  gathers blocks through raw pointers assuming exactly 256 of them, so a
  shorter slice reads out of bounds in a release build: UB reachable
  from a safe fn. Today's callers check the length first, but the fn
  was unsound. They are real asserts again.
- The scalar fallbacks went back behind #[allow(unreachable_code)],
  which Rust 1.78 does not honour on a statement, so clippy failed on
  aarch64. Every dispatcher, including the new gt_mask_xor_64, now ends
  in the NEON call on aarch64 and puts the AVX2 check and scalar
  fallback under cfg(not(aarch64)); scalar::gt_mask_xor is compiled only
  where it is used, as 951c7cb had it.

Bit6 also drops its explicit DOMAIN, now derived from BITS.
Fisher-Yates swapped at the draw-derived index and filled the inverse at
the permutation's values. Both offsets stay inside one 64-byte line, so
the cache cannot see them, but dudect on Apple M4 measured the build time
depending on which permutation is built, with no co-tenant: the swaps
carry the signal and the inverse fill alone shows none. That fits
store-to-load forwarding and memory disambiguation on byte-granular
accesses at secret offsets, which the one-cache-line argument does not
cover.

The builder now keeps both tables in registers and never uses a secret
value as an address: 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, which needs neither
swapped entry. Step i is public and j <= i, so only the chunks covering
0..=i are searched. Same 63 Lemire draws and swap sequence, so the
tables, and every ciphertext, are byte-identical; the Bit6 vectors pass
unchanged.

Dispatch: NEON on aarch64 (baseline, no runtime check), SSSE3 (pshufb)
on x86_64 when the CPU reports it, a u64 SWAR form with exact
byte-equality masks everywhere else. The textbook builder survives only
as a test-only reference, with a byte-at-a-time subtle_ng form; property
and edge-stream tests pin every builder's tables to it.

Each draw is decoded from an 8-byte copy of the keystream into a u64;
both copies are wiped, as the caller wipes the stream itself. Raised in
the review of #90 against the #82 zeroize audit.
…ength

Review of #82:
- The shared parser copied xt after checking only lengths, so a Bit6
  ciphertext with an xt byte of 64..=255 parsed; comparing it then read
  the right block at an invalid index (a debug panic, a silent zero in
  release). OreCipher gains SYMBOL_DOMAIN (256 by default, 64 for Bit6),
  and Left/CipherText parsing rejects any xt outside it.
- The Bit6 raw comparator now returns None for such symbols in either
  input before scanning.
- gt_mask_xor_64 asserts its output length in release, as
  gt_mask_xor_256 does.

Regression tests patch a pinned vector's first xt byte to 64; both fail
on the previous code. No ciphertext byte changes.
…ator's block

Review of #82 (A4): permute reads inverse[x[n]] with the plaintext
symbol, and invert reads permutation[j]. The tables' alignment defends
only cache-line granularity; sub-line timing can reveal which part of
the table was read, which for permute is plaintext bits. Both PRPs now
read every entry and select the wanted one in constant time
(ct_select_byte). Encryption calls permute once per block, so the cost
is up to 256 selects per block against ~10 µs of PRP setup.

The Bit6 comparator had the compare-side twin of this. Its prefix scan
is constant-time, but the resolution step then loaded a[l], f[l] and
right[l] by the latched index l. The left blocks are cache-resident
after the scan whatever l is; the right blocks are never touched by it,
so the right[l] load hits or misses according to l, and a dudect run
measured that as a timing signal (2 % of the spread on a 23-block
chained ciphertext, at the edge of detection on Bit6's five lines).
The scan now latches all three under the choice "this is the first
differing block" (ct_assign_bytes), so no load after it is indexed by
l. One extra 8-byte read and about 25 masked byte copies per block.
The brief's compare-side section records the measurement and the fix.

Same output, so no ciphertext byte changes; the legacy and Bit6
vectors pass unchanged.
Review of #82, two findings that change Bit6 bytes, done together so the
vectors are regenerated once (Bit6 is not frozen; legacy bytes are
unchanged):

- Bit6 ran PRF1/PRF2 under the caller's raw keys, which the legacy
  scheme also uses, and the count in byte 15 did not separate the
  inputs: a legacy N=15 left ciphertext can reproduce the PRF1 input of
  any Bit6 masking key, so under shared keys a legacy query acts as a
  Bit6 query token. Bit6 now keys PRF1/PRF2 with AES_k1("ORE.v2.bit6.prf1")
  and AES_k2("ORE.v2.bit6.prf2"). The labels' last byte (ASCII '1'/'2')
  is outside every legacy PRF input (byte 15 is 0..=14 for PRF1, 0 for
  PRF2), so no legacy ciphertext publishes a derived key.
- Seeds bound only the block count, so a zero-padded prefix collided with
  a shorter one and every position of an all-zero value shared one
  permutation (the pinned zero vector had eleven identical xt bytes).
  Seeds now carry the block index in byte 14, free because N <= 14.

Regression tests: a search over legacy N=15 encryptions for a Bit6 tag
under shared keys, and distinct symbols across an all-zero value's
positions. Both fail on the previous construction. The plan's section 4
claim that byte 15 separates the schemes is corrected.
…ad paths

Review of #82:
- A1 justified the σ-MMO hash by saying H's inputs are secret and never
  learnt. They are not: every left tag f[n] is the RO key at xt[n], and
  the comparator hashes it. Restated in the brief, hash.rs and the plan:
  unrevealed RO keys are secrets shared across ciphertexts with public
  nonces as offsets, so GKWY's multi-instance shape applies, and security
  rests on the BHKR bound (about p*C/2^128) rather than on input secrecy.
  The conclusion holds for realistic parameters. Marked pending sign-off.
- A4 said permute/invert reads were already constant-time. permute read
  inverse[x[n]] by plaintext symbol; both are now oblivious scans (the
  previous commit), and A4 lists the reads with the writes for the
  high-assurance tier.
…om-key benchmarked and rejected

A dudect run (2026-10-09) measured the Bit6 PRP builder's time depending
on which permutation it builds, on a core with no co-tenant, and isolated
it to the Fisher-Yates swaps; the inverse fill showed none. That fits
store-to-load forwarding on byte-granular accesses at secret offsets
within one L1-resident line, which the one-cache-line argument does not
cover. A4 now records it: the oblivious builder (NEON / SSSE3 / SWAR,
byte-identical tables) is the default on every target, and on aarch64 it
is faster than the indexed builder it replaced, so the high-assurance
tier, the MemJam posture call and the alignment-only default are marked
superseded rather than deleted. The architecture plan's tier discussion
and the Bit6 benchmark note (new addendum, mains-power numbers) follow.

Sort-by-random-key (vitaminc's construction) was prototyped inside
LemireFyPrp::from_stream and benchmarked as a candidate for the tier on
2026-10-08: +135% to +215% on the builder, +34% to +102% on Bit6 encrypt
(+26% to +98% on chained), and it changes every ciphertext. Rejected; it
stays rejected now that the FY form is oblivious, since it removes the
secret address by the same means, costs more and changes the wire format.
Its no-retry rule is adopted for every builder: a bad draw is resolved by a
fixed rule, never by consuming more stream, because left and right halves
are produced in separate calls and must agree.
LemireFyPrp::new wiped its keystream, but not the copies the compiler
made: the seed-keyed AES schedule (round key 0 is the seed) and the
permutation tables stayed in dead stack after encrypt returned. Moves
and returns leave those slots behind, and ZeroizeOnDrop only reaches the
final location. A memory-disclosure attacker holding a seed or a table
can invert xt[n] for every ciphertext with that prefix.

Every Bit6 encrypt entry point (OreCipher::encrypt and encrypt_left, and
the OreEncrypt impls, whose frames hold the decomposed plaintext) now
runs inside stack::scrubbed: the work runs in a frame of its own, then a
16 KiB memset (96 KiB in debug builds) zeroes the stack below the caller.
The PRP keys an encryption-only AES schedule, so no decryption schedule
is built.

New tests run an encryption on a fresh thread and search the dead stack
for every block's seed and tables. Without the scrub they find them
(release and debug, aarch64); with it they find none. They also measure
the depth the unscrubbed path reaches (about 6 KiB in release on aarch64)
and fail if it passes the scrub size.

Cost on an M1 Max: about 1% (bit6-encrypt-u64 11.34 -> 11.40 us).
Per-word volatile stores cost 5-12%, so the scrub is one memset kept
alive by black_box.
CMAC spec (review of #82):
- §3 gave the accumulator key as E("ORE.v2.chain.acc\x00"), a 17-byte
  label that cannot be one AES block, with the key slot TBD. The code
  uses the 16 bytes with no terminator, under k1. State that, with the
  bytes, and close open question 1.
- §1 described the Bit6 derivation the #82 review rejected. Give the
  current one: derived prf1/prf2 keys, and the block index in byte 14 of
  the seed input.
- §8.4 argued that the byte-0 tags keep absorb and finalize inputs apart.
  They do not: the inputs collide exactly when K1 = P_n xor F, with
  probability 2^-128, whatever byte 0 holds. The separation follows from
  the CMAC PRF bound in §8.2; the byte-0 tags only make the encoding
  injective.

Block width: the legacy Bit8 scheme shows zero runs from one ciphertext,
which outweighs its lower comparison leakage. The plan now recommends
Bit6 for new data, with Bit8 kept for data already stored, and the
README says so.
Rebasing onto main brought in orderable-bytes 0.2, where ENCODED_LEN
moved from ToOrderableBytes to FixedOrderableBytes. The longer trait
name pushed the Bit6 block-count line past the width limit.
OreAes128 writes the block index at byte N of its 16-byte left-tag input,
so it takes at most 15 bytes: encrypting a u128 or i128 panics with an
index out of bounds. The README sent u128/i128 to OreAes128 alongside
Decimal. Decimal (14 bytes) does fit; u128/i128 fit neither scheme.
Mirror of cad46bb for the Bit6 vectors. Every unequal pair in FULL_U64
shares one nonce, because each cipher_a() call reseeds the test RNG, so
the pinned fixtures never checked which operand supplies the hash nonce.

FULL_U64_ONE_CIPHER encrypts four values under one cipher, so each has its
own nonce. Pairs first differ at block 0, block 5 and block 10, and every
ordered pair is checked through compare_raw_slices and the typed Ord.
Reading the nonce from the first operand (raw or typed) now fails the
test. The existing vectors are unchanged.
…ions

Stack depth follows the optimisation level. A profile with opt-level 0 and
debug-assertions off got the 16 KiB release scrub while the Bit6 encrypt
paths reached ~49 KiB, and the residue test failed. Measured on aarch64:
~49 KiB at opt-level 0, at most ~6.5 KiB at 1, 2, 3, s and z, either way
for debug assertions. build.rs now sets cfg(ore_unoptimized) from
OPT_LEVEL, and that cfg picks the 96 KiB scrub.
The review brief and the plan's at-rest tier said right-only storage
reveals nothing. That is true of right ciphertexts, which are
semantically secure, but no scheme supports right-only storage yet
(#99): every scheme stores full ciphertexts and the only comparators
take two full ciphertexts. Until #99 lands, an attacker with the
database compares any two stored rows offline and learns their order
and first differing block.
@coderdan

Copy link
Copy Markdown
Contributor Author

Addressed your review from Slack:

  1. u128/i128 README advice: fixed in 750d8c3. Reproduced the panic: OreAes128 puts the block index at byte N of the left-tag input, so it handles at most 15 bytes. The README, Bit6 module docs and plan now say neither scheme supports u128/i128. Decimal (14 bytes) stays on OreAes128. The impls have never worked and have no tests; filed as OreEncrypt for u128/i128 panics on OreAes128 (never worked, no tests) #105.
  2. Bit6 cross-nonce fixtures: fixed in a4fa9ae, mirroring ORE v2 (1/n): compatibility vectors + bench baselines #78. FULL_U64_ONE_CIPHER encrypts four values under one cipher, so every unequal pair has two different nonces. Pairs first differ at blocks 0, 5 and 10, and each ordered pair is checked through compare_raw_slices and typed Ord. Reading the nonce from the first operand (raw or typed) now fails the test. The existing vectors are unchanged.
  3. Scrub size: fixed in e541afb. Measured on aarch64: about 49 KiB of stack at opt-level = 0, and at most about 6.5 KiB at 1, 2, 3, s and z, whether or not debug assertions are on. With opt-level = 0 and debug assertions off, the residue test failed. build.rs now sets cfg(ore_unoptimized) from OPT_LEVEL, and that cfg selects the 96 KiB scrub.
  4. Right-only claim: fixed in 697437d. The brief and plan now say right ciphertexts are semantically secure, but right-only storage isn't supported yet (Right-only ORE storage for unindexed stores (queues, columnar) — needs a trinary indicator #99). Until it is, stored full ciphertexts can be compared offline, which is the same exposure as legacy.

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved after a re-review of 750d8c3, a4fa9ae, e541afb and 697437d.

  • u128/i128: the README and the module docs no longer recommend OreAes128 for them. The panic is tracked in #105.
  • Bit6 cross-nonce fixtures: verified by mutation. If the raw or the typed comparator reads the nonce from the first operand, compare_raw_slices_across_nonces fails.
  • Scrub size: the residue tests pass at opt-level = 0 with debug assertions off (the case that failed before) and in release. build.rs is not excluded from the package.
  • Right-only claim: corrected in the brief and the plan.

Non-blocking: when #83 is rebased onto this, the brief sentence "right-only storage at rest reveals nothing" needs the chained length exception (#104).

🤖 Generated with Claude Code

coderdan added a commit that referenced this pull request Oct 12, 2026
…mposed string

The chained scheme builds a PRP per block through
LemireFyPrp::from_stream and runs a CMAC accumulator, and both leave
secrets in dead stack after encrypt returns (the defect the #82 review
found in LemireFyPrp::new). encrypt_var, encrypt_left_var, encrypt_str
and encrypt_left_str now run inside stack::scrubbed, as the Bit6 entry
points do. CmacAccumulator and the key derivation in init build
encryption-only AES schedules.

str_to_blocks returned the 6-bit decomposition of the string in a plain
Vec, freed without a wipe. The decomposition inverts, so the freed heap
chunk held the whole plaintext. It is now Zeroizing<Vec<u8>>.

New tests encrypt a string on a fresh thread and search the dead stack
for every block's permutation tables: found without the scrub, absent
with it, in release and debug.

Cost on an M1 Max: 1-4% (chained-encrypt-str-17 20.45 -> 20.99 us).
@coderdan
coderdan merged commit 463e5fa into main Oct 12, 2026
2 checks passed
@coderdan
coderdan deleted the feat/ore-v2-bit6 branch October 12, 2026 08:34
coderdan added a commit that referenced this pull request Oct 12, 2026
- The MMO validation claimed a pl/pgSQL comparator was validated; only
  the σ-MMO hash was implemented and checked (12 vectors). The doc now
  scopes its result to the hash and lists what a comparator still needs
  end-to-end vectors for.
- "LSB" in both the SQL and Rust is bit 0 of byte 0 of the block (bit
  120 of the big-endian field element), as in the frozen legacy hash.
  The SQL and summary now name that coordinate.
- The #82 and #83 zeroize audits marked items clean that left
  key-derived temporaries unwiped (from_stream's per-draw copy; the CMAC
  mixed values). Both audits are amended, pointing at the fixes on #82
  and #83, and say plainly that the wipes are source-level only.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants