diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index d20a6c76..bd48625d 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -269,7 +269,7 @@ Plans: **Goal:** Every path that creates, restores, or persists node key material proves the caller is authorized and the material is per-node — closing the three exploitable findings from `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`. A node that is already onboarded must refuse to have its identity replaced; a node flashed from the shared rootfs must never share another node's host keys; and the wallet spending key must not exist in cleartext outside the encrypted envelope. **Requirements**: KEY-01 (F-01, **Critical**) `seed.generate`/`seed.restore` are unauthenticated (`api/rpc/middleware.rs:25`) and `NodeIdentity::from_seed` (`identity.rs:79`) overwrites `node_key`/`nostr_secret`/FIPS key unconditionally — one unauthenticated POST with an attacker-chosen mnemonic hijacks a live node; gate on onboarding-incomplete (the unused `identity.rs:117` `key_exists` guard) + rate-limit; KEY-02 (F-03, **High**) first-boot per-device secret regeneration is fail-open and its completion marker is set even on failure (`image-recipe/_archived/build-auto-installer-iso.sh:1647,:1659,:1663`), over a fleet-shared cached rootfs that bakes SSH host keys + the TLS key — make it fail-closed and retried; KEY-03 (F-13, **High**) the BIP-84 account **private** key is imported into Bitcoin Core's wallet (`api/rpc/bitcoin.rs:203,:229-231`), duplicating the spending key outside the encrypted envelope — move to watch-only descriptors per `docs/security/PSBT-SIGNING-ARCHITECTURE.md`; KEY-04 on-node verification of C-3/C-4/C-6 from the audit's UNVERIFIED checklist (host-key uniqueness across two real nodes, rootfs tar contents on the build host, unauthenticated LAN reachability of the RPC endpoint); KEY-05 (F-10a, **Medium**, added 2026-08-02) **a defaulted RNG cannot be inherited anywhere in the crate**. The audit's F-10 recorded this as 2 call sites; it is **41 across 15 files** (`session.rs` 16, `pine_ha.rs` 6, `wallet/bdhke.rs` 4 — *ecash key material*, `mesh/x3dh.rs` 2 — *key-agreement material*, `storage_crypto.rs` 1 — *AEAD nonce*, +10 more; full table in the audit's §F-10a). Nothing is broken today — `rand::random()`/`thread_rng()` are ChaCha12 seeded from `getrandom(2)` — but this is the exact T1 structural shape that produced the 2026-07-30 COLDCARD defect, now with key material in its blast radius. Five layers, all required: (a) **sealed allowlist trait** at key-generation seams (private supertrait, so no other module *or crate* can implement it; exactly one production impl, `OsRng`) — this also retires the `impl rand::CryptoRng for CountingRng` false promise at `seed.rs:656`; (b) **`clippy.toml` `disallowed-methods`** banning `rand::thread_rng`/`rand::random` crate-wide, so enforcement is a compile failure in CI rather than a review convention (no `clippy.toml` exists today; CI already runs clippy); (c) **`cargo-deny`** failing on duplicate `rand` majors — two coexist today, which is the mechanism by which a bump could silently rebind (absorbs R-05); (d) **degenerate-entropy runtime check** before key generation (rejects all-zero / counter-like draws — the one layer that would catch the Coldcard failure *on the device* rather than in review); (e) **persist the CSPRNG-readiness verdict** that `seed.rs:59` already computes and discards, so a node can answer after the fact "was the pool seeded when this key was born?" (absorbs R-09). Supersedes R-13 **Depends on:** Nothing (independent security work; parallelizable with Phases 1–8). **Priority override: F-01 is Critical and live on every fleet node — this phase should be planned and executed ahead of its numeric position, which reflects append order in a shared roadmap, not sequencing.** -**Plans:** 5 plans + KEY-05 unplanned (needs a 6th plan) +**Plans:** 6 plans > **EXECUTION GATE (user instruction, 2026-08-02):** do **not** begin executing this phase until > (a) the concurrent agent working Phase 1 has finished, and (b) their changes are synced and @@ -277,8 +277,9 @@ Plans: > `bitcoin.rs` and — under KEY-05 — ~15 further files across the same crate that agent is > actively committing to. Verify a clean tree and a fetched `gitea-ai/main` before starting. > -> **KEY-05 is not yet planned.** The 5 plans below predate it; a 6th plan (or a re-plan) is -> required before this phase can be considered fully covered. +> **KEY-05 is planned** as `10-06` (added 2026-08-02). The other 5 plans predate KEY-05 and +> are unchanged by it. `10-06` is wave 2 because it shares `seed.rs` with `10-05` and +> `api/rpc/auth.rs` with `10-01`; see its ``. Plans: @@ -292,6 +293,7 @@ Plans: - [ ] 10-02-PLAN.md — On-node C-6 exposure measurement, live refusal proof, and the fresh-node onboarding non-regression (KEY-01/KEY-04) — depends on 10-01 - [ ] 10-04-PLAN.md — Fleet detection of image-baked host secrets, guarded one-time rotation, and C-3 two-node verification (KEY-02/KEY-04) — depends on 10-03 +- [ ] 10-06-PLAN.md — A defaulted RNG cannot be inherited anywhere in the crate: sealed allowlist, clippy ban, cargo-deny, degenerate-entropy check, persisted CSPRNG verdict (KEY-05) — depends on 10-01 and 10-05 ### Phase 11: Wallet Experience & LND UI Parity diff --git a/.planning/phases/10-key-material-hardening/10-06-PLAN.md b/.planning/phases/10-key-material-hardening/10-06-PLAN.md new file mode 100644 index 00000000..c1816b3b --- /dev/null +++ b/.planning/phases/10-key-material-hardening/10-06-PLAN.md @@ -0,0 +1,1017 @@ +--- +phase: 10-key-material-hardening +plan: 06 +type: execute +wave: 2 +depends_on: + - 10-01 + - 10-05 +files_modified: + - core/archipelago/src/entropy.rs + - core/archipelago/src/main.rs + - core/archipelago/src/seed.rs + - core/archipelago/src/session.rs + - core/archipelago/src/storage_crypto.rs + - core/archipelago/src/credentials/store.rs + - core/archipelago/src/device_tokens.rs + - core/archipelago/src/wallet/bdhke.rs + - core/archipelago/src/mesh/x3dh.rs + - core/archipelago/src/container/secrets.rs + - core/archipelago/src/api/rpc/package/install.rs + - core/archipelago/src/api/rpc/package/pine_ha.rs + - core/archipelago/src/api/rpc/auth.rs + - core/archipelago/src/bitcoin_rpc.rs + - core/archipelago/src/totp.rs + - core/archipelago/src/transport/chunking.rs + - core/archipelago/src/federation/invites.rs + - core/archipelago/src/fips/dial.rs + - core/clippy.toml + - core/deny.toml + - .github/workflows/ci.yml + - docs/security/KEY-05-ENTROPY-ENFORCEMENT.md +autonomous: false +requirements: [KEY-05] + +must_haves: + truths: + - "A newly written call to the banned defaulted-RNG entry points anywhere in the archipelago crate fails CI, because `cargo clippy --all-targets --all-features -- -D warnings` from `core/` errors on it — proven by deliberately re-introducing one, observing the failure, and reverting (KEY-05 layer b)" + - "Every production key, nonce and token draw under `core/archipelago/src` names `rand::rngs::OsRng` at its own call site, so the entropy backend is stated by Archipelago rather than inherited from a dependency default (KEY-05 layer a, F-10a)" + - "The mnemonic generation seam accepts only RNGs on a sealed allowlist whose marker trait lives in a private module of `entropy`, so no other module of the crate — and no downstream crate, were this crate ever split into a library — can add a member (KEY-05 layer a)" + - "The crate contains zero `impl rand::CryptoRng` blocks: the false marker promise at `seed.rs:656` is retired outright and the sealed allowlist is the only mechanism in its place (KEY-05 layer a, ROADMAP: 'this also retires the false promise')" + - "Ciphertext written by `storage_crypto::seal` and by the credential store before this plan still decrypts after it — the AEAD envelope layout `nonce ‖ ciphertext` is byte-identical (CLAUDE.md: migrations never destroy data)" + - "The prekey-bundle bytes emitted by `mesh/x3dh.rs` and the blinded-key-exchange values emitted by `wallet/bdhke.rs` are structurally unchanged: no wire field, derivation or ordering differs from before the migration" + - "A degenerate draw — all-zero, all-identical-bytes, or a wrapping ±1 counter — is refused before it can become key material or an AEAD nonce, and 100,000 genuine `OsRng` draws are all accepted (KEY-05 layer d)" + - "`cargo deny check bans` exits 0 against the tree as it stands and exits non-zero when a third `rand` version enters the dependency graph, so the existing 0.8.5/0.9.2 split is grandfathered and visible rather than silent (KEY-05 layer c, absorbs R-05/F-07)" + - "A node can answer from an on-disk record whether the kernel CSPRNG was seeded at the moment its master seed was generated, and that record contains no entropy, no key bytes and no seed material (KEY-05 layer e, absorbs R-09)" + - "The plan's enforcement is never enabled ahead of its migration: no commit in this plan adds `core/clippy.toml` while any unmigrated call site remains, so CI stays green for every other agent working this shared tree at every intermediate commit" + artifacts: + - path: "core/archipelago/src/entropy.rs" + provides: "The sealed key-generation RNG allowlist, the degenerate-entropy predicate, the CSPRNG-readiness ledger, and their tests" + contains: "KeyGenRng" + min_lines: 220 + - path: "core/clippy.toml" + provides: "The crate-wide compile-time ban on the defaulted-RNG entry points" + contains: "disallowed-methods" + - path: "core/deny.toml" + provides: "The duplicate-`rand`-major bans rule with its dated grandfather entry" + contains: "bans" + - path: ".github/workflows/ci.yml" + provides: "The supply-chain job step that runs cargo-deny at a pinned version" + contains: "cargo-deny" + - path: "docs/security/KEY-05-ENTROPY-ENFORCEMENT.md" + provides: "The per-site production/test classification table with file:line evidence, the degenerate predicate's false-positive argument, the sealing honesty statement, the cargo-deny policy decision record, and the captured clippy dry-run output" + contains: "F-10a" + min_lines: 150 + - path: "core/archipelago/src/seed.rs" + provides: "The mnemonic seam retyped to the sealed allowlist, with the relocated deterministic test RNG and the readiness-ledger call" + contains: "KeyGenRng" + key_links: + - from: "core/archipelago/src/seed.rs" + to: "core/archipelago/src/entropy.rs" + via: "generate_mnemonic_with is generic over the sealed KeyGenRng, so the master key hierarchy's entropy source is allowlist-constrained at the type level" + pattern: "KeyGenRng" + - from: "core/clippy.toml" + to: ".github/workflows/ci.yml" + via: "the existing clippy step already runs with -D warnings from core/, so a disallowed-methods hit is a build failure with no CI change required for layer b" + pattern: "disallowed-methods" + - from: "docs/security/KEY-05-ENTROPY-ENFORCEMENT.md" + to: "docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md" + via: "the classification table is the per-site resolution of F-10a's deliberately-unclassified raw match counts" + pattern: "F-10a" + - from: "core/archipelago/src/entropy.rs" + to: "core/archipelago/src/storage_crypto.rs" + via: "the AEAD nonce is drawn through the guarded helper, which is the mitigation for T-10-06-02" + pattern: "draw_key_bytes" +--- + + +Make it structurally impossible for a defaulted RNG to be inherited anywhere in the +`archipelago` crate, closing F-10a and superseding R-13 / absorbing R-05 and R-09. + +Purpose: `rand::random()` and `thread_rng()` are backed by ChaCha12 seeded from +`getrandom(2)` on the pinned `rand 0.8.5`, so **nothing in the F-10a table is broken +today**. What this plan removes is the *future* failure mode: 41 call sites across 15 +files — including Cashu blinded-key-exchange values, X3DH prekey material and a +ChaCha20-Poly1305 nonce — whose entropy backend is fixed by dependency and build +configuration rather than stated at the calling code, with no compile error if it +changes. That is the exact structural shape ("T1") that produced the 2026-07-30 +COLDCARD entropy defect, now with key material in its blast radius. + +Output: a sealed key-generation RNG allowlist, a crate-wide compile-time ban enforced +by the CI clippy step that already exists, a `cargo-deny` bans rule making the +`rand` major split visible, a degenerate-entropy runtime check, a durable +CSPRNG-readiness record, and the evidence document that classifies every call site. + + + +## EXECUTION GATE — do not begin executing this plan + +**User instruction, 2026-08-02.** Execution of Phase 10 is blocked until: + +1. the concurrent agent working **Phase 1** has finished, and +2. their changes are **synced and accounted for** — a clean working tree and a fetched + `gitea-ai/main`. + +This plan is a **planning artifact only** until that gate lifts. The gate matters more +for `10-06` than for any other plan in the phase: `10-06` edits eighteen files across +the same crate that agent is actively committing to, and at least four of them +(`container/secrets.rs`, `api/rpc/package/install.rs`, `api/rpc/package/config.rs`, +`api/rpc/package/dependencies.rs`) carried that agent's **uncommitted** changes when +this plan was written. The audit itself deferred R-13 for exactly this reason +(`ENTROPY-SEED-AUDIT-2026-07-31.md`, "Explicitly NOT implemented": *"blocked purely by +tree hygiene"*). + +Standing rules for the whole plan: + +- Stage explicitly by path (`git add `). **Never** `git add -A` or `git commit -a`. +- **No `git push`, no tag, no deploy** from this plan. +- Rust workspace root is `core/`; run `cargo` from there. +- On `rust-lld: undefined hidden symbol`, rebuild with `CARGO_INCREMENTAL=0`. +- A full `cargo test -p archipelago` contends with the other agent's builds. Prefer the + **targeted** invocations named in each task's ``; run the full suite once, at + the end, in Task 6. + + + +## Why this plan carries six tasks + +GSD's standard budget is 2–3 tasks per plan. This plan deliberately exceeds it, for two +reasons that are recorded here rather than left for a reviewer to rediscover: + +1. **The orchestrator mandated exactly one new plan** for KEY-05 (`10-06`), with + `10-01`..`10-05` committed and final. Splitting into `10-06`/`10-07` was not available. +2. **CI atomicity.** KEY-05 layer (b) turns the ban into a build failure. Splitting the + migration across plans that could interleave with other agents' commits would leave a + window in which `core/clippy.toml` exists while unmigrated sites remain — which breaks + the build for everyone on this tree. The task ordering below is the mitigation and it + only works if the tasks stay in one sequential plan. + +Tasks 3 and 4 are the two halves of the bulk migration, split so the crypto-consequential +files (Task 3) get their own context and their own round-trip proofs, rather than being +diluted among fifteen one-line swaps. + + + +## KEY-05 versus 10-CONTEXT.md + +`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; this plan implements it. + +Nothing else in `10-CONTEXT.md` is contradicted. In particular this plan honours: + +- **D-11 / the phase's shipping discipline** — 10-06 is independently revertible; it + changes no API contract, no wire format and no persisted envelope. +- The deferral of **F-09 / R-12** (TOTP modulo bias) stands. `totp.rs:305` is migrated + for its *entropy source* only; the `% charset.len()` selection is left exactly as it + is. See Task 4's trap note — this is the single easiest way to accidentally execute a + deferred item while doing KEY-05. +- The deferral of **F-11 / R-14** stands: no `neode-ui` file is touched by this plan. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/10-key-material-hardening/10-CONTEXT.md +@CLAUDE.md +@docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md +@core/archipelago/src/seed.rs +@.github/workflows/ci.yml + + + +## Facts established before planning — do not re-derive + +Verified against the working tree on 2026-08-02. Cited so tasks can start from evidence +rather than from a grep. + +**Crate shape.** `core/archipelago` is a **binary-only** crate: `Cargo.toml:8` declares +`[[bin]]` with `path = "src/main.rs"`; there is no `src/lib.rs`. Module declarations +therefore live in `src/main.rs` (see `src/main.rs:70` `pub mod seed;`, `:72` `mod session;`, +`:75` `mod storage_crypto;`, `:79` `mod totp;`). Unit tests are inline +`#[cfg(test)] mod tests` blocks. **Consequence for sealing:** the "no downstream crate can +implement it" clause is *vacuously* true today because nothing depends on this crate. Say +so in the doc; do not claim a guarantee the crate shape does not currently exercise. + +**Enforcement blast radius.** CI runs clippy with `working-directory: core` +(`.github/workflows/ci.yml:19`) and `cargo clippy --all-targets --all-features -- -D warnings` +(`:35`). The workspace members are `archipelago`, `container`, `openwrt`, `performance`, +`security` (`core/Cargo.toml:4-10`). `core/models`, `core/helpers` and `core/js-engine` are +**not** members and are referenced only by each other, so they are outside the clippy build +graph — even though `core/models/src/data_url.rs:163` and +`core/models/src/procedure_name.rs:32` both contain matches. Task 1 must confirm this with a +command rather than inherit the claim. + +**No enforcement exists today.** There is no `clippy.toml` and no `deny.toml` anywhere in +the repo, and no `#[allow(clippy::disallowed_methods)]` anywhere in `core/`. + +**Raw match inventory** (`grep -rn "rand::random\|thread_rng()" core/archipelago/src --include=*.rs`) += **43** lines, of which `seed.rs:87` and `seed.rs:671` are `///`/`//` comment lines inside +the already-remediated F-02 file, leaving **41 code matches across 15 files** — matching +F-10a exactly. Line-level inventory, for Task 1 to classify (**not** to trust as final — +`container/secrets.rs` line numbers have already drifted from the audit's `:90-93`/`:98-102` +to `:103`/`:112` because of the concurrent agent's uncommitted edits): + +| File | Lines | `#[cfg(test)]` begins | +|---|---|---| +| `session.rs` | 156, 178, 254, 294, 478, 489, 498, 511, 538, 569, 584, 602, 620, 651, 669, 685 | 470 | +| `api/rpc/package/pine_ha.rs` | 102, 490, 507, 521, 588, 665 | 978 | +| `wallet/bdhke.rs` | 133, 139, 169, 206 | 143 | +| `mesh/x3dh.rs` | 100, 114 | 291 | +| `container/secrets.rs` | 103, 112 | 274 | +| `api/rpc/package/install.rs` | 732, 1456 | 2871 | +| `storage_crypto.rs` | 39 | — | +| `credentials/store.rs` | 69 | — | +| `device_tokens.rs` | 64 | — | +| `federation/invites.rs` | 42 | — | +| `bitcoin_rpc.rs` | 62 | — | +| `totp.rs` | 305 | — | +| `transport/chunking.rs` | 149 | — | +| `fips/dial.rs` | 75 | — | +| `api/rpc/auth.rs` | 125 | — | + +**Two findings that fall out of the `#[cfg(test)]` column and that F-10a deliberately did +not assert** — Task 1 must reach them independently and record the evidence, but they are +noted here so the plan is honest about what it expects: + +- `session.rs`'s 16 matches are **not** 16 production sites. The `#[cfg(test)]` module + begins at `:470`, so twelve of them are test fixtures. F-10a's headline count is a raw + match count by design. +- `mesh/x3dh.rs:100` and `:114` are `let spk_id: u32 = rand::random();` and + `let otk_id: u32 = rand::random();` — **prekey identifiers**, not key material. The + actual X25519 secrets come from `crypto::generate_x25519_ephemeral()`. They are still + in scope (they are values that go on the wire), but F-10a's characterisation of this file + as "key material" overstates these two specific lines. Record the correction. + +**Call-site shapes already read** (so no task needs to re-derive them): +`storage_crypto.rs:39` — `let nonce_bytes: [u8; 12] = rand::random();` inside `seal()`, +whose envelope is `nonce ‖ ciphertext` with a 12-byte prefix (`:47-50`), decrypted by +`open()` splitting at 12 (`:58`). +`credentials/store.rs:69` — `let nonce_bytes: [u8; 12] = rand::random();`. +`wallet/bdhke.rs:133` — `let random_bytes: [u8; 32] = rand::random();` in `generate_secret()`; +`:139` — `let mut rng = rand::thread_rng();` immediately consumed by +`SecretKey::new(&mut rng)` in `random_blinding_factor()` — the one genuine +generic-over-RNG key-generation seam outside `seed.rs`. +`container/secrets.rs:103`/`:112` — `rand::thread_rng().fill_bytes(&mut buf)` in +`random_hex()` / `random_base64()`. +`federation/invites.rs:42` — `rand::thread_rng().fill(&mut token_bytes)` (needs the +`rand::Rng` trait in scope; `OsRng` satisfies it by blanket impl). +`totp.rs:305` — `let idx = (rand::random::() as usize) % charset.len();`. +`api/rpc/auth.rs:125` — `hex::encode(rand::random::<[u8; 2]>())`, a 2-byte display-name +suffix, not key material. +`fips/dial.rs:75` — `let id: u16 = rand::random();`. +`transport/chunking.rs:149` — `let message_id: u32 = rand::random();`. + +**The seam being generalised** (`seed.rs`): +`kernel_csprng_ready()` at `:59` returns `Option`; `MasterSeed::generate()` at `:106` +logs that verdict at `:107-113` and then **discards** it — that discard is what layer (e) +closes. `generate_mnemonic_with` at `:97` calls +`bip39::Mnemonic::generate_in_with(rng, Language::English, 24)` at `:100`. The false marker +`impl rand::CryptoRng for CountingRng` is at `:656`, inside the inline test module. +`MasterSeed::generate()`'s only non-test callers are `api/rpc/seed_rpc.rs:118` and +`ceremony.rs:71` — **the ceremony caller is why the readiness ledger must be best-effort** +(it runs offline, possibly with no `/var/lib/archipelago` at all). + +**The equivalence proof this plan leans on already exists in the repo.** +`seed.rs:672-678` asserts that `generate_mnemonic_with(CountingRng(0))` equals +`bip39::Mnemonic::from_entropy(&(0u8..32).collect())` exactly. That existing assertion is +the proof that "fill 32 bytes then `from_entropy`" and `generate_in_with` are the same +function. Task 2 relies on it and must keep it passing verbatim. + +**Persisted-path precedent.** `container/version_config.rs:37-38` resolves its base from +`ARCHIPELAGO_DATA_DIR` with a `/var/lib/archipelago` fallback, and `:166` documents that +this is process-global and how its tests handle that. The readiness ledger follows this +precedent, which is what lets it live entirely inside `entropy.rs` **without** modifying +`api/rpc/seed_rpc.rs` (a file `10-01` owns). + +**0600 precedent.** `seed.rs:345` sets `Permissions::from_mode(0o600)` on identity blobs; +`container/secrets.rs:207` does the same for generated secrets and `:269`/`:307` are tests +asserting it. The ledger reuses this pattern. + + + +## Collision check against 10-01 … 10-05 + +Derived from each plan's `files_modified` frontmatter. + +| Plan | Wave | Overlap with 10-06 | Resolution | +|---|---|---|---| +| `10-01` | 1 | `core/archipelago/src/api/rpc/auth.rs` | `depends_on: 10-01`. 10-06 runs in a later wave; it touches only line `:125` (a display-name suffix), far from 10-01's onboarding-gate work. | +| `10-05` | 1 | `core/archipelago/src/seed.rs` | `depends_on: 10-05`. 10-05 deletes `derive_bitcoin_xprv` (`seed.rs:225-250`); 10-06 edits `:59`, `:97-102`, `:106-120` and the test module at `:620-700`. Disjoint regions, but same file — sequenced, not parallelised. | +| `10-02` | 2 | none (`scripts/`, `docs/`) | Safe same-wave sibling. | +| `10-03` | 1 | none (`image-recipe/`, `tests/`, `docs/`) | Safe. | +| `10-04` | 2 | none — but note it owns `core/archipelago/src/bootstrap.rs` and `core/archipelago/src/api/rpc/system/handlers.rs` | **10-06 must not touch either file.** This is the reason the readiness ledger resolves its own path from `ARCHIPELAGO_DATA_DIR` instead of being wired through a bootstrap or system handler. | + +Wave assignment: `max(wave(10-01), wave(10-05)) + 1 = 2`. Same-wave siblings `10-02` and +`10-04` share zero files with `10-06`. + +**Re-check at execution time.** `container/secrets.rs`, `api/rpc/package/install.rs`, +`api/rpc/package/config.rs` and `api/rpc/package/dependencies.rs` carried a concurrent +agent's uncommitted changes when this plan was written. Tasks 3 and 4 carry explicit +`` assertions on tree cleanliness for exactly this reason. + + + + + + Task 1: Classify all 43 defaulted-RNG matches with file:line evidence, and pin the enforcement blast radius + docs/security/KEY-05-ENTROPY-ENFORCEMENT.md + + - docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md — §F-10a (the raw table and its explicit refusal to classify), F-02, F-07, F-09, F-10, and backlog rows R-05, R-09, R-13, R-16 + - .planning/ROADMAP.md — the KEY-05 requirement sentence, which names layers (a)–(e) + - core/Cargo.toml — the `members` list + - .github/workflows/ci.yml — the clippy step and its working directory + - Every file named in this plan's `` inventory table, at the listed line numbers plus surrounding context + + +Produce `docs/security/KEY-05-ENTROPY-ENFORCEMENT.md` as the evidence document this whole +plan is judged against. It contains no code changes. + +Re-run the inventory grep yourself and work from **your** output, not from this plan's +table — the concurrent agent may have moved lines again. For every match, record a row: +`file:line` | the exact source expression | `production` or `test` (decided by whether the +line falls inside that file's `#[cfg(test)]` module, with the module's start line cited as +the evidence) | what the drawn value becomes | `guarded` yes/no | `disposition`. + +`disposition` is one of exactly three values: + - `migrate` — the call site is rewritten to name `rand::rngs::OsRng` (the expected + disposition for nearly every row, test rows included); + - `allow` — the call site stays as it is and receives `#[allow(clippy::disallowed_methods)]` + with a justification comment on the line above. A justification must state *why the + default is required here*, not merely that the site is test code. "It is a test" is not + a justification: test fixtures migrate to `OsRng` as readily as production code does. + Record a per-row reason or do not use this disposition; + - `comment` — the match is inside a `//` or `///` line and is not a call at all (expected + for the two `seed.rs` rows). + +`guarded` marks whether the drawn bytes will additionally run the degenerate-entropy +predicate built in Task 2. Set `guarded: yes` only where the value is **key material or an +AEAD nonce** and the draw is **at least 12 bytes**. Set `guarded: no` with a stated reason +for short protocol identifiers — record explicitly that `mesh/x3dh.rs:100`/`:114` (u32 +prekey ids), `transport/chunking.rs:149` (u32 message id), `fips/dial.rs:75` (u16 dial id) +and `api/rpc/auth.rs:125` (2-byte display-name suffix) are `guarded: no` because a +predicate that rejects "all bytes identical" would false-positive on a 2-byte draw once in +256, which would be worse than the defect it guards against. + +Record two corrections to F-10a, each with the evidence line that establishes it: the +`session.rs` production-versus-test split, and the fact that the two `mesh/x3dh.rs` sites +are prekey identifiers rather than the key material F-10a's table column implies. F-10a +declined to classify on purpose; these are resolutions of that, not contradictions of it. + +Pin the enforcement blast radius mechanically rather than by assertion. Run +`cargo metadata --no-deps --format-version 1` from `core/` and list the package names it +returns; state whether `models`, `helpers` and `js-engine` appear. If they do not, record +that `core/models/src/data_url.rs:163` and `core/models/src/procedure_name.rs:32` are +outside the clippy build graph and therefore outside KEY-05's reach, and say so plainly as +a limitation rather than omitting them. If they *do* appear, add their rows to the +classification table and to this plan's migration scope. + +Add a `## Layer coverage` section mapping ROADMAP KEY-05 layers (a)–(e) to the tasks in +this plan, so a reader can see none was dropped. + +Add a `## Source precedence` note recording that KEY-05 postdates `10-CONTEXT.md` and +supersedes its deferral of R-05 and R-13, while F-09/R-12 and F-11/R-14 stay deferred. + +Leave placeholder headings, each marked as pending, for the sections later tasks fill: +`## Sealing: what it prevents and what it does not`, `## Degenerate-entropy predicate`, +`## CSPRNG-readiness ledger`, `## Clippy dry-run evidence`, `## cargo-deny policy`. + + + test -f docs/security/KEY-05-ENTROPY-ENFORCEMENT.md && N=$(grep -cE '^\| *`?core/(archipelago|models)/src/[^|]+:[0-9]+' docs/security/KEY-05-ENTROPY-ENFORCEMENT.md) && echo "classified rows: $N" && [ "$N" -ge 43 ] + + + - `docs/security/KEY-05-ENTROPY-ENFORCEMENT.md` exists and contains at least 43 table rows whose first cell is a `core/.../*.rs:NNN` reference. + - Every row carries all six columns; no cell is empty or reads "TBD". + - Every row's disposition is exactly one of `migrate`, `allow`, `comment`. + - Every `allow` row (if any) carries a written reason that is not solely "test code". + - The document names, for each of the 15 files, the line at which its `#[cfg(test)]` module begins, or states that the file has none. + - The document contains a `## Layer coverage` section that names layers (a), (b), (c), (d) and (e) and maps each to a task number in this plan. + - The document states, with `cargo metadata` output as the evidence, whether `models` is inside or outside the clippy build graph, and records the two `core/models` matches either as in-scope rows or as a stated limitation. + - The document records the `session.rs` production/test split and the `mesh/x3dh.rs` prekey-identifier correction, each citing the line that establishes it. + - No `.rs` file is modified by this task: `git status --porcelain -- 'core/**/*.rs'` prints nothing attributable to this task. + + Every one of the 43 matches has a derived, evidence-backed disposition, and the set of files this plan will actually edit is known rather than assumed. + + + + Task 2: TRACER — the sealed allowlist, the degenerate check and the readiness ledger, proven end-to-end through the mnemonic seam + core/archipelago/src/entropy.rs, core/archipelago/src/main.rs, core/archipelago/src/seed.rs, docs/security/KEY-05-ENTROPY-ENFORCEMENT.md + + - core/archipelago/src/seed.rs — all of it, but especially `:52-80` (`kernel_csprng_ready`), `:82-102` (`generate_mnemonic_with` and its doc comment), `:104-120` (`MasterSeed::generate`), `:326-350` (`write_identity_blob` and the 0600 pattern), and `:620-700` (the inline test module, `CountingRng`, and `mnemonic_generation_uses_injected_rng`) + - core/archipelago/src/main.rs:60-90 — the module declaration block + - core/archipelago/src/container/version_config.rs:30-45 and :160-185 — the `ARCHIPELAGO_DATA_DIR` resolution precedent and its test-isolation note + - docs/security/KEY-05-ENTROPY-ENFORCEMENT.md — Task 1's classification, especially which rows are `guarded: yes` + - docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md §7 — the F-02 remediation this generalises, and the exact guarantee its test provides + + + - `KeyGenRng` has exactly one member outside test configuration, `rand::rngs::OsRng`; a type declared in any other module cannot become a member because the supertrait it requires is unnameable there. + - `draw_key_bytes` fills a caller-supplied slice from a `KeyGenRng` and returns an error rather than bytes when the drawn buffer is all-zero, all-identical, or a wrapping ±1 counter. + - `draw_key_bytes` panics if handed a slice shorter than the guarded minimum, because the predicate's false-positive argument does not hold below it. + - 100,000 consecutive genuine `OsRng` draws of 32 bytes are all accepted. + - `generate_mnemonic_with` driven by the deterministic counter RNG still produces the identical 24-word known-answer list it produced before this task. + - Recording a readiness verdict appends one line to the ledger, creates the file 0600, never truncates an existing ledger, and returns `Ok` even when the directory cannot be created. + - A recorded ledger line contains only a schema version, a timestamp, a verdict and an event name — the words of a freshly generated mnemonic appear nowhere in the file. + + +This is the vertical slice: it wires the sealed allowlist, the degenerate predicate and the +readiness ledger through the single highest-consequence key-generation path in the codebase +(the master mnemonic) and proves the crate-wide gate will bite — **without** committing the +gate, so CI stays green for every other agent. + +Create `core/archipelago/src/entropy.rs` and declare `mod entropy;` in `src/main.rs` +alongside the existing declarations. + +**Layer (a) — the sealed allowlist.** Inside `entropy`, declare a **private** module named +`sealed` containing a trait `Sealed`. Declare `pub(crate) trait KeyGenRng: rand::RngCore + sealed::Sealed {}`. +Provide exactly one non-test pair of impls, both for `rand::rngs::OsRng`. Because `sealed` +is private to `entropy`, `sealed::Sealed` is unnameable from any other module, so no other +module of the crate — and no downstream crate, were this binary crate ever split into a +library — can add a member. + +Do **not** give `KeyGenRng` a `rand::CryptoRng` supertrait. That is deliberate and it is +what lets this task retire the false promise: see the seam rewrite below. + +Under `#[cfg(test)]`, add `pub(crate) mod testing` inside `entropy` containing the +deterministic `CountingRng` moved verbatim from `seed.rs:628-654` (same wrapping-add-1 +`fill_bytes` behaviour, same emitted byte sequence), with `Sealed` and `KeyGenRng` impls. +**Delete `impl rand::CryptoRng for CountingRng` (`seed.rs:656`) rather than moving it.** +After this task the crate contains no `impl rand::CryptoRng` block at all: sealed membership +is the only mechanism, so there is exactly one way to make the claim and it is checked by +the compiler. + +**Layer (d) — the degenerate-entropy predicate.** Define `const MIN_GUARDED_LEN: usize = 12;` +and an error type `DegenerateEntropy` with exactly three variants: `AllZero`, +`AllIdentical`, `Counter`. Define `fn is_degenerate(bytes: &[u8]) -> Option` +implementing exactly these three predicates and no others: + + - `AllZero` — every byte equals 0; + - `AllIdentical` — every byte equals `bytes[0]` (this subsumes `AllZero`; check `AllZero` + first so the reported variant is the more specific one); + - `Counter` — for every adjacent pair, `bytes[i+1] == bytes[i].wrapping_add(1)`, or for + every adjacent pair `bytes[i+1] == bytes[i].wrapping_sub(1)`. + +Nothing heuristic. No entropy estimator, no chi-squared, no "looks non-random" test — a +predicate that cannot be reasoned about exactly cannot be argued safe, and rejecting genuine +CSPRNG output is strictly worse than the defect being guarded. + +Define `pub(crate) fn draw_key_bytes(rng: &mut impl KeyGenRng, out: &mut [u8]) -> Result<(), DegenerateEntropy>` +which asserts `out.len() >= MIN_GUARDED_LEN` (a panic here is a programmer error, not an +input condition — it must be impossible to call the guard on a buffer too short for its +false-positive argument to hold), fills `out` from `rng`, runs `is_degenerate`, and on a +trip **zeroizes `out`, returns the error, and does not retry**. A retry would paper over a +genuinely broken RNG, which is the failure mode this layer exists to surface. The error path +must log which variant tripped and the buffer length and **must not log the bytes**. + +**Layer (e) — the readiness ledger.** Add `fn readiness_ledger_path() -> std::path::PathBuf` +resolving `ARCHIPELAGO_DATA_DIR` with a `/var/lib/archipelago` fallback, per +`container/version_config.rs:37-38`, and joining `security/csprng-readiness.jsonl`. It must +resolve **outside** `identity/`, so the KEY-02 rootfs identity sweep and +`backup.restore-identity` never have to reason about it. Add +`pub(crate) fn record_csprng_readiness(ready: Option, event: &str)` which appends one +JSON line `{"v":1,"ts":,"ready":,"event":}` and nothing +else, creating parent directories and setting mode `0o600` on creation per the `seed.rs:345` +pattern. It is **best-effort**: every failure path logs at warn and returns, because +`ceremony.rs:71` calls `MasterSeed::generate()` offline where no data directory need exist, +and a ledger write must never be able to fail key generation. `v` exists so a future schema +change does not orphan lines already on fleet nodes. + +**Wire the slice through `seed.rs`.** Change `generate_mnemonic_with`'s bound from +`R: rand::CryptoRng + rand::RngCore` to `R: crate::entropy::KeyGenRng`. Change its body to +draw 32 bytes into a local buffer via `entropy::draw_key_bytes`, build the mnemonic with +`bip39::Mnemonic::from_entropy`, and zeroize the buffer before returning. This replaces +`bip39::Mnemonic::generate_in_with` — and it is safe to do so because the repo's own +existing assertion at `seed.rs:672-678` already proves the two are the same function for the +same RNG output. Extend the existing doc comment to record that the entropy is now +inspectable at this seam, which is what layer (d) needs, and that the RNG type is +allowlist-constrained rather than merely documented. + +In `MasterSeed::generate()`, keep the existing `kernel_csprng_ready()` call and its three log +arms exactly as they are, and additionally pass the verdict to `record_csprng_readiness` with +event `master-seed-generate`. The verdict is currently computed and thrown away; that discard +is the whole of R-09. + +Update the two `seed.rs` comment lines that name the old bip39 default (`:87`, `:671`) only +so far as they remain accurate; do not delete the F-02 rationale. + +**Prove the gate will bite, without committing it.** Write `core/clippy.toml` containing a +`disallowed-methods` array with entries for `rand::thread_rng` and `rand::random`, each with +a `reason` pointing at KEY-05 and F-10a. Run `cargo clippy -p archipelago --all-targets` +from `core/` **without** `-D warnings`. Capture the output. Confirm it reports the lint on +the not-yet-migrated files and **zero** occurrences in `seed.rs` and `entropy.rs`. Paste the +per-file counts into the document's `## Clippy dry-run evidence` section. Then **delete +`core/clippy.toml`** before committing. Task 6 recreates it. Committing it now would turn +CI red — `-D warnings` plus `--all-targets` makes every remaining site, test code included, +a build failure for every other agent on this tree. + +Fill the document's `## Sealing: what it prevents and what it does not` section. State +plainly: sealing prevents any other module, and any downstream crate, from adding an +allowlist member; it does **not** prevent someone editing `entropy.rs` itself and adding +one, and it does not prevent code from calling an RNG directly without going through the +seam — that second gap is what layer (b) covers, and the two mechanisms are complementary +rather than redundant. Also state that the "downstream crate" clause is vacuous while +`core/archipelago` remains a binary-only crate. + +Fill `## Degenerate-entropy predicate` with the three predicates and the false-positive +argument computed explicitly for both `n = 12` and `n = 32` against a uniform source, plus +the resulting expected-false-positive figure over a generous lifetime draw count. Fill +`## CSPRNG-readiness ledger` with the path, the exact JSON schema, the 0600 and +best-effort properties, and an explicit statement that no entropy, key bytes or seed +material is ever written. + + + cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago entropy:: -- --nocapture && CARGO_INCREMENTAL=0 cargo test -p archipelago seed:: + + + - `cargo test -p archipelago entropy::` and `cargo test -p archipelago seed::` both pass with zero failures. + - The pre-existing `mnemonic_generation_uses_injected_rng` test still passes and its three assertions — `word_count() == 24`, equality with `bip39::Mnemonic::from_entropy` of the injected bytes, and the literal 24-word known-answer string — are byte-identical to their pre-task form. The only permitted edit to that test is the import path of the deterministic RNG. + - Comment-filtered, no `rand::CryptoRng` implementation remains anywhere in the crate: `grep -rn "impl rand::CryptoRng" core/archipelago/src --include=*.rs | grep -vE ':[0-9]+: *(//|///|\*)' | wc -l` returns 0. The comment filter is required — prose describing this deletion must not be able to satisfy or invalidate the gate. + - `grep -c "sealed" core/archipelago/src/entropy.rs` is non-zero, and the `sealed` module is declared without `pub`, verified by the absence of any `pub mod sealed` or `pub(crate) mod sealed` in the file. + - Exactly one non-test `impl KeyGenRng for` line exists in `entropy.rs`, and its type is `rand::rngs::OsRng`. Count with comment lines stripped: `grep -n 'impl KeyGenRng for' core/archipelago/src/entropy.rs | grep -vE ':[0-9]+: *(//|///)' | wc -l` equals 2 (one production, one `#[cfg(test)]`-gated). + - `grep -rn "KeyGenRng" core/archipelago/src --include=*.rs` shows `entropy.rs` and `seed.rs` only. + - A test named for the 100,000-draw batch exists and passes: 100,000 consecutive 32-byte `OsRng` draws produce zero `DegenerateEntropy` results. + - Tests exist and pass that a hand-built all-zero buffer yields `AllZero`, a hand-built all-`0xAB` buffer yields `AllIdentical`, an ascending wrapping counter yields `Counter`, and a descending one yields `Counter`. + - A test exists and passes that `draw_key_bytes` panics on a slice of length `MIN_GUARDED_LEN - 1`. + - A test exists and passes that, after `record_csprng_readiness` runs against a temporary `ARCHIPELAGO_DATA_DIR`, the ledger file mode is `0o600`, it contains exactly one line, that line parses as JSON with keys `v`, `ts`, `ready`, `event` and no others, and a second call appends rather than truncates. + - A test exists and passes asserting that none of the 24 words of a freshly generated mnemonic appears anywhere in the ledger file contents. + - `record_csprng_readiness` returns normally when `ARCHIPELAGO_DATA_DIR` points at an unwritable path — proven by a test, not by inspection. + - `git status --porcelain core/clippy.toml` prints nothing: the dry-run config is not committed by this task. + - `docs/security/KEY-05-ENTROPY-ENFORCEMENT.md` contains per-file lint counts from the dry run under `## Clippy dry-run evidence`, showing zero for `seed.rs` and `entropy.rs` and non-zero for at least ten other files. + - The document's false-positive argument gives an explicit numeric bound for both `n = 12` and `n = 32`. + - The document states that sealing does not prevent an edit to `entropy.rs` itself, and that the downstream-crate clause is vacuous for a binary-only crate. + + Rewriting `generate_mnemonic_with` touches the single most consequential function in the codebase. It is git-revertible and changes no persisted format, but a mistake here silently changes every key the fleet will ever derive — which is why the acceptance criteria pin the pre-existing known-answer assertions byte-for-byte rather than merely requiring the test to pass. + One path — master mnemonic generation — runs end-to-end through the sealed allowlist, the degenerate check and the readiness ledger; the crate has zero false `CryptoRng` promises; and the crate-wide lint has been shown on real output to fire where expected and stay silent where migrated, with CI still green. + + + + Task 3: Migrate the crypto-consequential sites, with round-trip and wire-shape proofs + core/archipelago/src/storage_crypto.rs, core/archipelago/src/credentials/store.rs, core/archipelago/src/device_tokens.rs, core/archipelago/src/session.rs, core/archipelago/src/wallet/bdhke.rs, core/archipelago/src/mesh/x3dh.rs, docs/security/KEY-05-ENTROPY-ENFORCEMENT.md + + - docs/security/KEY-05-ENTROPY-ENFORCEMENT.md — the rows for these six files, especially their `guarded` column + - core/archipelago/src/entropy.rs — the API built in Task 2 + - core/archipelago/src/storage_crypto.rs — all of it; `seal()` at :36-52 and `open()` at :54-75 are the envelope contract + - core/archipelago/src/credentials/store.rs — the region around :69 and its corresponding decrypt path + - core/archipelago/src/wallet/bdhke.rs — :128-142 and the existing test module from :143 + - core/archipelago/src/mesh/x3dh.rs — :88-130 and the existing test module from :291 + - core/archipelago/src/session.rs — :150-300 (the four production sites) and :470-694 (the twelve test sites) + - core/archipelago/src/device_tokens.rs — the region around :64 + + `git status --porcelain -- core/archipelago/src/storage_crypto.rs core/archipelago/src/credentials/store.rs core/archipelago/src/device_tokens.rs core/archipelago/src/session.rs core/archipelago/src/wallet/bdhke.rs core/archipelago/src/mesh/x3dh.rs` prints nothing — none of these files carries another agent's uncommitted work. Halt and report if any line appears. + + - A ciphertext produced by `storage_crypto::seal` before this change still opens after it, byte-for-byte on the plaintext. + - A credential blob sealed by the credential store before this change still opens after it. + - The `seal` output length remains `12 + plaintext.len() + 16` and its first 12 bytes remain the nonce. + - `random_blinding_factor` still returns a valid secp256k1 scalar, and two successive calls differ. + - A prekey bundle still serialises to the same field set, with the same types and the same ordering, as before the change. + - Session tokens are still 32 bytes and still hex-encode to the same length. + + +Migrate every row in these six files whose Task 1 disposition is `migrate`, replacing the +defaulted entry point with an explicit `rand::rngs::OsRng` at the call site. Where the Task 1 +row is `guarded: yes`, route the draw through `entropy::draw_key_bytes` and propagate the +`DegenerateEntropy` error rather than unwrapping it. + +**`storage_crypto.rs:39` is an AEAD nonce and is the highest-risk edit in this task.** The +envelope is `nonce ‖ ciphertext` with a 12-byte prefix that `open()` splits at a hard-coded +12. Change **only how the twelve bytes are obtained**. Do not change the prefix length, the +concatenation order, the `Vec::with_capacity` sizing, the cipher construction, or the error +strings. CLAUDE.md's "migrations never destroy data" invariant applies directly: every +at-rest blob a fleet node already holds was sealed by the old code and must open under the +new. Prove it with a test that embeds a **fixed** ciphertext produced by the pre-change code +under a fixed key and asserts it opens to the expected plaintext — a hardcoded vector, not a +seal-then-open round trip in the same process, because a same-process round trip would pass +even if the envelope layout had changed. Apply the same treatment to +`credentials/store.rs:69`. + +**`wallet/bdhke.rs` and `mesh/x3dh.rs` are protocol code — treat the wire as frozen.** +`random_blinding_factor` (`:139`) is the one genuine generic-over-RNG key-generation seam +outside `seed.rs`: replace `rand::thread_rng()` with `rand::rngs::OsRng` and leave +`SecretKey::new(&mut rng)` untouched. **Do not** attempt to route the blinding factor through +`entropy::draw_key_bytes`: intercepting it would mean reimplementing secp256k1's +rejection sampling into the curve order, which is a larger correctness risk than the guard +buys. Record that non-application in the document as a deliberate, reasoned exclusion rather +than silently omitting it. `generate_secret` (`:133`) draws 32 bytes and *is* guarded. +For `mesh/x3dh.rs:100` and `:114`, migrate the source and leave the values as `u32` prekey +identifiers with the same field names, types and ordering in `SignedPrekey` and +`OneTimePrekey` — these are `guarded: no` per Task 1. + +For `session.rs`, migrate all four production sites and all twelve test sites. The test +fixtures migrate too; a test that keeps the default is a site the lint will flag in Task 6 +because CI runs clippy with `--all-targets`. + +Add the wire-shape and validity tests named in the acceptance criteria to the existing +inline test modules of the files they cover. + +Fill the document's row-level `status` for these six files, and add the reasoned exclusion +for the blinding factor under a `## Deliberate non-applications of the guard` heading. + + + cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago storage_crypto:: credentials:: device_tokens:: session:: wallet::bdhke:: mesh::x3dh:: + + + - All targeted tests pass with zero failures. + - A test exists and passes in `storage_crypto.rs` that opens a **hardcoded** ciphertext vector, produced under the pre-change code, to its expected plaintext. + - A test exists and passes in the credential store that opens a hardcoded pre-change ciphertext vector. + - A test exists and passes asserting `seal(p, k).len() == 12 + p.len() + 16` and that the first 12 bytes of two seals of the same plaintext differ. + - A test exists and passes asserting `random_blinding_factor()` returns a scalar accepted by secp256k1 and that two calls differ. + - A test exists and passes asserting the serialised prekey-bundle field set and ordering, so a later refactor cannot silently reshape the wire. + - `git diff` for `mesh/x3dh.rs` shows no change to any struct field name, field type or field order. + - `git diff` for `storage_crypto.rs` touches only the nonce-drawing line and its imports — no change to `open()`, to the 12-byte split, or to the output assembly. + - Comment-filtered residual count across these six files is 0: `grep -rn "rand::random\|thread_rng()" core/archipelago/src/storage_crypto.rs core/archipelago/src/credentials/store.rs core/archipelago/src/device_tokens.rs core/archipelago/src/session.rs core/archipelago/src/wallet/bdhke.rs core/archipelago/src/mesh/x3dh.rs | grep -vE ':[0-9]+: *(//|///|\*)' | wc -l` returns 0, or returns exactly the count of rows Task 1 dispositioned `allow` in these files, each of which carries `#[allow(clippy::disallowed_methods)]`. + - `cd core && cargo fmt --all -- --check` exits 0. + - The document records the blinding-factor guard non-application with its stated reason. + + Every edit is a source-level substitution that changes no persisted format and no wire format; the round-trip vectors are the mechanical proof of that. Fully git-revertible. + The six files whose drawn values are key material, nonces or session credentials name their entropy source explicitly, and the AEAD envelopes and protocol wire shapes are proven unchanged by tests rather than by inspection. + + + + Task 4: Migrate the remaining nine files, preserving TOTP's deferred selection algorithm + core/archipelago/src/container/secrets.rs, core/archipelago/src/api/rpc/package/install.rs, core/archipelago/src/api/rpc/package/pine_ha.rs, core/archipelago/src/api/rpc/auth.rs, core/archipelago/src/bitcoin_rpc.rs, core/archipelago/src/totp.rs, core/archipelago/src/transport/chunking.rs, core/archipelago/src/federation/invites.rs, core/archipelago/src/fips/dial.rs, docs/security/KEY-05-ENTROPY-ENFORCEMENT.md + + - docs/security/KEY-05-ENTROPY-ENFORCEMENT.md — the rows for these nine files + - core/archipelago/src/entropy.rs — the API built in Task 2 + - core/archipelago/src/container/secrets.rs — `random_hex` and `random_base64` around :100-115, and the 0600 tests at :269 and :307 + - core/archipelago/src/totp.rs — :290-315, the charset definition and the selection loop + - core/archipelago/src/federation/invites.rs — the region around :42, and which trait brings `fill` into scope + - core/archipelago/src/api/rpc/package/pine_ha.rs — :95-110 and :485-670 + - core/archipelago/src/api/rpc/package/install.rs — the regions around :732 and :1456 + - core/archipelago/src/api/rpc/auth.rs — the region around :125 + - docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md — F-09, and the R-12 backlog row that keeps the modulo work out of scope + + `git status --porcelain -- core/archipelago/src/container/secrets.rs core/archipelago/src/api/rpc/package/install.rs core/archipelago/src/api/rpc/package/pine_ha.rs core/archipelago/src/api/rpc/auth.rs` prints nothing. `container/secrets.rs` and `install.rs` both carried a concurrent agent's uncommitted changes when this plan was written, and `api/rpc/auth.rs` is edited by `10-01`; all three must be clean and `10-01` must be committed before this task runs. + +Migrate every remaining row whose Task 1 disposition is `migrate`, in the nine files listed. +These are predominantly one-line substitutions of an explicit `rand::rngs::OsRng` for the +defaulted entry point, with `entropy::draw_key_bytes` used wherever Task 1 marked the row +`guarded: yes`. + +**`totp.rs:305` carries the one trap in this task.** The audit's F-09 records that the +selection there uses `% charset.len()` with a 32-character charset, so the bias is presently +**zero** — 32 divides 256 exactly — and the fix for the latent bias is R-12, which +`10-CONTEXT.md` explicitly **defers**. Migrate the *entropy source* only. The expression +must keep drawing a `u8` and keep reducing it with `% charset.len()`; the distribution is +then bit-for-bit identical to today's. Do **not** substitute `SliceRandom::choose`, do not +change the charset, and do not add a uniformity test — all three are R-12, and executing a +deferred item is a scope violation. Add a comment at the call site recording that only the +source changed and that the selection remains R-12's business. + +`container/secrets.rs`'s `random_hex` and `random_base64` are the original F-10 and are +`guarded: yes` at their typical lengths — route them through `entropy::draw_key_bytes`, and +where a caller requests fewer than `MIN_GUARDED_LEN` bytes, draw unguarded from `OsRng` +directly rather than tripping the guard's length assertion. Leave the 0600 behaviour and its +tests at `:269` and `:307` untouched; CLAUDE.md's rootless-0600 invariant is test-enforced +there and must stay green. + +`federation/invites.rs:42` uses `fill`, which comes from the `rand::Rng` trait; `OsRng` +satisfies it by blanket impl, so the trait import is what needs attention, not the call. + +`api/rpc/auth.rs:125`, `fips/dial.rs:75`, `transport/chunking.rs:149` are short +non-key-material identifiers: migrate the source, leave them unguarded per Task 1. + +Do not touch `api/rpc/package/config.rs` or `api/rpc/package/dependencies.rs` — they carry +the concurrent agent's work and contain no matches. + +Mark every row in the document as migrated, with its final `file:line`. + + + cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago totp:: container::secrets:: federation:: transport:: bitcoin_rpc:: + + + - All targeted tests pass with zero failures, including the two `container/secrets.rs` mode-0600 assertions. + - `git diff core/archipelago/src/totp.rs` shows the `% charset.len()` reduction still present and the charset definition unchanged; no `SliceRandom` import is added anywhere in the crate. + - `cd core && CARGO_INCREMENTAL=0 cargo build -p archipelago` exits 0. + - Comment-filtered residual count across the whole crate is 0 or exactly the Task 1 `allow` count: `grep -rn "rand::random\|thread_rng()" core/archipelago/src --include=*.rs | grep -vE ':[0-9]+: *(//|///|\*)' | wc -l`. + - Every `#[allow(clippy::disallowed_methods)]` in the crate has a justification comment on the line immediately above it that names KEY-05, and the total number of such attributes equals the Task 1 `allow` count. + - `cd core && cargo fmt --all -- --check` exits 0. + - `git status --porcelain -- core/archipelago/src/api/rpc/package/config.rs core/archipelago/src/api/rpc/package/dependencies.rs` prints nothing attributable to this task. + - Every row in the document is marked migrated, with a final `file:line` that resolves in the current tree. + + Source-level substitutions with no persisted or wire consequence; git-revertible. + No call site anywhere in `core/archipelago/src` inherits its entropy backend from a dependency default, and TOTP's deferred selection algorithm is provably untouched. + + + + Task 5: CHECKPOINT — cargo-deny adoption scope and tool legitimacy + + - docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md — F-07 (the two `rand` majors and where each comes from) and backlog row R-05 + - .github/workflows/ci.yml — the existing job structure + - docs/security/KEY-05-ENTROPY-ENFORCEMENT.md — Task 1's `## Layer coverage` mapping for layer (c) + + +Two questions, both requiring a human, before `cargo-deny` is wired into CI as a blocking +gate for every pull request on this repository. + +**Q1 — Does the `advisories` section block the build, or only `bans`?** + +**Q2 — Tool legitimacy.** `cargo-deny` is new third-party tooling entering CI. No +`RESEARCH.md` exists for this phase and therefore no Package Legitimacy Audit table exists, +so the fallback policy applies and `cargo-deny` is treated as `[ASSUMED]`. This checkpoint +is blocking and is **not** auto-approvable: `workflow.auto_advance` does not apply to a +package-legitimacy gate. + + +R-05 and F-07 ask for one specific thing: a `bans` rule that fails on duplicate `rand` +majors, "so the split is visible rather than silent". KEY-05 layer (c) restates exactly that +and nothing more. The `advisories` section is a separate, larger commitment with a real +day-two cost, so it is surfaced here rather than enabled quietly alongside the thing that was +actually requested. + +The day-one problem the human needs to know about: **two `rand` majors coexist in the graph +today.** `rand 0.8.5` is the direct dependency (`core/archipelago/Cargo.toml:68`) and also +arrives via `archipelago-security`, `bip39 2.1.0`, `mainline`, `secp256k1` and +`tungstenite 0.20.1`; `rand 0.9.2` arrives transitively via `totp-rs 5.7.0` and +`tungstenite 0.26.2` through `nostr-sdk`. A naive duplicate-`rand` rule therefore fails on +the tree as it stands. The recommended configuration below grandfathers the *known* pair by +exact version, so the current split is tolerated and recorded while any change to it — a +third version, a bump of either — fails the build. That preserves R-05's actual intent +without turning CI red on the day it lands. + +The `advisories` cost, stated honestly: an advisories gate fails builds when a **new CVE is +published against an existing dependency**, with no change to this repository. On a repo +where multiple agents commit and push continuously, that means an unrelated upstream +disclosure can block everyone's work at an arbitrary hour, and the fix is often 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). That is a policy call about how the +team wants to be interrupted, not a technical detail. + +**Pre-step required before answering, and it is part of the resume condition:** verify +`cargo-deny` on `crates.io/crates/cargo-deny` — confirm the publisher is EmbarkStudios, the +repository link resolves to `github.com/EmbarkStudios/cargo-deny`, and the version to be +pinned in CI is a real published version with a plausible download count and release date. +Record the version string that will be pinned. + + + + + + + Confirm the crates.io legitimacy check with the version string to pin, then select: bans-only, bans-blocking-advisories-warning, or both-blocking. Record the selection and its rationale in `docs/security/KEY-05-ENTROPY-ENFORCEMENT.md` under `## cargo-deny policy` before Task 6 begins. + A blocking CI gate is trivially revertible in git, but while it is live it gates every pull request from every agent on this shared repository. A false stop blocks unrelated work, and an `advisories` gate can fail with no local change at all. That day-two cost is why this is a human decision rather than a planner default. + + + + Task 6: Enable both gates atomically and prove the ban actually fails a build + core/clippy.toml, core/deny.toml, .github/workflows/ci.yml, docs/security/KEY-05-ENTROPY-ENFORCEMENT.md + + - docs/security/KEY-05-ENTROPY-ENFORCEMENT.md — Task 1's classification, Task 2's dry-run evidence, and Task 5's recorded `## cargo-deny policy` decision including the pinned version string + - .github/workflows/ci.yml — all of it; the Rust job at :14-38 and the `working-directory: core` default at :19 + - core/Cargo.toml — the `members` list, for the deny scope + - docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md — F-07, for the exact duplicate-`rand` semantics being encoded + + Task 4's residual-count criterion passes on the current tree — no unmigrated call site remains anywhere in `core/archipelago/src`. Enabling the lint before this holds turns CI red for every other agent on this shared repository, so assert it before writing `core/clippy.toml`, not after. + +This task turns the two gates on. Its whole risk is ordering, so assert the precondition +first and stop if it fails. + +**Layer (b).** Recreate `core/clippy.toml` with the `disallowed-methods` array from Task 2's +dry run — one entry per banned path, each carrying a `reason` that names KEY-05 and points at +`docs/security/KEY-05-ENTROPY-ENFORCEMENT.md`. `core/` is the directory the CI clippy step +runs from, which is why the file belongs there and not at the repository root. **No change to +`ci.yml` is needed for this layer:** the existing step already runs +`cargo clippy --all-targets --all-features -- -D warnings`, so a `disallowed_methods` hit is +already a build failure. Record that in the document — the reason layer (b) is cheap is that +the enforcement channel already existed and only the policy file was missing. + +**Prove the gate fails a build, do not assume it.** Temporarily reintroduce a single banned +call into a scratch location in the crate, run the full CI clippy invocation from `core/`, +capture the error output showing the build failing, then revert the reintroduction and +confirm the same invocation now exits 0. Paste both outcomes into the document under +`## Clippy dry-run evidence`. A lint config that is never observed to fail is indistinguishable +from one that is misconfigured. + +**Layer (c).** Create `core/deny.toml` implementing the option selected in Task 5. The +required semantics, independent of mechanism: the tree as it stands passes; a **third** +`rand` version, or a change to either member of the current pair, fails. The intended shape +is a global `multiple-versions = "allow"` with a per-crate `deny-multiple-versions` entry for +`rand`, plus a `skip` entry pinning the currently-tolerated second version exactly, carried +with a dated comment explaining it is a grandfather entry from 2026-08-02 and citing F-07 for +where each version comes from. If the pinned `cargo-deny` version does not support per-crate +`deny-multiple-versions`, fall back to a global `multiple-versions = "deny"` with a generated +skip-list covering every currently-duplicated crate **except** `rand`, and record in the +document which mechanism was used and why — the semantics above are the contract, the config +keys are the means. + +Add a CI step named for supply-chain checking to the Rust job in `.github/workflows/ci.yml`, +pinning the exact `cargo-deny` version recorded in Task 5, and running only the check subsets +the selected option authorises. Do not enable `advisories` unless Task 5 selected an option +that includes it. + +**Verify the deny rule bites**, by the same standard as the lint: introduce a synthetic third +`rand` version into the graph, observe `cargo deny check bans` exit non-zero, revert, and +observe it exit 0. Capture both into the document. If introducing a third version proves +impractical, temporarily remove the grandfather `skip` entry and show the rule firing on the +existing pair instead — and say in the document which of the two demonstrations was used. + +Finalise `docs/security/KEY-05-ENTROPY-ENFORCEMENT.md`: fill `## cargo-deny policy` with +Task 5's decision, its rationale and the pinned version; mark every `## Layer coverage` row +with the task that closed it; and add a `## What this does not close` section recording, at +minimum, that F-07's advisory half remains open if Task 5 selected `bans-only`, that F-09/R-12 +and F-11/R-14 remain deferred, that sealing does not prevent an edit to `entropy.rs` itself, +and that mnemonics generated before this change came from the previous source — which was and +remains a genuine CSPRNG, so this removes a future failure mode rather than repairing a past +one, in the same terms §7 of the audit uses. + +Run the full workspace test suite once, at the end. Per the environment constraints, this +contends with the other agent's builds — run it once, not iteratively. + + + cd core && CARGO_INCREMENTAL=0 cargo clippy --all-targets --all-features -- -D warnings && cargo deny check bans && CARGO_INCREMENTAL=0 cargo test --all-features + + + - `cd core && cargo clippy --all-targets --all-features -- -D warnings` exits 0 with `core/clippy.toml` present. + - `core/clippy.toml` contains a `disallowed-methods` array with at least two entries, each carrying a `reason` field whose text names KEY-05. + - The document contains captured output of the CI clippy invocation **failing** on a deliberately reintroduced banned call, and of the same invocation exiting 0 after reverting it. + - `cd core && cargo deny check bans` exits 0 against the tree as it stands. + - The document contains captured output of `cargo deny check bans` exiting non-zero under the demonstration described in the action, and names which demonstration was used. + - `core/deny.toml` contains a dated grandfather comment citing F-07 and naming both currently-resolved `rand` versions. + - `.github/workflows/ci.yml` contains a step invoking cargo-deny at the exact version recorded in Task 5, and enables `advisories` if and only if Task 5 selected an option including it. + - `cd core && CARGO_INCREMENTAL=0 cargo test --all-features` passes with zero failures. + - `cd core && cargo fmt --all -- --check` exits 0. + - The document's `## Layer coverage` section marks all five layers (a)–(e) as closed, each naming the task that closed it. + - The document contains a `## What this does not close` section naming at minimum the four items listed in the action. + - No file outside this plan's `files_modified` list is staged in any commit produced by this task, verified with `git diff --cached --name-only`. + + Both gates block every pull request on a repository several agents commit to. Reverting is a file deletion, but while live a misconfiguration stops unrelated work — which is why the acceptance criteria require the gates to be *observed* failing and then passing rather than merely configured. + The ban is a compile failure in CI, observed failing a real build and then passing; the duplicate-`rand` rule is live with its split grandfathered and change-detecting; and the evidence document is complete, including what KEY-05 does not close. + + + + + +## Framing + +**Nothing in the F-10a table is exploitable today.** `rand::random()` and +`rand::thread_rng()` on the pinned `rand 0.8.5` are `ReseedingRng` — +seeded from `getrandom(2)`, reseeded every 64 KiB, fork-protected. There is no present +vulnerability here and this register does not claim one. + +The threat being modelled is **a future silent rebinding of the entropy source**: the T1 +structural shape, in which a dependency bump, a feature-flag change or a refactor moves the +backing RNG with no compile error, no test failure and no diff in Archipelago's own source. +The 2026-07-30 COLDCARD defect is the worked example of that shape actually firing. Severity +below is rated on the **consequence if the rebinding occurs**; likelihood is uniformly +conditional on a future change, which is why the dispositions are structural controls rather +than incident response. + +## Trust Boundaries + +| Boundary | Description | +|---|---| +| dependency graph → key-generation call sites | The entropy backend for 41 call sites is selected by `Cargo.lock` resolution and crate feature flags, not by Archipelago source. A change on the far side of this boundary alters key material with no first-party diff. | +| future contributor (human or agent) → crate source | New code can reach the defaulted entry points as easily as existing code did. Review is the only control today, and F-10a exists because review did not catch 41 of them. | +| daemon → at-rest encrypted blobs | `storage_crypto::seal` and the credential store draw a 12-byte AEAD nonce per encryption; nonce quality is what separates confidentiality from keystream reuse. | +| daemon → remote peers and clients | Session tokens, device tokens, federation invites and X3DH prekey bundles cross this boundary; their unpredictability is the whole of their security. | +| node → its own future auditor | A node cannot presently answer "was the kernel pool seeded when these keys were born" — `seed.rs:59` computes the verdict and `MasterSeed::generate` discards it. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|---|---|---|---|---|---| +| T-10-06-01 | Tampering | `seed.rs` mnemonic seam — the whole key hierarchy including the fleet release-root signing key (`seed.rs:11`) | critical | mitigate | Task 2: `generate_mnemonic_with` is generic over the sealed `KeyGenRng` whose only production member is `OsRng`; the entropy is drawn into an inspectable buffer and passed to `from_entropy`, with the pre-existing known-answer test proving the injected RNG is the one consumed. | +| T-10-06-02 | Information Disclosure | `storage_crypto.rs:39`, `credentials/store.rs:69` — ChaCha20-Poly1305 nonces | high | mitigate | Task 3: nonces drawn via `entropy::draw_key_bytes` from explicit `OsRng`; a rebinding to a low-entropy source would cause nonce reuse, keystream recovery and Poly1305 forgery. Envelope layout proven unchanged by hardcoded pre-change ciphertext vectors. | +| T-10-06-03 | Spoofing | `session.rs` (4 production sites), `device_tokens.rs:64`, `federation/invites.rs:42` | high | mitigate | Task 3 and Task 4: explicit `OsRng` at every site; predictable tokens would permit session and device impersonation without any credential compromise. | +| T-10-06-04 | Tampering | `wallet/bdhke.rs` blinding factors — Cashu ecash key material | high | mitigate | Task 3: `random_blinding_factor` takes `OsRng` explicitly. Guard deliberately not applied (would require reimplementing secp256k1 rejection sampling); non-application recorded with its reason. | +| T-10-06-05 | Tampering | Any call site written **after** this plan — enforcement decay | high | mitigate | Task 6: `core/clippy.toml` `disallowed-methods` makes the defaulted entry points a compile failure under the existing `-D warnings` CI step, so enforcement is mechanical rather than a review convention. Observed failing a real build before being accepted. | +| T-10-06-06 | Tampering | Dependency graph — a `rand` bump silently rebinding the backend (F-07: 0.8.5 and 0.9.2 already coexist; 0.9 removed `ThreadRng` fork protection) | high | mitigate | Task 6: `core/deny.toml` bans a change to the duplicate-`rand` set, with the current pair grandfathered by exact version and dated. | +| T-10-06-07 | Denial of Service | The degenerate-entropy check itself — a false positive refusing a genuine key generation | medium | mitigate | Task 2: predicate restricted to three exactly-analysable cases; guarded draws floored at 12 bytes so the false-positive bound is stated numerically for both `n=12` and `n=32`; validated against 100,000 genuine `OsRng` draws; hard error with no silent retry, because a retry would mask a genuinely broken RNG. | +| T-10-06-08 | Information Disclosure | The CSPRNG-readiness ledger — a new file written near key material | high | mitigate | Task 2: schema is exactly `{v, ts, ready, event}`; mode 0600; located outside `identity/`; a test asserts no mnemonic word appears in the file contents. No entropy, key bytes or seed material is ever written. | +| T-10-06-09 | Repudiation | A node cannot establish post-hoc whether the kernel pool was seeded when its keys were generated | low | mitigate | Task 2: `record_csprng_readiness` persists the verdict `seed.rs:59` already computes and `MasterSeed::generate` currently discards (R-09). Best-effort so it can never fail generation, including on the offline `ceremony.rs:71` path. | +| T-10-06-10 | Spoofing | `mesh/x3dh.rs:100`,`:114` prekey identifiers, `transport/chunking.rs:149`, `fips/dial.rs:75`, `api/rpc/auth.rs:125` | low | accept | Source migrated to explicit `OsRng`, but the degenerate guard is **not** applied: these are 2-to-4-byte protocol identifiers, and an "all bytes identical" predicate on a 2-byte draw false-positives once in 256. Accepted because collision or prediction of a prekey id or a dial id is not a confidentiality or authentication boundary — the X25519 secrets come from `crypto::generate_x25519_ephemeral()`, not from these lines. | +| T-10-06-SC | Tampering | `cargo-deny` entering CI as new third-party tooling | high | mitigate | No `RESEARCH.md` and therefore no Package Legitimacy Audit table exists for this phase, so the fallback policy applies and `cargo-deny` is treated as `[ASSUMED]`. Task 5 is a blocking human checkpoint requiring verification on `crates.io/crates/cargo-deny` (publisher, repository link, version reality) before it is wired in, and pins an exact version in CI. Not auto-approvable; `workflow.auto_advance` does not apply. | + +**ASVS level 1, block on high.** All `high` and `critical` threats above carry `mitigate` +dispositions with named tasks. The single `accept` (T-10-06-10) is rated `low` with its +reasoning stated, and is the direct consequence of Task 1's per-site classification rather +than a blanket exemption. + + + +## Artifacts this phase produces + +**New module** — `core/archipelago/src/entropy.rs`, declared as `mod entropy;` in +`core/archipelago/src/main.rs`. + +**Symbols created in `entropy`:** + +| Symbol | Kind | Visibility | Purpose | +|---|---|---|---| +| `sealed` | module | private to `entropy` | Makes `Sealed` unnameable outside `entropy`, which is what seals the allowlist | +| `sealed::Sealed` | trait | private | The unnameable supertrait | +| `KeyGenRng` | trait | `pub(crate)` | The allowlist: `rand::RngCore + sealed::Sealed`, deliberately **without** a `rand::CryptoRng` supertrait | +| `impl Sealed for rand::rngs::OsRng` | impl | — | The sole production allowlist membership | +| `impl KeyGenRng for rand::rngs::OsRng` | impl | — | The sole production allowlist membership | +| `MIN_GUARDED_LEN` | const | `pub(crate)` | 12 — the floor below which the degenerate predicate's false-positive argument does not hold | +| `DegenerateEntropy` | enum | `pub(crate)` | Variants `AllZero`, `AllIdentical`, `Counter` | +| `is_degenerate` | fn | `pub(crate)` | The predicate, `&[u8] -> Option` | +| `draw_key_bytes` | fn | `pub(crate)` | Guarded fill: `(&mut impl KeyGenRng, &mut [u8]) -> Result<(), DegenerateEntropy>` | +| `readiness_ledger_path` | fn | private | `ARCHIPELAGO_DATA_DIR` (fallback `/var/lib/archipelago`) joined with `security/csprng-readiness.jsonl` | +| `record_csprng_readiness` | fn | `pub(crate)` | Best-effort append of one readiness line | +| `testing` | module | `pub(crate)`, `#[cfg(test)]` | Home of the deterministic test RNG | +| `testing::CountingRng` | struct | `pub(crate)`, `#[cfg(test)]` | Relocated from `seed.rs:628`; implements `RngCore`, `Sealed`, `KeyGenRng` — and **not** `rand::CryptoRng` | + +**Symbols removed:** `impl rand::CryptoRng for CountingRng` (`seed.rs:656`) and the +`CountingRng` definition at `seed.rs:628-654`. After this plan the crate contains zero +`impl rand::CryptoRng` blocks. + +**Symbols retyped:** `seed.rs::generate_mnemonic_with` — bound changes from +`R: rand::CryptoRng + rand::RngCore` to `R: crate::entropy::KeyGenRng`; body changes from +`bip39::Mnemonic::generate_in_with` to a guarded 32-byte draw plus +`bip39::Mnemonic::from_entropy`. + +**Configuration files created:** + +| Path | Contents | +|---|---| +| `core/clippy.toml` | `disallowed-methods` entries for `rand::thread_rng` and `rand::random`, each with a `reason` naming KEY-05 | +| `core/deny.toml` | `bans` rule detecting any change to the duplicate-`rand` set, with the 2026-08-02 grandfather entry | + +**Runtime path created:** `/security/csprng-readiness.jsonl`, +mode `0600`, append-only JSON Lines, schema `{"v":1,"ts":,"ready":,"event":}`. +Deliberately **outside** `identity/`. + +**CI change:** one new step in the `rust` job of `.github/workflows/ci.yml` invoking +`cargo-deny` at a version pinned in Task 5. The clippy step itself is unchanged — layer (b) +rides the existing `-D warnings` invocation. + +**Document created:** `docs/security/KEY-05-ENTROPY-ENFORCEMENT.md`, with sections +`## Layer coverage`, `## Source precedence`, the per-site classification table, +`## Sealing: what it prevents and what it does not`, `## Degenerate-entropy predicate`, +`## CSPRNG-readiness ledger`, `## Deliberate non-applications of the guard`, +`## Clippy dry-run evidence`, `## cargo-deny policy`, `## What this does not close`. + +**Tests created (names are the contract; each must exist and pass):** + +| Test | Module | Asserts | +|---|---|---| +| `sealed_allowlist_has_one_production_member` | `entropy` | Only `OsRng` is a non-test member | +| `degenerate_rejects_all_zero` | `entropy` | All-zero buffer yields `AllZero` | +| `degenerate_rejects_all_identical` | `entropy` | All-`0xAB` buffer yields `AllIdentical` | +| `degenerate_rejects_ascending_counter` | `entropy` | Wrapping +1 sequence yields `Counter` | +| `degenerate_rejects_descending_counter` | `entropy` | Wrapping −1 sequence yields `Counter` | +| `degenerate_accepts_100k_osrng_draws` | `entropy` | 100,000 genuine 32-byte `OsRng` draws all accepted | +| `draw_key_bytes_panics_below_min_guarded_len` | `entropy` | Guard cannot be called on a too-short buffer | +| `readiness_ledger_is_0600_and_append_only` | `entropy` | Mode, single line, append on second call | +| `readiness_record_schema_is_exactly_four_keys` | `entropy` | Keys `v`, `ts`, `ready`, `event` and no others | +| `readiness_record_contains_no_mnemonic_words` | `entropy` | No word of a freshly generated mnemonic appears in the ledger | +| `readiness_record_survives_unwritable_data_dir` | `entropy` | Returns normally rather than failing generation | +| `mnemonic_generation_uses_injected_rng` | `seed` | **Pre-existing**; assertions byte-identical, only the RNG import path changes | +| `mnemonic_generation_is_256_bit` | `seed` | **Pre-existing**; unchanged | +| `opens_pre_migration_ciphertext_vector` | `storage_crypto` | Hardcoded pre-change ciphertext still decrypts | +| `seal_envelope_layout_unchanged` | `storage_crypto` | Length `12 + n + 16`; two seals differ in the nonce prefix | +| `opens_pre_migration_ciphertext_vector` | `credentials::store` | Hardcoded pre-change credential blob still decrypts | +| `blinding_factor_is_valid_and_varies` | `wallet::bdhke` | Valid secp256k1 scalar; two calls differ | +| `prekey_bundle_wire_shape_unchanged` | `mesh::x3dh` | Field set, types and ordering pinned | + + + +## Phase-level checks + +Run from `core/` unless stated otherwise. Prefer these targeted forms over the full suite +while the concurrent Phase 1 agent is building; the full suite runs once, in Task 6. + +1. `CARGO_INCREMENTAL=0 cargo clippy --all-targets --all-features -- -D warnings` exits 0 — the CI gate, with `core/clippy.toml` live. +2. The document contains captured evidence of that same invocation **failing** on a deliberately reintroduced banned call, and passing after revert. +3. `grep -rn "rand::random\|thread_rng()" core/archipelago/src --include=*.rs | grep -vE ':[0-9]+: *(//|///|\*)' | wc -l` equals the Task 1 `allow` count (expected 0). +4. `grep -rn "impl rand::CryptoRng" core/archipelago/src --include=*.rs | grep -vE ':[0-9]+: *(//|///|\*)' | wc -l` returns 0. +5. `CARGO_INCREMENTAL=0 cargo test -p archipelago entropy::` and `... seed::` pass. +6. `cargo deny check bans` exits 0, and the document holds captured evidence of it exiting non-zero under the change-detection demonstration. +7. `CARGO_INCREMENTAL=0 cargo test --all-features` passes (once, Task 6). +8. `cargo fmt --all -- --check` exits 0. +9. `git diff --stat` touches only paths in this plan's `files_modified`. +10. Every `## Layer coverage` row in the document is marked closed with the task that closed it. + + + +- All five KEY-05 layers are implemented: (a) sealed allowlist at the mnemonic seam with the false `CryptoRng` promise retired; (b) `clippy.toml` ban enforced by the existing `-D warnings` CI step and observed failing a real build; (c) `cargo-deny` bans rule detecting any change to the duplicate-`rand` set; (d) a three-predicate degenerate-entropy check with a stated numeric false-positive bound; (e) the CSPRNG-readiness verdict persisted. +- No layer was dropped. Any deliberate non-application within a layer — the blinding-factor guard, the short-identifier guards — is recorded with evidence and reasoning in the document, not omitted. +- All 43 matches carry a derived, evidence-backed disposition; the classification was performed, not assumed. +- CI is green at every intermediate commit: no commit adds `core/clippy.toml` while an unmigrated site remains. +- Data written before the change still decrypts, proven by hardcoded pre-change ciphertext vectors rather than same-process round trips. +- No wire format, derivation or on-the-wire value in `wallet/bdhke.rs` or `mesh/x3dh.rs` differs. +- F-09/R-12 and F-11/R-14 remain unexecuted; `totp.rs`'s `% charset.len()` selection is untouched. +- Nothing was pushed, tagged or deployed. + + + +Create `.planning/phases/10-key-material-hardening/10-06-SUMMARY.md` when done. +