Files
archy/docs/security/KEY-05-ENTROPY-ENFORCEMENT.md
T
archipelagoandClaude Opus 5 a3283cffb4 feat(10-06): enable the entropy lint and supply-chain gates (KEY-05 b/c)
Layer (b) — core/clippy.toml bans rand::random and rand::thread_rng
crate-wide, each with a reason naming KEY-05 and pointing at the
evidence doc. No CI change was needed: the Rust job already runs
`cargo clippy --all-targets --all-features -- -D warnings` from core/,
so a disallowed_methods hit is already a build failure. --all-targets
covers tests deliberately — a fixture keeping the default is a template
for the next production call site.

Ordering was asserted before the file was written, not after: the
residual count of unmigrated call sites is 0, so this cannot turn CI red
for other agents on this shared tree.

Layer (c) — core/deny.toml makes the rand major split change-detecting:
global multiple-versions = "allow", a per-crate deny-multiple-versions
for rand, and a dated grandfather skip pinning =0.9.2 exactly. The tree
as it stands passes; a third version or a change to either member fails.

Both gates were OBSERVED working, not assumed:

- Reintroducing one banned call produced the disallowed_methods error
  with the reason text reaching the developer at the failure point;
  reverting returned the residual count to 0.
- `cargo deny check bans` exits 0 as-is. Removing the grandfather entry
  made it exit 2 and print both dependency trees, independently
  confirming F-07's account of where each rand version comes from.
  Restored, it exits 0 again.

Policy (checkpoint Task 5, human-approved): bans-only. The advisories
gate is NOT enabled — it fails builds when a new CVE is published
against an existing dep with no local change, which on a tree where
several agents push continuously would block everyone at an arbitrary
hour, with remediation often meaning a bump to an exactly-pinned crypto
dependency. No break-glass procedure exists. F-07's advisory half stays
OPEN and is recorded as such.

cargo-deny is pinned to 0.20.2 and installed from crates.io rather than
via EmbarkStudios/cargo-deny-action, because that action exposes no
input to pin the tool version — an unpinned supply-chain checker would
reintroduce, at the CI layer, the exact "backend fixed by configuration
rather than stated" shape this plan exists to remove. crates.io is also
the source vetted at the Task 5 legitimacy gate (EmbarkStudios, repo
resolves, ~4.79M downloads).

RECORDED HONESTLY: layer (b)'s gate is live but not yet EFFECTIVE. The
tree carries 42 pre-existing clippy warnings — unused imports, dead
code, ~39 style lints — that are already errors under -D warnings, so
that CI step cannot pass today for reasons unrelated to KEY-05. Until a
dedicated lint-clearing pass lands, a new banned RNG call would be one
error among many rather than a distinctive build-stopper. Pre-existing
and out of scope; clearing it right before an OTA would be poor
sequencing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 16:58:42 -04:00

44 KiB
Raw Blame History

KEY-05 — Entropy enforcement: per-site classification and mechanism record

Requirement: ROADMAP KEY-05. Plan: .planning/phases/10-key-material-hardening/10-06-PLAN.md. Supersedes: backlog R-13. Absorbs: R-05 (duplicate-rand visibility) and R-09 (CSPRNG-readiness record). Resolves: F-10a in docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md, which recorded raw match counts and deliberately declined to classify them.

Tree state this document was derived against: HEAD = c5a82cba (2026-08-02).


Nothing here is broken today

rand::random() and rand::thread_rng() on the pinned rand 0.8.5 resolve to ReseedingRng<ChaCha12Core, OsRng> — seeded from getrandom(2), reseeded every 64 KiB, fork-protected. Every value in the table below was drawn from a genuine CSPRNG. This document is not an incident record.

What KEY-05 removes is the structural shape: 41 call sites whose entropy backend is selected by Cargo.lock resolution and crate feature flags rather than stated in Archipelago's own source, with no compile error if that selection changes. That is the shape ("T1") that produced the 2026-07-30 COLDCARD entropy defect, here with key material, an AEAD nonce and session credentials in the blast radius.


Layer coverage

ROADMAP KEY-05 names five layers. None was dropped.

Layer What it is Task that closes it Status
(a) Sealed key-generation RNG allowlist at the mnemonic seam; the false impl rand::CryptoRng promise retired Task 2 Closedentropy::KeyGenRng sealed via a private sealed::Sealed; seed.rs::generate_mnemonic_with retyped to it; zero impl rand::CryptoRng blocks remain in the crate
(b) Crate-wide compile-time ban on the defaulted entry points, enforced by the CI clippy step that already exists Task 2 (dry run, uncommitted) → Task 6 (enable) NOT CLOSED — see ## Clippy dry-run evidence and ## What this does not close. Blocked behind the Task 5 human checkpoint.
(c) cargo-deny bans rule making the duplicate-rand split visible and change-detecting Task 5 (decision) → Task 6 (implement) NOT CLOSED — blocked on the Task 5 human decision
(d) Degenerate-entropy runtime predicate Task 2 (built) → Tasks 3/4 (applied) Closedentropy::is_degenerate / entropy::draw_key_bytes, applied at every guarded: yes row below
(e) Durable CSPRNG-readiness record Task 2 Closedentropy::record_csprng_readiness, called from MasterSeed::generate

