Skip to content

feat(permutation)!: oblivious key generation via sort-by-random-key - #282

Merged
coderdan merged 10 commits into
fix/198-lemire-bounded-drawfrom
feat/280-oblivious-shuffle
Sep 14, 2026
Merged

coderdan merged 10 commits into
fix/198-lemire-bounded-drawfrom
feat/280-oblivious-shuffle

Conversation

@coderdan

@coderdan coderdan commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Closes #280. Stacked on #281 (merge that first; this PR targets its branch). The vitaminc-random / vitaminc-password changes that came out of reviewing this PR now live in #336, a sibling on #281; this PR carries only the permutation change.

What

Replaces Fisher–Yates in PermutationKey::random with a constant-time oblivious shuffle: sort an array of random keys through a fixed Batcher odd-even mergesort network (the djbsort / NTRU Prime construction).

Even with #281's fixed-count draw, Fisher–Yates still executes key.swap(i, j) where j derives from the secret-seeded RNG — a secret-dependent memory access pattern. permute/depermute were already hardened with ConditionallySelectable scans; key generation was the remaining gap flagged in #198's "constant-time note".

Construction (packages/permutation/src/shuffle.rs)

  1. Pack w[i] = (rng.next_u64() << 8) | i — high 56 bits random sort key, low byte carries the index.
  2. Sort through the Batcher network: a fixed, data-independent gate schedule generated by a const fn (Knuth 5.3.4 iterative form), one &'static [(u8, u8)] per IsPermutable length (8–128, hung off the sealed trait so every length is compile-time guaranteed a schedule). Compare-exchange is branchless subtle (ct_gt + conditional_select): both slots read and written on every gate, gate indices always public.
  3. Collision check over the random bits, accumulated into a Choice mask. Exactly one batch is attempted: a collision returns RandomError::SeedRejected and the seed must be discarded. Retrying from the same stream would make runtime reveal whether the retained seed collided (a predicate of the secret), and such a seed can never derive a stable key anyway. Entropy-seeded callers build a fresh SafeRand and call again. Whole-batch rejection keeps exact uniformity (index-broken ties bias toward identity) and obliviousness (patching would leak which positions collided). p ≈ N²/2⁵⁷, ≤ 2⁻⁴³ at N=128.
  4. Strip low bytes → the permutation (gather form).

Working state is wrapped in Zeroizing and the permutation is written straight into the key's Protected slot. Instruction trace, memory trace, and per-instruction latency are functions of N only, except the single accept/reject branch on the collision predicate.

