**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 |
| `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/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/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 |
**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
| `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 `n−1` must match:
`256^−(n−1) = 2^−8(n−1)`. This already includes `AllZero` as a subset.
- `P(Counter)` — the first byte is free, the remaining `n−1` are then determined; ascending
and descending are disjoint for `n ≥ 2` (they would require `+1 ≡ −1 (mod 256)`):
`2 · 2^−8(n−1)`.
- Union bound: `P(degenerate) ≤ 3 · 2^−8(n−1)`.
| `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 |
| `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
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`
`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. |