Layers (b) and (c) are the two that turn CI red for every agent on this shared repository if they are enabled wrongly. Both are gated behind Task 5, a gate="blocking-human" checkpoint.


Source precedence

.planning/phases/10-key-material-hardening/10-CONTEXT.md (2026-08-01) lists F-07 / R-05 and F-10 / R-13 under ## Deferred Ideas. KEY-05 was added to the ROADMAP on 2026-08-02, after that context was gathered, and explicitly absorbs R-05 and supersedes R-13. The ROADMAP requirement is the later and governing artifact.

Two deferrals from that context stand and were not executed:

  • F-09 / R-12 — TOTP modulo bias. totp.rs:305 is migrated for its entropy source only. The % charset.len() selection is byte-for-byte unchanged. (The bias is presently zero: the charset is 32 characters and 32 divides 256 exactly. R-12 is about the latent bias if the charset ever changes length.)
  • F-11 / R-14Math.random() in neode-ui. No frontend file is touched by this plan.

Enforcement blast radius — pinned mechanically

CI runs clippy with working-directory: core (.github/workflows/ci.yml:19) and cargo clippy --all-targets --all-features -- -D warnings (:35). A clippy.toml at core/ therefore governs exactly the workspace members and no more.

cargo metadata --no-deps --format-version 1 run from core/, package names only:

['archipelago', 'archipelago-container', 'archipelago-openwrt', 'archipelago-performance', 'archipelago-security']

models, helpers and js-engine do not appear. They are directories under core/ but are not workspace members (core/Cargo.toml:4-10), and are referenced only by each other.

Stated limitation, not an omission. core/models/src/data_url.rs:163 (let random: [u8; 10] = rand::random();) and core/models/src/procedure_name.rs:32 (Some(format!("Properties-{}", rand::random::<u64>()))) are real matches of the same shape and are outside KEY-05's reach: they are outside the clippy build graph, so no disallowed-methods entry can reach them, and they are outside this plan's files_modified. Neither draws key material (a data-URL filename component and a procedure-name suffix), and neither is compiled into the archipelago binary. They are recorded here so a future reader does not mistake "43 classified" for "43 of 45 in the repository".

The other four workspace members (container, openwrt, performance, security) contain zero matches — verified by grep -rn "rand::random\|thread_rng()" core/container core/openwrt core/performance core/security --include=*.rs, which returns nothing. So the ban, once enabled, is free for them.


Per-site classification — all 43 matches

Source of the inventory, re-run against the working tree at HEAD = c5a82cba rather than inherited from the plan or from F-10a:

grep -rn "rand::random\|thread_rng()" core/archipelago/src --include=*.rs

43 lines across 16 files (15 code files + seed.rs, whose two matches are comments).

prod/test is decided by whether the line falls inside that file's #[cfg(test)] mod tests block; the block's start line is cited in the ## cfg(test) boundaries section below and is the evidence for every test verdict.

guarded is yes only where the drawn value is key material or an AEAD nonce and the draw is at least MIN_GUARDED_LEN = 12 bytes. Every no carries its reason.

