diff --git a/core/archipelago/src/api/rpc/auth.rs b/core/archipelago/src/api/rpc/auth.rs index e9b49ef3..dcdfff5b 100644 --- a/core/archipelago/src/api/rpc/auth.rs +++ b/core/archipelago/src/api/rpc/auth.rs @@ -122,7 +122,13 @@ impl RpcHandler { // get a unique suffix so each device keeps its own credential; // explicitly named devices keep replace-in-place semantics. if name == "companion" { - name = format!("companion-{}", hex::encode(rand::random::<[u8; 2]>())); + // KEY-05: source named. Two bytes of display-name disambiguation, not + // key material — the credential itself is minted by + // `device_tokens::create`, which is guarded. Unguarded here because a + // degenerate predicate on a 2-byte draw false-positives once in 256. + let mut suffix = [0u8; 2]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut suffix); + name = format!("companion-{}", hex::encode(suffix)); } let token = crate::device_tokens::create(&self.config.data_dir, &name).await?; Ok(serde_json::json!({ "name": name, "token": token })) diff --git a/core/archipelago/src/api/rpc/onboarding_gate.rs b/core/archipelago/src/api/rpc/onboarding_gate.rs index ccc05344..3bbfa35e 100644 --- a/core/archipelago/src/api/rpc/onboarding_gate.rs +++ b/core/archipelago/src/api/rpc/onboarding_gate.rs @@ -230,7 +230,10 @@ mod tests { #[tokio::test] async fn refusal_survives_the_error_sanitizer_and_names_the_recovery_path() { let sanitized = crate::api::rpc::middleware::sanitize_error_message(REFUSAL); - assert_ne!(sanitized, "Operation failed. Check server logs for details."); + assert_ne!( + sanitized, + "Operation failed. Check server logs for details." + ); assert!( sanitized.contains("system.factory-reset"), "the refusal must not be a dead end: {sanitized}" @@ -384,8 +387,7 @@ mod tests { // 4) The attack: a valid but attacker-chosen 24-word mnemonic, posted // unauthenticated at seed.restore. let (attacker_mnemonic, _seed_b) = crate::seed::MasterSeed::generate().unwrap(); - let attacker_words: Vec = - attacker_mnemonic.words().map(str::to_string).collect(); + let attacker_words: Vec = attacker_mnemonic.words().map(str::to_string).collect(); assert_eq!(attacker_words.len(), 24); let result = super::super::seed_rpc::restore_node_identity_from_words( diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index a28030c7..f82f9678 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -729,7 +729,16 @@ impl RpcHandler { let searx_dir = "/var/lib/archipelago/searxng"; let settings_path = format!("{}/settings.yml", searx_dir); if !tokio::fs::try_exists(&settings_path).await.unwrap_or(false) { - let secret: [u8; 32] = rand::random(); + // KEY-05: SearXNG's `server.secret_key` signs that app's own + // tokens — an app secret, so source named and draw guarded. + let mut secret = [0u8; 32]; + crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut secret).map_err( + |e| { + anyhow::anyhow!( + "Refusing to write a SearXNG secret_key from degenerate entropy: {e}" + ) + }, + )?; let secret_hex = hex::encode(secret); let settings = format!( "use_default_settings: true\ngeneral:\n instance_name: Archipelago Search\nserver:\n secret_key: \"{}\"\n bind_address: \"0.0.0.0\"\n port: 8080\n limiter: false\nui:\n default_theme: simple\n", @@ -1453,7 +1462,12 @@ impl RpcHandler { use hmac::{Hmac, Mac}; use sha2::Sha256; - let salt_bytes: [u8; 16] = rand::random(); + // KEY-05: the salt is half of the stored `rpcauth=` credential line, so + // source named and draw guarded. + let mut salt_bytes = [0u8; 16]; + crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut salt_bytes).map_err(|e| { + anyhow::anyhow!("Refusing to build an rpcauth line from degenerate salt entropy: {e}") + })?; let salt_hex = hex::encode(salt_bytes); let mut mac = Hmac::::new_from_slice(salt_hex.as_bytes()) .expect("HMAC accepts any key length"); diff --git a/core/archipelago/src/api/rpc/package/pine_ha.rs b/core/archipelago/src/api/rpc/package/pine_ha.rs index 965b1c60..c6d21e6d 100644 --- a/core/archipelago/src/api/rpc/package/pine_ha.rs +++ b/core/archipelago/src/api/rpc/package/pine_ha.rs @@ -99,7 +99,13 @@ async fn ensure_status_token() -> Option { warn!("pine/HA seed: cannot create {}: {}", NODE_SECRETS_DIR, e); return None; } - let raw: [u8; 32] = rand::random(); + // KEY-05: a bearer status token written 0600 under NODE_SECRETS_DIR — source + // named, 32-byte draw guarded. + let mut raw = [0u8; 32]; + if let Err(e) = crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut raw) { + warn!("pine/HA seed: refusing to mint a status token from degenerate entropy: {e}"); + return None; + } let token = hex::encode(raw); if let Err(e) = tokio::fs::write(&path, &token).await { warn!("pine/HA seed: writing status token failed: {e}"); @@ -479,7 +485,15 @@ async fn seed_claude_conversation(storage: &std::path::Path) -> ClaudeSeed { }; } - let id = |raw: [u8; 16]| hex::encode(raw); + // KEY-05: source named. Home Assistant config-entry / subentry ids are + // identifiers HA needs only for uniqueness — not credentials and not key + // material — so they are drawn unguarded per the classification table in + // docs/security/KEY-05-ENTROPY-ENFORCEMENT.md. + let id = || { + let mut raw = [0u8; 16]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut raw); + hex::encode(raw) + }; // Shape mirrors what HA 2026.7's anthropic config flow creates (entry // version 2.4 with conversation + ai_task subentries). Bookkeeping // fields (created_at/modified_at/discovery_keys) must be written here: @@ -487,7 +501,7 @@ async fn seed_claude_conversation(storage: &std::path::Path) -> ClaudeSeed { // never migrates appended entries — a missing created_at is a // KeyError that crash-loops HA at boot. entries.push(json!({ - "entry_id": id(rand::random()), + "entry_id": id(), "version": 2, "minor_version": 4, "domain": "anthropic", @@ -504,7 +518,7 @@ async fn seed_claude_conversation(storage: &std::path::Path) -> ClaudeSeed { "discovery_keys": {}, "subentries": [ { - "subentry_id": id(rand::random()), + "subentry_id": id(), "subentry_type": "conversation", "title": "Claude conversation", "unique_id": null, @@ -518,7 +532,7 @@ async fn seed_claude_conversation(storage: &std::path::Path) -> ClaudeSeed { } }, { - "subentry_id": id(rand::random()), + "subentry_id": id(), "subentry_type": "ai_task_data", "title": "Claude AI Task", "unique_id": null, @@ -585,7 +599,9 @@ async fn seed_wyoming_config_entries(storage: &std::path::Path) -> bool { if exists { continue; } - let entry_id: [u8; 16] = rand::random(); + // KEY-05: source named; an HA entry identifier, unguarded (see above). + let mut entry_id = [0u8; 16]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut entry_id); entries.push(json!({ "entry_id": hex::encode(entry_id), "version": 1, @@ -662,7 +678,10 @@ async fn seed_assist_pipeline(storage: &std::path::Path, claude_entity: Option<& // ULID-shaped id (26 chars, Crockford base32) — HA only needs uniqueness. let id: String = { const ALPHABET: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz"; - let raw: [u8; 26] = rand::random(); + // KEY-05: source named; an HA pipeline identifier, unguarded (see above). + // The `% 32` reduction is unchanged and unbiased — 32 divides 256 exactly. + let mut raw = [0u8; 26]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut raw); raw.iter() .map(|b| ALPHABET[(*b % 32) as usize] as char) .collect() diff --git a/core/archipelago/src/api/rpc/system/handlers.rs b/core/archipelago/src/api/rpc/system/handlers.rs index efb4c22b..30ed79da 100644 --- a/core/archipelago/src/api/rpc/system/handlers.rs +++ b/core/archipelago/src/api/rpc/system/handlers.rs @@ -534,14 +534,15 @@ const HOST_KEY_ROTATION_FILE: &str = "host-key-rotation.json"; async fn host_secrets_status(dir: &Path) -> serde_json::Value { let unknown = || serde_json::json!({ "verdict": "unknown" }); - let audit: serde_json::Value = match tokio::fs::read_to_string(dir.join(HOST_SECRETS_AUDIT_FILE)) - .await - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - { - Some(v) => v, - None => return unknown(), - }; + let audit: serde_json::Value = + match tokio::fs::read_to_string(dir.join(HOST_SECRETS_AUDIT_FILE)) + .await + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + { + Some(v) => v, + None => return unknown(), + }; let verdict = audit .get("verdict") @@ -679,7 +680,9 @@ impl TlsMaterial { /// or failure, so staging artefacts never accumulate next to the live cert. async fn clear_staging(&self) { let mut cmd = self.cmd(RM_BIN); - cmd.arg("-f").arg(self.key_staging()).arg(self.crt_staging()); + cmd.arg("-f") + .arg(self.key_staging()) + .arg(self.crt_staging()); let _ = cmd.output().await; } @@ -785,8 +788,10 @@ impl TlsMaterial { // validated by this point, so the only way to land in that window is a // rename failure on an already-created sibling, which does not need // space or allocation and effectively cannot fail here. - self.swap_into_place(&self.crt_staging(), &self.crt()).await?; - self.swap_into_place(&self.key_staging(), &self.key()).await?; + self.swap_into_place(&self.crt_staging(), &self.crt()) + .await?; + self.swap_into_place(&self.key_staging(), &self.key()) + .await?; Ok(()) } @@ -1180,7 +1185,9 @@ mod tls_regen_tests { let key = dir.join(TLS_KEY_NAME); let crt = dir.join(TLS_CRT_NAME); let status = std::process::Command::new(OPENSSL_BIN) - .args(["req", "-x509", "-nodes", "-days", "3650", "-newkey", "rsa:2048"]) + .args([ + "req", "-x509", "-nodes", "-days", "3650", "-newkey", "rsa:2048", + ]) .arg("-keyout") .arg(&key) .arg("-out") @@ -1335,7 +1342,10 @@ exec {OPENSSL_BIN} "$@" .output() .unwrap(); assert!(key_pub.status.success() && crt_pub.status.success()); - assert_eq!(key_pub.stdout, crt_pub.stdout, "installed pair is mismatched"); + assert_eq!( + key_pub.stdout, crt_pub.stdout, + "installed pair is mismatched" + ); // And the SAN carries the new hostname, which is why we regenerate. let text = std::process::Command::new(OPENSSL_BIN) diff --git a/core/archipelago/src/bitcoin_rpc.rs b/core/archipelago/src/bitcoin_rpc.rs index 9f2f162d..26c2b894 100644 --- a/core/archipelago/src/bitcoin_rpc.rs +++ b/core/archipelago/src/bitcoin_rpc.rs @@ -58,8 +58,18 @@ async fn read_password() -> String { } /// 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 bytes: [u8; 16] = rand::random(); + 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) } diff --git a/core/archipelago/src/container/secrets.rs b/core/archipelago/src/container/secrets.rs index d8b733d6..86d99b70 100644 --- a/core/archipelago/src/container/secrets.rs +++ b/core/archipelago/src/container/secrets.rs @@ -98,9 +98,28 @@ fn readable_nonempty(path: &Path) -> bool { .unwrap_or(false) } +/// Fill `buf` from an explicitly named `OsRng`, guarded when it is long enough +/// for the degenerate predicate's false-positive bound to hold. +/// +/// KEY-05 / F-10: these are the manifest-declared `generated_secrets` — app +/// passwords and API keys — and were the original F-10 finding. Every production +/// caller requests 16 or 32 bytes, so the guard is live in practice; the short +/// branch exists so a future caller asking for fewer cannot trip the guard's +/// length assertion, which is a programmer-error panic and not an input +/// condition. +fn fill_secret_bytes(buf: &mut [u8]) { + if buf.len() >= crate::entropy::MIN_GUARDED_LEN { + crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, buf).unwrap_or_else(|e| { + panic!("refusing to generate an app secret from degenerate entropy: {e} (KEY-05)") + }); + } else { + rand::rngs::OsRng.fill_bytes(buf); + } +} + fn random_hex(bytes: usize) -> String { let mut buf = vec![0u8; bytes]; - rand::thread_rng().fill_bytes(&mut buf); + fill_secret_bytes(&mut buf); hex::encode(buf) } @@ -109,7 +128,7 @@ fn random_hex(bytes: usize) -> String { fn random_base64(bytes: usize) -> String { use base64::Engine as _; let mut buf = vec![0u8; bytes]; - rand::thread_rng().fill_bytes(&mut buf); + fill_secret_bytes(&mut buf); base64::engine::general_purpose::STANDARD.encode(buf) } diff --git a/core/archipelago/src/credentials/store.rs b/core/archipelago/src/credentials/store.rs index 0d66dba0..bad1595e 100644 --- a/core/archipelago/src/credentials/store.rs +++ b/core/archipelago/src/credentials/store.rs @@ -97,7 +97,9 @@ pub async fn save_credentials(data_dir: &Path, store: &CredentialStore) -> Resul let mut output = Vec::with_capacity(ENCRYPTED_MAGIC.len() + encrypted.len()); output.extend_from_slice(ENCRYPTED_MAGIC); output.extend_from_slice(&encrypted); - fs::write(&path, output).await.context("Writing credentials") + fs::write(&path, output) + .await + .context("Writing credentials") } /// Derive a 32-byte encryption key from the node's identity key via SHA-256. @@ -117,7 +119,14 @@ async fn load_encryption_key(data_dir: &Path) -> Result<[u8; 32]> { } fn encrypt_credentials(data: &[u8], key: &[u8; 32]) -> Result> { - let nonce_bytes: [u8; 12] = rand::random(); + // KEY-05: the nonce names `OsRng` and is inspected before use. Nonce reuse + // under ChaCha20-Poly1305 recovers the keystream and forges the Poly1305 tag, + // so this draw is guarded even though it is exactly at `MIN_GUARDED_LEN`. + // The deterministic-nonce seam below is untouched — only the *source* of the + // random nonce changed. + let mut nonce_bytes = [0u8; 12]; + crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut nonce_bytes) + .map_err(|e| anyhow::anyhow!("Refusing to encrypt with degenerate nonce entropy: {}", e))?; encrypt_credentials_with_nonce(data, key, nonce_bytes) } @@ -166,8 +175,8 @@ fn decrypt_credentials(data: &[u8], key: &[u8; 32]) -> Result> { #[cfg(test)] mod tests { - use super::*; use super::super::types::{CredentialProof, CredentialSubject, VerifiableCredential}; + use super::*; /// Tempdir with a deterministic `identity/node_key` so the encryption key /// can be derived. Never a real key. @@ -233,7 +242,10 @@ mod tests { nonce[0] = first_byte; let plaintext = serde_json::to_vec(&sample_store(label)).unwrap(); let blob = encrypt_credentials_with_nonce(&plaintext, &key, nonce).unwrap(); - assert_eq!(blob[0], first_byte, "test must actually trigger the collision"); + assert_eq!( + blob[0], first_byte, + "test must actually trigger the collision" + ); // Written WITHOUT magic: this is the legacy on-disk population. std::fs::write(store_path(dir.path()), &blob).unwrap(); @@ -312,7 +324,10 @@ mod tests { .unwrap(); let on_disk = std::fs::read(store_path(dir.path())).unwrap(); - assert!(on_disk.starts_with(ENCRYPTED_MAGIC), "save must mark the file"); + assert!( + on_disk.starts_with(ENCRYPTED_MAGIC), + "save must mark the file" + ); // Still genuinely encrypted, not plaintext. assert!(!on_disk.windows(4).any(|w| w == b"did:")); @@ -350,6 +365,41 @@ mod tests { assert!(load_credentials(dir.path()).await.is_err()); } + /// KEY-05 regression: a credential blob written before the entropy migration + /// must still open after it. + /// + /// **Hardcoded on purpose.** Every other test in this module seals and opens + /// in the same process, which passes even if the envelope layout changed, + /// because both halves changed together. This one pins the on-disk format + /// `MAGIC ‖ nonce ‖ ciphertext ‖ tag` against bytes this crate did not + /// produce: they come from an independent RFC 8439 ChaCha20-Poly1305 + /// implementation, validated first against the RFC's own §2.8.2 vector. + /// + /// Key derivation pinned too: `SHA-256("archipelago-credential-store-v1" ‖ + /// [0xAB; 32])`, i.e. the key `test_dir_with_node_key` produces. A change to + /// the domain separator or the derivation would fail this test, which is the + /// point — that would strand every credential store in the fleet. + #[tokio::test] + async fn opens_pre_migration_ciphertext_vector() { + const VECTOR: [u8; 56] = [ + 0x41, 0x52, 0x43, 0x48, 0x59, 0x43, 0x52, 0x45, 0x44, 0x31, 0x07, 0x07, 0x07, 0x07, + 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x75, 0x51, 0xf7, 0x32, 0x46, 0x8c, + 0xeb, 0x45, 0x1d, 0xe4, 0x20, 0x8f, 0x02, 0xaf, 0x56, 0xfe, 0x70, 0x8d, 0xc8, 0xf8, + 0x7e, 0xf2, 0xdb, 0xa2, 0x53, 0x23, 0xdb, 0x20, 0xfe, 0x15, 0x5f, 0x8e, 0x48, 0x95, + ]; + let dir = test_dir_with_node_key(); + std::fs::write(store_path(dir.path()), VECTOR).unwrap(); + + let loaded = load_credentials(dir.path()) + .await + .expect("pre-migration credential blob must still decrypt"); + assert!(loaded.credentials.is_empty()); + + // And the vector really is the marked format, not something that fell + // through to the plaintext path. + assert!(VECTOR.starts_with(ENCRYPTED_MAGIC)); + } + /// A magic-prefixed file that fails authentication (tampered / wrong key) /// must error rather than fall through to another format. #[tokio::test] diff --git a/core/archipelago/src/device_tokens.rs b/core/archipelago/src/device_tokens.rs index fdfaf232..024303d2 100644 --- a/core/archipelago/src/device_tokens.rs +++ b/core/archipelago/src/device_tokens.rs @@ -61,7 +61,12 @@ fn ct_eq(a: &[u8], b: &[u8]) -> bool { /// replaced, so re-showing the pairing QR never piles up stale entries. /// Returns the plaintext token — the only time it ever exists outside the QR. pub async fn create(data_dir: &Path, name: &str) -> Result { - let token_bytes: [u8; 32] = rand::random(); + // KEY-05: a device token is a bearer credential — its unpredictability is + // the whole of its security — so the source is named and the draw guarded. + let mut token_bytes = [0u8; 32]; + crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut token_bytes).map_err(|e| { + anyhow::anyhow!("Refusing to mint a device token from degenerate entropy: {e}") + })?; let token = hex::encode(token_bytes); let mut tokens = load(data_dir).await; diff --git a/core/archipelago/src/entropy.rs b/core/archipelago/src/entropy.rs new file mode 100644 index 00000000..818d868f --- /dev/null +++ b/core/archipelago/src/entropy.rs @@ -0,0 +1,671 @@ +//! Entropy policy for key generation — the KEY-05 mechanism module. +//! +//! Three independent controls live here, each closing a different half of the +//! same structural defect recorded as **F-10a** in +//! `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` and classified per-site in +//! `docs/security/KEY-05-ENTROPY-ENFORCEMENT.md`: +//! +//! - **The sealed allowlist** ([`KeyGenRng`], layer *a*). A key-generation seam +//! typed `R: KeyGenRng` can only be driven by a type this module blessed. The +//! marker's supertrait lives in a private module, so membership is unnameable +//! — and therefore unaddable — from any other module of this crate, and from +//! any downstream crate were this binary ever split into a library. +//! - **The degenerate-entropy predicate** ([`is_degenerate`], [`draw_key_bytes`], +//! layer *d*). Key material and AEAD nonces are inspected before they are +//! used, and a draw that is all-zero, all-identical or a wrapping ±1 counter +//! is refused outright rather than retried. +//! - **The CSPRNG-readiness ledger** ([`record_csprng_readiness`], layer *e*). +//! `seed::kernel_csprng_ready()` already computes whether the kernel pool was +//! initialised at generation time; before this module that verdict was logged +//! and discarded. It is now durable, so a node can answer the question after +//! the fact. +//! +//! **Nothing here fixes a present defect.** `rand 0.8.5`'s `thread_rng()` is a +//! fork-protected ChaCha12 CSPRNG seeded from `getrandom(2)`; every key this +//! fleet has ever generated came from a genuine CSPRNG. What these controls +//! remove is the *future* failure mode in which a dependency bump, feature-flag +//! change or refactor rebinds the entropy backend with no compile error, no test +//! failure and no diff in Archipelago's own source — the shape ("T1") that +//! produced the 2026-07-30 COLDCARD entropy defect. + +use rand::RngCore; +use std::path::PathBuf; +use zeroize::Zeroize; + +// ─── Layer (a): the sealed key-generation RNG allowlist ───────────────── + +/// Private supertrait module. This is the whole sealing mechanism: `Sealed` is +/// nameable only from inside `entropy`, so `impl KeyGenRng for MyType` cannot +/// compile anywhere else — the required `Sealed` bound is unsatisfiable and +/// unimplementable outside this file. +mod sealed { + pub trait Sealed {} +} + +/// The allowlist of RNGs permitted to drive key generation. +/// +/// Deliberately **without** a `rand::CryptoRng` supertrait. `CryptoRng` is a +/// marker with no compiler-checked content — implementing it is a promise, and +/// a promise a caller can make about their own type is not a control. Sealed +/// membership is checkable: the compiler enforces that the set of members is +/// exactly the set written in this file. After KEY-05 the crate contains zero +/// `impl rand::CryptoRng` blocks, so there is one mechanism for this claim +/// rather than two, and the one that remains is the one the compiler verifies. +pub(crate) trait KeyGenRng: RngCore + sealed::Sealed { + /// Whether draws from this source are subject to [`is_degenerate`]. + /// + /// `true` for every member that exists in a production build, and not + /// overridable outside this module because the trait is sealed. + /// + /// The single `#[cfg(test)]` member sets it `false`, and that is not a + /// weakening of the guard — it is what makes the guard compatible with the + /// crate's strongest existing proof. [`testing::CountingRng`] exists to emit + /// the published test vector `0x00, 0x01, … 0x1f`, which is *by + /// construction* exactly the ascending-counter pattern the predicate + /// rejects. `seed.rs`'s `mnemonic_generation_uses_injected_rng` pins the + /// 24-word mnemonic that vector produces, and that known-answer pin is the + /// only evidence the crate has that the RNG named at the call site is the + /// one `bip39` actually consumes. Guarding the counter would make that pin + /// unrepresentable and delete the proof to satisfy the guard. + /// + /// The opt-out cannot reach a shipped binary: the only implementor that + /// sets it `false` is itself `#[cfg(test)]`-gated and is not compiled into + /// the `archipelago` binary at all. + const GUARD_DRAWS: bool = true; +} + +impl sealed::Sealed for rand::rngs::OsRng {} + +/// The sole production member. `OsRng` is a direct `getrandom(2)` wrapper with +/// no userspace state, no reseeding schedule and no fork hazard — the thing a +/// defaulted `thread_rng()` happens to be backed by today, named explicitly so +/// that it cannot stop being so silently. +impl KeyGenRng for rand::rngs::OsRng {} + +// ─── Layer (d): the degenerate-entropy predicate ──────────────────────── + +/// The shortest draw the predicate is allowed to inspect. +/// +/// Below twelve bytes the false-positive argument in +/// `docs/security/KEY-05-ENTROPY-ENFORCEMENT.md` does not hold: on a two-byte +/// draw, `AllIdentical` fires once in 256 on genuine CSPRNG output, which would +/// be a far worse defect than the one being guarded. Twelve is also exactly the +/// ChaCha20-Poly1305 nonce width, so every AEAD nonce in the crate is guardable +/// at the floor rather than above it. +pub(crate) const MIN_GUARDED_LEN: usize = 12; + +/// The three — and only three — patterns [`is_degenerate`] recognises. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DegenerateEntropy { + /// Every byte is `0x00`. + AllZero, + /// Every byte equals the first byte (and the first byte is not `0x00`, + /// which would be reported as the more specific [`Self::AllZero`]). + AllIdentical, + /// Every adjacent pair differs by a wrapping +1, or every adjacent pair by + /// a wrapping −1. + Counter, +} + +impl std::fmt::Display for DegenerateEntropy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + Self::AllZero => "all bytes zero", + Self::AllIdentical => "all bytes identical", + Self::Counter => "wrapping ±1 counter", + }; + f.write_str(s) + } +} + +impl std::error::Error for DegenerateEntropy {} + +/// Is this buffer one of the three exactly-analysable degenerate shapes? +/// +/// **Nothing heuristic.** No entropy estimator, no chi-squared, no +/// "looks non-random" scoring. A predicate whose false-positive rate cannot be +/// computed in closed form cannot be argued safe, and refusing genuine CSPRNG +/// output on a key-generation path is strictly worse than the defect being +/// guarded against. These three cases are what a rebound-to-broken RNG actually +/// emits (a zeroed buffer, an uninitialised constant fill, a counter PRNG); each +/// has a false-positive probability computable exactly for any length. +pub(crate) fn is_degenerate(bytes: &[u8]) -> Option { + if bytes.is_empty() { + return None; + } + + if bytes.iter().all(|b| *b == 0) { + return Some(DegenerateEntropy::AllZero); + } + + // Checked after AllZero so the reported variant is always the more specific + // one, even though AllZero is a strict subset of AllIdentical. + if bytes.iter().all(|b| *b == bytes[0]) { + return Some(DegenerateEntropy::AllIdentical); + } + + // A single byte cannot form a counter; `windows(2)` is empty and `all` + // would vacuously succeed, so guard the length explicitly. + if bytes.len() >= 2 { + let ascending = bytes.windows(2).all(|w| w[1] == w[0].wrapping_add(1)); + let descending = bytes.windows(2).all(|w| w[1] == w[0].wrapping_sub(1)); + if ascending || descending { + return Some(DegenerateEntropy::Counter); + } + } + + None +} + +/// Fill `out` with key material from an allowlisted RNG, refusing a degenerate +/// draw. +/// +/// On a trip the buffer is **zeroized**, the variant and the buffer length are +/// logged, and the error is returned. There is deliberately **no retry**: a +/// retry would paper over a genuinely broken RNG, which is precisely the failure +/// this layer exists to surface. The bytes themselves are never logged. +/// +/// # Panics +/// +/// If `out.len() < MIN_GUARDED_LEN`. Calling the guard on a buffer too short for +/// its false-positive argument to hold is a programmer error, not an input +/// condition — a caller that legitimately needs fewer bytes must draw from +/// `OsRng` directly and unguarded, and say so. +pub(crate) fn draw_key_bytes( + rng: &mut R, + out: &mut [u8], +) -> Result<(), DegenerateEntropy> { + assert!( + out.len() >= MIN_GUARDED_LEN, + "draw_key_bytes called on a {}-byte buffer; the degenerate-entropy \ + predicate's false-positive bound only holds at {} bytes or more — draw \ + unguarded from OsRng instead (KEY-05)", + out.len(), + MIN_GUARDED_LEN + ); + + rng.fill_bytes(out); + + if !R::GUARD_DRAWS { + return Ok(()); + } + + if let Some(kind) = is_degenerate(out) { + out.zeroize(); + tracing::error!( + "refusing degenerate entropy draw: {} over {} bytes — the RNG backing \ + this call site is not producing usable key material (KEY-05 layer d)", + kind, + out.len() + ); + return Err(kind); + } + + Ok(()) +} + +// ─── Layer (e): the CSPRNG-readiness ledger ───────────────────────────── + +/// Schema version, so a later change does not orphan lines already written on +/// fleet nodes. +const READINESS_SCHEMA_VERSION: u8 = 1; + +/// One ledger line. A struct rather than `serde_json::json!` so the field order +/// on disk is the declared order and the schema is a compile-time object rather +/// than a literal that can drift. +/// +/// These four fields are the whole record. There is no field for entropy, key +/// bytes, seed material, mnemonic words or a hash of any of them — a readiness +/// ledger that carried any of those would be a new place to steal a key from, +/// sitting next to the identity directory. +#[derive(serde::Serialize)] +struct ReadinessRecord<'a> { + v: u8, + ts: String, + ready: Option, + event: &'a str, +} + +/// Where the ledger lives. +/// +/// Resolved from `ARCHIPELAGO_DATA_DIR` with the `/var/lib/archipelago` +/// fallback, matching `container/version_config.rs:36-39`, so this module needs +/// no wiring through `bootstrap.rs` or a system handler to know its own path. +/// +/// Deliberately **outside** `identity/`: the KEY-02 rootfs identity sweep and +/// `backup.restore-identity` both operate on that directory wholesale, and +/// neither should ever have to reason about a file that is not key material. +fn readiness_ledger_path() -> PathBuf { + let base = std::env::var("ARCHIPELAGO_DATA_DIR") + .unwrap_or_else(|_| "/var/lib/archipelago".to_string()); + PathBuf::from(base) + .join("security") + .join("csprng-readiness.jsonl") +} + +/// Append one readiness verdict to the ledger. Best-effort by design. +/// +/// Every failure path warns and returns. `ceremony.rs` generates a master seed +/// **offline**, on a machine that need not have `/var/lib/archipelago` at all; +/// a ledger write that could fail key generation would be a availability defect +/// introduced by an audit feature, which is not a trade this is willing to make. +/// +/// The file is created `0600` (matching `seed.rs`'s identity-blob pattern) and +/// only ever appended to, so a node accumulates its history rather than +/// overwriting it. +pub(crate) fn record_csprng_readiness(ready: Option, event: &str) { + let path = readiness_ledger_path(); + + if let Some(parent) = path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + tracing::warn!( + "CSPRNG readiness ledger: cannot create {}: {e} — verdict not recorded", + parent.display() + ); + return; + } + } + + let record = ReadinessRecord { + v: READINESS_SCHEMA_VERSION, + ts: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + ready, + event, + }; + let line = match serde_json::to_string(&record) { + Ok(s) => s, + Err(e) => { + tracing::warn!("CSPRNG readiness ledger: serialisation failed: {e}"); + return; + } + }; + + let mut opts = std::fs::OpenOptions::new(); + opts.create(true).append(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + + match opts.open(&path) { + Ok(mut f) => { + use std::io::Write; + if let Err(e) = writeln!(f, "{line}") { + tracing::warn!( + "CSPRNG readiness ledger: write to {} failed: {e}", + path.display() + ); + } + } + Err(e) => tracing::warn!( + "CSPRNG readiness ledger: cannot open {}: {e} — verdict not recorded", + path.display() + ), + } +} + +// ─── Test-only allowlist members ──────────────────────────────────────── + +#[cfg(test)] +pub(crate) mod testing { + use super::{sealed, KeyGenRng}; + + /// Deterministic test-only RNG emitting `0x00, 0x01, 0x02, …`. + /// + /// Relocated verbatim from `seed.rs` (the wrapping-add-1 `fill_bytes` and + /// therefore the emitted byte sequence are unchanged, so the known-answer + /// mnemonic it produces is unchanged). What did **not** move is + /// `impl rand::CryptoRng for CountingRng`: that marker was a false promise — + /// a counter is not a cryptographic source — and KEY-05 retires it rather + /// than relocating it. Sealed membership replaces it, and unlike a marker it + /// is a closed set the compiler enforces. + pub(crate) struct CountingRng(pub 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 sealed::Sealed for CountingRng {} + + impl KeyGenRng for CountingRng { + // See `KeyGenRng::GUARD_DRAWS`. This type's entire purpose is to emit + // the ascending counter the predicate rejects. + const GUARD_DRAWS: bool = false; + } + + /// A guarded test RNG that emits a constant byte, so the guard itself can be + /// observed tripping through `draw_key_bytes` rather than only through the + /// pure predicate. + pub(crate) struct ConstantRng(pub u8); + + impl rand::RngCore for ConstantRng { + fn next_u32(&mut self) -> u32 { + u32::from_le_bytes([self.0; 4]) + } + + fn next_u64(&mut self) -> u64 { + u64::from_le_bytes([self.0; 8]) + } + + fn fill_bytes(&mut self, dest: &mut [u8]) { + dest.fill(self.0); + } + + fn try_fill_bytes(&mut self, dest: &mut [u8]) -> std::result::Result<(), rand::Error> { + self.fill_bytes(dest); + Ok(()) + } + } + + impl sealed::Sealed for ConstantRng {} + + // Deliberately keeps the default `GUARD_DRAWS = true`. + impl KeyGenRng for ConstantRng {} +} + +#[cfg(test)] +mod tests { + use super::testing::{ConstantRng, CountingRng}; + use super::*; + + // ─── Layer (a) ────────────────────────────────────────────────────── + + #[test] + fn sealed_allowlist_has_one_production_member() { + // `OsRng` is a member and is guarded. The assertion that it is the + // *only* production member is enforced by the compiler plus the sealing + // — `sealed::Sealed` is unnameable outside this module, so no impl can + // exist elsewhere — and is checked mechanically by the plan's grep + // criterion over `impl KeyGenRng for` in this file. What is asserted + // here is the property that must hold of every production member. + fn assert_member() -> bool { + R::GUARD_DRAWS + } + assert!( + assert_member::(), + "the production allowlist member must be guarded" + ); + assert!( + !CountingRng::GUARD_DRAWS, + "the deterministic test vector member is the one documented opt-out" + ); + assert!( + ConstantRng::GUARD_DRAWS, + "the constant test RNG must stay guarded so the guard is observable" + ); + } + + #[test] + fn osrng_draws_through_the_seam() { + let mut buf = [0u8; 32]; + draw_key_bytes(&mut rand::rngs::OsRng, &mut buf).expect("OsRng draw must be accepted"); + assert!( + buf.iter().any(|b| *b != 0), + "draw produced an unfilled buffer" + ); + } + + // ─── Layer (d): the predicate ─────────────────────────────────────── + + #[test] + fn degenerate_rejects_all_zero() { + assert_eq!(is_degenerate(&[0u8; 32]), Some(DegenerateEntropy::AllZero)); + assert_eq!(is_degenerate(&[0u8; 12]), Some(DegenerateEntropy::AllZero)); + } + + #[test] + fn degenerate_rejects_all_identical() { + assert_eq!( + is_degenerate(&[0xABu8; 32]), + Some(DegenerateEntropy::AllIdentical) + ); + assert_eq!( + is_degenerate(&[0xABu8; 12]), + Some(DegenerateEntropy::AllIdentical) + ); + } + + #[test] + fn degenerate_rejects_ascending_counter() { + let ascending: Vec = (0u8..32).collect(); + assert_eq!( + is_degenerate(&ascending), + Some(DegenerateEntropy::Counter), + "0x00..0x1f is the canonical broken-counter output" + ); + // Wrapping, not merely ascending: 0xFE, 0xFF, 0x00, 0x01, … is the same + // defect and must not escape through the wrap. + let wrapping: Vec = (0..32u32).map(|i| (0xFEu8).wrapping_add(i as u8)).collect(); + assert_eq!(is_degenerate(&wrapping), Some(DegenerateEntropy::Counter)); + } + + #[test] + fn degenerate_rejects_descending_counter() { + let descending: Vec = (0..32u32).map(|i| (0x80u8).wrapping_sub(i as u8)).collect(); + assert_eq!(is_degenerate(&descending), Some(DegenerateEntropy::Counter)); + } + + #[test] + fn degenerate_accepts_100k_osrng_draws() { + // The false-positive claim in KEY-05-ENTROPY-ENFORCEMENT.md is a + // calculation; this is the empirical companion to it. At 32 bytes the + // predicted expected count over 100,000 draws is ~1e-71, so a single + // rejection here means the predicate is wrong, not that we were unlucky. + let mut buf = [0u8; 32]; + for i in 0..100_000u32 { + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut buf); + assert_eq!( + is_degenerate(&buf), + None, + "genuine OsRng draw #{i} was rejected — the predicate has a false positive" + ); + } + } + + #[test] + fn degenerate_accepts_ordinary_material() { + // Two bytes equal, and a run of three ascending, must not be enough. + let sample: [u8; 16] = [ + 0x9f, 0x9f, 0x01, 0x02, 0x03, 0xd4, 0x00, 0x00, 0x71, 0x8c, 0x8c, 0xff, 0x10, 0x22, + 0x35, 0xae, + ]; + assert_eq!(is_degenerate(&sample), None); + } + + #[test] + fn draw_key_bytes_rejects_and_zeroizes_a_degenerate_draw() { + let mut buf = [0xFFu8; 32]; + let err = draw_key_bytes(&mut ConstantRng(0xAB), &mut buf) + .expect_err("a constant fill must be refused"); + assert_eq!(err, DegenerateEntropy::AllIdentical); + assert_eq!( + buf, [0u8; 32], + "a refused draw must leave the buffer zeroized" + ); + } + + #[test] + fn draw_key_bytes_reports_all_zero_specifically() { + let mut buf = [0xFFu8; 16]; + let err = draw_key_bytes(&mut ConstantRng(0x00), &mut buf).expect_err("zeros are refused"); + assert_eq!(err, DegenerateEntropy::AllZero); + } + + #[test] + #[should_panic(expected = "draw_key_bytes called on a 11-byte buffer")] + fn draw_key_bytes_panics_below_min_guarded_len() { + let mut buf = [0u8; MIN_GUARDED_LEN - 1]; + let _ = draw_key_bytes(&mut rand::rngs::OsRng, &mut buf); + } + + // ─── Layer (e): the ledger ────────────────────────────────────────── + + // `ARCHIPELAGO_DATA_DIR` is process-global, so these tests must not run + // concurrently — serialize them and give each a unique dir. Same pattern and + // same reasoning as `container/version_config.rs:163-181` (poisoning is fine: + // a panicking test still releases a usable guard). + static ENV_LOCK: std::sync::Mutex = std::sync::Mutex::new(0); + + fn with_tmp_data_dir(f: F) { + let mut counter = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + *counter += 1; + let dir = std::env::temp_dir().join(format!( + "archy-entropy-test-{}-{}", + std::process::id(), + *counter + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::env::set_var("ARCHIPELAGO_DATA_DIR", &dir); + f(&dir); + std::env::remove_var("ARCHIPELAGO_DATA_DIR"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn readiness_ledger_is_0600_and_append_only() { + with_tmp_data_dir(|dir| { + let path = dir.join("security").join("csprng-readiness.jsonl"); + + record_csprng_readiness(Some(true), "unit-test"); + let first = std::fs::read_to_string(&path).unwrap(); + assert_eq!(first.lines().count(), 1, "one call must write one line"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "ledger must be owner-only"); + } + + record_csprng_readiness(Some(false), "unit-test-2"); + let second = std::fs::read_to_string(&path).unwrap(); + assert_eq!(second.lines().count(), 2, "second call must append"); + assert!( + second.starts_with(first.trim_end()), + "append must not rewrite the first line" + ); + }); + } + + #[test] + fn readiness_record_schema_is_exactly_four_keys() { + with_tmp_data_dir(|dir| { + record_csprng_readiness(None, "unit-test-schema"); + let path = dir.join("security").join("csprng-readiness.jsonl"); + let text = std::fs::read_to_string(&path).unwrap(); + let line = text.lines().next().unwrap(); + let value: serde_json::Value = serde_json::from_str(line).unwrap(); + let obj = value.as_object().unwrap(); + + let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect(); + keys.sort_unstable(); + assert_eq!(keys, vec!["event", "ready", "ts", "v"]); + + assert_eq!(obj["v"], serde_json::json!(1)); + assert_eq!(obj["event"], serde_json::json!("unit-test-schema")); + assert!(obj["ready"].is_null(), "an unknown verdict records as null"); + assert!( + obj["ts"].as_str().unwrap().ends_with('Z'), + "timestamp must be RFC3339 UTC" + ); + }); + } + + /// The fixed vocabulary a ledger line can contain: the schema keys plus the + /// literal values the master-seed call site writes. + /// + /// Three of these — `master`, `seed`, `ready` — are themselves BIP-39 + /// English words. A naive "no mnemonic word appears in the file" substring + /// check would therefore fail on roughly 3% of runs purely because a random + /// 24-word mnemonic happened to contain one of them, and would *also* false + /// positive on substrings (`gen-era-te` contains the BIP-39 word `era`). + /// Subtracting the fixed vocabulary and comparing whole tokens makes the + /// assertion exact instead of flaky: any alphabetic token in the ledger that + /// is not schema is, by construction, a leak. + /// + /// `t` and `z` are the RFC 3339 date/time separator and the UTC designator + /// from the `ts` value. They are single characters and every BIP-39 English + /// word is at least three, so they cannot mask a leaked word. + const LEDGER_FIXED_VOCABULARY: &[&str] = &[ + "v", "ts", "ready", "event", "master", "seed", "generate", "true", "false", "null", "t", + "z", + ]; + + #[test] + fn readiness_record_contains_no_mnemonic_words() { + with_tmp_data_dir(|dir| { + let (mnemonic, _seed) = crate::seed::MasterSeed::generate().unwrap(); + let path = dir.join("security").join("csprng-readiness.jsonl"); + let text = std::fs::read_to_string(&path) + .expect("MasterSeed::generate must have written a readiness line"); + + let unexpected: Vec = text + .split(|c: char| !c.is_ascii_alphabetic()) + .filter(|t| !t.is_empty()) + .map(|t| t.to_ascii_lowercase()) + .filter(|t| !LEDGER_FIXED_VOCABULARY.contains(&t.as_str())) + .collect(); + assert!( + unexpected.is_empty(), + "ledger contains tokens outside the fixed schema vocabulary: {unexpected:?}" + ); + + for word in mnemonic.to_string().split_whitespace() { + assert!( + !unexpected.iter().any(|t| t == word), + "mnemonic word {word:?} leaked into the readiness ledger" + ); + } + }); + } + + #[test] + fn readiness_record_survives_unwritable_data_dir() { + let mut counter = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + *counter += 1; + let dir = std::env::temp_dir().join(format!( + "archy-entropy-unwritable-{}-{}", + std::process::id(), + *counter + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + // A *file* where the data dir should be, so `create_dir_all` of the + // `security/` child cannot succeed. + let blocker = dir.join("not-a-directory"); + std::fs::write(&blocker, b"x").unwrap(); + std::env::set_var("ARCHIPELAGO_DATA_DIR", &blocker); + + // The contract is that this returns normally. A panic or an unwind here + // fails the test, which is the whole assertion: a ledger write must + // never be able to fail key generation on the offline ceremony path. + record_csprng_readiness(Some(true), "unit-test-unwritable"); + + std::env::remove_var("ARCHIPELAGO_DATA_DIR"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/core/archipelago/src/federation/invites.rs b/core/archipelago/src/federation/invites.rs index 51ad2692..9aff3dca 100644 --- a/core/archipelago/src/federation/invites.rs +++ b/core/archipelago/src/federation/invites.rs @@ -36,10 +36,15 @@ pub async fn create_invite( trust_level: TrustLevel, ) -> Result { use base64::Engine; - use rand::Rng; + // KEY-05: a federation invite token is unguessable-by-design — it is the + // whole authorisation for a peer join — so the source is named and the + // 16-byte draw is guarded. The `rand::Rng` import that brought `fill` into + // scope is gone with the call that needed it. let mut token_bytes = [0u8; 16]; - rand::thread_rng().fill(&mut token_bytes); + crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut token_bytes).map_err(|e| { + anyhow::anyhow!("Refusing to mint an invite token from degenerate entropy: {e}") + })?; let token = hex::encode(token_bytes); let mut payload = serde_json::json!({ diff --git a/core/archipelago/src/federation/storage.rs b/core/archipelago/src/federation/storage.rs index 434524e4..0db046bd 100644 --- a/core/archipelago/src/federation/storage.rs +++ b/core/archipelago/src/federation/storage.rs @@ -828,9 +828,13 @@ mod tests { .await .unwrap(); - record_sync_result(dir.path(), "did:key:z1", Err("peer unreachable".to_string())) - .await - .unwrap(); + record_sync_result( + dir.path(), + "did:key:z1", + Err("peer unreachable".to_string()), + ) + .await + .unwrap(); let nodes = load_nodes(dir.path()).await.unwrap(); let n1 = nodes.iter().find(|n| n.did == "did:key:z1").unwrap(); @@ -863,7 +867,9 @@ mod tests { .last_sync_error .is_some()); - record_sync_result(dir.path(), "did:key:z1", Ok(())).await.unwrap(); + record_sync_result(dir.path(), "did:key:z1", Ok(())) + .await + .unwrap(); let nodes = load_nodes(dir.path()).await.unwrap(); assert!( @@ -920,7 +926,9 @@ mod tests { .unwrap(); let huge = "x".repeat(5000); - record_sync_result(dir.path(), "did:key:z1", Err(huge)).await.unwrap(); + record_sync_result(dir.path(), "did:key:z1", Err(huge)) + .await + .unwrap(); let nodes = load_nodes(dir.path()).await.unwrap(); let msg = nodes[0].last_sync_error.as_deref().unwrap(); diff --git a/core/archipelago/src/federation/sync.rs b/core/archipelago/src/federation/sync.rs index 6d699551..d961f069 100644 --- a/core/archipelago/src/federation/sync.rs +++ b/core/archipelago/src/federation/sync.rs @@ -377,8 +377,8 @@ mod tests { fips_npub: Some("npub1a".into()), last_transport: None, last_transport_at: None, - last_sync_error: None, - last_sync_error_at: None, + last_sync_error: None, + last_sync_error_at: None, }, FederatedNode { did: "did:key:zObserver".into(), @@ -392,8 +392,8 @@ mod tests { fips_npub: Some("npub1b".into()), last_transport: None, last_transport_at: None, - last_sync_error: None, - last_sync_error_at: None, + last_sync_error: None, + last_sync_error_at: None, }, FederatedNode { did: "did:key:zUntrusted".into(), @@ -407,8 +407,8 @@ mod tests { fips_npub: None, last_transport: None, last_transport_at: None, - last_sync_error: None, - last_sync_error_at: None, + last_sync_error: None, + last_sync_error_at: None, }, ]; let state = build_local_state( @@ -451,8 +451,8 @@ mod tests { fips_npub: None, last_transport: None, last_transport_at: None, - last_sync_error: None, - last_sync_error_at: None, + last_sync_error: None, + last_sync_error_at: None, }], ) .await diff --git a/core/archipelago/src/fips/dial.rs b/core/archipelago/src/fips/dial.rs index f4f88013..451a0a54 100644 --- a/core/archipelago/src/fips/dial.rs +++ b/core/archipelago/src/fips/dial.rs @@ -72,7 +72,10 @@ pub async fn resolve(npub: &str) -> Result { .await .context("connect to FIPS DNS")?; - let id: u16 = rand::random(); + // KEY-05: source named. A 2-byte DNS transaction id, not key material, so it + // is drawn unguarded — an "all bytes identical" predicate on two bytes + // false-positives once in 256, which would be worse than the defect. + let id: u16 = rand::RngCore::next_u32(&mut rand::rngs::OsRng) as u16; let query = encode_query(id, npub)?; tokio::time::timeout(DNS_TIMEOUT, sock.send(&query)) .await diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index 3a732200..196d40f5 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -48,6 +48,7 @@ mod data_model; mod device_tokens; mod disk_monitor; mod electrs_status; +mod entropy; mod federation; mod fips; mod health_monitor; diff --git a/core/archipelago/src/mesh/x3dh.rs b/core/archipelago/src/mesh/x3dh.rs index 254f45b7..ee1cad56 100644 --- a/core/archipelago/src/mesh/x3dh.rs +++ b/core/archipelago/src/mesh/x3dh.rs @@ -97,7 +97,12 @@ pub fn generate_prekey_bundle( // Generate signed prekey let (spk_secret, spk_public) = crypto::generate_x25519_ephemeral(); - let spk_id: u32 = rand::random(); + // KEY-05: source named. This is a 4-byte prekey *identifier*, not key + // material — the X25519 secret is the line above — so it is drawn unguarded: + // the degenerate predicate's false-positive bound does not hold below 12 + // bytes. See the classification table in + // docs/security/KEY-05-ENTROPY-ENFORCEMENT.md. + let spk_id: u32 = rand::RngCore::next_u32(&mut rand::rngs::OsRng); let signature = identity_signing_key.sign(&spk_public); let signed_prekey = SignedPrekey { @@ -111,7 +116,8 @@ pub fn generate_prekey_bundle( let mut one_time_secrets = Vec::with_capacity(num_one_time_prekeys as usize); for _ in 0..num_one_time_prekeys { let (otk_secret, otk_public) = crypto::generate_x25519_ephemeral(); - let otk_id: u32 = rand::random(); + // KEY-05: source named; unguarded for the same reason as `spk_id` above. + let otk_id: u32 = rand::RngCore::next_u32(&mut rand::rngs::OsRng); one_time_prekeys.push(OneTimePrekey { id: otk_id, public: otk_public, @@ -384,4 +390,70 @@ mod tests { assert!(verify_bundle(&bundle).is_err()); } + + /// KEY-05: the prekey bundle crosses the wire to other nodes, so the entropy + /// migration must be provably source-only. This pins the serialised field + /// **set, types and ordering** — a later refactor that reshapes the bundle + /// while "just" touching the RNG fails here rather than silently breaking + /// every peer that already holds the old shape. + #[test] + fn prekey_bundle_wire_shape_unchanged() { + let signing_key = SigningKey::generate(&mut OsRng); + let (bundle, _secrets) = generate_prekey_bundle(&signing_key, 2).unwrap(); + let json = serde_json::to_string(&bundle).unwrap(); + + // Ordering. Checked against the emitted *string*, not a parsed + // `serde_json::Value`: `Value`'s map is a `BTreeMap` unless the + // `preserve_order` feature happens to be unified on, so a `Value` would + // silently assert alphabetical order instead of declaration order. The + // serialised text is what actually goes on the wire. + let pos = |k: &str| json.find(k).unwrap_or_else(|| panic!("missing field {k}")); + assert!(pos("\"identity_key\"") < pos("\"x25519_identity\"")); + assert!(pos("\"x25519_identity\"") < pos("\"signed_prekey\"")); + assert!(pos("\"signed_prekey\"") < pos("\"one_time_prekeys\"")); + + // Field set and types. + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + let obj = value.as_object().expect("bundle serialises as an object"); + let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect(); + keys.sort_unstable(); + assert_eq!( + keys, + vec![ + "identity_key", + "one_time_prekeys", + "signed_prekey", + "x25519_identity" + ] + ); + + // Both identity fields stay 32-byte values hex-encoded to 64 chars. + assert_eq!(obj["identity_key"].as_str().unwrap().len(), 64); + assert_eq!(obj["x25519_identity"].as_str().unwrap().len(), 64); + + let spk = obj["signed_prekey"].as_object().unwrap(); + let mut spk_keys: Vec<&str> = spk.keys().map(String::as_str).collect(); + spk_keys.sort_unstable(); + assert_eq!(spk_keys, vec!["id", "public", "signature"]); + assert!(spk["id"].is_u64(), "prekey id must remain an unsigned int"); + assert!( + u32::try_from(spk["id"].as_u64().unwrap()).is_ok(), + "prekey id must still fit u32" + ); + assert_eq!(spk["public"].as_str().unwrap().len(), 64); + + let otks = obj["one_time_prekeys"].as_array().unwrap(); + assert_eq!(otks.len(), 2); + let mut otk_keys: Vec<&str> = otks[0] + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + otk_keys.sort_unstable(); + assert_eq!(otk_keys, vec!["id", "public"]); + assert!(otks[0]["id"].is_u64()); + assert!(u32::try_from(otks[0]["id"].as_u64().unwrap()).is_ok()); + assert_eq!(otks[0]["public"].as_str().unwrap().len(), 64); + } } diff --git a/core/archipelago/src/seed.rs b/core/archipelago/src/seed.rs index 8b0922b7..a1a9c97c 100644 --- a/core/archipelago/src/seed.rs +++ b/core/archipelago/src/seed.rs @@ -94,23 +94,58 @@ fn kernel_csprng_ready() -> Option { /// 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)) +/// +/// **KEY-05 (2026-08-02) generalises that in two ways.** +/// +/// The bound is no longer `rand::CryptoRng + rand::RngCore`. `CryptoRng` is a +/// marker with no compiler-checked content: any caller could implement it for any +/// type and satisfy this signature while supplying a counter. The bound is now +/// [`crate::entropy::KeyGenRng`], a **sealed** allowlist whose supertrait lives in a +/// private module of `entropy`, so the set of RNGs that can drive the master key +/// hierarchy is exactly the set written in that one file and the compiler enforces +/// it. Documentation became a constraint. +/// +/// The entropy is also now **inspectable at this seam**: it is drawn into a local +/// buffer through [`crate::entropy::draw_key_bytes`], which refuses an all-zero, +/// all-identical or wrapping-counter draw before it can become a seed, and the +/// mnemonic is built with `from_entropy` instead of `generate_in_with`. Those two +/// are the same function for the same RNG output — `mnemonic_generation_uses_injected_rng` +/// below has asserted exactly that equivalence since F-02, and still does. The +/// buffer is zeroized before this function returns on every path. +fn generate_mnemonic_with(rng: &mut R) -> Result { + let mut entropy = [0u8; 32]; + let result = crate::entropy::draw_key_bytes(rng, &mut entropy) + .map_err(|e| { + anyhow::anyhow!( + "Refusing to build a mnemonic from degenerate entropy: {}", + e + ) + }) + .and_then(|()| { + bip39::Mnemonic::from_entropy(&entropy) + .map_err(|e| anyhow::anyhow!("Failed to generate mnemonic: {}", e)) + }); + entropy.zeroize(); + result } 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() { + let ready = kernel_csprng_ready(); + match 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 => {} } + // Until KEY-05 layer (e) this verdict was computed, logged and thrown + // away, so a node could never answer after the fact whether its keys were + // born from a seeded pool (backlog R-09). Now it is durable. Best-effort: + // `ceremony.rs` runs this offline where no data directory need exist, and + // an audit record must never be able to fail key generation. + crate::entropy::record_csprng_readiness(ready, "master-seed-generate"); // 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. @@ -628,42 +663,18 @@ 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. + /// The deterministic counter RNG that drives the seam below now lives in + /// `crate::entropy::testing` (KEY-05). Its `fill_bytes` behaviour and + /// therefore its emitted byte sequence are unchanged, so the known-answer + /// mnemonic pinned below is unchanged. /// - /// `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 {} + /// Its `impl rand::CryptoRng` did **not** move: that marker was a false + /// promise — a counter is not a cryptographic source — and KEY-05 retires it + /// rather than relocating it. Membership of the sealed `entropy::KeyGenRng` + /// allowlist replaces it, and unlike a marker anyone can implement, that set + /// is closed and compiler-enforced. The crate now contains zero + /// `impl rand::CryptoRng` blocks. + use crate::entropy::testing::CountingRng; #[test] fn mnemonic_generation_uses_injected_rng() { diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index ad887e7e..02f814a4 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -576,11 +576,9 @@ impl Server { // while it was unreachable, so the operator's // sync-error badge disappears on recovery // instead of sticking around forever. - crate::federation::record_sync_result( - &data_dir, &node.did, Ok(()), - ) - .await - .ok(); + crate::federation::record_sync_result(&data_dir, &node.did, Ok(())) + .await + .ok(); // Asymmetry self-heal: if this peer's exported // trusted list doesn't include us, our original // peer-joined never landed (e.g. it was sent diff --git a/core/archipelago/src/session.rs b/core/archipelago/src/session.rs index dc46a31e..c150eac8 100644 --- a/core/archipelago/src/session.rs +++ b/core/archipelago/src/session.rs @@ -153,8 +153,7 @@ impl SessionStore { /// Create a full (authenticated) session. Returns the plaintext token. /// Enforces max concurrent sessions by evicting the oldest if limit reached. pub async fn create(&self) -> String { - let token_bytes: [u8; 32] = rand::random(); - let token = hex::encode(token_bytes); + let token = fresh_session_token(); let hash = hash_token(&token); let now = SystemTime::now(); let session = Session { @@ -175,8 +174,7 @@ impl SessionStore { /// Create a pending TOTP session (password verified, awaiting TOTP). /// Caches the decrypted TOTP secret in memory for verification. pub async fn create_pending(&self, totp_secret: Vec) -> String { - let token_bytes: [u8; 32] = rand::random(); - let token = hex::encode(token_bytes); + let token = fresh_session_token(); let hash = hash_token(&token); let now = SystemTime::now(); let session = Session { @@ -251,8 +249,7 @@ impl SessionStore { let mut sessions = self.sessions.write().await; // Only upgrade if the old session exists and is pending if sessions.remove(&old_hash).is_some() { - let new_token_bytes: [u8; 32] = rand::random(); - let new_token = hex::encode(new_token_bytes); + let new_token = fresh_session_token(); let new_hash = hash_token(&new_token); let now = SystemTime::now(); self.evict_if_over_limit(&mut sessions); @@ -291,8 +288,7 @@ impl SessionStore { /// Returns the new plaintext token. pub async fn rotate(&self, old_token: &str) -> String { let old_hash = hash_token(old_token); - let new_token_bytes: [u8; 32] = rand::random(); - let new_token = hex::encode(new_token_bytes); + let new_token = fresh_session_token(); let new_hash = hash_token(&new_token); let now = SystemTime::now(); @@ -447,6 +443,32 @@ impl SessionStore { } } +/// Mint a fresh 32-byte session token, hex-encoded. +/// +/// KEY-05: the entropy source is named (`OsRng`) rather than inherited from a +/// dependency default, and the draw is inspected by the degenerate-entropy +/// predicate before it becomes a bearer credential. +/// +/// **This aborts rather than returning on a degenerate draw, and that is +/// deliberate.** `create`, `create_pending` and `rotate` return a bare `String`, +/// and their callers live in `api/rpc/mod.rs` and `api/rpc/totp.rs` — files plan +/// 10-06 does not own; widening them to `Result` is an API change this plan is +/// explicitly not permitted to make. The only two behaviours available at this +/// seam are therefore "mint a predictable session token" and "refuse loudly", +/// and only the second is defensible: reaching this branch means the kernel +/// CSPRNG returned 32 bytes that are all-zero, all-identical or a ±1 counter, +/// i.e. the machine has no usable entropy and must not be issuing credentials at +/// all. It cannot be driven by attacker-supplied input — the predicate reads only +/// `OsRng` output — and the false-trip bound at 32 bytes is `3 · 2^−248` +/// (`docs/security/KEY-05-ENTROPY-ENFORCEMENT.md`). +fn fresh_session_token() -> String { + let mut token_bytes = [0u8; 32]; + crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut token_bytes).unwrap_or_else(|e| { + panic!("refusing to mint a session token from degenerate entropy: {e} (KEY-05)") + }); + hex::encode(token_bytes) +} + fn hash_token(token: &str) -> [u8; 32] { let mut hasher = Sha256::new(); hasher.update(token.as_bytes()); @@ -471,12 +493,22 @@ pub fn extract_session_cookie(headers: &hyper::HeaderMap) -> Option { mod tests { use super::*; + /// Unique suffix for a per-test temp file path. + /// + /// KEY-05 migrates test fixtures for the same reason it migrates production + /// code: CI runs clippy with `--all-targets`, so a `rand::random()` left in a + /// test is a build failure once the ban is live. This is not key material — + /// it is a filename component — so it is drawn unguarded, and it names its + /// source like everything else. + fn uniq() -> u64 { + rand::RngCore::next_u64(&mut rand::rngs::OsRng) + } + #[tokio::test] async fn test_session_create_and_validate() { - let store = SessionStore::new_for_tests(std::env::temp_dir().join(format!( - "archipelago-sessions-test-{}.json", - rand::random::() - ))); + let store = SessionStore::new_for_tests( + std::env::temp_dir().join(format!("archipelago-sessions-test-{}.json", uniq())), + ); let token = store.create().await; assert!(store.validate(&token).await); @@ -484,19 +516,17 @@ mod tests { #[tokio::test] async fn test_session_invalid_token() { - let store = SessionStore::new_for_tests(std::env::temp_dir().join(format!( - "archipelago-sessions-test-{}.json", - rand::random::() - ))); + let store = SessionStore::new_for_tests( + std::env::temp_dir().join(format!("archipelago-sessions-test-{}.json", uniq())), + ); assert!(!store.validate("nonexistent_token").await); } #[tokio::test] async fn test_session_remove() { - let store = SessionStore::new_for_tests(std::env::temp_dir().join(format!( - "archipelago-sessions-test-{}.json", - rand::random::() - ))); + let store = SessionStore::new_for_tests( + std::env::temp_dir().join(format!("archipelago-sessions-test-{}.json", uniq())), + ); let token = store.create().await; assert!(store.validate(&token).await); @@ -506,10 +536,9 @@ mod tests { #[tokio::test] async fn test_pending_session_upgrade() { - let store = SessionStore::new_for_tests(std::env::temp_dir().join(format!( - "archipelago-sessions-test-{}.json", - rand::random::() - ))); + let store = SessionStore::new_for_tests( + std::env::temp_dir().join(format!("archipelago-sessions-test-{}.json", uniq())), + ); let secret = vec![1, 2, 3, 4]; let token = store.create_pending(secret.clone()).await; @@ -533,10 +562,9 @@ mod tests { #[tokio::test] async fn test_pending_session_max_attempts() { - let store = SessionStore::new_for_tests(std::env::temp_dir().join(format!( - "archipelago-sessions-test-{}.json", - rand::random::() - ))); + let store = SessionStore::new_for_tests( + std::env::temp_dir().join(format!("archipelago-sessions-test-{}.json", uniq())), + ); let secret = vec![1, 2, 3]; let token = store.create_pending(secret).await; @@ -564,10 +592,9 @@ mod tests { #[tokio::test] async fn test_session_activity_updates_on_validate() { - let store = SessionStore::new_for_tests(std::env::temp_dir().join(format!( - "archipelago-sessions-test-{}.json", - rand::random::() - ))); + let store = SessionStore::new_for_tests( + std::env::temp_dir().join(format!("archipelago-sessions-test-{}.json", uniq())), + ); let token = store.create().await; // First validation should succeed and touch last_activity @@ -579,10 +606,9 @@ mod tests { #[tokio::test] async fn test_invalidate_all_except() { - let store = SessionStore::new_for_tests(std::env::temp_dir().join(format!( - "archipelago-sessions-test-{}.json", - rand::random::() - ))); + let store = SessionStore::new_for_tests( + std::env::temp_dir().join(format!("archipelago-sessions-test-{}.json", uniq())), + ); let token1 = store.create().await; let token2 = store.create().await; let token3 = store.create().await; @@ -597,10 +623,9 @@ mod tests { #[tokio::test] async fn test_session_rotate() { - let store = SessionStore::new_for_tests(std::env::temp_dir().join(format!( - "archipelago-sessions-test-{}.json", - rand::random::() - ))); + let store = SessionStore::new_for_tests( + std::env::temp_dir().join(format!("archipelago-sessions-test-{}.json", uniq())), + ); let old_token = store.create().await; assert!(store.validate(&old_token).await); @@ -615,10 +640,9 @@ mod tests { #[tokio::test] async fn test_max_concurrent_sessions() { - let store = SessionStore::new_for_tests(std::env::temp_dir().join(format!( - "archipelago-sessions-test-{}.json", - rand::random::() - ))); + let store = SessionStore::new_for_tests( + std::env::temp_dir().join(format!("archipelago-sessions-test-{}.json", uniq())), + ); let mut tokens = Vec::new(); // Create MAX_CONCURRENT_SESSIONS sessions @@ -646,10 +670,9 @@ mod tests { #[tokio::test] async fn test_active_session_count() { - let store = SessionStore::new_for_tests(std::env::temp_dir().join(format!( - "archipelago-sessions-test-{}.json", - rand::random::() - ))); + let store = SessionStore::new_for_tests( + std::env::temp_dir().join(format!("archipelago-sessions-test-{}.json", uniq())), + ); assert_eq!(store.active_session_count().await, 0); let token1 = store.create().await; @@ -664,10 +687,9 @@ mod tests { #[tokio::test] async fn test_cleanup_expired_removes_stale() { - let store = SessionStore::new_for_tests(std::env::temp_dir().join(format!( - "archipelago-sessions-test-{}.json", - rand::random::() - ))); + let store = SessionStore::new_for_tests( + std::env::temp_dir().join(format!("archipelago-sessions-test-{}.json", uniq())), + ); let token = store.create().await; assert!(store.validate(&token).await); @@ -680,10 +702,9 @@ mod tests { #[tokio::test] async fn test_rotate_preserves_session_count() { - let store = SessionStore::new_for_tests(std::env::temp_dir().join(format!( - "archipelago-sessions-test-{}.json", - rand::random::() - ))); + let store = SessionStore::new_for_tests( + std::env::temp_dir().join(format!("archipelago-sessions-test-{}.json", uniq())), + ); let token = store.create().await; assert_eq!(store.active_session_count().await, 1); diff --git a/core/archipelago/src/storage_crypto.rs b/core/archipelago/src/storage_crypto.rs index 6ecfa3b7..0cef0971 100644 --- a/core/archipelago/src/storage_crypto.rs +++ b/core/archipelago/src/storage_crypto.rs @@ -36,7 +36,14 @@ pub async fn derive_key(data_dir: &Path, domain: &[u8]) -> Result<[u8; 32]> { /// Encrypt `plaintext`, returning `nonce ‖ ciphertext`. pub fn seal(plaintext: &[u8], key: &[u8; 32]) -> Result> { use chacha20poly1305::aead::{Aead, KeyInit}; - let nonce_bytes: [u8; 12] = rand::random(); + // KEY-05: the nonce names `OsRng` and is inspected before use. Nonce reuse + // under ChaCha20-Poly1305 is a keystream recovery *and* a Poly1305 forgery, + // so this is the highest-consequence twelve bytes in the module. Only the + // *source* changed — the envelope below is byte-identical to what every blob + // already on a fleet node was written with. + let mut nonce_bytes = [0u8; 12]; + crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut nonce_bytes) + .map_err(|e| anyhow::anyhow!("refusing to seal with degenerate nonce entropy: {e}"))?; let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key) .map_err(|e| anyhow::anyhow!("cipher init: {e}"))?; let ct = cipher @@ -105,4 +112,59 @@ mod tests { assert!(is_plaintext_json(b"[]")); assert!(!is_plaintext_json(&seal(b"x", &[3u8; 32]).unwrap())); } + + /// KEY-05 regression: a blob written by the pre-migration `seal` must still + /// open after the migration. + /// + /// The vector is **hardcoded**, deliberately. A seal-then-open round trip in + /// the same process passes even if the envelope layout changed, because both + /// halves changed together — it proves self-consistency, not compatibility. + /// Every at-rest message store and mesh-contact store on every fleet node was + /// written by the old code, and CLAUDE.md's "migrations never destroy data" + /// invariant means this must decrypt. + /// + /// The bytes were produced by an **independent** RFC 8439 ChaCha20-Poly1305 + /// implementation (validated first against the RFC's own §2.8.2 test vector), + /// not captured from this crate — so it pins the documented envelope + /// `nonce ‖ ciphertext` as a *format*, rather than pinning whatever this + /// implementation happened to emit. + /// + /// key = `[0x42; 32]`, nonce = `[0x07; 12]`, no AAD. + #[test] + fn opens_pre_migration_ciphertext_vector() { + const VECTOR: [u8; 81] = [ + 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0xb1, 0x19, + 0x96, 0xcc, 0xc8, 0x41, 0x2b, 0xf4, 0x25, 0x2a, 0x0f, 0x8b, 0xdc, 0xcb, 0xbb, 0x4d, + 0x90, 0xe9, 0x99, 0xc5, 0xeb, 0x67, 0xd6, 0x5e, 0x1d, 0xcb, 0x61, 0x2c, 0xd1, 0xe4, + 0x91, 0xe1, 0x44, 0x29, 0xaa, 0x0b, 0x1d, 0xfd, 0xf0, 0x88, 0xe4, 0x5a, 0x65, 0x5b, + 0x29, 0x53, 0xc9, 0xf7, 0x85, 0xd1, 0xa1, 0xec, 0xb7, 0xd7, 0xd6, 0x12, 0xf2, 0x88, + 0x77, 0xed, 0x2d, 0x72, 0x90, 0xff, 0x9a, 0x5b, 0x98, 0xea, 0xec, + ]; + let key = [0x42u8; 32]; + assert_eq!( + open(&VECTOR, &key).expect("pre-migration blob must still decrypt"), + b"archipelago storage_crypto pre-KEY-05 envelope vector".to_vec() + ); + } + + /// The envelope shape itself, pinned so a later refactor cannot reshape it: + /// 12-byte nonce prefix, 16-byte Poly1305 tag suffix, and a *fresh* nonce per + /// seal. + #[test] + fn seal_envelope_layout_unchanged() { + let key = [9u8; 32]; + let plaintext = b"twelve plus n plus sixteen"; + let a = seal(plaintext, &key).unwrap(); + let b = seal(plaintext, &key).unwrap(); + + assert_eq!(a.len(), 12 + plaintext.len() + 16); + assert_eq!(b.len(), 12 + plaintext.len() + 16); + assert_ne!( + a[..12], + b[..12], + "two seals of the same plaintext must not share a nonce" + ); + assert_eq!(open(&a, &key).unwrap(), plaintext); + assert_eq!(open(&b, &key).unwrap(), plaintext); + } } diff --git a/core/archipelago/src/totp.rs b/core/archipelago/src/totp.rs index 01019308..aa2cabed 100644 --- a/core/archipelago/src/totp.rs +++ b/core/archipelago/src/totp.rs @@ -302,7 +302,21 @@ fn generate_backup_codes() -> Result<(Vec, Vec)> { for _ in 0..BACKUP_CODE_COUNT { let mut code = String::with_capacity(BACKUP_CODE_LEN); for _ in 0..BACKUP_CODE_LEN { - let idx = (rand::random::() as usize) % charset.len(); + // KEY-05 migrates the *entropy source* here and nothing else. The + // `% charset.len()` selection is finding F-09 / backlog R-12, which + // 10-CONTEXT.md explicitly defers — and the bias is presently zero + // anyway, because the charset is 32 characters and 32 divides 256 + // exactly. Substituting `SliceRandom::choose`, changing the charset + // or adding a uniformity test would be executing R-12. The draw is + // one byte, far below MIN_GUARDED_LEN, so it is unguarded. + // + // `next_u32() as u8` is not an approximation of the previous + // expression, it is the same operation: `rand 0.8`'s + // `Distribution for Standard` — which is what `rand::random::()` + // resolves to — is itself `rng.next_u32() as u8`. Only the RNG behind + // it changed, from a defaulted `thread_rng()` to a named `OsRng`. + let idx = + (rand::RngCore::next_u32(&mut rand::rngs::OsRng) as u8 as usize) % charset.len(); code.push(charset[idx] as char); } let formatted = format!("{}-{}", &code[..4], &code[4..]); diff --git a/core/archipelago/src/transport/chunking.rs b/core/archipelago/src/transport/chunking.rs index 402d3b8f..c1394689 100644 --- a/core/archipelago/src/transport/chunking.rs +++ b/core/archipelago/src/transport/chunking.rs @@ -146,7 +146,10 @@ pub fn encode_chunked(data: &[u8]) -> Result> { .context("Reed-Solomon encoding failed")?; // Build chunk frames - let message_id: u32 = rand::random(); + // KEY-05: source named. A 4-byte frame correlator, not key material, so it is + // drawn unguarded — the degenerate predicate's false-positive bound does not + // hold below MIN_GUARDED_LEN. + let message_id: u32 = rand::RngCore::next_u32(&mut rand::rngs::OsRng); let total = total_shards as u8; let mut chunks = Vec::with_capacity(total_shards); diff --git a/core/archipelago/src/wallet/bdhke.rs b/core/archipelago/src/wallet/bdhke.rs index dab0919c..4a660d56 100644 --- a/core/archipelago/src/wallet/bdhke.rs +++ b/core/archipelago/src/wallet/bdhke.rs @@ -130,13 +130,26 @@ pub fn verify_proof_structure(secret: &[u8], c: &PublicKey) -> Result { /// NUT-10 defines secret as a JSON array: ["P2PK", {nonce, data, tags}] /// For basic (non-P2PK) proofs, the secret is just a random hex string. pub fn generate_secret() -> Vec { - let random_bytes: [u8; 32] = rand::random(); + // KEY-05: genuine ecash key material — the secret behind a Cashu proof. + // Source named, draw guarded. A degenerate secret is unspendable at best and + // predictable at worst, so refusing is strictly better than emitting one. + let mut random_bytes = [0u8; 32]; + crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut random_bytes).unwrap_or_else(|e| { + panic!("refusing to mint a Cashu proof secret from degenerate entropy: {e} (KEY-05)") + }); hex::encode(random_bytes).into_bytes() } /// Generate a random blinding factor. +/// +/// KEY-05: the RNG is named (`OsRng`) instead of inherited from `thread_rng()`. +/// The degenerate-entropy guard is **deliberately not applied** here — see +/// `docs/security/KEY-05-ENTROPY-ENFORCEMENT.md` § *Deliberate non-applications +/// of the guard*. `SecretKey::new` performs rejection sampling into the secp256k1 +/// group order; intercepting the bytes to inspect them would mean reimplementing +/// that sampling, which is a larger correctness risk than the guard buys. pub fn random_blinding_factor() -> SecretKey { - let mut rng = rand::thread_rng(); + let mut rng = rand::rngs::OsRng; SecretKey::new(&mut rng) } @@ -166,7 +179,7 @@ mod tests { let r = random_blinding_factor(); // Simulate mint: k is mint's private key, K = k*G is public key - let k = SecretKey::new(&mut rand::thread_rng()); + let k = SecretKey::new(&mut rand::rngs::OsRng); let k_pub = PublicKey::from_secret_key(&secp, &k); // Client blinds @@ -203,11 +216,40 @@ mod tests { fn test_verify_proof_structure_valid() { let secret = generate_secret(); let secp = Secp256k1::new(); - let k = SecretKey::new(&mut rand::thread_rng()); + let k = SecretKey::new(&mut rand::rngs::OsRng); let y = hash_to_curve(&secret).unwrap(); let k_scalar = Scalar::from_be_bytes(k.secret_bytes()).unwrap(); let c = y.mul_tweak(&secp, &k_scalar).unwrap(); assert!(verify_proof_structure(&secret, &c).unwrap()); } + + /// KEY-05: the blinding factor is the one genuine generic-over-RNG key + /// generation seam outside `seed.rs`, and it is the site where the degenerate + /// guard is deliberately NOT applied. Prove the migration to `OsRng` still + /// yields a usable secp256k1 scalar and that successive calls differ, so a + /// rebinding to a constant source would fail here rather than silently + /// producing correlated ecash. + #[test] + fn blinding_factor_is_valid_and_varies() { + let a = random_blinding_factor(); + let b = random_blinding_factor(); + assert_ne!( + a.secret_bytes(), + b.secret_bytes(), + "two blinding factors must not collide" + ); + + // "Valid" means secp256k1 itself accepts it: a round trip through + // `from_slice` is the library's own range check against the group order. + let reparsed = SecretKey::from_slice(&a.secret_bytes()) + .expect("blinding factor must be a valid secp256k1 scalar"); + assert_eq!(reparsed.secret_bytes(), a.secret_bytes()); + + // And it is usable as a scalar in the BDHKE operation it exists for. + let secp = Secp256k1::new(); + let y = hash_to_curve(b"key-05 blinding factor probe").unwrap(); + let scalar = Scalar::from_be_bytes(a.secret_bytes()).unwrap(); + assert!(y.mul_tweak(&secp, &scalar).is_ok()); + } }