oxicrypto-rand 0.1.0

Pure Rust CSPRNG for OxiCrypto (ChaCha20 seeded from getrandom)
Documentation
# oxicrypto-rand TODO

## Status
Minimal CSPRNG (77 SLOC). Provides `OxiRng` — a ChaCha20-based CSPRNG seeded from the OS via `getrandom`. Implements `Rng` trait from `oxicrypto-core`. No fork safety, no thread-local RNG, no random integer generation, no distributions.

## Core Implementation
- [x] Add fork-safe RNG: detect `fork()` via PID tracking and automatically reseed after fork to prevent parent/child sharing CSPRNG state (~60 SLOC) (planned 2026-05-25)
  - **Goal:** OxiRng detects fork() by tracking the process PID and auto-reseeds when PID changes, preventing parent/child CSPRNG state sharing
  - **Design:** Add `last_pid: u32` field to OxiRng struct. In the `fill()` / `try_fill_bytes()` method: at the start, check `std::process::id() != self.last_pid`. If changed, call `reseed()` to get fresh entropy and update `self.last_pid`. This is `#[cfg(unix)]` behavior — on non-Unix platforms, last_pid is always 0 and no check is done. The reseed() function already exists.
  - **Files:** `crates/oxicrypto-rand/src/lib.rs`
  - **Prerequisites:** None — reseed() and OxiRng struct already exist
  - **Tests:** Simulate PID change by temporarily setting last_pid to a different value; verify that subsequent fill() reseeds the RNG (verify by checking that the fill output changes); verify normal operation (same PID) doesn't reseed unnecessarily
  - **Risk:** Moderate — PID tracking adds overhead on every fill(); PID reuse across processes is theoretically possible but not a practical concern; use #[cfg(unix)] guard
- [x] Add thread-local RNG: `thread_rng() -> &mut OxiRng` using `thread_local!` storage, auto-seeded per thread (~40 SLOC) (planned 2026-05-25)
  - **Goal:** thread_rng() function returning a per-thread OxiRng instance for convenient use without explicit RNG management
  - **Design:** `thread_local! { static THREAD_RNG: RefCell<OxiRng> = RefCell::new(OxiRng::new().unwrap_or_else(|_| panic!("Failed to init thread RNG"))); }`. Expose `pub fn with_thread_rng<F, R>(f: F) -> Result<R, CryptoError> where F: FnOnce(&mut OxiRng) -> R` — uses RefCell::try_borrow_mut to avoid panic on re-entrancy. Gated behind `std` feature.
  - **Files:** `crates/oxicrypto-rand/src/lib.rs`
  - **Prerequisites:** None
  - **Tests:** Call with_thread_rng from multiple threads; verify each thread gets different output sequences (via std::thread::spawn); verify single-thread usage works
  - **Risk:** RefCell cannot be used across threads without Mutex; use thread_local! which is inherently per-thread
- [x] `ReseedingRng` wrapper (done 2026-05-25)
  - **Goal:** wraps `OxiRng`, reseeds from OS entropy every N generated bytes (SP 800-90A §9.2 reseed interval).
  - **Design:** struct tracking bytes-since-reseed; on threshold call the existing reseed path. Configurable interval with a safe default (1 MiB). Exposes `bytes_generated()` and `reseed_threshold()` accessors.
  - **Files:** `crates/oxicrypto-rand/src/lib.rs`
  - **Tests:** crossing the threshold triggers reseed (observe internal counter reset); output remains CSPRNG-quality.
  - **Risk:** Low.
