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)
+11 -1
View File
@@ -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)
}
+21 -2
View File
@@ -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)
}
+55 -5
View File
@@ -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]
+6 -1
View File
@@ -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;
+671
View File
@@ -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);
}
}
+7 -2
View File
@@ -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!({
+13 -5
View File
@@ -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();
+8 -8
View File
@@ -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
+4 -1
View File
@@ -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
+1
View File
@@ -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;
+74 -2
View File
@@ -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);
}
}
+52 -41
View File
@@ -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() {
+3 -5
View File
@@ -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
+77 -56
View File
@@ -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);
+63 -1
View File
@@ -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);
}
}
+15 -1
View File
@@ -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..]);
+4 -1
View File
@@ -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);
+46 -4
View File
@@ -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());
}
}