Files
archy/core/archipelago/src/entropy.rs
T
archipelagoandClaude Opus 5 09a1f7621c 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>
2026-08-02 16:27:34 -04:00

672 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}