Files
archy/docs/security/KEY-05-ENTROPY-ENFORCEMENT.md
T
archipelagoandClaude Opus 5 257ca7e6ac docs(10-06): classify all 43 defaulted-RNG call sites with file:line evidence (KEY-05, F-10a)
F-10a recorded raw grep counts and deliberately declined to classify them.
This resolves that: every one of the 43 matches under core/archipelago/src
now carries a production/test verdict (evidenced by its file's
`#[cfg(test)] mod tests` line), what the drawn value becomes, whether the
degenerate-entropy guard applies, and a disposition.

Tally: 41 migrate, 2 comment, 0 allow. No site needed an exemption, so the
crate-wide ban will have no holes to audit.

Two corrections to F-10a, each derived independently with its evidence line:
session.rs is 4 production sites not 16 (mod tests begins :471), and
mesh/x3dh.rs:100/:114 are u32 prekey identifiers, not key material -- the
X25519 secrets come from crypto::generate_x25519_ephemeral() at :99/:113.

The enforcement blast radius is pinned with `cargo metadata` output rather
than asserted: models, helpers and js-engine are not workspace members, so
the two core/models matches are outside the clippy build graph and are
recorded as a stated limitation rather than omitted.

Requirement: KEY-05. Supersedes R-13, absorbs R-05 and R-09.

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

19 KiB

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

(Filled by Task 2.)

Degenerate-entropy predicate

(Filled by Task 2.)

CSPRNG-readiness ledger

(Filled by Task 2.)

Deliberate non-applications of the guard

(Filled by Task 3.)

Clippy dry-run evidence

(Filled by Task 2; extended by Task 6 with the observed build failure and recovery.)

cargo-deny policy

(PENDING — Task 5 is a gate="blocking-human" checkpoint. Not filled.)

What this does not close

(Filled by Task 6.)