diff --git a/core/archipelago/src/seed.rs b/core/archipelago/src/seed.rs index 9a6ef07b..3175cd4a 100644 --- a/core/archipelago/src/seed.rs +++ b/core/archipelago/src/seed.rs @@ -79,6 +79,28 @@ fn kernel_csprng_ready() -> Option { None } +/// Generate a 24-word English BIP-39 mnemonic from an **explicitly supplied** CSPRNG. +/// +/// The entropy source is an argument here, never a transitive-dependency default. +/// See `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` finding F-02 / `[ARCHY-1]`: +/// a bare `bip39::Mnemonic::generate(24)` resolves through `Mnemonic::generate_in` +/// to `&mut rand::thread_rng()` *inside* the `bip39` crate, so the RNG backing every +/// Archipelago key — including the fleet release-root signing key — would be chosen +/// by a dependency's default rather than stated at this call site. +/// +/// That is precisely the structural shape of the 2026-07-30 COLDCARD entropy defect +/// ("T1"), where a refactor silently rebound seed generation to a non-cryptographic +/// PRNG with no compile error and no test failure. Naming the source here means a +/// future `rand` or `bip39` bump cannot rebind it silently, and it creates the seam +/// that `mnemonic_generation_uses_injected_rng` needs to prove the passed RNG is the +/// one actually consumed. +fn generate_mnemonic_with( + rng: &mut R, +) -> Result { + bip39::Mnemonic::generate_in_with(rng, bip39::Language::English, 24) + .map_err(|e| anyhow::anyhow!("Failed to generate mnemonic: {}", e)) +} + impl MasterSeed { /// Generate a new 24-word BIP-39 mnemonic and derive the master seed. pub fn generate() -> Result<(bip39::Mnemonic, Self)> { @@ -89,8 +111,10 @@ impl MasterSeed { ), None => {} } - let mnemonic = bip39::Mnemonic::generate(24) - .map_err(|e| anyhow::anyhow!("Failed to generate mnemonic: {}", e))?; + // OsRng is passed explicitly: a direct getrandom(2) wrapper with no + // userspace state, chosen here rather than inherited. See + // `generate_mnemonic_with` for why this is stated and not defaulted. + let mnemonic = generate_mnemonic_with(&mut rand::rngs::OsRng)?; let seed = Self::from_mnemonic(&mnemonic); Ok((mnemonic, seed)) } @@ -594,6 +618,102 @@ mod tests { assert_eq!(mnemonic.word_count(), 24); } + /// Deterministic test-only RNG emitting 0x00, 0x01, 0x02, … so a mnemonic + /// generated through the injection seam is fully predictable. + /// + /// `CryptoRng` is a marker trait — implementing it is a promise that the + /// source is suitable for cryptographic use. That promise is false here and + /// deliberately so: this type exists only to stand in at the seam under + /// `cfg(test)` and must never be reachable from production code. + struct CountingRng(u8); + + impl rand::RngCore for CountingRng { + fn next_u32(&mut self) -> u32 { + let mut b = [0u8; 4]; + self.fill_bytes(&mut b); + u32::from_le_bytes(b) + } + + fn next_u64(&mut self) -> u64 { + let mut b = [0u8; 8]; + self.fill_bytes(&mut b); + u64::from_le_bytes(b) + } + + fn fill_bytes(&mut self, dest: &mut [u8]) { + for byte in dest.iter_mut() { + *byte = self.0; + self.0 = self.0.wrapping_add(1); + } + } + + fn try_fill_bytes(&mut self, dest: &mut [u8]) -> std::result::Result<(), rand::Error> { + self.fill_bytes(dest); + Ok(()) + } + } + + impl rand::CryptoRng for CountingRng {} + + #[test] + fn mnemonic_generation_uses_injected_rng() { + // Regression guard for [ARCHY-1] / F-02 in + // docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md. This test cannot exist + // against a bare `bip39::Mnemonic::generate(24)` call: there is no seam + // to inject through, so there is no way to observe *which* RNG was used. + let mut rng = CountingRng(0); + let mnemonic = generate_mnemonic_with(&mut rng).unwrap(); + + assert_eq!(mnemonic.word_count(), 24, "must remain 256-bit / 24 words"); + + // The injected RNG drew exactly 32 bytes and they became the mnemonic's + // entropy verbatim — proof that the RNG passed at the call site, not + // bip39's transitive `rand::thread_rng()` default, is the one consumed. + let expected_entropy: Vec = (0u8..32).collect(); + let from_entropy = bip39::Mnemonic::from_entropy(&expected_entropy).unwrap(); + assert_eq!( + mnemonic.to_string(), + from_entropy.to_string(), + "generated mnemonic must be exactly from_entropy(injected RNG output)" + ); + + // Known-answer pin, so a silent rebinding of the entropy source (a rand + // or bip39 bump, a feature-flag change) fails loudly rather than quietly. + // + // These words are a public test vector derived from entropy 0x00..=0x1f — + // a deliberately weak, published value. It is not, and must never be, a + // real seed. + assert_eq!( + mnemonic.to_string(), + "abandon amount liar amount expire adjust cage candy arch gather drum \ + bullet absurd math era live bid rhythm alien crouch range attend \ + journey unaware", + "injected-RNG known-answer mnemonic" + ); + + // Same RNG state in, same mnemonic out. + let mut rng2 = CountingRng(0); + assert_eq!( + generate_mnemonic_with(&mut rng2).unwrap().to_string(), + mnemonic.to_string(), + ); + } + + #[test] + fn mnemonic_generation_is_256_bit() { + // Production path: OsRng, explicitly passed. 24 words = 256 bits, and two + // successive productions from the real entropy source must differ. + let (a, _) = MasterSeed::generate().unwrap(); + let (b, _) = MasterSeed::generate().unwrap(); + assert_eq!(a.word_count(), 24); + assert_eq!(b.word_count(), 24); + assert_ne!( + a.to_string(), + b.to_string(), + "real entropy must not repeat across calls" + ); + } + #[test] fn test_generate_nondeterministic() { // Regression guard against a fixed/seeded RNG ever being wired into