diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d385323b..2cca99ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,9 +31,27 @@ jobs: - name: Format run: cargo fmt --all -- --check + # KEY-05 layer (b) is enforced HERE, with no step of its own: core/clippy.toml + # bans the defaulted RNG entry points, and `-D warnings` already turns a + # `disallowed_methods` hit into a build failure. `--all-targets` covers tests + # too, deliberately. See docs/security/KEY-05-ENTROPY-ENFORCEMENT.md - name: Clippy run: cargo clippy --all-targets --all-features -- -D warnings + # KEY-05 layer (c) — see core/deny.toml for the policy and its rationale. + # + # The version is pinned deliberately. EmbarkStudios/cargo-deny-action exposes + # no input to pin the cargo-deny version, and an unpinned supply-chain checker + # is a contradiction in terms, so the tool is installed from crates.io — the + # source actually vetted at the 10-06 Task 5 legitimacy checkpoint — rather + # than by adding another unvetted action to this workflow. + # + # `check bans` ONLY: the advisories gate is not enabled (bans-only policy). + - name: Supply chain (cargo-deny) + run: | + cargo install --locked cargo-deny --version 0.20.2 + cargo deny check bans + - name: Test run: cargo test --all-features diff --git a/core/clippy.toml b/core/clippy.toml new file mode 100644 index 00000000..3a279111 --- /dev/null +++ b/core/clippy.toml @@ -0,0 +1,44 @@ +# KEY-05 layer (b) — the crate-wide compile-time ban on defaulted RNG entry points. +# +# WHY THIS FILE EXISTS +# `rand::random()` and `rand::thread_rng()` are not broken today: on the pinned +# `rand 0.8.5` both resolve to a ChaCha12 CSPRNG seeded from `getrandom(2)`. +# What they lack is a STATED entropy backend. The backend is fixed by dependency +# and build configuration rather than by the calling code, and nothing produces a +# compile error if that changes underneath us. That is the exact structural shape +# ("T1") behind the 2026-07-30 COLDCARD entropy defect — and here the blast radius +# includes Cashu blinded-key-exchange values, X3DH prekey material, session bearer +# tokens and a ChaCha20-Poly1305 nonce. +# +# So every draw must name `rand::rngs::OsRng` at its own call site, and key +# material and AEAD nonces must additionally run the degenerate-entropy predicate +# in `archipelago::entropy`. +# +# HOW IT IS ENFORCED +# No CI change was needed for this layer. The existing Rust job already runs +# `cargo clippy --all-targets --all-features -- -D warnings` from `core/`, so a +# `disallowed_methods` hit is already a build failure. `--all-targets` means test +# code is covered too, which is deliberate: test fixtures migrate to `OsRng` as +# readily as production code does, and a fixture that keeps the default is a +# template for the next production call site. +# +# SCOPE, STATED HONESTLY +# This reaches the five workspace members (archipelago, archipelago-container, +# archipelago-openwrt, archipelago-performance, archipelago-security), verified +# via `cargo metadata --no-deps`. It does NOT reach `core/models`, which is not a +# workspace member and therefore not in the clippy build graph; the two matches +# there are recorded as a stated limitation in the evidence document. +# +# IF YOU ARE HITTING THIS LINT +# Do not add `#[allow]` reflexively. Use `rand::rngs::OsRng` at the call site. If +# the value is key material or an AEAD nonce of at least 12 bytes, route it through +# `crate::entropy::draw_key_bytes`. An `#[allow(clippy::disallowed_methods)]` needs a +# justification comment on the line above stating why the DEFAULT is required here — +# "it is a test" is not a justification. +# +# See: docs/security/KEY-05-ENTROPY-ENFORCEMENT.md + +disallowed-methods = [ + { path = "rand::random", reason = "KEY-05: inherits its entropy backend from a dependency default instead of stating it. Use rand::rngs::OsRng at the call site; for key material or AEAD nonces >= 12 bytes use crate::entropy::draw_key_bytes. See docs/security/KEY-05-ENTROPY-ENFORCEMENT.md" }, + { path = "rand::thread_rng", reason = "KEY-05: inherits its entropy backend from a dependency default instead of stating it. Use rand::rngs::OsRng at the call site; for key material or AEAD nonces >= 12 bytes use crate::entropy::draw_key_bytes. See docs/security/KEY-05-ENTROPY-ENFORCEMENT.md" }, +] diff --git a/core/deny.toml b/core/deny.toml new file mode 100644 index 00000000..01726e69 --- /dev/null +++ b/core/deny.toml @@ -0,0 +1,59 @@ +# KEY-05 layer (c) — make the `rand` major-version split VISIBLE rather than silent. +# +# WHY +# F-07 / R-05 asked for exactly one thing: a rule that fails the build when the +# duplicate `rand` majors in this graph change, "so the split is visible rather +# than silent". Two majors coexist today and that is tolerated-and-recorded, not +# fixed — bumping is not casual on a tree that pins `bip39` and `bitcoin` exactly. +# +# The contract this file encodes: +# - the tree AS IT STANDS passes; +# - a THIRD `rand` version, or a change to either member of the current pair, +# FAILS. +# +# POLICY DECISION (checkpoint 10-06 Task 5, decided 2026-08-02) +# Selected: **bans-only**. The `advisories` section is deliberately NOT enabled. +# An advisories gate fails builds when a new CVE is published against an existing +# dependency, with no change to this repository — on a tree where several agents +# commit and push continuously, an unrelated upstream disclosure would block +# everyone at an arbitrary hour, and the remediation is often a dependency bump +# that is itself a phase-sized change. No break-glass procedure exists today. +# Consequence, recorded rather than glossed: F-07's advisory half stays OPEN. +# +# Tool legitimacy: cargo-deny is published by EmbarkStudios +# (github.com/EmbarkStudios/cargo-deny), ~4.79M all-time downloads. CI pins 0.20.2 +# (published 2026-07-09). + +[bans] +# Global default stays permissive: this repo has many legitimately-duplicated +# transitive crates, and turning them all into build failures is not what F-07 +# asked for and would be a large, unrelated cleanup. +multiple-versions = "allow" + +# ...but `rand` specifically is change-detecting. +[[bans.deny]] +name = "rand" +deny-multiple-versions = true + +# Grandfather entry — dated 2026-08-02, per F-07. +# +# Tolerates the CURRENTLY KNOWN second major so the rule lands green instead of +# turning CI red on the day it ships. Anything outside this exact pair trips the +# rule above. +# +# rand 0.8.5 — direct dependency (core/archipelago/Cargo.toml), and also via +# archipelago-security, bip39 2.1.0, mainline, secp256k1, +# tungstenite 0.20.1 +# rand 0.9.2 — transitive only, via totp-rs 5.7.0 and tungstenite 0.26.2 +# (through nostr-sdk) +# +# WHEN THIS FIRES: do not widen the skip list reflexively. A third version means a +# new dependency brought its own `rand`; decide deliberately whether to accept it, +# and if so record where it comes from here, the same way these two are recorded. +[[bans.skip]] +name = "rand" +version = "=0.9.2" + +# Deliberately empty: see the policy decision above. Re-enabling this is a policy +# change that needs a break-glass procedure agreed first, not a quiet edit. +# [advisories] diff --git a/docs/security/KEY-05-ENTROPY-ENFORCEMENT.md b/docs/security/KEY-05-ENTROPY-ENFORCEMENT.md index ceb9fbfd..71be9513 100644 --- a/docs/security/KEY-05-ENTROPY-ENFORCEMENT.md +++ b/docs/security/KEY-05-ENTROPY-ENFORCEMENT.md @@ -213,28 +213,468 @@ section records the derivation independently.) ## Sealing: what it prevents and what it does not -*(Filled by Task 2.)* +`core/archipelago/src/entropy.rs` declares a **private** module `sealed` containing a trait +`Sealed`, and + +```rust +pub(crate) trait KeyGenRng: rand::RngCore + sealed::Sealed { … } +``` + +`sealed::Sealed` is nameable only from inside `entropy`, so `impl KeyGenRng for MyType` +written anywhere else cannot compile — the required supertrait bound is unsatisfiable and +unimplementable there. + +**What it prevents.** + +- No other module of this crate can add a member to the key-generation allowlist. +- No downstream crate can, either. +- `seed.rs::generate_mnemonic_with` is typed `R: KeyGenRng`, so the entropy source for the + entire master key hierarchy — node Ed25519 `did:key`, node Nostr key, FIPS mesh key, + per-identity keys, the BIP-84 wallet, LND aezeed entropy, and the fleet release-root + **signing** key — is constrained at the type level rather than by a doc comment. + +**What it does not prevent, stated plainly.** + +- **It does not prevent someone editing `entropy.rs` itself and adding a member.** Sealing + makes the allowlist a closed set that is *reviewable in one file*; it does not make it + immutable. That is the honest limit of the mechanism. +- **It does not prevent code calling an RNG directly, bypassing the seam entirely.** A new + `let k: [u8; 32] = rand::random();` in some unrelated module never mentions `KeyGenRng` and + sealing has nothing to say about it. **That gap is exactly what layer (b) covers.** The two + mechanisms are complementary, not redundant: (a) constrains what can drive a seam, (b) + constrains what can be written at all. +- **The "no downstream crate" clause is vacuous today.** `core/archipelago` is a + **binary-only** crate — `core/archipelago/Cargo.toml:8` declares `[[bin]]` with + `path = "src/main.rs"` and there is no `src/lib.rs`, so nothing depends on it and there are + no downstream crates to exclude. The clause is stated because it becomes load-bearing the + day this is split into a library, not because it is doing work now. + +### The false `CryptoRng` promise is retired, not relocated + +`seed.rs` previously carried `impl rand::CryptoRng for CountingRng` — a marker asserting that +an ascending counter is suitable for cryptographic use. `CryptoRng` has no compiler-checked +content: it is a promise any caller can make about any type, which is why the old bound +`R: rand::CryptoRng + rand::RngCore` was satisfiable by a counter in the first place. + +KEY-05 **deletes** that impl rather than moving it. After this plan the crate contains **zero** +`impl rand::CryptoRng` blocks — verified comment-filtered, so prose describing the deletion can +neither satisfy nor invalidate the check: + +``` +$ grep -rn "impl rand::CryptoRng" core/archipelago/src --include=*.rs \ + | grep -vE ':[0-9]+: *(//|///|\*)' | wc -l +0 +``` + +There is now exactly one mechanism for the claim "this RNG may generate keys", and it is the +one the compiler verifies. + +### Deviation from the plan: `KeyGenRng::GUARD_DRAWS` + +The plan specified `draw_key_bytes` as unconditionally guarded *and* required +`generate_mnemonic_with` to route through it *and* required the pre-existing +`mnemonic_generation_uses_injected_rng` known-answer assertions to stay byte-identical. **Those +three requirements are mutually unsatisfiable**, and the contradiction is not incidental: that +test's RNG emits `0x00, 0x01, … 0x1f`, which *is* the ascending-counter pattern layer (d) +exists to reject. Guarding it makes the known-answer pin unrepresentable. + +Resolution: `KeyGenRng` carries an associated constant + +```rust +const GUARD_DRAWS: bool = true; +``` + +which `draw_key_bytes` consults. Three properties make this an acceptable seam rather than a +hole: + +1. **It is inside the seal.** Only a type blessed in `entropy.rs` can set it, because only such + a type can implement `KeyGenRng` at all. +2. **The only member that sets it `false` is `#[cfg(test)]`-gated.** `testing::CountingRng` is + not compiled into the `archipelago` binary, so in a production build *every* allowlist + member is guarded. `sealed_allowlist_has_one_production_member` asserts + `::GUARD_DRAWS` is `true`. +3. **The guard is still observed tripping through `draw_key_bytes`**, not merely through the + pure predicate: `testing::ConstantRng` keeps the default `GUARD_DRAWS = true`, and + `draw_key_bytes_rejects_and_zeroizes_a_degenerate_draw` proves the full path — refusal, + variant, and buffer zeroization. + +The alternative — dropping the known-answer pin to satisfy the guard — would have deleted the +crate's only proof that the RNG named at the call site is the one `bip39` consumes. That proof +is the entire point of the F-02 remediation this plan generalises. + +--- ## Degenerate-entropy predicate -*(Filled by Task 2.)* +`entropy::is_degenerate(&[u8]) -> Option` recognises **exactly three** +patterns and nothing else: + +| Variant | Predicate | Why this shape | +|---|---|---| +| `AllZero` | every byte is `0x00` | what a buffer looks like when the fill never happened | +| `AllIdentical` | every byte equals `bytes[0]` | an uninitialised constant fill; checked *after* `AllZero` so the reported variant is the more specific one | +| `Counter` | every adjacent pair satisfies `b[i+1] == b[i].wrapping_add(1)`, **or** every adjacent pair satisfies `b[i+1] == b[i].wrapping_sub(1)` | a counter PRNG standing in for a CSPRNG — the 2026-07-30 COLDCARD shape | + +**Nothing heuristic.** No entropy estimator, no chi-squared, no "looks non-random" scoring. A +predicate whose false-positive rate cannot be computed in closed form cannot be argued safe, +and refusing genuine CSPRNG output on a key-generation path is strictly worse than the defect +being guarded against. + +### False-positive bound, computed + +For a uniform random `n`-byte buffer (`n ≥ 2`): + +- `P(AllIdentical)` — the first byte is free, the remaining `n−1` must match: + `256^−(n−1) = 2^−8(n−1)`. This already includes `AllZero` as a subset. +- `P(Counter)` — the first byte is free, the remaining `n−1` are then determined; ascending + and descending are disjoint for `n ≥ 2` (they would require `+1 ≡ −1 (mod 256)`): + `2 · 2^−8(n−1)`. +- Union bound: `P(degenerate) ≤ 3 · 2^−8(n−1)`. + +| `n` | Bound | As a probability | +|---|---|---| +| 2 | `3 · 2^−8` | **1.17 × 10⁻²** — about 1 in 85 | +| 4 | `3 · 2^−24` | 1.79 × 10⁻⁷ — about 1 in 5.6 million | +| **12** (`MIN_GUARDED_LEN`, the ChaCha20-Poly1305 nonce width) | `3 · 2^−88` | **9.7 × 10⁻²⁷** | +| **32** (session tokens, Cashu secrets, master-seed entropy) | `3 · 2^−248` | **6.6 × 10⁻⁷⁵** | + +Over a deliberately generous lifetime budget of **10¹² guarded draws across the whole fleet, +forever**, the expected number of false rejections is **9.7 × 10⁻¹⁵ at n = 12** and +**6.6 × 10⁻⁶³ at n = 32**. A false stop is not a risk this predicate meaningfully carries at or +above the floor. + +### Why twelve is the floor, and why it is a panic + +The `n = 2` and `n = 4` rows are the argument. On a 2-byte draw the predicate fires on genuine +CSPRNG output about **once in 85** — vastly worse than the defect it guards against. That is why +`draw_key_bytes` **panics** rather than erroring on a buffer shorter than `MIN_GUARDED_LEN`: +calling the guard where its own bound does not hold is a programmer error, not an input +condition. A caller that legitimately needs fewer bytes draws from `OsRng` directly and +unguarded, and the classification table above records every such site with its reason. + +Twelve is also exactly the ChaCha20-Poly1305 nonce width, so every AEAD nonce in the crate is +guardable *at* the floor rather than below it. + +### On a trip: refuse, zeroize, do not retry + +`draw_key_bytes` zeroizes the buffer, logs the variant and the buffer **length**, and returns +the error. **There is no retry.** A retry would paper over a genuinely broken RNG, which is +precisely the failure this layer exists to surface. The bytes themselves are never logged. + +### Empirical companion + +`degenerate_accepts_100k_osrng_draws` runs 100,000 consecutive 32-byte `OsRng` draws through +`is_degenerate` and asserts every one is accepted. Given the 6.6 × 10⁻⁷⁵ bound above, a single +rejection there means the predicate is wrong, not that the run was unlucky. + +--- ## CSPRNG-readiness ledger -*(Filled by Task 2.)* +**Path.** `/security/csprng-readiness.jsonl`, with +`ARCHIPELAGO_DATA_DIR` falling back to `/var/lib/archipelago` — the same resolution +`container/version_config.rs:36-39` uses. Resolving its own path is what lets layer (e) live +entirely inside `entropy.rs` **without** touching `bootstrap.rs` or `api/rpc/system/handlers.rs`, +both of which belong to plan `10-04`. + +Deliberately **outside `identity/`**: the KEY-02 rootfs identity sweep and +`backup.restore-identity` operate on that directory wholesale, and neither should ever have to +reason about a file that is not key material. + +**Schema.** One JSON object per line, append-only: + +```json +{"v":1,"ts":"2026-08-02T18:04:11Z","ready":true,"event":"master-seed-generate"} +``` + +| Field | Meaning | +|---|---| +| `v` | schema version — exists so a future change does not orphan lines already on fleet nodes | +| `ts` | RFC 3339 UTC, second precision | +| `ready` | `true` / `false` / `null` — the verdict `seed.rs::kernel_csprng_ready()` computes via `getrandom(GRND_NONBLOCK)`; `null` on a non-Linux build or an unexpected errno | +| `event` | which generation event this verdict belongs to; `master-seed-generate` from `MasterSeed::generate` | + +**No entropy, no key bytes, no seed material, no mnemonic word, and no hash of any of them is +ever written.** A readiness ledger that carried any of those would be a new place to steal a +key from, sitting one directory away from `identity/`. The record is a +`#[derive(serde::Serialize)]` struct with exactly four fields rather than a `json!` literal, so +the schema is a compile-time object that cannot drift. + +`readiness_record_contains_no_mnemonic_words` proves this the strong way: it generates a real +mnemonic through `MasterSeed::generate()` against a temporary data dir and asserts the ledger's +alphabetic token set is a **subset of the fixed schema vocabulary** — from which "no mnemonic +word leaked" follows, since any leaked word would be a token outside that set. The test does +**not** do a naive substring search, and the reason is recorded in the test itself: `master`, +`seed` and `ready` are themselves BIP-39 English words, and `generate` contains the BIP-39 word +`era` as a substring (`gen-era-te`), so a naive check would be flaky *and* wrong in both +directions. + +**Permissions.** Created `0o600` via `OpenOptions::mode`, matching the identity-blob pattern at +`seed.rs` and the generated-secret pattern at `container/secrets.rs:207`. + +**Best-effort, by design.** Every failure path — cannot create the directory, cannot open the +file, cannot write, cannot serialise — logs at `warn` and returns. `ceremony.rs` generates a +master seed **offline**, on a machine that need not have `/var/lib/archipelago` at all. An +audit record that could fail key generation would be an availability defect introduced by a +security feature, which is not a trade worth making. +`readiness_record_survives_unwritable_data_dir` proves this with a real unwritable path (a +*file* where the data directory should be), not by inspection. + +**What it closes.** `MasterSeed::generate` computed the readiness verdict, logged it into three +branches, and then discarded it. That discard is the whole of backlog **R-09**: a node could +never answer, after the fact, whether the kernel pool was seeded when its keys were born. It +can now. ## Deliberate non-applications of the guard -*(Filled by Task 3.)* +Layer (d) is applied at every `guarded: yes` row in the classification table. It is **not** +applied at the sites below. Each is recorded with its reason rather than silently omitted, +because a guard that is quietly skipped somewhere is worse than one that is openly bounded. + +### 1. `wallet/bdhke.rs` — the Cashu blinding factor + +`random_blinding_factor` migrates to an explicit `OsRng` but does **not** route through +`draw_key_bytes`. The draw is consumed by `secp256k1::SecretKey::new(&mut rng)`, which performs +**rejection sampling** into the curve group order — it draws, tests the candidate against the +order, and redraws on rejection. Intercepting the bytes to inspect them would mean +reimplementing that sampling in Archipelago, and getting rejection sampling subtly wrong on an +ecash key is a materially larger correctness risk than the guard buys against a hypothetical +future RNG rebinding. + +The migration is still worth doing on its own: the *source* is now named, which is the whole of +layer (a)'s claim, and `blinding_factor_is_valid_and_varies` pins that successive factors are +valid, in-range secp256k1 scalars and differ — so a rebinding to a constant source fails there +rather than silently producing correlated ecash. + +### 2. Short protocol identifiers — below `MIN_GUARDED_LEN` + +| Site | Width | Why unguarded | +|---|---|---| +| `mesh/x3dh.rs:100`, `:114` | 4 B (`u32` prekey ids) | Below the floor. Not key material — the X25519 secrets come from `crypto::generate_x25519_ephemeral()`. | +| `transport/chunking.rs:149` | 4 B (`u32` message id) | Below the floor; a frame correlator. | +| `fips/dial.rs:75` | 2 B (`u16` DNS transaction id) | Below the floor; `AllIdentical` would false-positive **once in 256**. | +| `api/rpc/auth.rs:125` | 2 B (display-name suffix) | Below the floor; same 1-in-256 argument. The actual credential is minted by `device_tokens::create`, which **is** guarded. | +| `totp.rs:305` | 1 B | A single byte cannot be meaningfully inspected at all. | + +The bound table in `## Degenerate-entropy predicate` is the argument: at two bytes the predicate +fires on genuine CSPRNG output about once in 85, which is a far worse defect than the one it +guards against. `draw_key_bytes` **panics** below the floor precisely so that this reasoning +cannot be bypassed by accident. + +### 3. Non-credential identifiers at or above the floor + +`api/rpc/package/pine_ha.rs:490`, `:507`, `:521`, `:588` (16-byte Home Assistant config-entry +and subentry ids) and `:665` (a 26-byte ULID-shaped id) are long enough to guard but are **not +key material or AEAD nonces**: Home Assistant requires only uniqueness from them and they +authenticate nothing. Guarding them would widen the guard's contract from "key material" to +"anything random", which makes the `guarded` column meaningless and puts a panic path on an app +config-seeding routine for no security gain. `pine_ha.rs:102` — the actual status **bearer +token** in the same file — *is* guarded, which is the distinction the column exists to record. + +### 4. Where a degenerate draw aborts rather than propagating + +`draw_key_bytes` returns a `Result`, and every site whose function already returns `Result` +propagates it: `storage_crypto::seal`, `credentials::encrypt_credentials`, +`device_tokens::create`, `federation::invites::create_invite`, the two `install.rs` sites, and +`seed::generate_mnemonic_with`. `pine_ha.rs:102` returns `Option` and degrades to `None` with a +`warn!`. + +Four sites **abort** instead, and this is a deviation from the plan's "propagate rather than +unwrap" instruction that needs stating: + +| Site | Why it cannot propagate | +|---|---| +| `session.rs::fresh_session_token` | `create`, `create_pending` and `rotate` return a bare `String`; their callers are in `api/rpc/mod.rs` and `api/rpc/totp.rs`, files plan 10-06 does not own. Widening them to `Result` is an API change this plan is not permitted to make. | +| `wallet/bdhke.rs::generate_secret` | returns `Vec` | +| `bitcoin_rpc.rs::generate_random_password` | returns `String`, and its caller is a `OnceCell` initialiser that also returns `String` | +| `container/secrets.rs::fill_secret_bytes` | `random_hex` / `random_base64` return `String` | + +In every one of the four, the only two available behaviours are *emit a predictable credential* +or *refuse loudly*, and only the second is defensible. Reaching the branch means the kernel +CSPRNG returned 12–32 bytes that are all-zero, all-identical or a ±1 counter — the machine has +no usable entropy and must not be issuing credentials at all. None of the four can be driven by +attacker-supplied input: the predicate reads only `OsRng` output. The false-trip bound is +`3 · 2^−88` at 12 bytes and `3 · 2^−248` at 32. + +Making these propagate properly is a worthwhile follow-up, but it is an API change across files +this plan does not own, so it is recorded here rather than performed. ## Clippy dry-run evidence -*(Filled by Task 2; extended by Task 6 with the observed build failure and recovery.)* +A lint config that is never observed to fail is indistinguishable from one that is +misconfigured, so the ban was **observed firing** rather than assumed. Run from `core/`, +2026-08-02, clippy 1.95.0. + +### The ban fires + +A single banned call was reintroduced into `entropy.rs` and clippy re-run: + +``` +warning: use of a disallowed method `rand::random` + --> archipelago/src/entropy.rs:675:5 + | +675 | rand::random::() + | ^^^^^^^^^^^^^^^^^^^ + | + = note: KEY-05: inherits its entropy backend from a dependency default instead of + stating it. Use rand::rngs::OsRng at the call site; for key material or AEAD + nonces >= 12 bytes use crate::entropy::draw_key_bytes. See + docs/security/KEY-05-ENTROPY-ENFORCEMENT.md + = note: `#[warn(clippy::disallowed_methods)]` on by default +``` + +The `reason` string reaches the developer at the point of failure, which is the whole +value of the `reason` field. Under the CI invocation's `-D warnings` this is an error. + +### The reintroduction was reverted + +After `git checkout core/archipelago/src/entropy.rs`, the residual count is **0**: + +``` +grep -rn "rand::random\|thread_rng()" core/archipelago/src --include=*.rs \ + | grep -vE ':[0-9]+: *(//|///|\*)' | wc -l +0 +``` + +### ⚠️ The enforcement channel is currently NOT green — a finding, not a side note + +Layer (b) was designed to need no CI change because the Rust job already runs +`cargo clippy --all-targets --all-features -- -D warnings`. That reasoning is sound, but +the measured state of the tree is not: + +**`cargo clippy --all-targets --all-features` emits 42 pre-existing warnings** on this +tree, unrelated to KEY-05 — `unused import: DeviceProbe`, `constant ELECTRUM is never +used`, `value assigned to last_err is never read`, plus ~39 style lints +(`redundant_guards`, `manual_map`, `needless_return`, `nonminimal_bool`, +`items_after_test_module`, and others). Under `-D warnings` **every one of them is +already an error**, so that CI step cannot currently pass for reasons that have nothing +to do with this plan. + +Consequences, stated plainly: + +1. KEY-05 layer (b) is **correctly configured and proven to fire**, but the gate it rides + on is red for other reasons. Until those 42 are cleared, a new banned RNG call would be + one error among many rather than the distinctive build-stopper the design intends. +2. This is **pre-existing and out of scope here** — clearing 42 lints across the crate is + its own change, and doing it immediately before an OTA would be poor sequencing. +3. It is recorded rather than quietly absorbed, because a reader would otherwise + reasonably conclude from "no CI change was needed" that the gate is live and effective. + It is live; it is not yet effective. + +Recommended follow-up: a dedicated lint-clearing pass, after which layer (b) becomes a +real gate. Tracked in `## What this does not close`. + +## cargo-deny evidence + +Verified by the same standard — the rule was observed both passing and failing. + +**A. The tree as it stands passes.** `cargo deny check bans` → `bans ok`, exit 0. + +**B. The rule bites.** The plan offered two demonstrations; the second was used +(introducing a synthetic third `rand` was impractical without perturbing the lockfile). +The grandfather `[[bans.skip]]` entry was temporarily removed and the rule fired on the +existing pair, printing the full dependency trees for both versions and exiting **2**: + +``` + ├ rand v0.8.5 (direct, + archipelago-security, bip39, mainline, + │ secp256k1, tungstenite 0.20.1) + ├ rand v0.9.2 (totp-rs 5.7.0; tungstenite 0.26.2 via nostr-sdk) + +bans FAILED +``` + +This also independently confirms F-07's account of where each version comes from. + +**C. Restored.** The grandfather entry was put back and `cargo deny check bans` returns +`bans ok`, exit 0. ## cargo-deny policy -*(PENDING — Task 5 is a `gate="blocking-human"` checkpoint. Not filled.)* +**Decision (checkpoint 10-06 Task 5, human-approved 2026-08-02): `bans` only. `advisories` NOT +enabled.** Pinned version: **cargo-deny 0.20.2**. + +### Tool legitimacy (the required pre-step) + +`cargo-deny` was verified on crates.io before being wired into CI: + +| Check | Result | +|---|---| +| Publisher / repository | EmbarkStudios — `github.com/EmbarkStudios/cargo-deny`, resolves | +| Homepage | same as repository | +| Latest published version | `0.20.2`, published 2026-07-09 | +| Downloads | ~4,786,401 all-time; ~1,285,082 recent | +| Version pinned in CI | `0.20.2` | + +Disposition: legitimate, actively maintained, plausible download history for a tool of its age. + +### Why bans-only + +R-05 / F-07 / KEY-05(c) asked for exactly one thing: fail the build when the duplicate `rand` +majors change, "so the split is visible rather than silent". That is what shipped. + +The `advisories` section is a materially larger, separate commitment and was declined **for now**, +with the cost stated rather than glossed: an advisories gate fails builds when a **new CVE is +published against an existing dependency, with no change to this repository**. On a tree where +several agents commit and push continuously, an unrelated upstream disclosure would block +everyone at an arbitrary hour, and the remediation is frequently 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. No break-glass procedure exists today. That is a policy call +about how the team wants to be interrupted, so it was taken by a human, not defaulted by a planner. + +### Mechanism + +`core/deny.toml` uses a global `multiple-versions = "allow"` with a per-crate +`[[bans.deny]] name = "rand", deny-multiple-versions = true`, plus a dated `[[bans.skip]]` +grandfather entry pinning `=0.9.2` exactly. The contract, independent of config keys: + +- the tree **as it stands** passes; +- a **third** `rand` version, or a change to either member of the current pair, **fails**. + +### CI wiring, and one deliberate deviation from the plan's suggestion + +The plan anticipated the `EmbarkStudios/cargo-deny-action`. That action was inspected and +**not** used: it exposes **no input to pin the cargo-deny version**, and an unpinned +supply-chain checker is a contradiction in terms — it would reintroduce, at the CI layer, exactly +the "backend fixed by configuration rather than stated" failure shape this whole plan exists to +remove. Instead the CI step installs the tool from crates.io at an exact version +(`cargo install --locked cargo-deny --version 0.20.2`), which is also the source that was +legitimacy-checked above, and avoids adding a second, unvetted third-party action to the workflow. + +Cost of this choice, stated honestly: `cargo install` is slower than a prebuilt-binary action on +a cold cache. The existing `actions-rust-lang/setup-rust-toolchain@v1` caching mitigates it. ## What this does not close -*(Filled by Task 6.)* +Recorded so that nothing here is mistaken for a stronger guarantee than it is. + +- **F-07's advisory half remains OPEN.** Bans-only was selected; there is still no + dependency-advisory (CVE) gate in CI. This stays in the backlog as R-05's unfinished remainder, + and adopting it needs an agreed break-glass procedure first. +- **The two `rand` majors are still both in the graph.** This layer makes the split *visible and + change-detecting*; it does not unify it. Unifying means bumping exactly-pinned crypto + dependencies and is not in scope here. +- **F-09 / R-12 remains deferred.** `totp.rs` still selects its charset with `% charset.len()`. + The bias is presently **zero** (32 divides 256 exactly), and only the *entropy source* was + migrated. The selection algorithm was deliberately left untouched. +- **F-11 / R-14 remains deferred.** +- **`core/models` is outside the enforcement graph.** `cargo metadata --no-deps` confirms the + workspace members are `archipelago`, `archipelago-container`, `archipelago-openwrt`, + `archipelago-performance` and `archipelago-security`. `core/models/src/data_url.rs:163` and + `core/models/src/procedure_name.rs:32` are real matches of the same shape that **no + `disallowed-methods` entry can reach**. This is a stated limitation, not an omission. +- **Sealing does not prevent an edit to `entropy.rs` itself.** The allowlist is sealed against + *other modules* adding a member; anyone editing `entropy.rs` can still add one. The mechanism + raises the act from an invisible default to a deliberate, reviewable change to a file whose + entire purpose is this guarantee — that is the honest claim, and it is not "impossible". +- **Mnemonics generated before this change came from the previous source.** That source was, and + remains, `getrandom(2)`-backed on the pinned `rand 0.8.5` — so nothing already generated is + suspect. This plan removes a *future* failure mode; it is not a remediation of past key material, + and no re-generation is implied or required. +- **Layer (b)'s gate is live but not yet effective.** The tree carries 42 pre-existing clippy + warnings that are already errors under the CI step's `-D warnings`, so that step cannot pass + today for reasons unrelated to KEY-05. The ban is correctly configured and proven to fire (see + `## Clippy dry-run evidence`), but it needs a dedicated lint-clearing pass before a new banned + RNG call stands out as the distinctive build-stopper the design intends. Out of scope here. +- **The degenerate-entropy predicate is not a health check for the kernel CSPRNG.** It rejects + three specific catastrophic shapes at the moment of a draw. It cannot detect a subtly-biased or + backdoored generator, and it is not evidence that one is absent.