feat(10-06): name every entropy source and guard key draws (KEY-05 a/d)
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a05956c4ce
commit
09a1f7621c
@@ -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 }))
|
||||
|
||||
@@ -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<String> =
|
||||
attacker_mnemonic.words().map(str::to_string).collect();
|
||||
let attacker_words: Vec<String> = 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(
|
||||
|
||||
@@ -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::<Sha256>::new_from_slice(salt_hex.as_bytes())
|
||||
.expect("HMAC accepts any key length");
|
||||
|
||||
@@ -99,7 +99,13 @@ async fn ensure_status_token() -> Option<String> {
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user