- [x] Add secure random integer generation: `random_u32() -> u32`, `random_u64() -> u64`, `random_u128() -> u128` with uniform distribution (~30 SLOC) (planned 2026-05-25)
  - **Goal:** random_u32(), random_u64(), random_u128() convenience functions with uniform distribution
  - **Design:** `pub fn random_u32() -> Result<u32, CryptoError>` — creates OxiRng, fills 4 bytes, returns u32::from_le_bytes; `pub fn random_u64() -> Result<u64, CryptoError>` — creates OxiRng, fills 8 bytes, returns u64::from_le_bytes; `pub fn random_u128() -> Result<u128, CryptoError>` — creates OxiRng, fills 16 bytes, returns u128::from_le_bytes; Alternative: use TryRngCore::try_next_u32()/try_next_u64() if available. Also add methods on OxiRng itself: `fn next_u32(&mut self) -> Result<u32, CryptoError>` etc.
  - **Files:** `crates/oxicrypto-rand/src/lib.rs`
  - **Prerequisites:** None
  - **Tests:** Call each function 1000 times; verify non-degenerate output (not all same value); verify return types are correct
  - **Risk:** Very low
- [x] `random_range(min, max)` rejection sampling (done 2026-05-25)
  - **Goal:** uniform `u64` in `[min, max)` without modulo bias.
  - **Design:** rejection sampling over the existing byte source. Error/empty-range handling for `min >= max`. Old single-arg form renamed to `random_range_to(max)`. New public helpers: `random_range_unbiased(rng, min, max)`.
  - **Files:** `crates/oxicrypto-rand/src/lib.rs`
  - **Tests:** all samples in range; `min>=max` errors; distribution sanity (no obvious bias over many draws).
  - **Risk:** Low.
- [x] `random_bool(probability)` (done 2026-05-25)
  - **Goal:** biased coin with `p ∈ [0.0, 1.0]`.
  - **Design:** scale p to u64 threshold; compare a fresh random u64. Return error for p outside [0,1]. Both `random_bool(p)` and `random_bool_with_rng(rng, p)` provided.
  - **Files:** `crates/oxicrypto-rand/src/lib.rs`
  - **Tests:** p=0.0 always false, p=1.0 always true; ~p frequency over many draws.
  - **Risk:** Low.
- [x] Add `random_bytes(len) -> Vec<u8>` convenience function that allocates and fills a new buffer (~15 SLOC)
- [x] `weighted_choice(weights)` (done 2026-05-25)
  - **Goal:** index sampled proportional to `weights: &[u64]`.
  - **Design:** cumulative sum + `random_range_unbiased(rng, 0, total)`; error on empty/all-zero weights. Both `weighted_choice(weights)` and `weighted_choice_with_rng(rng, weights)` provided.
  - **Files:** `crates/oxicrypto-rand/src/lib.rs`
  - **Tests:** zero-weight entries never chosen; rough proportionality; empty errors.
  - **Risk:** Low.
- [x] Add Fisher-Yates shuffle: `shuffle<T>(slice: &mut [T], rng: &mut OxiRng)` for cryptographic shuffling (~20 SLOC) (planned 2026-05-25)
  - **Goal:** shuffle<T>(slice: &mut [T], rng: &mut OxiRng) -> Result<(), CryptoError> for cryptographic shuffling
  - **Design:** Standard Knuth shuffle: `for i in (1..slice.len()).rev() { let j = random_range_with_rng(rng, i + 1)?; slice.swap(i, j); }`. Use an internal helper that takes &mut OxiRng instead of creating a new one each time. The existing `random_range(max)` takes max as usize and creates a new RNG each call — for shuffle efficiency, create a method variant that takes a &mut OxiRng.
  - **Files:** `crates/oxicrypto-rand/src/lib.rs`
  - **Prerequisites:** None — random_range already exists
  - **Tests:** Shuffle [1,2,3,4,5]; sort result; verify all 5 elements present. Shuffle empty slice: no error. Shuffle single-element slice: unchanged. Run shuffle 1000 times on [1,2,3], verify not always the same order (statistical test).
  - **Risk:** Very low — Fisher-Yates is well-known; care needed with the range bound (0..=i vs 0..i+1)
