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>
672 lines
27 KiB
Rust
672 lines
27 KiB
Rust
//! 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);
|
||
}
|
||
}
|