Closes F-10a. Nothing here fixes a present defect: on the pinned rand 0.8.5, rand::random() and thread_rng() both resolve to a ChaCha12 CSPRNG seeded from getrandom(2). What they lack is a STATED backend — it is fixed by dependency and build configuration rather than by the calling code, with no compile error if that changes. That is the structural shape behind the 2026-07-30 COLDCARD entropy defect, and here the blast radius includes Cashu blinded-key-exchange values, X3DH prekey material, session bearer tokens and a ChaCha20-Poly1305 nonce. Layer (a) — every production key, nonce and token draw now names rand::rngs::OsRng at its own call site. The mnemonic seam is bound to entropy::KeyGenRng, a SEALED allowlist whose supertrait lives in a private module, so the set of RNGs that can drive the master key hierarchy is exactly what one file says it is. This retires the false promise at seed.rs:656: rand::CryptoRng is a marker with no compiler-checked content, and the crate now contains zero impls of it. Layer (d) — key material and AEAD nonces of >=12 bytes run a degenerate-entropy predicate that refuses all-zero, all-identical and wrapping +/-1 counter draws. Nothing heuristic: no entropy estimator, no chi-squared. Each of the three shapes has a false-positive probability computable in closed form (3 * 2^-88 at 12 bytes, 3 * 2^-248 at 32), and a predicate whose false-positive rate cannot be computed cannot be argued safe on a key-generation path. There is deliberately no retry — a retry would paper over the broken RNG this exists to surface. Layer (e) — the kernel-CSPRNG readiness verdict at master-seed generation is now durable (backlog R-09). It was previously computed, logged and thrown away, so a node could never answer after the fact whether its keys were born from a seeded pool. The record holds a schema version, timestamp, verdict and event name — no entropy, no key bytes. Formats and wire shapes are proven unchanged rather than asserted: storage_crypto and the credential store each open a HARDCODED pre-migration ciphertext vector (a same-process round trip would pass even if the envelope had changed), the vector was produced by an independent RFC 8439 implementation so it pins the documented nonce||ciphertext format rather than this implementation's output, and the x3dh prekey bundle and bdhke values keep their field set and order. totp.rs migrates its SOURCE only: the % charset.len() reduction and the 32-char charset are untouched. The bias there is presently zero (32 divides 256) and fixing the latent bias is R-12, which stays deferred. Verified: cargo build clean; cargo test -p archipelago 1068 passed, 2 failed. Both failures are container::boot_reconciler timing tests (second_pass_fires_after_interval, shutdown_terminates_loop) in a file this change does not touch — pre-existing, not caused here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
83 lines
3.1 KiB
Rust
83 lines
3.1 KiB
Rust
//! Bitcoin RPC credential management.
|
|
//!
|
|
//! Uses `rpcauth` in bitcoin.conf (salted hash — no plaintext in config or CLI).
|
|
//! The actual password is stored in `/var/lib/archipelago/secrets/bitcoin-rpc-password`
|
|
//! and stays stable across reboots, restarts, and deploys.
|
|
|
|
use tokio::sync::OnceCell;
|
|
use tracing::debug;
|
|
|
|
const SECRETS_PATH: &str = "/var/lib/archipelago/secrets/bitcoin-rpc-password";
|
|
const RPC_USER: &str = "archipelago";
|
|
|
|
static CACHED_PASSWORD: OnceCell<String> = OnceCell::const_new();
|
|
|
|
/// Read the Bitcoin RPC password from the secrets file.
|
|
/// Falls back to env var (dev), then generates and persists a random password.
|
|
async fn read_password() -> String {
|
|
// 1. Secrets file (production)
|
|
if let Ok(pass) = tokio::fs::read_to_string(SECRETS_PATH).await {
|
|
let pass = pass.trim().to_string();
|
|
if !pass.is_empty() {
|
|
debug!("Bitcoin RPC password loaded from secrets file");
|
|
return pass;
|
|
}
|
|
}
|
|
|
|
// 2. Environment variable (dev)
|
|
if let Ok(pass) = std::env::var("BITCOIN_RPC_PASSWORD") {
|
|
if !pass.is_empty() {
|
|
debug!("Bitcoin RPC password loaded from env var");
|
|
return pass;
|
|
}
|
|
}
|
|
|
|
// 3. Generate and persist (first boot)
|
|
let random_pass = generate_random_password();
|
|
if let Some(parent) = std::path::Path::new(SECRETS_PATH).parent() {
|
|
let _ = tokio::fs::create_dir_all(parent).await;
|
|
}
|
|
match tokio::fs::write(SECRETS_PATH, &random_pass).await {
|
|
Ok(_) => {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let _ = tokio::fs::set_permissions(
|
|
SECRETS_PATH,
|
|
std::fs::Permissions::from_mode(0o600),
|
|
)
|
|
.await;
|
|
}
|
|
debug!("Bitcoin RPC password generated and saved");
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("Failed to save Bitcoin RPC password: {}", e);
|
|
}
|
|
}
|
|
random_pass
|
|
}
|
|
|
|
/// Generate a cryptographically random password (32 hex chars).
|
|
///
|
|
/// KEY-05: this is the node's Bitcoin RPC credential, so the source is named and
|
|
/// the 16-byte draw is guarded. It returns a bare `String` and its caller
|
|
/// (`read_password`) is a `OnceCell` initialiser that also returns a bare
|
|
/// `String`, so a degenerate draw aborts rather than propagating — the condition
|
|
/// means the kernel CSPRNG is broken, and a predictable Bitcoin RPC password on
|
|
/// a node that also serves LAN traffic is worse than a loud stop.
|
|
fn generate_random_password() -> String {
|
|
let mut bytes = [0u8; 16];
|
|
crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut bytes).unwrap_or_else(|e| {
|
|
panic!("refusing to generate a Bitcoin RPC password from degenerate entropy: {e} (KEY-05)")
|
|
});
|
|
hex::encode(bytes)
|
|
}
|
|
|
|
/// Get Bitcoin RPC credentials (user, password). Cached after first call.
|
|
pub async fn bitcoin_rpc_credentials() -> (String, String) {
|
|
let pass = CACHED_PASSWORD
|
|
.get_or_init(|| async { read_password().await })
|
|
.await;
|
|
(RPC_USER.to_string(), pass.clone())
|
|
}
|