Files

79 KiB
Raw Permalink Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
10-key-material-hardening 06 execute 2
10-01
10-05
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
false
KEY-05
truths artifacts key_links
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
path provides contains min_lines
core/archipelago/src/entropy.rs The sealed key-generation RNG allowlist, the degenerate-entropy predicate, the CSPRNG-readiness ledger, and their tests KeyGenRng 220
path provides contains
core/clippy.toml The crate-wide compile-time ban on the defaulted-RNG entry points disallowed-methods
path provides contains
core/deny.toml The duplicate-`rand`-major bans rule with its dated grandfather entry bans
path provides contains
.github/workflows/ci.yml The supply-chain job step that runs cargo-deny at a pinned version cargo-deny
path provides contains min_lines
docs/security/KEY-05-ENTROPY-ENFORCEMENT.md 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 F-10a 150
path provides contains
core/archipelago/src/seed.rs The mnemonic seam retyped to the sealed allowlist, with the relocated deterministic test RNG and the readiness-ledger call KeyGenRng
from to via pattern
core/archipelago/src/seed.rs core/archipelago/src/entropy.rs generate_mnemonic_with is generic over the sealed KeyGenRng, so the master key hierarchy's entropy source is allowlist-constrained at the type level KeyGenRng
from to via pattern
core/clippy.toml .github/workflows/ci.yml 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 disallowed-methods
from to via pattern
docs/security/KEY-05-ENTROPY-ENFORCEMENT.md docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md the classification table is the per-site resolution of F-10a's deliberately-unclassified raw match counts F-10a
from to via pattern
core/archipelago/src/entropy.rs core/archipelago/src/storage_crypto.rs the AEAD nonce is drawn through the guarded helper, which is the mitigation for T-10-06-02 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>

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 <paths>). 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 <verify>; run the full suite once, at the end, in Task 6. </execution_gate>

<scope_note>

Why this plan carries six tasks

GSD's standard budget is 23 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. </scope_note>

<source_precedence>

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. </source_precedence>

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.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

<interface_context>

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:39let 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:69let nonce_bytes: [u8; 12] = rand::random();. wallet/bdhke.rs:133let random_bytes: [u8; 32] = rand::random(); in generate_secret(); :139let 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/:112rand::thread_rng().fill_bytes(&mut buf) in random_hex() / random_base64(). federation/invites.rs:42rand::thread_rng().fill(&mut token_bytes) (needs the rand::Rng trait in scope; OsRng satisfies it by blanket impl). totp.rs:305let idx = (rand::random::<u8>() as usize) % charset.len();. api/rpc/auth.rs:125hex::encode(rand::random::<[u8; 2]>()), a 2-byte display-name suffix, not key material. fips/dial.rs:75let id: u16 = rand::random();. transport/chunking.rs:149let message_id: u32 = rand::random();.

The seam being generalised (seed.rs): kernel_csprng_ready() at :59 returns Option<bool>; MasterSeed::generate() at :106 logs that verdict at :107-113 and then discards it — that discard is what layer (e) closes. generate_mnemonic_with<R: rand::CryptoRng + rand::RngCore> 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:71the 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. </interface_context>

<file_collision_analysis>

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 <precondition> assertions on tree cleanliness for exactly this reason. </file_collision_analysis>

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) &amp;&amp; echo "classified rows: $N" &amp;&amp; [ "$N" -ge 43 ]</automated> </verify> <acceptance_criteria> - docs/security/KEY-05-ENTROPY-ENFORCEMENT.mdexists and contains at least 43 table rows whose first cell is acore/.../.rs:NNNreference. - Every row carries all six columns; no cell is empty or reads "TBD". - Every row's disposition is exactly one ofmigrate, allow, comment. - Every allowrow (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 coveragesection that names layers (a), (b), (c), (d) and (e) and maps each to a task number in this plan. - The document states, withcargo metadataoutput as the evidence, whethermodelsis inside or outside the clippy build graph, and records the twocore/modelsmatches either as in-scope rows or as a stated limitation. - The document records thesession.rsproduction/test split and themesh/x3dh.rsprekey-identifier correction, each citing the line that establishes it. - No.rsfile is modified by this task:git status --porcelain -- 'core/**/*.rs'` prints nothing attributable to this task. </acceptance_criteria> 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<DegenerateEntropy> 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<bool>, event: &str) which appends one JSON line {"v":1,"ts":<RFC3339>,"ready":<true|false|null>,"event":<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:: <acceptance_criteria> - 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. </acceptance_criteria> 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:: <acceptance_criteria> - 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. </acceptance_criteria> 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:: <acceptance_criteria> - 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. </acceptance_criteria> 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. Recommended — bans blocking, advisories not enabled Delivers precisely what R-05/F-07/KEY-05(c) asked for. No new class of unrelated build failure is introduced. No interruption cost on a shared tree where other agents are mid-phase. The rand split becomes visible and change-detecting immediately. No CVE gate; RustSec advisories against dependencies remain undetected by CI. F-07's other half — "no dependency-advisory gate in CI" — stays open and must be recorded as such in the document and left in the backlog rather than quietly considered closed. bans blocking, advisories non-blocking CVE visibility without the interruption cost; closes both halves of F-07 in a reporting sense. A non-blocking warning in CI output is a convention, not a gate, and this plan exists precisely because conventions decay — the same argument that makes layer (b) a compile failure rather than a review rule argues against a warning here. Creates a channel that will be ignored. Both bans and advisories blocking Strongest posture; closes F-07 completely; no advisory can land unnoticed. An upstream CVE disclosure blocks every agent on this repo with no local change, at an unpredictable time, with a remediation that may require bumping an exactly-pinned crypto dependency. Needs an agreed break-glass procedure before it is safe to adopt, and none exists today. 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 <acceptance_criteria> - 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. </acceptance_criteria> 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.

<threat_model>

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<ChaCha12Core, OsRng> — 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. </threat_model>

<artifacts_produced>

Artifacts this phase produces

New modulecore/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<DegenerateEntropy>
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: <ARCHIPELAGO_DATA_DIR>/security/csprng-readiness.jsonl, mode 0600, append-only JSON Lines, schema {"v":1,"ts":<RFC3339>,"ready":<bool|null>,"event":<string>}. 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
</artifacts_produced>
## 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.

<success_criteria>

  • 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. </success_criteria>
Create `.planning/phases/10-key-material-hardening/10-06-SUMMARY.md` when done.