fix(quick-260731-upz): make the master-seed RNG explicit (ARCHY-1 / F-02)
bip39::Mnemonic::generate(24) resolves through Mnemonic::generate_in to &mut rand::thread_rng() INSIDE the bip39 crate (bip39-2.1.0/src/lib.rs: 311-313 -> :296-298 -> :267-283), so the entropy source behind Archipelago's entire key hierarchy -- node Ed25519 did:key, node Nostr key, FIPS mesh key, per-identity keys, the BIP-84 wallet, the LND aezeed entropy, and the fleet release-root SIGNING key -- was chosen by a dependency default rather than stated at the call site. Not a vulnerability today: rand 0.8.5's thread_rng is a fork-protected ChaCha12 CSPRNG seeded from getrandom(2). But it 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. - New private helper generate_mnemonic_with<R: CryptoRng + RngCore> calls bip39's injectable generate_in_with; MasterSeed::generate passes OsRng explicitly, with the rationale pinned in a doc comment - mnemonic_generation_uses_injected_rng: drives generation from a deterministic test RNG and asserts the result equals from_entropy(exactly the bytes that RNG emitted) -- direct proof the INJECTED rng is consumed -- plus a known-answer pin and a determinism check. This test cannot be written against the previous code: there was no seam to inject through - mnemonic_generation_is_256_bit: the OsRng path yields 24 words and two successive productions differ No change to derivation paths, word count, the empty-BIP-39-passphrase decision, or the at-rest encryption envelope. Verified: CARGO_INCREMENTAL=0 cargo test -p archipelago seed:: -> 25 passed, 0 failed. Full analysis: docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (F-02, §4, §7). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5ba80e49b7
commit
8b51b7e2dc
@@ -79,6 +79,28 @@ fn kernel_csprng_ready() -> Option<bool> {
|
||||
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<R: rand::CryptoRng + rand::RngCore>(
|
||||
rng: &mut R,
|
||||
) -> Result<bip39::Mnemonic> {
|
||||
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<u8> = (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
|
||||
|
||||
Reference in New Issue
Block a user