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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
|
||||
#[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]
|
||||
|
||||
@@ -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<String> {
|
||||
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;
|
||||
|
||||
@@ -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<DegenerateEntropy> {
|
||||
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<R: KeyGenRng>(
|
||||
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<bool>,
|
||||
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<bool>, 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<R: KeyGenRng>() -> bool {
|
||||
R::GUARD_DRAWS
|
||||
}
|
||||
assert!(
|
||||
assert_member::<rand::rngs::OsRng>(),
|
||||
"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<u8> = (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<u8> = (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<u8> = (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<u64> = std::sync::Mutex::new(0);
|
||||
|
||||
fn with_tmp_data_dir<F: FnOnce(&std::path::Path)>(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<String> = 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);
|
||||
}
|
||||
}
|
||||
@@ -36,10 +36,15 @@ pub async fn create_invite(
|
||||
trust_level: TrustLevel,
|
||||
) -> Result<String> {
|
||||
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!({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -72,7 +72,10 @@ pub async fn resolve(npub: &str) -> Result<Ipv6Addr> {
|
||||
.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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,23 +94,58 @@ fn kernel_csprng_ready() -> Option<bool> {
|
||||
/// future `rand` or `bip39` bump cannot rebind it silently, and it creates the seam
|
||||
/// that `mnemonic_generation_uses_injected_rng` needs to prove the passed RNG is the
|
||||
/// one actually consumed.
|
||||
fn generate_mnemonic_with<R: rand::CryptoRng + rand::RngCore>(
|
||||
rng: &mut R,
|
||||
) -> Result<bip39::Mnemonic> {
|
||||
bip39::Mnemonic::generate_in_with(rng, bip39::Language::English, 24)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to generate mnemonic: {}", e))
|
||||
///
|
||||
/// **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<R: crate::entropy::KeyGenRng>(rng: &mut R) -> Result<bip39::Mnemonic> {
|
||||
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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<u8>) -> 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<String> {
|
||||
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::<u64>()
|
||||
)));
|
||||
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::<u64>()
|
||||
)));
|
||||
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::<u64>()
|
||||
)));
|
||||
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::<u64>()
|
||||
)));
|
||||
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::<u64>()
|
||||
)));
|
||||
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::<u64>()
|
||||
)));
|
||||
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::<u64>()
|
||||
)));
|
||||
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::<u64>()
|
||||
)));
|
||||
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::<u64>()
|
||||
)));
|
||||
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::<u64>()
|
||||
)));
|
||||
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::<u64>()
|
||||
)));
|
||||
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::<u64>()
|
||||
)));
|
||||
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);
|
||||
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,7 +302,21 @@ fn generate_backup_codes() -> Result<(Vec<String>, Vec<String>)> {
|
||||
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::<u8>() 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<u8> for Standard` — which is what `rand::random::<u8>()`
|
||||
// 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..]);
|
||||
|
||||
@@ -146,7 +146,10 @@ pub fn encode_chunked(data: &[u8]) -> Result<Vec<Chunk>> {
|
||||
.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);
|
||||
|
||||
|
||||
@@ -130,13 +130,26 @@ pub fn verify_proof_structure(secret: &[u8], c: &PublicKey) -> Result<bool> {
|
||||
/// 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<u8> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user