79 KiB
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 |
|
|
false |
|
|
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:
- the concurrent agent working Phase 1 has finished, and
- 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>). Nevergit add -Aorgit commit -a. - No
git push, no tag, no deploy from this plan. - Rust workspace root is
core/; runcargofrom there. - On
rust-lld: undefined hidden symbol, rebuild withCARGO_INCREMENTAL=0. - A full
cargo test -p archipelagocontends 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 2–3 tasks per plan. This plan deliberately exceeds it, for two reasons that are recorded here rather than left for a reviewer to rediscover:
- The orchestrator mandated exactly one new plan for KEY-05 (
10-06), with10-01..10-05committed and final. Splitting into10-06/10-07was not available. - 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.tomlexists 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:305is 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-uifile 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:100and:114arelet spk_id: u32 = rand::random();andlet otk_id: u32 = rand::random();— prekey identifiers, not key material. The actual X25519 secrets come fromcrypto::generate_x25519_ephemeral(). They are still in scope (they are values that go on the wire), but F-10a's characterisation of this file as "key material" overstates these two specific lines. Record the correction.
Call-site shapes already read (so no task needs to re-derive them):
storage_crypto.rs:39 — let nonce_bytes: [u8; 12] = rand::random(); inside seal(),
whose envelope is nonce ‖ ciphertext with a 12-byte prefix (:47-50), decrypted by
open() splitting at 12 (:58).
credentials/store.rs:69 — let nonce_bytes: [u8; 12] = rand::random();.
wallet/bdhke.rs:133 — let random_bytes: [u8; 32] = rand::random(); in generate_secret();
:139 — let mut rng = rand::thread_rng(); immediately consumed by
SecretKey::new(&mut rng) in random_blinding_factor() — the one genuine
generic-over-RNG key-generation seam outside seed.rs.
container/secrets.rs:103/:112 — rand::thread_rng().fill_bytes(&mut buf) in
random_hex() / random_base64().
federation/invites.rs:42 — rand::thread_rng().fill(&mut token_bytes) (needs the
rand::Rng trait in scope; OsRng satisfies it by blanket impl).
totp.rs:305 — let idx = (rand::random::<u8>() as usize) % charset.len();.
api/rpc/auth.rs:125 — hex::encode(rand::random::<[u8; 2]>()), a 2-byte display-name
suffix, not key material.
fips/dial.rs:75 — let id: u16 = rand::random();.
transport/chunking.rs:149 — let message_id: u32 = rand::random();.
The seam being generalised (seed.rs):
kernel_csprng_ready() at :59 returns Option<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:71 — the ceremony caller is why the readiness ledger must be best-effort
(it runs offline, possibly with no /var/lib/archipelago at all).
The equivalence proof this plan leans on already exists in the repo.
seed.rs:672-678 asserts that generate_mnemonic_with(CountingRng(0)) equals
bip39::Mnemonic::from_entropy(&(0u8..32).collect()) exactly. That existing assertion is
the proof that "fill 32 bytes then from_entropy" and generate_in_with are the same
function. Task 2 relies on it and must keep it passing verbatim.
Persisted-path precedent. container/version_config.rs:37-38 resolves its base from
ARCHIPELAGO_DATA_DIR with a /var/lib/archipelago fallback, and :166 documents that
this is process-global and how its tests handle that. The readiness ledger follows this
precedent, which is what lets it live entirely inside entropy.rs without modifying
api/rpc/seed_rpc.rs (a file 10-01 owns).
0600 precedent. seed.rs:345 sets Permissions::from_mode(0o600) on identity blobs;
container/secrets.rs:207 does the same for generated secrets and :269/:307 are tests
asserting it. The ledger reuses this pattern.
</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>
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 namerand::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 toOsRngas 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 twoseed.rsrows).
guarded marks whether the drawn bytes will additionally run the degenerate-entropy
predicate built in Task 2. Set guarded: yes only where the value is key material or an
AEAD nonce and the draw is at least 12 bytes. Set guarded: no with a stated reason
for short protocol identifiers — record explicitly that mesh/x3dh.rs:100/:114 (u32
prekey ids), transport/chunking.rs:149 (u32 message id), fips/dial.rs:75 (u16 dial id)
and api/rpc/auth.rs:125 (2-byte display-name suffix) are guarded: no because a
predicate that rejects "all bytes identical" would false-positive on a 2-byte draw once in
256, which would be worse than the defect it guards against.
Record two corrections to F-10a, each with the evidence line that establishes it: the
session.rs production-versus-test split, and the fact that the two mesh/x3dh.rs sites
are prekey identifiers rather than the key material F-10a's table column implies. F-10a
declined to classify on purpose; these are resolutions of that, not contradictions of it.
Pin the enforcement blast radius mechanically rather than by assertion. Run
cargo metadata --no-deps --format-version 1 from core/ and list the package names it
returns; state whether models, helpers and js-engine appear. If they do not, record
that core/models/src/data_url.rs:163 and core/models/src/procedure_name.rs:32 are
outside the clippy build graph and therefore outside KEY-05's reach, and say so plainly as
a limitation rather than omitting them. If they do appear, add their rows to the
classification table and to this plan's migration scope.
Add a ## Layer coverage section mapping ROADMAP KEY-05 layers (a)–(e) to the tasks in
this plan, so a reader can see none was dropped.
Add a ## Source precedence note recording that KEY-05 postdates 10-CONTEXT.md and
supersedes its deferral of R-05 and R-13, while F-09/R-12 and F-11/R-14 stay deferred.
Leave placeholder headings, each marked as pending, for the sections later tasks fill:
## Sealing: what it prevents and what it does not, ## Degenerate-entropy predicate,
## CSPRNG-readiness ledger, ## Clippy dry-run evidence, ## cargo-deny policy.
test -f docs/security/KEY-05-ENTROPY-ENFORCEMENT.md && N=$(grep -cE '^| ?core/(archipelago|models)/src/[^|]+:[0-9]+' docs/security/KEY-05-ENTROPY-ENFORCEMENT.md) && echo "classified rows: $N" && [ "$N" -ge 43 ]</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.
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 equalsbytes[0](this subsumesAllZero; checkAllZerofirst 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 pairbytes[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.
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.
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.
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.
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 module — core/archipelago/src/entropy.rs, declared as mod entropy; in
core/archipelago/src/main.rs.
Symbols created in entropy:
| Symbol | Kind | Visibility | Purpose |
|---|---|---|---|
sealed |
module | private to entropy |
Makes Sealed unnameable outside entropy, which is what seals the allowlist |
sealed::Sealed |
trait | private | The unnameable supertrait |
KeyGenRng |
trait | pub(crate) |
The allowlist: rand::RngCore + sealed::Sealed, deliberately without a rand::CryptoRng supertrait |
impl Sealed for rand::rngs::OsRng |
impl | — | The sole production allowlist membership |
impl KeyGenRng for rand::rngs::OsRng |
impl | — | The sole production allowlist membership |
MIN_GUARDED_LEN |
const | pub(crate) |
12 — the floor below which the degenerate predicate's false-positive argument does not hold |
DegenerateEntropy |
enum | pub(crate) |
Variants AllZero, AllIdentical, Counter |
is_degenerate |
fn | pub(crate) |
The predicate, &[u8] -> Option<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> |
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.
CARGO_INCREMENTAL=0 cargo clippy --all-targets --all-features -- -D warningsexits 0 — the CI gate, withcore/clippy.tomllive.- The document contains captured evidence of that same invocation failing on a deliberately reintroduced banned call, and passing after revert.
grep -rn "rand::random\|thread_rng()" core/archipelago/src --include=*.rs | grep -vE ':[0-9]+: *(//|///|\*)' | wc -lequals the Task 1allowcount (expected 0).grep -rn "impl rand::CryptoRng" core/archipelago/src --include=*.rs | grep -vE ':[0-9]+: *(//|///|\*)' | wc -lreturns 0.CARGO_INCREMENTAL=0 cargo test -p archipelago entropy::and... seed::pass.cargo deny check bansexits 0, and the document holds captured evidence of it exiting non-zero under the change-detection demonstration.CARGO_INCREMENTAL=0 cargo test --all-featurespasses (once, Task 6).cargo fmt --all -- --checkexits 0.git diff --stattouches only paths in this plan'sfiles_modified.- Every
## Layer coveragerow 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
CryptoRngpromise retired; (b)clippy.tomlban enforced by the existing-D warningsCI step and observed failing a real build; (c)cargo-denybans rule detecting any change to the duplicate-randset; (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.tomlwhile 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.rsormesh/x3dh.rsdiffers. - 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>