Verification

  • Gate counts match the closed form (k² − k + 4)·2^(k−2) − 1 for n = 2…128
  • Zero-one principle exhaustively at N=8 (256 cases) and N=16 (65 536 cases)
  • Randomized u64 sort checks at N=64/128 against sort_unstable
  • Output validity for every IsPermutable size incl. new N=128 invert/complement coverage
  • Seed determinism; position-matrix chi-squared uniformity (from fix(random)!: replace rejection sampling with fixed-count Lemire draw #281) now exercises this path
  • Schedule sanity: every gate ordered and in bounds

Follow-ups tracked in #280: asm inspection in CI (cmov/csel, no branches on masks), dudect timing tests, symbolic trace verification.

Breaking changes

  • PermutationKey::from_seed / from_controlled_seed derive different keys from the same seed (algorithm change). Stacked on fix(random)!: replace rejection sampling with fixed-count Lemire draw #281 so seed-derived keys break once, not twice.
  • PermutationKey::random / from_seed can return RandomError::SeedRejected; RandomError gains that variant, so exhaustive matches need an arm.

https://claude.ai/code/session_012cUAnQ2qY9RD5TaAKyZpUm

@coderdan
coderdan force-pushed the feat/280-oblivious-shuffle branch from 4364a7b to 434d845 Compare August 16, 2026 00:51

@coderdan coderdan left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Security review follow-up

The initial review analysis was performed by GPT-5.6 Sol. After discussion with @coderdan, the collision-timing claim has been refined to distinguish whole-batch rejection from the current seeded architecture.

  1. Zeroize the extracted permutation buffer (packages/permutation/src/shuffle.rs, around lines 138–142)

    The plain out: [u8; N] temporary contains the complete permutation. For PermutationKey<128> in an optimized x86-64 build, it can remain on the stack after being copied into protected storage; the later wipe only clears w. This leaves an unwiped copy even after the protected key is dropped. Wrap the extraction buffer in Zeroizing and transfer it using a zeroing replacement, or construct it directly in protected storage.

  2. Refined collision-handling finding (random_permutation / PermutationKey::from_seed)

    Discarding an entire colliding batch does not reveal the eventual accepted permutation. With independent random batches, the rejection count is independent of the accepted permutation, so whole-batch rejection itself is sound.

    The narrower issue is that the current implementation does not discard the seed. from_seed initializes one deterministic RNG, and random_permutation advances that same RNG and sorts another batch after a collision. Runtime therefore reveals whether that retained seed caused an initial collision: a predicate of the seed, although not of the final permutation. This event is extraordinarily rare (about 2^-43 for N = 128) and is not plausibly exploitable in the usual setting, but it conflicts with a strict constant-time-with-respect-to-seed claim.

    The intended seed lifecycle discussed with @coderdan removes that concern:

    • generate a fresh independent seed;
    • attempt exactly one batch;
    • on collision, destroy the seed and generate another;
    • retain only a seed whose first batch succeeds.

    Under that architecture, provisioning time reveals only how many independent rejected seeds preceded the accepted seed. It reveals nothing about the retained seed beyond the already-known fact that it belongs to the collision-free set. To enforce this lifecycle, seeded derivation should return a collision error after one batch rather than retrying from the same RNG stream; the seed-generation layer can then retry with a new seed. Rejected seeds must not be reused, while caller-supplied fixed seeds should fail on collision.

    Conclusion: treat this as an architecture/API mismatch, not a general objection to whole-batch rejection. Once the fresh-seed rejection lifecycle is enforced, fixed-work collision handling is unnecessary for this threat model.

@coderdan
coderdan force-pushed the feat/280-oblivious-shuffle branch from 416225e to bfa83d9 Compare August 16, 2026 02:19
@coderdan

Copy link
Copy Markdown
Contributor Author

Both findings from the security review follow-up are addressed in bfa83d9:

  1. Zeroizationpermutation_from_words now builds the extracted permutation inside Zeroizing<[u8; N]> end to end; the only remaining copy is the unavoidable move into the Protected container (same baseline as the rest of the crate).

  2. Seed lifecyclerandom_permutation attempts exactly one batch. A collision returns the new RandomError::SeedRejected instead of drawing more words from the same RNG stream. PermutationKey::from_seed docs now state the contract explicitly: derivation is deterministic; SeedRejected means the seed can never derive a key and must be discarded; only seeds whose first derivation succeeds may be retained. Retry belongs to the seed-generation layer, where timing reveals only the count of independent, discarded seeds — nothing about the retained one.

Also from the Claude review pass: the random-word sorting test now covers every supported length (N=32 previously had no sorting-correctness coverage at all).

@coderdan

coderdan commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto the re-based #281 (fix/198-lemire-bounded-draw at f81411a) with rebase --onto, so only this PR's four commits replay. One conflict, in PermutationKey::random: #281 now draws Fisher-Yates with next_below(i + 1), this PR replaces the body with the oblivious shuffle. Resolved to the shuffle. The Fisher-Yates replay test that #281 added (key_is_fisher_yates_over_next_below) describes an algorithm this PR removes, so it is dropped in the first commit; #281's chi-squared position-matrix test stays and now exercises the shuffle. Workspace green locally (fmt, clippy, tests, docs), first rebased commit compiles on its own.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

✅ No CRAP threshold violations

619 function(s) analyzed · threshold 30

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

🧬 Mutation testing (cargo-mutants, --in-diff)

caught missed unviable timeout
67 0 40 0

✅ Every mutant in the changed lines was caught by a test.

@coderdan

coderdan commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

CI was red on the first Rust-gate run this branch has ever had (it predates #319). Fixed in 6a6aff5, all in shuffle.rs tests plus one mutants exclusion:

  • CRAP: schedule was 0% covered because production only calls it inside const blocks. The bounds test now calls it at runtime and a should_panic test covers the unsupported-length arm. CRAP 56 to 7.
  • Mutants: inner guard i + j + k < n was unexercised because every shipped size is a power of two; a zero-one test over every n from 2 to 16 now reaches it. The pack line uses + instead of | because ^ was an equivalent mutant. The outer merge-loop bound has a genuinely equivalent mutant (one extra no-op iteration); rewritten as n > j + k so it has a distinct name and excluded by that name in .cargo/mutants.toml with the reasoning.

Local: 100 mutants on shuffle.rs, 58 caught, 42 unviable, 0 missed; CRAP clean workspace-wide.

Copilot AI 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.

🔵 Needs a closer look

The security-sensitive collision policy contradicts the stated redraw behavior, and the constant-time documentation currently overstates the implementation guarantee.

Pull request overview

Replaces Fisher–Yates permutation generation with a sorting-network-based oblivious shuffle.

Changes:

  • Adds compile-time Batcher sorting networks and validation tests.
  • Integrates oblivious generation and collision rejection.
  • Updates deterministic examples and removes paste.
File summaries
File Description
.cargo/mutants.toml Excludes an equivalent network-generator mutant.
Cargo.lock Removes paste from the lockfile.
packages/permutation/Cargo.toml Removes the unused paste dependency.
packages/permutation/README.md Updates seed-derived outputs.
packages/permutation/src/key.rs Uses the oblivious shuffle for key generation.
packages/permutation/src/lib.rs Registers the shuffle module.
packages/permutation/src/shuffle.rs Implements and tests the sorting-network shuffle.
packages/random/src/lib.rs Adds the seed-rejection error variant.
Review details
  • Files reviewed: 7/8 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/permutation/src/shuffle.rs Outdated
Comment thread packages/permutation/src/shuffle.rs Outdated

Copilot AI 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.

🟡 Changes recommended

Two new documentation claims incorrectly promise exact uniformity despite the bounded sampler’s documented statistical bias.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 11/12 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread packages/password/src/lib.rs Outdated
Comment thread packages/random/src/generatable.rs Outdated
@coderdan
coderdan requested a balanced review from Copilot September 9, 2026 22:43

Copilot AI 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.

🔵 Needs a closer look

The cryptographic constant-time guarantees require the planned assembly and timing verification before final approval.

Review details
  • Files reviewed: 11/12 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@coderdan

Copy link
Copy Markdown
Contributor Author

Local assembly inspection of the shuffle path

Copilot's last pass deferred on the constant-time claims pending the #280 follow-ups. As an interim check I inspected the release codegen for the whole from_seed path (branch tip 873335f) on two targets. This is not the CI gate from #280, just a manual read of the output.

Method. A throwaway example with two #[inline(never)] wrappers calling PermutationKey::<8>::from_seed and PermutationKey::<128>::from_seed, built --release for aarch64-apple-darwin and x86_64-apple-darwin, dumped with cargo asm --everything. Everything below from_seed inlines into the wrapper, so the full path is visible in one function.

Sort loop (N = 128: loop counter runs 2942 bytes / 2 = 1471 gates, matching the closed form):

  • Gate indices are loaded from the static schedule table; both slots are loaded and both stored on every gate.
  • ct_gt compiles to the shift/or cascade followed by bl subtle::black_box. No compare-and-branch on the words.
  • The swap is subtle's xor-mask form: sub x8, x8, w0, uxtb (arm64) / neg rax (x86-64) builds the all-ones mask, then eor/and/eor. There is no csel/cmov because nothing is selected, only masked. Same shape at N = 8 (fully unrolled words, looped gates).

Collision scan. LLVM rewrote (a >> 8).ct_eq(&(b >> 8)) as (a ^ b) < 256 and emitted cmp; cset lo (arm64) / cmp; setb (x86-64). Flag-to-register, no branch, accumulated through black_box.

Every conditional branch in the function, both targets:

  • Bounds check and the split_at_mut ordering check on the gate indices (public table data).
  • Loop counters (gate loop, scan loop, wipe loops).
  • The single tst w19, #0xff; b.ne / test bl, bl; jne after the scan: the documented accept/reject branch.

Confirmed baseline caveat. On the accept path the permutation is memcpy'd (128 bytes) from the local key's Protected slot into the returned Result; the destination is wiped on drop, the source slot is not, because it was moved out of. On the reject path the slot is wiped. This is the "unavoidable move into the Protected container" already noted above, not a new finding.

Net: codegen matches the module doc's claim (trace is a function of N only, except the accept/reject branch) on both targets at the current tip. Still worth landing the #280 CI check so this holds across rustc upgrades.

@coderdan
coderdan requested a review from freshtonic September 11, 2026 00:52

@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.

The oblivious permutation implementation itself matches amended #280: the fixed network, branchless compare-exchange, collision handling, protected output path, supported sizes, and verification are convincing. This layer also contains independently breaking RNG APIs and fixed-seed behavior changes that are not required by #280 and should be split into their own compatibility boundary. The password documentation must also be brought into line with the newly documented bounded-reduction bias.

Comment thread packages/random/src/bounded.rs Outdated
/// The type of the value drawn. This is the bound's own type for `u32`
/// and [`Protected<u32>`], and [`Protected<u32>`] for a
/// [`Protected<NonZeroU32>`] bound, since `0` is a valid draw.
type Output;

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.

This associated type breaks every downstream BoundedRng implementation, but issue #280 is scoped to oblivious permutation generation and does not require reshaping this public trait. Please split the associated-output and Protected<NonZeroU32> API into its own issue/PR so this security fix has a reviewable compatibility boundary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Split out to #336 (sibling on #281) together with the Protected<NonZeroU32> impl. This branch is rebased without commit ddacd18; bounded.rs is untouched here now.

Comment thread packages/random/src/generatable.rs Outdated
// Because a 0 would be an invalid value we must try again (rejection sampling)
Self::random(rng)
}
let offset = rng.next_below(u32::from(u16::MAX)) as u16;

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.

This changes fixed-seed output and stream consumption for NonZeroU16; the accompanying password changes are likewise independent of the oblivious shuffle requested by #280. Please move these behavior changes to a separately specified layer rather than coupling another seed-compatibility break to permutation generation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Moved to #336 with the password draw-loop refactor and the bias-bound docs. This branch no longer touches generatable.rs or vitaminc-password; the only vitaminc-random change left is the RandomError::SeedRejected variant the shuffle needs.

Comment thread packages/password/src/lib.rs Outdated

@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.

Re-review at head 873335f: the previously requested split is not present in the PR's merge diff. Despite the updated body and replies saying the RNG/password work moved to #336, this head still includes the breaking BoundedRng::Output/Protected<NonZeroU32> changes, the NonZeroU16 fixed-seed change, and the password refactor/docs; the password README still makes the conflicting equal-probability claim. The permutation implementation itself remains sound, but please push/rebase the advertised permutation-only branch before re-review.

@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.

Re-review at the still-current head 873335f. The exact merge diff continues to include the breaking RNG-trait, NonZeroU16, and password changes that the PR body says were moved to #336, and the password README mismatch remains. The permutation work itself is ready, but the advertised permutation-only rebase has not reached this PR. Please update the remote head before requesting another re-review.

@coderdan
coderdan force-pushed the feat/280-oblivious-shuffle branch from 873335f to 1362ac8 Compare September 11, 2026 16:01
@coderdan
coderdan requested a review from freshtonic September 11, 2026 16:02
@coderdan

Copy link
Copy Markdown
Contributor Author

Pushed: head is now 1362ac8, permutation-only on top of #281. The four random/password commits and the README fix are in #336.

Fisher-Yates key generation addressed memory with the secret draw
(`key.swap(i, j)` where `j` derives from the secret-seeded RNG), so key
derivation had a secret-dependent memory trace even after the bounded
draw was made fixed-count. The permute/depermute paths were already
hardened with ConditionallySelectable scans; key generation was the
remaining gap.

Replace the algorithm with the djbsort/NTRU Prime construction: pack
`(next_u64() << 8) | i`, sort through a Batcher odd-even mergesort
network with a branchless subtle compare-exchange, and strip the low
bytes. The gate schedule is a compile-time constant per length (Knuth
5.3.4 iterative form), so instruction trace, memory trace, and latency
are functions of N only. Colliding random keys discard the whole batch
(p ~ N^2/2^57): whole-batch restart preserves exact uniformity (a tie
broken by the packed index would bias toward identity order) and
obliviousness (patching colliding keys would leak which positions
collided); restart timing depends only on discarded randomness.

Tests: gate counts against the closed form (k^2 - k + 4)*2^(k-2) - 1,
exhaustive zero-one verification at N=8 and N=16, randomized u64 sort
checks at N=64/128, permutation validity for every IsPermutable size,
seed determinism, and the existing position-matrix chi-squared
uniformity test now exercising the new path.

BREAKING CHANGE: PermutationKey::from_seed and from_controlled_seed
derive different keys from the same seed than previous releases (the
key-generation algorithm changed). Landing alongside the bounded-draw
change so seed-derived keys break once, not twice.

Closes #280

Claude-Session: https://claude.ai/code/session_012cUAnQ2qY9RD5TaAKyZpUm
Key collisions occur with probability ~2^-43, so the discard branch was
unreachable by any test driving the RNG. Extract the sort + collision
check + strip into permutation_from_words and test it with crafted
words: equal sort keys (distinct payloads) must reject the batch, and
distinct descending keys must reverse the payloads.

Claude-Session: https://claude.ai/code/session_012cUAnQ2qY9RD5TaAKyZpUm
Two review findings (Claude review + GPT-5.6 Sol/Codex, refined on the
PR):

1. Seed lifecycle. random_permutation retried a colliding batch by
   drawing more words from the same RNG. For an entropy-seeded RNG that
   is benign, but the intended architecture derives permutations from a
   long-term seed handed to clients: a seed whose first batch collides
   can never yield a stable derivation, and in-stream retry makes
   runtime reveal whether the *retained* seed collided — a predicate of
   the secret seed, not of discardable randomness. Now exactly one
   batch is attempted; a collision returns the new
   RandomError::SeedRejected, and the caller must discard the seed and
   provision a fresh one. Retry moves to the seed-generation layer,
   where timing reveals only how many independent, rejected seeds
   preceded the accepted one.

2. Zeroization. The extracted permutation — itself secret key material
   — was built and returned in plain [u8; N] stack arrays; only the
   packed u64 words were wiped. The output buffer now lives in
   Zeroizing end to end, leaving only the unavoidable move into the
   Protected container.

Also extends the random-word network test to every supported length:
zero-one covers 8/16 exhaustively, but N=32 previously had no sorting-
correctness test at all, so a length-specific transcription bug would
have produced silently biased keys.

BREAKING CHANGE: PermutationKey::random / from_seed can now return
RandomError::SeedRejected (probability ~2^-43 per seed). Treat it as
"seed unusable": discard the seed and generate a fresh one; never
retain a seed whose first derivation failed. In vitaminc-random,
RandomError gains the SeedRejected variant, so an exhaustive match on
RandomError must add an arm.

Claude-Session: https://claude.ai/code/session_012cUAnQ2qY9RD5TaAKyZpUm
…rom IsPermutable

Two review cleanups on the shuffle internals:

- batcher_gate_count and batcher_schedule duplicated the same 4-deep
  loop nest, so the subtle merge guard existed in two copies that could
  drift apart — and the gates == G assert only catches drift that
  changes the count. Both are now thin wrappers over one batcher_fill
  (counting pass passes an empty slice; gates beyond out.len() are
  counted but not stored).

- The gate schedule is a property of the array length alone, but lived
  as an associated const on the crate-wide IsPermutable marker, forcing
  every element type to carry a schedule no code reads and requiring
  the paste proc macro just to name the per-length consts. The
  schedules now live privately in shuffle.rs behind a const fn length
  lookup referenced through inline const blocks, so an unsupported
  length is still a compile-time error. IsPermutable reverts to a pure
  marker and nothing uses the paste macro any more; the dependency is
  dropped in the following commit.

Claude-Session: https://claude.ai/code/session_012cUAnQ2qY9RD5TaAKyZpUm
`paste` is archived and flagged by cargo-audit as RUSTSEC-2024-0436. The
previous commit removed vitaminc-permutation's only use of it, so it comes
off the manifest and out of the lockfile; downstream `cargo audit` runs no
longer attribute the advisory to this crate.

Claude-Session: https://claude.ai/code/session_012cUAnQ2qY9RD5TaAKyZpUm
…P and mutants

First run of the Rust gates on this branch (it predates #319, so only
the Go workflow had ever run) found one CRAP violation and three
surviving mutants, all in `shuffle.rs`.

`schedule` scored 56: complexity 7 from one match arm per size, and 0%
coverage, because production only evaluates it inside `const` blocks
and llvm-cov cannot see compile-time execution. The bounds test now
calls it at runtime, and a `should_panic` test covers the unsupported
length arm. Same production code, 100% covered, CRAP 7.

`batcher_fill`'s inner guard `i + j + k < n` had a surviving `<=`
mutant: every shipped size is a power of two, where a partial final
merge block never occurs, so nothing exercised it. A zero-one-principle
test now builds the schedule at runtime for every n from 2 to 16 and
checks sorting and gate bounds; odd n reach the guard and the mutant
dies with an out-of-bounds gate.

The outer merge loop's bound has an equivalent mutant: one extra
iteration whose every inner step the guard rejects, unobservable by
construction. It is rewritten as `n > j + k` so its mutant name differs
from the inner guard's, and that one name is excluded in
.cargo/mutants.toml with the reasoning. The inner guard stays mutated.

Packing `(random << 8) | index` had an equivalent `^` mutant since the
low byte is zero after the shift. `+` produces the same word and has no
equivalent mutant.

Claude-Session: https://claude.ai/code/session_01QvrvBVdcecdr4LfywiGnDd
…izes must be powers of two

Two review findings on the shuffle internals:

The supported lengths lived in two lists: the `IsPermutable` impls in
lib.rs and the `match N` in `schedule`. Nothing but this crate's own tests
kept them aligned, and a length with an impl but no schedule compiled
here and failed as a post-monomorphization const-eval panic in the
*downstream* crate's build. The schedule is now an associated const on
`IsPermutable`, built with `batcher_schedule::<{ batcher_gate_count(N) }>`
directly in the impl, so a missing network is E0046 when this crate
compiles. The `paste` dependency that motivated splitting the two apart
was never needed for this shape: a `macro_rules!` over the lengths does
it. The wider element types alias the `[u8; N]` schedule, since the
network depends on the length alone. The runtime `schedule` lookup, its
`_ => panic!` arm and `should_panic` test, and the duplicate `N <= 256`
assert are gone.

`batcher_fill`'s inner `i + j + k < n` guard is dead for every shipped
size: for a power of two every merge block is full, and emitting the gate
list with and without it is byte-identical at 8 through 128. The function
now asserts `n.is_power_of_two()` (every caller is const, so a violation
is a compile error) and the loops are written in their natural form. That
removes the reshaped `n > j + k` bound, its comment, the odd-length test
that existed only to exercise the guard, and the unanchored `>`/`>=`
exclusion in .cargo/mutants.toml that would have silently excluded any
later `>` written in the function.

The exhaustive zero-one test now runs over every power of two up to 16,
and at 8 and 16 asserts the runtime-built gate list is byte-for-byte the
compile-time `SCHEDULE`, which subsumes the separate binary-input test.
The `is_sorted` helper is std's since 1.82.

Claude-Session: https://claude.ai/code/session_014zPfvCbCsGsFwF547vgoEt
… slot

`PermutationKey::random` took the `Zeroizing<[u8; N]>` the shuffle
returned and handed `generate(|| *out)` a `Copy` of it, so a plain
`[u8; N]` holding the whole key sat in the closure's return slot and the
`f()` temporary with nothing to wipe it. On main the only plain-array
temporary in this path was the non-secret identity. `random_permutation`
now takes `&mut [u8; N]` and the key passes its own `Protected` slot
through `inner_mut`, so no unwiped copy of the permutation exists at any
point; on collision the zeroed slot drops and wipes like any key.

`compare_exchange` copied both words into locals before selecting. It now
uses `subtle`'s `conditional_swap`, whose only transient is the masked
xor of the two words, never a copy of either.

Docs brought in line with the code: the module doc claimed instruction
trace was a function of `N` only, but `permutation_from_words` branches
on the collision predicate by design, so the doc names that one exception
instead of contradicting the function-level doc. `from_seed` gave the
collision probability as "≈ 2⁻⁴³" unconditionally; that is the N = 128
worst case of N²/2⁵⁷, and it now says so. `random_permutation` also
tells an entropy-seeded caller what to do on `SeedRejected`: build a
fresh generator and call again.

Claude-Session: https://claude.ai/code/session_014zPfvCbCsGsFwF547vgoEt
Each sample in `key_position_uniformity` is a permutation matrix, not N
independent categorical draws, so the raw Pearson sum over the N² cells
is not χ²((N − 1)²): its mean is N/(N − 1) times that. With the 85.35
threshold read off χ²(49), the true false-alarm rate for a correct
generator was about 1%, not the 0.1% the comment claimed, so any re-roll
of the seed or sample count had a one-in-a-hundred chance of failing on
correct code. The statistic is now scaled by (N − 1)/N, which recovers
χ²(49) (simulated over uniform permutations: mean 49.0, 0.1% above the
threshold), and the comment says what "deterministic" does and does not
buy.

Claude-Session: https://claude.ai/code/session_014zPfvCbCsGsFwF547vgoEt
…twork

Removing the dead `i + j + k < n` guard left the merge loop's `<` bound
with an equivalent `<=` mutant: the extra iteration is now absorbed by the
division guard instead, so it emits no gate and no test can see it. An
`assert!` on the invariant the loop bounds imply, `i + j + k < n`, turns
that off-by-one into a compile-time failure (every caller is const) and
makes the mutant killable, with no exclusion in .cargo/mutants.toml.
Verified with `cargo mutants --re batcher_fill`: 25 caught, 0 missed.

Claude-Session: https://claude.ai/code/session_014zPfvCbCsGsFwF547vgoEt
@coderdan
coderdan force-pushed the feat/280-oblivious-shuffle branch from 1362ac8 to adda436 Compare September 13, 2026 01:05

@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.

The rebase resolves the prior scope blockers: this merge diff is now limited to the oblivious permutation implementation, its documentation/dependency cleanup, and the required SeedRejected error. The Batcher schedule, constant-time compare-exchange, collision handling, protected output path, zeroized working state, and verification remain intact. No remaining spec or repository-standards concerns; CI is green.

@coderdan
coderdan merged commit 7e0b650 into main Sep 14, 2026
5 checks passed
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.

Constant-time oblivious permutation generation: sort-by-random-key over a Batcher network

3 participants