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>
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 | Closed — entropy::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) | Closed — entropy::is_degenerate / entropy::draw_key_bytes, applied at every guarded: yes row below |
| (e) | Durable CSPRNG-readiness record | Task 2 | Closed — entropy::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:305is 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-14 —
Math.random()inneode-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:471 — mod 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 :113 —
let (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.)