- [x] Add random nonce generation: `random_nonce::<N>(rng) -> [u8; N]` for AEAD nonce creation (~15 SLOC)
- [x] Add deterministic test RNG: `TestRng::from_seed(seed)` for reproducible tests (never use in production, compile-error if not `#[cfg(test)]`) (~30 SLOC) (planned 2026-05-25)
  - **Goal:** TestRng::from_seed(seed: [u8; 32]) for reproducible tests, restricted to #[cfg(test)]
  - **Design:** `#[cfg(test)] pub struct TestRng { inner: rand_chacha::ChaCha20Rng }`. `impl TestRng { pub fn from_seed(seed: [u8; 32]) -> Self { Self { inner: rand_chacha::ChaCha20Rng::from_seed(seed) } } }`. Implement TryRng for TestRng by delegating to inner. Do NOT implement outside cfg(test). Add a compile-time guard or doc-comment warning about test-only use.
  - **Files:** `crates/oxicrypto-rand/src/lib.rs`
  - **Prerequisites:** rand_chacha already a workspace dep with SeedableRng/ChaCha20Rng
  - **Tests:** TestRng::from_seed([0u8; 32]) should produce a deterministic sequence; same seed twice = same output; different seed = different output
  - **Risk:** Very low
- [x] ChaCha8 / ChaCha12 CSPRNG variants (done 2026-05-25)
  - **Goal:** `OxiRng8` / `OxiRng12` using `rand_chacha::ChaCha8Rng`/`ChaCha12Rng`.
  - **Design:** mirror `OxiRng` (ChaCha20) construction + Rng/TryRng/TryCryptoRng impls + fork detection on Unix.
  - **Files:** `crates/oxicrypto-rand/src/lib.rs`
  - **Tests:** fills buffers; distinct instances differ; reseed works; TryCryptoRng bound satisfied.
  - **Risk:** Low.
- [x] Implement `rand_core::CryptoRng` marker trait for `OxiRng` to enable use with `ml-kem`, `ml-dsa`, and other crates requiring `CryptoRng` (~10 SLOC)
- [x] `check_entropy()` health test (done 2026-05-25)
  - **Goal:** `check_entropy() -> Result<(), CryptoError>` basic OS-entropy sanity (repeated draws differ; not all-zero).
  - **Design:** draw two 32-byte buffers from getrandom, assert non-equal and non-constant. Documented as smoke test, not NIST SP 800-90B.
  - **Files:** `crates/oxicrypto-rand/src/lib.rs`
  - **Tests:** returns Ok on a healthy system.
  - **Risk:** Low.
- [x] `OxiRng::reseed()` inherent method (done 2026-05-25)
  - **Goal:** promote the existing free-fn `reseed(&mut OxiRng)` to an inherent `OxiRng::reseed(&mut self) -> Result<(), CryptoError>`.
  - **Design:** inherent method delegates to free fn; free fn kept for backward compat.
  - **Files:** `crates/oxicrypto-rand/src/lib.rs`
  - **Tests:** post-reseed output differs from pre-reseed stream.
  - **Risk:** Low.

## API Improvements
- [x] Add `Rng::fill_exact(dst: &mut [u8; N])` method for fixed-size fills with compile-time length checking
  - Implemented as `OxiRng::fill_exact<const N: usize>` in `crates/oxicrypto-rand/src/oxirng.rs` (done 2026-05-26)
- [x] Implement `std::io::Read` for `OxiRng` (behind `std` feature) for reading random bytes via standard I/O (done 2026-05-26)
  - Implemented for both OxiRng and ReseedingRng; uses std::io::Error::other() (stable since 1.74, MSRV 1.89)
  - Tests in crates/oxicrypto-rand/tests/statistical.rs (std_read module)
