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:
archipelago
2026-08-02 16:27:34 -04:00
co-authored by Claude Opus 5
parent a05956c4ce
commit 09a1f7621c
23 changed files with 1208 additions and 162 deletions
+7 -1
View File
@@ -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()
+23 -13
View File
@@ -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)