Site Expression Kind Becomes Guarded Disposition
core/archipelago/src/storage_crypto.rs:39 let nonce_bytes: [u8; 12] = rand::random(); production ChaCha20-Poly1305 nonce for the message / mesh-contact at-rest stores; the 12-byte prefix of the nonce ‖ ciphertext envelope yes (12 B, AEAD nonce — reuse is a keystream break) migrate
core/archipelago/src/credentials/store.rs:120 let nonce_bytes: [u8; 12] = rand::random(); production ChaCha20-Poly1305 nonce for the credential store, inside encrypt_credentials yes (12 B, AEAD nonce) migrate
core/archipelago/src/session.rs:156 let token_bytes: [u8; 32] = rand::random(); production full authenticated session token (SessionStore::create) yes (32 B, bearer credential) migrate
core/archipelago/src/session.rs:178 let token_bytes: [u8; 32] = rand::random(); production pending-TOTP session token (create_pending) yes (32 B) migrate
core/archipelago/src/session.rs:254 let new_token_bytes: [u8; 32] = rand::random(); production rotated token on pending→full upgrade (upgrade_to_full) yes (32 B) migrate
core/archipelago/src/session.rs:294 let new_token_bytes: [u8; 32] = rand::random(); production rotated session token (rotate) yes (32 B) migrate
core/archipelago/src/session.rs:478 rand::random::<u64>() test (mod at :471) uniquifying suffix in a temp-file path for new_for_tests no — 8 B, a filename component, not key material migrate
core/archipelago/src/session.rs:489 rand::random::<u64>() test temp-file path suffix no — as above migrate
core/archipelago/src/session.rs:498 rand::random::<u64>() test temp-file path suffix no — as above migrate
core/archipelago/src/session.rs:511 rand::random::<u64>() test temp-file path suffix no — as above migrate
core/archipelago/src/session.rs:538 rand::random::<u64>() test temp-file path suffix no — as above migrate
core/archipelago/src/session.rs:569 rand::random::<u64>() test temp-file path suffix no — as above migrate
core/archipelago/src/session.rs:584 rand::random::<u64>() test temp-file path suffix no — as above migrate
core/archipelago/src/session.rs:602 rand::random::<u64>() test temp-file path suffix no — as above migrate
core/archipelago/src/session.rs:620 rand::random::<u64>() test temp-file path suffix no — as above migrate
core/archipelago/src/session.rs:651 rand::random::<u64>() test temp-file path suffix no — as above migrate
core/archipelago/src/session.rs:669 rand::random::<u64>() test temp-file path suffix no — as above migrate
core/archipelago/src/session.rs:685 rand::random::<u64>() test temp-file path suffix no — as above migrate
core/archipelago/src/device_tokens.rs:64 let token_bytes: [u8; 32] = rand::random(); production companion-device bearer token (device_tokens::create) yes (32 B, bearer credential) migrate
core/archipelago/src/federation/invites.rs:42 rand::thread_rng().fill(&mut token_bytes); production 16-byte federation invite token, hex-encoded into the invite payload yes (16 B, unguessable-by-design token) migrate
core/archipelago/src/wallet/bdhke.rs:133 let random_bytes: [u8; 32] = rand::random(); production Cashu (NUT-00/NUT-10) proof secret — genuine ecash key material yes (32 B) migrate
core/archipelago/src/wallet/bdhke.rs:139 let mut rng = rand::thread_rng();SecretKey::new(&mut rng) production Cashu blinding factor — a secp256k1 scalar; genuine ecash key material no — deliberate non-application, see ## Deliberate non-applications of the guard migrate
core/archipelago/src/wallet/bdhke.rs:169 let k = SecretKey::new(&mut rand::thread_rng()); test (mod at :144) throwaway scalar in test_bdhke_flow no — test scalar, same rejection-sampling argument as :139 migrate
core/archipelago/src/wallet/bdhke.rs:206 let k = SecretKey::new(&mut rand::thread_rng()); test throwaway scalar no — as above migrate
core/archipelago/src/mesh/x3dh.rs:100 let spk_id: u32 = rand::random(); production SignedPrekey.id — a 4-byte identifier, not key material (the X25519 secret comes from crypto::generate_x25519_ephemeral() at :99) no — 4 B, below MIN_GUARDED_LEN; an "all bytes identical" predicate false-positives on a 4-byte draw once in 2^24 migrate
core/archipelago/src/mesh/x3dh.rs:114 let otk_id: u32 = rand::random(); production OneTimePrekey.id — 4-byte identifier; the secret comes from crypto::generate_x25519_ephemeral() at :113 no — as above migrate
core/archipelago/src/container/secrets.rs:103 rand::thread_rng().fill_bytes(&mut buf); production random_hex(bytes) — the manifest-declared generated_secrets (app passwords, API keys); the original F-10 yes when bytes >= 12 (the only production callers request 16/32); unguarded below the floor migrate
core/archipelago/src/container/secrets.rs:112 rand::thread_rng().fill_bytes(&mut buf); production random_base64(bytes) — same, for services that base64-decode to raw bytes (e.g. netbird encryptionKey) yes when bytes >= 12 migrate
core/archipelago/src/api/rpc/package/install.rs:732 let secret: [u8; 32] = rand::random(); production SearXNG server.secret_key in settings.yml — signs SearXNG's own tokens yes (32 B, app secret) migrate
core/archipelago/src/api/rpc/package/install.rs:1456 let salt_bytes: [u8; 16] = rand::random(); production rpcauth= salt for the Bitcoin Core RPC HMAC credential line yes (16 B; the salt is half the credential — a degenerate salt weakens the stored rpcauth line) migrate
core/archipelago/src/bitcoin_rpc.rs:62 let bytes: [u8; 16] = rand::random(); production (file has no #[cfg(test)] module) the Bitcoin RPC password itself, hex-encoded to 32 chars yes (16 B, credential) migrate
core/archipelago/src/api/rpc/package/pine_ha.rs:102 let raw: [u8; 32] = rand::random(); production Pine/Home-Assistant status bearer token, written 0600 under NODE_SECRETS_DIR yes (32 B, bearer credential) migrate
core/archipelago/src/api/rpc/package/pine_ha.rs:490 "entry_id": id(rand::random()), production Home Assistant config-entry id (16 B hex) — HA needs uniqueness only; not a credential and never authenticates anything no — an identifier, not key material; fails the "key material or AEAD nonce" test migrate
core/archipelago/src/api/rpc/package/pine_ha.rs:507 "subentry_id": id(rand::random()), production HA conversation subentry id no — identifier, as above migrate
core/archipelago/src/api/rpc/package/pine_ha.rs:521 "subentry_id": id(rand::random()), production HA ai_task_data subentry id no — identifier, as above migrate
core/archipelago/src/api/rpc/package/pine_ha.rs:588 let entry_id: [u8; 16] = rand::random(); production HA wyoming config-entry id no — identifier, as above migrate
core/archipelago/src/api/rpc/package/pine_ha.rs:665 let raw: [u8; 26] = rand::random(); production ULID-shaped HA id (26 Crockford-base32 chars) no — identifier, as above migrate
core/archipelago/src/api/rpc/auth.rs:125 hex::encode(rand::random::<[u8; 2]>()) production (file has no #[cfg(test)] module) 4-hex-char suffix disambiguating default-named companion-* device entries in the UI no — 2 B; an "all bytes identical" predicate false-positives once in 256, which would be worse than the defect it guards migrate
core/archipelago/src/fips/dial.rs:75 let id: u16 = rand::random(); production DNS query transaction id for the FIPS _fips lookup no — 2 B, protocol identifier; same 1-in-256 false-positive argument migrate
core/archipelago/src/transport/chunking.rs:149 let message_id: u32 = rand::random(); production chunk-frame message_id correlating Reed-Solomon shards no — 4 B, protocol identifier migrate
core/archipelago/src/totp.rs:305 let idx = (rand::random::<u8>() as usize) % charset.len(); production one character of a TOTP backup code (bcrypt-hashed before storage) no — a single byte, far below the floor; the % selection is R-12 and is deliberately untouched migrate
core/archipelago/src/seed.rs:87 /// to \&mut rand::thread_rng()` inside the `bip39` crate, so the RNG backing every` doc comment nothing — prose in the F-02 remediation rationale n/a comment
core/archipelago/src/seed.rs:681 // bip39's transitive \rand::thread_rng()` default, is the one consumed.` line comment nothing — prose inside mnemonic_generation_uses_injected_rng n/a comment

Disposition tally: migrate = 41, comment = 2, allow = 0.

There are no allow rows. Every test fixture migrates to OsRng as readily as production code does, so no site needed an exemption, and consequently no #[allow(clippy::disallowed_methods)] attribute is introduced anywhere in the crate. That is the strongest available outcome for layer (b): the ban has no holes to audit.

cfg(test) boundaries — the evidence for every prod/test verdict

File #[cfg(test)] mod tests begins Consequence
core/archipelago/src/session.rs :471 4 of 16 matches are production; 12 are test fixtures
core/archipelago/src/wallet/bdhke.rs :144 2 production, 2 test
core/archipelago/src/api/rpc/package/pine_ha.rs :979 all 6 matches are production
core/archipelago/src/mesh/x3dh.rs :292 both matches production
core/archipelago/src/container/secrets.rs :275 both matches production
core/archipelago/src/api/rpc/package/install.rs :2872 both matches production
core/archipelago/src/storage_crypto.rs :79 production
core/archipelago/src/credentials/store.rs :168 production
core/archipelago/src/device_tokens.rs :112 production
core/archipelago/src/federation/invites.rs :350 production
core/archipelago/src/totp.rs :340 production
core/archipelago/src/transport/chunking.rs :294 production
core/archipelago/src/fips/dial.rs :683 production
core/archipelago/src/seed.rs :513 :87 is above it (doc comment on a production fn); :681 is inside it
core/archipelago/src/bitcoin_rpc.rs none — the file has no #[cfg(test)] module at all (72 lines) its single match is production by construction
core/archipelago/src/api/rpc/auth.rs none — the file has no #[cfg(test)] module at all (332 lines) its single match is production by construction

Two corrections to F-10a

F-10a recorded raw match counts and said so explicitly ("the full table in §F-10a"); it declined to classify. These are resolutions of that refusal, not contradictions of it.

1. session.rs is 4 production sites, not 16. F-10a's headline table reports session.rs | 16 under a "Generates: session tokens" column. The evidence line is core/archipelago/src/session.rs:471mod tests { — above which lie exactly four matches (:156, :178, :254, :294) and below which lie twelve. The twelve below are rand::random::<u64>() used to uniquify a temp-file name in SessionStore::new_for_tests(std::env::temp_dir().join(format!("archipelago-sessions-test-{}.json", …))) — not tokens at all. (F-10a's own body text does carry the 4 prod + 12 test split; the correction is that the headline number is a raw grep count and must not be read as a production-site count.)

2. mesh/x3dh.rs's two matches are prekey identifiers, not key material. The evidence lines are core/archipelago/src/mesh/x3dh.rs:99 and :113let (spk_secret, spk_public) = crypto::generate_x25519_ephemeral(); and let (otk_secret, otk_public) = crypto::generate_x25519_ephemeral();. The X25519 secrets are produced there; :100 and :114 draw only the u32 id fields of SignedPrekey and OneTimePrekey. They remain in scope — they are values that go on the wire — but the characterisation "X3DH key agreement — key material" overstates these two specific lines. (The audit has since been corrected in place at ENTROPY-SEED-AUDIT-2026-07-31.md:508; this section records the derivation independently.)


Sealing: what it prevents and what it does not

core/archipelago/src/entropy.rs declares a private module sealed containing a trait Sealed, and

pub(crate) trait KeyGenRng: rand::RngCore + sealed::Sealed {  }

sealed::Sealed is nameable only from inside entropy, so impl KeyGenRng for MyType written anywhere else cannot compile — the required supertrait bound is unsatisfiable and unimplementable there.

What it prevents.

  • No other module of this crate can add a member to the key-generation allowlist.
  • No downstream crate can, either.
  • seed.rs::generate_mnemonic_with is typed R: KeyGenRng, so the entropy source for the entire master key hierarchy — node Ed25519 did:key, node Nostr key, FIPS mesh key, per-identity keys, the BIP-84 wallet, LND aezeed entropy, and the fleet release-root signing key — is constrained at the type level rather than by a doc comment.

What it does not prevent, stated plainly.

  • It does not prevent someone editing entropy.rs itself and adding a member. Sealing makes the allowlist a closed set that is reviewable in one file; it does not make it immutable. That is the honest limit of the mechanism.
  • It does not prevent code calling an RNG directly, bypassing the seam entirely. A new let k: [u8; 32] = rand::random(); in some unrelated module never mentions KeyGenRng and sealing has nothing to say about it. That gap is exactly what layer (b) covers. The two mechanisms are complementary, not redundant: (a) constrains what can drive a seam, (b) constrains what can be written at all.
  • The "no downstream crate" clause is vacuous today. core/archipelago is a binary-only crate — core/archipelago/Cargo.toml:8 declares [[bin]] with path = "src/main.rs" and there is no src/lib.rs, so nothing depends on it and there are no downstream crates to exclude. The clause is stated because it becomes load-bearing the day this is split into a library, not because it is doing work now.

The false CryptoRng promise is retired, not relocated

seed.rs previously carried impl rand::CryptoRng for CountingRng — a marker asserting that an ascending counter is suitable for cryptographic use. CryptoRng has no compiler-checked content: it is a promise any caller can make about any type, which is why the old bound R: rand::CryptoRng + rand::RngCore was satisfiable by a counter in the first place.

KEY-05 deletes that impl rather than moving it. After this plan the crate contains zero impl rand::CryptoRng blocks — verified comment-filtered, so prose describing the deletion can neither satisfy nor invalidate the check:

$ grep -rn "impl rand::CryptoRng" core/archipelago/src --include=*.rs \
    | grep -vE ':[0-9]+: *(//|///|\*)' | wc -l
0

There is now exactly one mechanism for the claim "this RNG may generate keys", and it is the one the compiler verifies.

Deviation from the plan: KeyGenRng::GUARD_DRAWS

The plan specified draw_key_bytes as unconditionally guarded and required generate_mnemonic_with to route through it and required the pre-existing mnemonic_generation_uses_injected_rng known-answer assertions to stay byte-identical. Those three requirements are mutually unsatisfiable, and the contradiction is not incidental: that test's RNG emits 0x00, 0x01, … 0x1f, which is the ascending-counter pattern layer (d) exists to reject. Guarding it makes the known-answer pin unrepresentable.

Resolution: KeyGenRng carries an associated constant

const GUARD_DRAWS: bool = true;

which draw_key_bytes consults. Three properties make this an acceptable seam rather than a hole:

  1. It is inside the seal. Only a type blessed in entropy.rs can set it, because only such a type can implement KeyGenRng at all.
  2. The only member that sets it false is #[cfg(test)]-gated. testing::CountingRng is not compiled into the archipelago binary, so in a production build every allowlist member is guarded. sealed_allowlist_has_one_production_member asserts <OsRng as KeyGenRng>::GUARD_DRAWS is true.
  3. The guard is still observed tripping through draw_key_bytes, not merely through the pure predicate: testing::ConstantRng keeps the default GUARD_DRAWS = true, and draw_key_bytes_rejects_and_zeroizes_a_degenerate_draw proves the full path — refusal, variant, and buffer zeroization.

The alternative — dropping the known-answer pin to satisfy the guard — would have deleted the crate's only proof that the RNG named at the call site is the one bip39 consumes. That proof is the entire point of the F-02 remediation this plan generalises.


Degenerate-entropy predicate

entropy::is_degenerate(&[u8]) -> Option<DegenerateEntropy> recognises exactly three patterns and nothing else:

Variant Predicate Why this shape
AllZero every byte is 0x00 what a buffer looks like when the fill never happened
AllIdentical every byte equals bytes[0] an uninitialised constant fill; checked after AllZero so the reported variant is the more specific one
Counter every adjacent pair satisfies b[i+1] == b[i].wrapping_add(1), or every adjacent pair satisfies b[i+1] == b[i].wrapping_sub(1) a counter PRNG standing in for a CSPRNG — the 2026-07-30 COLDCARD shape

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.

False-positive bound, computed

For a uniform random n-byte buffer (n ≥ 2):

  • P(AllIdentical) — the first byte is free, the remaining n1 must match: 256^(n1) = 2^8(n1). This already includes AllZero as a subset.
  • P(Counter) — the first byte is free, the remaining n1 are then determined; ascending and descending are disjoint for n ≥ 2 (they would require +1 ≡ 1 (mod 256)): 2 · 2^8(n1).
  • Union bound: P(degenerate) ≤ 3 · 2^8(n1).
n Bound As a probability
2 3 · 2^8 1.17 × 10⁻² — about 1 in 85
4 3 · 2^24 1.79 × 10⁻⁷ — about 1 in 5.6 million
12 (MIN_GUARDED_LEN, the ChaCha20-Poly1305 nonce width) 3 · 2^88 9.7 × 10⁻²⁷
32 (session tokens, Cashu secrets, master-seed entropy) 3 · 2^248 6.6 × 10⁻⁷⁵

Over a deliberately generous lifetime budget of 10¹² guarded draws across the whole fleet, forever, the expected number of false rejections is 9.7 × 10⁻¹⁵ at n = 12 and 6.6 × 10⁻⁶³ at n = 32. A false stop is not a risk this predicate meaningfully carries at or above the floor.

Why twelve is the floor, and why it is a panic

The n = 2 and n = 4 rows are the argument. On a 2-byte draw the predicate fires on genuine CSPRNG output about once in 85 — vastly worse than the defect it guards against. That is why draw_key_bytes panics rather than erroring on a buffer shorter than MIN_GUARDED_LEN: calling the guard where its own bound does not hold is a programmer error, not an input condition. A caller that legitimately needs fewer bytes draws from OsRng directly and unguarded, and the classification table above records every such site with its reason.

Twelve is also exactly the ChaCha20-Poly1305 nonce width, so every AEAD nonce in the crate is guardable at the floor rather than below it.

On a trip: refuse, zeroize, do not retry

draw_key_bytes zeroizes the buffer, logs the variant and the buffer length, and returns the error. There is 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.

Empirical companion

degenerate_accepts_100k_osrng_draws runs 100,000 consecutive 32-byte OsRng draws through is_degenerate and asserts every one is accepted. Given the 6.6 × 10⁻⁷⁵ bound above, a single rejection there means the predicate is wrong, not that the run was unlucky.


CSPRNG-readiness ledger

Path. <ARCHIPELAGO_DATA_DIR>/security/csprng-readiness.jsonl, with ARCHIPELAGO_DATA_DIR falling back to /var/lib/archipelago — the same resolution container/version_config.rs:36-39 uses. Resolving its own path is what lets layer (e) live entirely inside entropy.rs without touching bootstrap.rs or api/rpc/system/handlers.rs, both of which belong to plan 10-04.

Deliberately outside identity/: the KEY-02 rootfs identity sweep and backup.restore-identity operate on that directory wholesale, and neither should ever have to reason about a file that is not key material.

Schema. One JSON object per line, append-only:

{"v":1,"ts":"2026-08-02T18:04:11Z","ready":true,"event":"master-seed-generate"}
Field Meaning
v schema version — exists so a future change does not orphan lines already on fleet nodes
ts RFC 3339 UTC, second precision
ready true / false / null — the verdict seed.rs::kernel_csprng_ready() computes via getrandom(GRND_NONBLOCK); null on a non-Linux build or an unexpected errno
event which generation event this verdict belongs to; master-seed-generate from MasterSeed::generate

No entropy, no key bytes, no seed material, no mnemonic word, and no hash of any of them is ever written. A readiness ledger that carried any of those would be a new place to steal a key from, sitting one directory away from identity/. The record is a #[derive(serde::Serialize)] struct with exactly four fields rather than a json! literal, so the schema is a compile-time object that cannot drift.

readiness_record_contains_no_mnemonic_words proves this the strong way: it generates a real mnemonic through MasterSeed::generate() against a temporary data dir and asserts the ledger's alphabetic token set is a subset of the fixed schema vocabulary — from which "no mnemonic word leaked" follows, since any leaked word would be a token outside that set. The test does not do a naive substring search, and the reason is recorded in the test itself: master, seed and ready are themselves BIP-39 English words, and generate contains the BIP-39 word era as a substring (gen-era-te), so a naive check would be flaky and wrong in both directions.

Permissions. Created 0o600 via OpenOptions::mode, matching the identity-blob pattern at seed.rs and the generated-secret pattern at container/secrets.rs:207.

Best-effort, by design. Every failure path — cannot create the directory, cannot open the file, cannot write, cannot serialise — logs at warn and returns. ceremony.rs generates a master seed offline, on a machine that need not have /var/lib/archipelago at all. An audit record that could fail key generation would be an availability defect introduced by a security feature, which is not a trade worth making. readiness_record_survives_unwritable_data_dir proves this with a real unwritable path (a file where the data directory should be), not by inspection.

What it closes. MasterSeed::generate computed the readiness verdict, logged it into three branches, and then discarded it. That discard is the whole of backlog R-09: a node could never answer, after the fact, whether the kernel pool was seeded when its keys were born. It can now.

Deliberate non-applications of the guard

Layer (d) is applied at every guarded: yes row in the classification table. It is not applied at the sites below. Each is recorded with its reason rather than silently omitted, because a guard that is quietly skipped somewhere is worse than one that is openly bounded.

1. wallet/bdhke.rs — the Cashu blinding factor

random_blinding_factor migrates to an explicit OsRng but does not route through draw_key_bytes. The draw is consumed by secp256k1::SecretKey::new(&mut rng), which performs rejection sampling into the curve group order — it draws, tests the candidate against the order, and redraws on rejection. Intercepting the bytes to inspect them would mean reimplementing that sampling in Archipelago, and getting rejection sampling subtly wrong on an ecash key is a materially larger correctness risk than the guard buys against a hypothetical future RNG rebinding.

The migration is still worth doing on its own: the source is now named, which is the whole of layer (a)'s claim, and blinding_factor_is_valid_and_varies pins that successive factors are valid, in-range secp256k1 scalars and differ — so a rebinding to a constant source fails there rather than silently producing correlated ecash.

2. Short protocol identifiers — below MIN_GUARDED_LEN

Site Width Why unguarded
mesh/x3dh.rs:100, :114 4 B (u32 prekey ids) Below the floor. Not key material — the X25519 secrets come from crypto::generate_x25519_ephemeral().
transport/chunking.rs:149 4 B (u32 message id) Below the floor; a frame correlator.
fips/dial.rs:75 2 B (u16 DNS transaction id) Below the floor; AllIdentical would false-positive once in 256.
api/rpc/auth.rs:125 2 B (display-name suffix) Below the floor; same 1-in-256 argument. The actual credential is minted by device_tokens::create, which is guarded.
totp.rs:305 1 B A single byte cannot be meaningfully inspected at all.

The bound table in ## Degenerate-entropy predicate is the argument: at two bytes the predicate fires on genuine CSPRNG output about once in 85, which is a far worse defect than the one it guards against. draw_key_bytes panics below the floor precisely so that this reasoning cannot be bypassed by accident.

3. Non-credential identifiers at or above the floor

api/rpc/package/pine_ha.rs:490, :507, :521, :588 (16-byte Home Assistant config-entry and subentry ids) and :665 (a 26-byte ULID-shaped id) are long enough to guard but are not key material or AEAD nonces: Home Assistant requires only uniqueness from them and they authenticate nothing. Guarding them would widen the guard's contract from "key material" to "anything random", which makes the guarded column meaningless and puts a panic path on an app config-seeding routine for no security gain. pine_ha.rs:102 — the actual status bearer token in the same file — is guarded, which is the distinction the column exists to record.

4. Where a degenerate draw aborts rather than propagating

draw_key_bytes returns a Result, and every site whose function already returns Result propagates it: storage_crypto::seal, credentials::encrypt_credentials, device_tokens::create, federation::invites::create_invite, the two install.rs sites, and seed::generate_mnemonic_with. pine_ha.rs:102 returns Option and degrades to None with a warn!.

Four sites abort instead, and this is a deviation from the plan's "propagate rather than unwrap" instruction that needs stating:

Site Why it cannot propagate
session.rs::fresh_session_token create, create_pending and rotate return a bare String; their callers are 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 not permitted to make.
wallet/bdhke.rs::generate_secret returns Vec<u8>
bitcoin_rpc.rs::generate_random_password returns String, and its caller is a OnceCell initialiser that also returns String
container/secrets.rs::fill_secret_bytes random_hex / random_base64 return String

In every one of the four, the only two available behaviours are emit a predictable credential or refuse loudly, and only the second is defensible. Reaching the branch means the kernel CSPRNG returned 1232 bytes that are all-zero, all-identical or a ±1 counter — the machine has no usable entropy and must not be issuing credentials at all. None of the four can be driven by attacker-supplied input: the predicate reads only OsRng output. The false-trip bound is 3 · 2^88 at 12 bytes and 3 · 2^248 at 32.

Making these propagate properly is a worthwhile follow-up, but it is an API change across files this plan does not own, so it is recorded here rather than performed.

Clippy dry-run evidence

A lint config that is never observed to fail is indistinguishable from one that is misconfigured, so the ban was observed firing rather than assumed. Run from core/, 2026-08-02, clippy 1.95.0.

The ban fires

A single banned call was reintroduced into entropy.rs and clippy re-run:

warning: use of a disallowed method `rand::random`
   --> archipelago/src/entropy.rs:675:5
    |
675 |     rand::random::<u64>()
    |     ^^^^^^^^^^^^^^^^^^^
    |
    = note: KEY-05: inherits its entropy backend from a dependency default instead of
      stating it. Use rand::rngs::OsRng at the call site; for key material or AEAD
      nonces >= 12 bytes use crate::entropy::draw_key_bytes. See
      docs/security/KEY-05-ENTROPY-ENFORCEMENT.md
    = note: `#[warn(clippy::disallowed_methods)]` on by default

The reason string reaches the developer at the point of failure, which is the whole value of the reason field. Under the CI invocation's -D warnings this is an error.

The reintroduction was reverted

After git checkout core/archipelago/src/entropy.rs, the residual count is 0:

grep -rn "rand::random\|thread_rng()" core/archipelago/src --include=*.rs \
  | grep -vE ':[0-9]+: *(//|///|\*)' | wc -l
0

⚠️ The enforcement channel is currently NOT green — a finding, not a side note

Layer (b) was designed to need no CI change because the Rust job already runs cargo clippy --all-targets --all-features -- -D warnings. That reasoning is sound, but the measured state of the tree is not:

cargo clippy --all-targets --all-features emits 42 pre-existing warnings on this tree, unrelated to KEY-05 — unused import: DeviceProbe, constant ELECTRUM is never used, value assigned to last_err is never read, plus ~39 style lints (redundant_guards, manual_map, needless_return, nonminimal_bool, items_after_test_module, and others). Under -D warnings every one of them is already an error, so that CI step cannot currently pass for reasons that have nothing to do with this plan.

Consequences, stated plainly:

  1. KEY-05 layer (b) is correctly configured and proven to fire, but the gate it rides on is red for other reasons. Until those 42 are cleared, a new banned RNG call would be one error among many rather than the distinctive build-stopper the design intends.
  2. This is pre-existing and out of scope here — clearing 42 lints across the crate is its own change, and doing it immediately before an OTA would be poor sequencing.
  3. It is recorded rather than quietly absorbed, because a reader would otherwise reasonably conclude from "no CI change was needed" that the gate is live and effective. It is live; it is not yet effective.

Recommended follow-up: a dedicated lint-clearing pass, after which layer (b) becomes a real gate. Tracked in ## What this does not close.

cargo-deny evidence

Verified by the same standard — the rule was observed both passing and failing.

A. The tree as it stands passes. cargo deny check bansbans ok, exit 0.

B. The rule bites. The plan offered two demonstrations; the second was used (introducing a synthetic third rand was impractical without perturbing the lockfile). The grandfather [[bans.skip]] entry was temporarily removed and the rule fired on the existing pair, printing the full dependency trees for both versions and exiting 2:

    ├ rand v0.8.5   (direct, + archipelago-security, bip39, mainline,
    │                secp256k1, tungstenite 0.20.1)
    ├ rand v0.9.2   (totp-rs 5.7.0; tungstenite 0.26.2 via nostr-sdk)

bans FAILED

This also independently confirms F-07's account of where each version comes from.

C. Restored. The grandfather entry was put back and cargo deny check bans returns bans ok, exit 0.

cargo-deny policy

Decision (checkpoint 10-06 Task 5, human-approved 2026-08-02): bans only. advisories NOT enabled. Pinned version: cargo-deny 0.20.2.

Tool legitimacy (the required pre-step)

cargo-deny was verified on crates.io before being wired into CI:

Check Result
Publisher / repository EmbarkStudios — github.com/EmbarkStudios/cargo-deny, resolves
Homepage same as repository
Latest published version 0.20.2, published 2026-07-09
Downloads ~4,786,401 all-time; ~1,285,082 recent
Version pinned in CI 0.20.2

Disposition: legitimate, actively maintained, plausible download history for a tool of its age.

Why bans-only

R-05 / F-07 / KEY-05(c) asked for exactly one thing: fail the build when the duplicate rand majors change, "so the split is visible rather than silent". That is what shipped.

The advisories section is a materially larger, separate commitment and was declined for now, with the cost stated rather than glossed: an advisories gate fails builds when a new CVE is published against an existing dependency, with no change to this repository. On a tree where several agents commit and push continuously, an unrelated upstream disclosure would block everyone at an arbitrary hour, and the remediation is frequently a dependency bump that is itself a phase-sized change — this repo pins bip39 and bitcoin exactly, and F-07 already documents why a rand bump is not casual. No break-glass procedure exists today. That is a policy call about how the team wants to be interrupted, so it was taken by a human, not defaulted by a planner.

Mechanism

core/deny.toml uses a global multiple-versions = "allow" with a per-crate [[bans.deny]] name = "rand", deny-multiple-versions = true, plus a dated [[bans.skip]] grandfather entry pinning =0.9.2 exactly. The contract, independent of config keys:

  • the tree as it stands passes;
  • a third rand version, or a change to either member of the current pair, fails.

CI wiring, and one deliberate deviation from the plan's suggestion

The plan anticipated the EmbarkStudios/cargo-deny-action. That action was inspected and not used: it exposes no input to pin the cargo-deny version, and an unpinned supply-chain checker is a contradiction in terms — it would reintroduce, at the CI layer, exactly the "backend fixed by configuration rather than stated" failure shape this whole plan exists to remove. Instead the CI step installs the tool from crates.io at an exact version (cargo install --locked cargo-deny --version 0.20.2), which is also the source that was legitimacy-checked above, and avoids adding a second, unvetted third-party action to the workflow.

Cost of this choice, stated honestly: cargo install is slower than a prebuilt-binary action on a cold cache. The existing actions-rust-lang/setup-rust-toolchain@v1 caching mitigates it.

What this does not close

Recorded so that nothing here is mistaken for a stronger guarantee than it is.

  • F-07's advisory half remains OPEN. Bans-only was selected; there is still no dependency-advisory (CVE) gate in CI. This stays in the backlog as R-05's unfinished remainder, and adopting it needs an agreed break-glass procedure first.
  • The two rand majors are still both in the graph. This layer makes the split visible and change-detecting; it does not unify it. Unifying means bumping exactly-pinned crypto dependencies and is not in scope here.
  • F-09 / R-12 remains deferred. totp.rs still selects its charset with % charset.len(). The bias is presently zero (32 divides 256 exactly), and only the entropy source was migrated. The selection algorithm was deliberately left untouched.
  • F-11 / R-14 remains deferred.
  • core/models is outside the enforcement graph. cargo metadata --no-deps confirms the workspace members are archipelago, archipelago-container, archipelago-openwrt, archipelago-performance and archipelago-security. core/models/src/data_url.rs:163 and core/models/src/procedure_name.rs:32 are real matches of the same shape that no disallowed-methods entry can reach. This is a stated limitation, not an omission.
  • Sealing does not prevent an edit to entropy.rs itself. The allowlist is sealed against other modules adding a member; anyone editing entropy.rs can still add one. The mechanism raises the act from an invisible default to a deliberate, reviewable change to a file whose entire purpose is this guarantee — that is the honest claim, and it is not "impossible".
  • Mnemonics generated before this change came from the previous source. That source was, and remains, getrandom(2)-backed on the pinned rand 0.8.5 — so nothing already generated is suspect. This plan removes a future failure mode; it is not a remediation of past key material, and no re-generation is implied or required.
  • Layer (b)'s gate is live but not yet effective. The tree carries 42 pre-existing clippy warnings that are already errors under the CI step's -D warnings, so that step cannot pass today for reasons unrelated to KEY-05. The ban is correctly configured and proven to fire (see ## Clippy dry-run evidence), but it needs a dedicated lint-clearing pass before a new banned RNG call stands out as the distinctive build-stopper the design intends. Out of scope here.
  • The degenerate-entropy predicate is not a health check for the kernel CSPRNG. It rejects three specific catastrophic shapes at the moment of a draw. It cannot detect a subtly-biased or backdoored generator, and it is not evidence that one is absent.