The repo is source code and guidelines only. Nothing about how Archipelago's own fleet is run, or how the team works, stays in it. Untracked (kept on disk, gitignored) — 250 files: - .planning/ (199) and loop/ — internal development process - fleet operations tooling that targets specific nodes: deploy-to-target, deploy-tailscale, deploy-config-defaults, setup-target-dev, setup-aiui-server, setup-https-dev, debug-frontend, node-profile, fleet-fips-pair/unpair, image-recipe/sync-from-live.sh - image-recipe/INTEGRATION-GUIDE.md and docs/multinode-testing-plan.md, both of which are live-server workflow and fleet node inventories - the Phase 10 on-node verification and evidence records, which cite .planning/ as their evidence base KEY-05-ENTROPY-ENFORCEMENT.md was initially moved out with the other Phase 10 docs and then put back: it is cited as normative rationale from ten places in the codebase, including core/clippy.toml, which bans rand::thread_rng and points at it for the reason. That makes it a guideline, not an internal record. Node names removed from source (48 occurrences across comments, manifests and test fixtures): archi-dev-box, archy-x250*, shorty-s, framework-pt, zaza-optiplex, archi-thinkpad. Comments keep the engineering context and the date, which is what carried the meaning; the machine name did not. Three of those were live test values rather than comments and were replaced with valid stand-ins, not prose: two mDNS hostnames and a mesh peer name. An earlier pass substituted "a test node" into a hostname assertion, producing an invalid hostname; caught and fixed as test-node.local. Wipe mechanism: .local-only/manifest.txt inventories every local-only path and .local-only/wipe.sh deletes them on one confirmation, refusing to touch anything git still tracks. Both are themselves untracked, so the public repo does not carry a map of internal filenames. Verified: cargo check -p archipelago --all-features clean; archipelago-container 75/75 tests pass; appOrigin vitest 7/7; audit-secrets 5/5; every relative link in tracked markdown resolves (0 broken). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
44 KiB
KEY-05 — Entropy enforcement: per-site classification and mechanism record
Requirement: ROADMAP KEY-05.
Supersedes: backlog R-13. Absorbs: R-05 (duplicate-rand visibility) and R-09
(CSPRNG-readiness record). Resolves: F-10a in
docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md, which recorded raw match counts and
deliberately declined to classify them.
Tree state this document was derived against: HEAD = c5a82cba (2026-08-02).
Nothing here is broken today
rand::random() and rand::thread_rng() on the pinned rand 0.8.5 resolve to
ReseedingRng<ChaCha12Core, OsRng> — seeded from getrandom(2), reseeded every 64 KiB,
fork-protected. Every value in the table below was drawn from a genuine CSPRNG. This
document is not an incident record.
What KEY-05 removes is the structural shape: 41 call sites whose entropy backend is
selected by Cargo.lock resolution and crate feature flags rather than stated in
Archipelago's own source, with no compile error if that selection changes. That is the shape
("T1") that produced the 2026-07-30 COLDCARD entropy defect, here with key material, an AEAD
nonce and session credentials in the blast radius.
Layer coverage
ROADMAP KEY-05 names five layers. None was dropped.
| Layer | What it is | Task that closes it | Status |
|---|---|---|---|
| (a) | Sealed key-generation RNG allowlist at the mnemonic seam; the false impl rand::CryptoRng promise retired |
Task 2 | Closed — entropy::KeyGenRng sealed via a private sealed::Sealed; seed.rs::generate_mnemonic_with retyped to it; zero impl rand::CryptoRng blocks remain in the crate |
| (b) | Crate-wide compile-time ban on the defaulted entry points, enforced by the CI clippy step that already exists | Task 2 (dry run, uncommitted) → Task 6 (enable) | NOT CLOSED — see ## Clippy dry-run evidence and ## What this does not close. Blocked behind the Task 5 human checkpoint. |
| (c) | cargo-deny bans rule making the duplicate-rand split visible and change-detecting |
Task 5 (decision) → Task 6 (implement) | NOT CLOSED — blocked on the Task 5 human decision |
| (d) | Degenerate-entropy runtime predicate | Task 2 (built) → Tasks 3/4 (applied) | Closed — entropy::is_degenerate / entropy::draw_key_bytes, applied at every guarded: yes row below |
| (e) | Durable CSPRNG-readiness record | Task 2 | Closed — entropy::record_csprng_readiness, called from MasterSeed::generate |
Layers (b) and (c) are the two that turn CI red for every agent on this shared repository if
they are enabled wrongly. Both are gated behind Task 5, a gate="blocking-human" checkpoint.
Source precedence
The Phase 10 hardening work 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.
Two deferrals from that context stand and were not executed:
- F-09 / R-12 — TOTP modulo bias.
totp.rs:305is migrated for its entropy source only. The% charset.len()selection is byte-for-byte unchanged. (The bias is presently zero: the charset is 32 characters and 32 divides 256 exactly. R-12 is about the latent bias if the charset ever changes length.) - F-11 / R-14 —
Math.random()inneode-ui. No frontend file is touched by this plan.
Enforcement blast radius — pinned mechanically
CI runs clippy with working-directory: core (.github/workflows/ci.yml:19) and
cargo clippy --all-targets --all-features -- -D warnings (:35). A clippy.toml at
core/ therefore governs exactly the workspace members and no more.
cargo metadata --no-deps --format-version 1 run from core/, package names only:
['archipelago', 'archipelago-container', 'archipelago-openwrt', 'archipelago-performance', 'archipelago-security']
models, helpers and js-engine do not appear. They are directories under core/ but
are not workspace members (core/Cargo.toml:4-10), and are referenced only by each other.
Stated limitation, not an omission. core/models/src/data_url.rs:163
(let random: [u8; 10] = rand::random();) and core/models/src/procedure_name.rs:32
(Some(format!("Properties-{}", rand::random::<u64>()))) are real matches of the same shape
and are outside KEY-05's reach: they are outside the clippy build graph, so no
disallowed-methods entry can reach them, and they are outside this plan's files_modified.
Neither draws key material (a data-URL filename component and a procedure-name suffix), and
neither is compiled into the archipelago binary. They are recorded here so a future reader
does not mistake "43 classified" for "43 of 45 in the repository".
The other four workspace members (container, openwrt, performance, security) contain
zero matches — verified by
grep -rn "rand::random\|thread_rng()" core/container core/openwrt core/performance core/security --include=*.rs,
which returns nothing. So the ban, once enabled, is free for them.
Per-site classification — all 43 matches
Source of the inventory, re-run against the working tree at HEAD = c5a82cba rather than
inherited from the plan or from F-10a:
grep -rn "rand::random\|thread_rng()" core/archipelago/src --include=*.rs
→ 43 lines across 16 files (15 code files + seed.rs, whose two matches are comments).
prod/test is decided by whether the line falls inside that file's #[cfg(test)] mod tests
block; the block's start line is cited in the ## cfg(test) boundaries section below and is
the evidence for every test verdict.
guarded is yes only where the drawn value is key material or an AEAD nonce and the
draw is at least MIN_GUARDED_LEN = 12 bytes. Every no carries its reason.
| Site | Expression | Kind | Becomes | Guarded | Disposition |
|---|---|---|---|---|---|
core/archipelago/src/storage_crypto.rs:39 |
let nonce_bytes: [u8; 12] = rand::random(); |
production | ChaCha20-Poly1305 nonce for the message / mesh-contact at-rest stores; the 12-byte prefix of the nonce ‖ ciphertext envelope |
yes (12 B, AEAD nonce — reuse is a keystream break) | migrate |
core/archipelago/src/credentials/store.rs:120 |
let nonce_bytes: [u8; 12] = rand::random(); |
production | ChaCha20-Poly1305 nonce for the credential store, inside encrypt_credentials |
yes (12 B, AEAD nonce) | migrate |
core/archipelago/src/session.rs:156 |
let token_bytes: [u8; 32] = rand::random(); |
production | full authenticated session token (SessionStore::create) |
yes (32 B, bearer credential) | migrate |
core/archipelago/src/session.rs:178 |
let token_bytes: [u8; 32] = rand::random(); |
production | pending-TOTP session token (create_pending) |
yes (32 B) | migrate |
core/archipelago/src/session.rs:254 |
let new_token_bytes: [u8; 32] = rand::random(); |
production | rotated token on pending→full upgrade (upgrade_to_full) |
yes (32 B) | migrate |
core/archipelago/src/session.rs:294 |
let new_token_bytes: [u8; 32] = rand::random(); |
production | rotated session token (rotate) |
yes (32 B) | migrate |
core/archipelago/src/session.rs:478 |
rand::random::<u64>() |
test (mod at :471) |
uniquifying suffix in a temp-file path for new_for_tests |
no — 8 B, a filename component, not key material | migrate |
core/archipelago/src/session.rs:489 |
rand::random::<u64>() |
test | temp-file path suffix | no — as above | migrate |
core/archipelago/src/session.rs:498 |
rand::random::<u64>() |
test | temp-file path suffix | no — as above | migrate |
core/archipelago/src/session.rs:511 |
rand::random::<u64>() |
test | temp-file path suffix | no — as above | migrate |
core/archipelago/src/session.rs:538 |
rand::random::<u64>() |
test | temp-file path suffix | no — as above | migrate |
core/archipelago/src/session.rs:569 |
rand::random::<u64>() |
test | temp-file path suffix | no — as above | migrate |
core/archipelago/src/session.rs:584 |
rand::random::<u64>() |
test | temp-file path suffix | no — as above | migrate |
core/archipelago/src/session.rs:602 |
rand::random::<u64>() |
test | temp-file path suffix | no — as above | migrate |
core/archipelago/src/session.rs:620 |
rand::random::<u64>() |
test | temp-file path suffix | no — as above | migrate |
core/archipelago/src/session.rs:651 |
rand::random::<u64>() |
test | temp-file path suffix | no — as above | migrate |
core/archipelago/src/session.rs:669 |
rand::random::<u64>() |
test | temp-file path suffix | no — as above | migrate |
core/archipelago/src/session.rs:685 |
rand::random::<u64>() |
test | temp-file path suffix | no — as above | migrate |
core/archipelago/src/device_tokens.rs:64 |
let token_bytes: [u8; 32] = rand::random(); |
production | companion-device bearer token (device_tokens::create) |
yes (32 B, bearer credential) | migrate |
core/archipelago/src/federation/invites.rs:42 |
rand::thread_rng().fill(&mut token_bytes); |
production | 16-byte federation invite token, hex-encoded into the invite payload | yes (16 B, unguessable-by-design token) | migrate |
core/archipelago/src/wallet/bdhke.rs:133 |
let random_bytes: [u8; 32] = rand::random(); |
production | Cashu (NUT-00/NUT-10) proof secret — genuine ecash key material | yes (32 B) | migrate |
core/archipelago/src/wallet/bdhke.rs:139 |
let mut rng = rand::thread_rng(); → SecretKey::new(&mut rng) |
production | Cashu blinding factor — a secp256k1 scalar; genuine ecash key material | no — deliberate non-application, see ## Deliberate non-applications of the guard |
migrate |
core/archipelago/src/wallet/bdhke.rs:169 |
let k = SecretKey::new(&mut rand::thread_rng()); |
test (mod at :144) |
throwaway scalar in test_bdhke_flow |
no — test scalar, same rejection-sampling argument as :139 |
migrate |
core/archipelago/src/wallet/bdhke.rs:206 |
let k = SecretKey::new(&mut rand::thread_rng()); |
test | throwaway scalar | no — as above | migrate |
core/archipelago/src/mesh/x3dh.rs:100 |
let spk_id: u32 = rand::random(); |
production | SignedPrekey.id — a 4-byte identifier, not key material (the X25519 secret comes from crypto::generate_x25519_ephemeral() at :99) |
no — 4 B, below MIN_GUARDED_LEN; an "all bytes identical" predicate false-positives on a 4-byte draw once in 2^24 |
migrate |
core/archipelago/src/mesh/x3dh.rs:114 |
let otk_id: u32 = rand::random(); |
production | OneTimePrekey.id — 4-byte identifier; the secret comes from crypto::generate_x25519_ephemeral() at :113 |
no — as above | migrate |
core/archipelago/src/container/secrets.rs:103 |
rand::thread_rng().fill_bytes(&mut buf); |
production | random_hex(bytes) — the manifest-declared generated_secrets (app passwords, API keys); the original F-10 |
yes when bytes >= 12 (the only production callers request 16/32); unguarded below the floor |
migrate |
core/archipelago/src/container/secrets.rs:112 |
rand::thread_rng().fill_bytes(&mut buf); |
production | random_base64(bytes) — same, for services that base64-decode to raw bytes (e.g. netbird encryptionKey) |
yes when bytes >= 12 |
migrate |
core/archipelago/src/api/rpc/package/install.rs:732 |
let secret: [u8; 32] = rand::random(); |
production | SearXNG server.secret_key in settings.yml — signs SearXNG's own tokens |
yes (32 B, app secret) | migrate |
core/archipelago/src/api/rpc/package/install.rs:1456 |
let salt_bytes: [u8; 16] = rand::random(); |
production | rpcauth= salt for the Bitcoin Core RPC HMAC credential line |
yes (16 B; the salt is half the credential — a degenerate salt weakens the stored rpcauth line) |
migrate |
core/archipelago/src/bitcoin_rpc.rs:62 |
let bytes: [u8; 16] = rand::random(); |
production (file has no #[cfg(test)] module) |
the Bitcoin RPC password itself, hex-encoded to 32 chars | yes (16 B, credential) | migrate |
core/archipelago/src/api/rpc/package/pine_ha.rs:102 |
let raw: [u8; 32] = rand::random(); |
production | Pine/Home-Assistant status bearer token, written 0600 under NODE_SECRETS_DIR |
yes (32 B, bearer credential) | migrate |
core/archipelago/src/api/rpc/package/pine_ha.rs:490 |
"entry_id": id(rand::random()), |
production | Home Assistant config-entry id (16 B hex) — HA needs uniqueness only; not a credential and never authenticates anything | no — an identifier, not key material; fails the "key material or AEAD nonce" test | migrate |
core/archipelago/src/api/rpc/package/pine_ha.rs:507 |
"subentry_id": id(rand::random()), |
production | HA conversation subentry id | no — identifier, as above | migrate |
core/archipelago/src/api/rpc/package/pine_ha.rs:521 |
"subentry_id": id(rand::random()), |
production | HA ai_task_data subentry id |
no — identifier, as above | migrate |
core/archipelago/src/api/rpc/package/pine_ha.rs:588 |
let entry_id: [u8; 16] = rand::random(); |
production | HA wyoming config-entry id |
no — identifier, as above | migrate |
core/archipelago/src/api/rpc/package/pine_ha.rs:665 |
let raw: [u8; 26] = rand::random(); |
production | ULID-shaped HA id (26 Crockford-base32 chars) | no — identifier, as above | migrate |
core/archipelago/src/api/rpc/auth.rs:125 |
hex::encode(rand::random::<[u8; 2]>()) |
production (file has no #[cfg(test)] module) |
4-hex-char suffix disambiguating default-named companion-* device entries in the UI |
no — 2 B; an "all bytes identical" predicate false-positives once in 256, which would be worse than the defect it guards | migrate |
core/archipelago/src/fips/dial.rs:75 |
let id: u16 = rand::random(); |
production | DNS query transaction id for the FIPS _fips lookup |
no — 2 B, protocol identifier; same 1-in-256 false-positive argument | migrate |
core/archipelago/src/transport/chunking.rs:149 |
let message_id: u32 = rand::random(); |
production | chunk-frame message_id correlating Reed-Solomon shards |
no — 4 B, protocol identifier | migrate |
core/archipelago/src/totp.rs:305 |
let idx = (rand::random::<u8>() as usize) % charset.len(); |
production | one character of a TOTP backup code (bcrypt-hashed before storage) | no — a single byte, far below the floor; the % selection is R-12 and is deliberately untouched |
migrate |
core/archipelago/src/seed.rs:87 |
/// to \&mut rand::thread_rng()` inside the `bip39` crate, so the RNG backing every` |
doc comment | nothing — prose in the F-02 remediation rationale | n/a | comment |
core/archipelago/src/seed.rs:681 |
// bip39's transitive \rand::thread_rng()` default, is the one consumed.` |
line comment | nothing — prose inside mnemonic_generation_uses_injected_rng |
n/a | comment |
Disposition tally: migrate = 41, comment = 2, allow = 0.
There are no allow rows. Every test fixture migrates to OsRng as readily as production
code does, so no site needed an exemption, and consequently no
#[allow(clippy::disallowed_methods)] attribute is introduced anywhere in the crate. That
is the strongest available outcome for layer (b): the ban has no holes to audit.
cfg(test) boundaries — the evidence for every prod/test verdict
| File | #[cfg(test)] mod tests begins |
Consequence |
|---|---|---|
core/archipelago/src/session.rs |
:471 |
4 of 16 matches are production; 12 are test fixtures |
core/archipelago/src/wallet/bdhke.rs |
:144 |
2 production, 2 test |
core/archipelago/src/api/rpc/package/pine_ha.rs |
:979 |
all 6 matches are production |
core/archipelago/src/mesh/x3dh.rs |
:292 |
both matches production |
core/archipelago/src/container/secrets.rs |
:275 |
both matches production |
core/archipelago/src/api/rpc/package/install.rs |
:2872 |
both matches production |
core/archipelago/src/storage_crypto.rs |
:79 |
production |
core/archipelago/src/credentials/store.rs |
:168 |
production |
core/archipelago/src/device_tokens.rs |
:112 |
production |
core/archipelago/src/federation/invites.rs |
:350 |
production |
core/archipelago/src/totp.rs |
:340 |
production |
core/archipelago/src/transport/chunking.rs |
:294 |
production |
core/archipelago/src/fips/dial.rs |
:683 |
production |
core/archipelago/src/seed.rs |
:513 |
:87 is above it (doc comment on a production fn); :681 is inside it |
core/archipelago/src/bitcoin_rpc.rs |
none — the file has no #[cfg(test)] module at all (72 lines) |
its single match is production by construction |
core/archipelago/src/api/rpc/auth.rs |
none — the file has no #[cfg(test)] module at all (332 lines) |
its single match is production by construction |
Two corrections to F-10a
F-10a recorded raw match counts and said so explicitly ("the full table in §F-10a"); it declined to classify. These are resolutions of that refusal, not contradictions of it.
1. session.rs is 4 production sites, not 16. F-10a's headline table reports
session.rs | 16 under a "Generates: session tokens" column. The evidence line is
core/archipelago/src/session.rs:471 — mod tests { — above which lie exactly four matches
(:156, :178, :254, :294) and below which lie twelve. The twelve below are
rand::random::<u64>() used to uniquify a temp-file name in
SessionStore::new_for_tests(std::env::temp_dir().join(format!("archipelago-sessions-test-{}.json", …)))
— not tokens at all. (F-10a's own body text does carry the 4 prod + 12 test split; the
correction is that the headline number is a raw grep count and must not be read as a
production-site count.)
2. mesh/x3dh.rs's two matches are prekey identifiers, not key material. The evidence
lines are core/archipelago/src/mesh/x3dh.rs:99 and :113 —
let (spk_secret, spk_public) = crypto::generate_x25519_ephemeral(); and
let (otk_secret, otk_public) = crypto::generate_x25519_ephemeral();. The X25519 secrets are
produced there; :100 and :114 draw only the u32 id fields of SignedPrekey and
OneTimePrekey. They remain in scope — they are values that go on the wire — but the
characterisation "X3DH key agreement — key material" overstates these two specific lines.
(The audit has since been corrected in place at ENTROPY-SEED-AUDIT-2026-07-31.md:508; this
section records the derivation independently.)
Sealing: what it prevents and what it does not
core/archipelago/src/entropy.rs declares a private module sealed containing a trait
Sealed, and
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_withis typedR: KeyGenRng, so the entropy source for the entire master key hierarchy — node Ed25519did: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.rsitself 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 mentionsKeyGenRngand 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/archipelagois a binary-only crate —core/archipelago/Cargo.toml:8declares[[bin]]withpath = "src/main.rs"and there is nosrc/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
const GUARD_DRAWS: bool = true;
which draw_key_bytes consults. Three properties make this an acceptable seam rather than a
hole:
- It is inside the seal. Only a type blessed in
entropy.rscan set it, because only such a type can implementKeyGenRngat all. - The only member that sets it
falseis#[cfg(test)]-gated.testing::CountingRngis not compiled into thearchipelagobinary, so in a production build every allowlist member is guarded.sealed_allowlist_has_one_production_memberasserts<OsRng as KeyGenRng>::GUARD_DRAWSistrue. - The guard is still observed tripping through
draw_key_bytes, not merely through the pure predicate:testing::ConstantRngkeeps the defaultGUARD_DRAWS = true, anddraw_key_bytes_rejects_and_zeroizes_a_degenerate_drawproves 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
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 remainingn−1must match:256^−(n−1) = 2^−8(n−1). This already includesAllZeroas a subset.P(Counter)— the first byte is free, the remainingn−1are then determined; ascending and descending are disjoint forn ≥ 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
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:
{"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
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
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:
- 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.
- 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.
- 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
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
randversion, 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
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
randmajors 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.rsstill 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/modelsis outside the enforcement graph.cargo metadata --no-depsconfirms the workspace members arearchipelago,archipelago-container,archipelago-openwrt,archipelago-performanceandarchipelago-security.core/models/src/data_url.rs:163andcore/models/src/procedure_name.rs:32are real matches of the same shape that nodisallowed-methodsentry can reach. This is a stated limitation, not an omission.- Sealing does not prevent an edit to
entropy.rsitself. The allowlist is sealed against other modules adding a member; anyone editingentropy.rscan 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 pinnedrand 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.