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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
96dba73a16
commit
257ca7e6ac
@@ -0,0 +1,240 @@
|
||||
# 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: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-14** — `Math.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: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.)*
|
||||
Reference in New Issue
Block a user