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 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-30 20:18:32 -04:00
parent 35f6e75e83
commit 08ee5ed036

View File

@ -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<bool> {
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<bool> {
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<String> = (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");