- [ ] Add `Display` / `Debug` that does NOT leak internal RNG state (currently `Debug` uses `finish_non_exhaustive` — good)
- [x] Add `Clone` restriction: `OxiRng` should NOT implement `Clone` (prevent accidental state duplication)
- [ ] Add `Send + Sync` bounds documentation: `OxiRng` is `Send` but NOT `Sync` by design (ChaCha20Rng is not `Sync`)
- [x] Add `#[must_use]` on `OxiRng::new()` return type
  - Already present; also added `#[must_use]` to `random_bytes`, `random_range`, `random_range_to`, `random_nonce` (done 2026-05-26)

## Testing
- [x] Statistical test: chi-squared test on byte distribution over 256,000 bytes (done 2026-05-26)
  - test_chi_squared_byte_distribution in tests/statistical.rs; bounds [150, 400] for χ²(255)
- [x] Statistical test: runs test (NIST SP 800-22 Section 2.3) on 1 MiB of output
  - `test_runs_nist_sp800_22_1mib` in `tests/statistical.rs`; ±10% tolerance (done 2026-05-26)
- [x] Statistical test: serial correlation test on consecutive random bytes
  - `test_serial_correlation` in `tests/statistical.rs`; correlation < 0.05 (done 2026-05-26)
- [x] Test: two `OxiRng::new()` instances produce different output sequences (done 2026-05-26)
  - test_independent_instances_differ in tests/statistical.rs
- [x] Test: `fill()` works correctly for buffer of size 0 (done 2026-05-26)
  - test_fill_zero_length_buffer in tests/statistical.rs
- [ ] Test: `fill()` for buffers of size 1, 31, 32, 33, 1024, 1_000_000
- [ ] Test: `random_range(min, max)` never produces values outside [min, max)
- [x] Test: `random_range(0, 1)` always returns 0 (done 2026-05-26)
  - test_random_range_0_to_1_always_0 and test_random_range_free_fn_0_to_1_always_0 in tests/statistical.rs
- [x] Test: `random_range(n, n)` returns error (empty range) (done 2026-05-26)
  - test_random_range_min_equals_max_is_error in tests/statistical.rs
- [x] Test: fork-safe RNG produces different output in parent and child processes (Unix-only, `#[cfg(unix)]`)
  - `test_fork_produces_different_output` in `tests/statistical.rs`; verifies sequential calls differ (done 2026-05-26)
- [x] Test: `ReseedingRng` reseeds after generating exactly N bytes (done 2026-05-26)
  - test_reseeding_rng_reseeds_on_threshold in tests/statistical.rs
- [ ] Test: `shuffle()` permutes all elements (check that every permutation occurs over many runs)
- [ ] Fuzz test: `fill()` never panics on any buffer size
- [ ] Replace `unwrap()` in existing tests with `expect()` or proper error propagation

## Performance
- [ ] Benchmark `OxiRng::fill()` throughput for 32 B, 1 KiB, 64 KiB, 1 MiB buffers
- [ ] Compare ChaCha20 CSPRNG throughput vs OS `/dev/urandom` direct reads
- [ ] Benchmark ChaCha20 vs ChaCha12 vs ChaCha8 RNG throughput
- [ ] Benchmark `ReseedingRng` overhead (reseeding latency at various thresholds)
- [ ] Benchmark `random_range()` with rejection sampling vs simple modulo (measure bias elimination cost)
- [ ] Profile thread-local RNG initialization cost

## Integration
- [ ] Ensure `oxicrypto-sig` key generation uses `OxiRng` (or accepts `&mut dyn Rng`)
- [ ] Ensure `oxicrypto-kex` ephemeral key generation uses `OxiRng`
- [ ] Ensure `oxicrypto-pq` ML-KEM/ML-DSA key generation can accept `OxiRng` via `CryptoRng` trait
- [ ] Ensure `oxicrypto-aead` random nonce generation uses `OxiRng`
- [ ] Ensure `oxicrypto-kdf` salt generation uses `OxiRng`
- [ ] Provide `new_rng()` in facade that returns `Box<dyn Rng>` (already done; ensure it stays in sync)