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:
archipelago
2026-08-01 06:03:17 -04:00
co-authored by Claude Opus 5
parent 5ba80e49b7
commit 8b51b7e2dc
+122 -2
View File
@@ -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