feat(10-06): enable the entropy lint and supply-chain gates (KEY-05 b/c)
Layer (b) — core/clippy.toml bans rand::random and rand::thread_rng crate-wide, each with a reason naming KEY-05 and pointing at the evidence doc. No CI change was needed: the 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 covers tests deliberately — a fixture keeping the default is a template for the next production call site. Ordering was asserted before the file was written, not after: the residual count of unmigrated call sites is 0, so this cannot turn CI red for other agents on this shared tree. Layer (c) — core/deny.toml makes the rand major split change-detecting: global multiple-versions = "allow", a per-crate deny-multiple-versions for rand, and a dated grandfather skip pinning =0.9.2 exactly. The tree as it stands passes; a third version or a change to either member fails. Both gates were OBSERVED working, not assumed: - Reintroducing one banned call produced the disallowed_methods error with the reason text reaching the developer at the failure point; reverting returned the residual count to 0. - `cargo deny check bans` exits 0 as-is. Removing the grandfather entry made it exit 2 and print both dependency trees, independently confirming F-07's account of where each rand version comes from. Restored, it exits 0 again. Policy (checkpoint Task 5, human-approved): bans-only. The advisories gate is NOT enabled — it fails builds when a new CVE is published against an existing dep with no local change, which on a tree where several agents push continuously would block everyone at an arbitrary hour, with remediation often meaning a bump to an exactly-pinned crypto dependency. No break-glass procedure exists. F-07's advisory half stays OPEN and is recorded as such. cargo-deny is pinned to 0.20.2 and installed from crates.io rather than via EmbarkStudios/cargo-deny-action, because that action exposes no input to pin the tool version — an unpinned supply-chain checker would reintroduce, at the CI layer, the exact "backend fixed by configuration rather than stated" shape this plan exists to remove. crates.io is also the source vetted at the Task 5 legitimacy gate (EmbarkStudios, repo resolves, ~4.79M downloads). RECORDED HONESTLY: layer (b)'s gate is live but not yet EFFECTIVE. The tree carries 42 pre-existing clippy warnings — unused imports, dead code, ~39 style lints — that are already errors under -D warnings, so that CI step cannot pass today for reasons unrelated to KEY-05. Until a dedicated lint-clearing pass lands, a new banned RNG call would be one error among many rather than a distinctive build-stopper. Pre-existing and out of scope; clearing it right before an OTA would be poor sequencing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c966395eb9
commit
a3283cffb4
@@ -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
|
||||
`<OsRng as KeyGenRng>::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<DegenerateEntropy>` 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.** `<ARCHIPELAGO_DATA_DIR>/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<u8>` |
|
||||
| `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::<u64>()
|
||||
| ^^^^^^^^^^^^^^^^^^^
|
||||
|
|
||||
= 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.
|
||||
|
||||
Reference in New Issue
Block a user