Files
archy/core/archipelago/src/entropy.rs
T

672 lines
27 KiB
Rust
Raw Normal View History

//! 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);
}
}