From 08ee5ed036d72c62f52406a400b7bcd35ae736a8 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 30 Jul 2026 20:18:32 -0400 Subject: [PATCH] feat(seed): audit kernel CSPRNG readiness + RNG non-determinism regression test Seed entropy comes from bip39 -> rand::thread_rng -> getrandom(2), which blocks until the kernel pool is initialized -- but that ordering was invisible in logs on first-boot ISO flows where the seed is generated early. MasterSeed::generate() now probes getrandom(GRND_NONBLOCK) and logs whether the pool was already seeded (warn if it would block). Also adds a regression test that 64 generated mnemonics are all unique with sane word diversity, guarding against a fixed/seeded RNG ever being wired into seed generation. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/seed.rs | 67 ++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/core/archipelago/src/seed.rs b/core/archipelago/src/seed.rs index a5793255..a8d34703 100644 --- a/core/archipelago/src/seed.rs +++ b/core/archipelago/src/seed.rs @@ -49,9 +49,46 @@ pub struct MasterSeed { bytes: [u8; SEED_LEN], } +/// Probe whether the kernel CSPRNG is fully initialized, without blocking. +/// +/// `getrandom(2)` already blocks until the entropy pool is initialized, so a +/// seed can never be drawn from an unseeded pool. This probe exists to make +/// that ordering auditable in the logs: first-boot ISO flows generate the seed +/// early, when a slow-to-seed pool would otherwise be invisible. +#[cfg(target_os = "linux")] +fn kernel_csprng_ready() -> Option { + let mut byte = [0u8; 1]; + let ret = unsafe { + libc::getrandom( + byte.as_mut_ptr() as *mut libc::c_void, + byte.len(), + libc::GRND_NONBLOCK, + ) + }; + if ret == 1 { + Some(true) + } else if std::io::Error::last_os_error().raw_os_error() == Some(libc::EAGAIN) { + Some(false) + } else { + None + } +} + +#[cfg(not(target_os = "linux"))] +fn kernel_csprng_ready() -> Option { + None +} + impl MasterSeed { /// Generate a new 24-word BIP-39 mnemonic and derive the master seed. pub fn generate() -> Result<(bip39::Mnemonic, Self)> { + match kernel_csprng_ready() { + Some(true) => tracing::info!("kernel CSPRNG initialized; generating master seed"), + Some(false) => tracing::warn!( + "kernel CSPRNG not yet initialized; getrandom() will block until the pool is seeded" + ), + None => {} + } let mnemonic = bip39::Mnemonic::generate(24) .map_err(|e| anyhow::anyhow!("Failed to generate mnemonic: {}", e))?; let seed = Self::from_mnemonic(&mnemonic); @@ -557,6 +594,36 @@ mod tests { assert_eq!(mnemonic.word_count(), 24); } + #[test] + fn test_generate_nondeterministic() { + // Regression guard against a fixed/seeded RNG ever being wired into + // seed generation: with real entropy, collisions are impossible. + let mnemonics: Vec = (0..64) + .map(|_| MasterSeed::generate().unwrap().0.to_string()) + .collect(); + let unique: std::collections::HashSet<&String> = mnemonics.iter().collect(); + assert_eq!(unique.len(), mnemonics.len(), "duplicate mnemonics generated"); + + // 64 × 24 = 1536 draws from the 2048-word list should hit ~1080 + // distinct words; a low-entropy source concentrates on far fewer. + let distinct: std::collections::HashSet<&str> = mnemonics + .iter() + .flat_map(|m| m.split_whitespace()) + .collect(); + assert!( + distinct.len() > 384, + "only {} distinct words across 64 mnemonics", + distinct.len() + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn test_kernel_csprng_ready_on_test_host() { + // By the time tests run, the pool has long been initialized. + assert_eq!(kernel_csprng_ready(), Some(true)); + } + #[test] fn test_invalid_mnemonic_rejected() { let result = MasterSeed::from_mnemonic_words("not a valid mnemonic");