Files
archy/docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md
T
archipelagoandClaude Opus 5 cc00884b98 docs: repair links left dangling by the operations-doc removal
Untracking the ops docs broke every reference to them. Repoints or removes
those references across README, architecture, ROADMAP, the archive index,
lifecycle TESTING, the hardening plan, and the security docs — pointing at
the issue tracker where a live task list was meant, and dropping the entry
entirely where it only existed to link an internal file.

Also fixes two pre-existing broken links found by validating every relative
link in the tracked docs:
- README linked docs/OPEN_SOURCE_READINESS.md, which never existed
  (underscores vs hyphens).
- reticulum-daemon/README.md linked a local Claude session plan at
  ../../.claude/plans/enchanted-strolling-rocket.md — outside the repo, and
  a path that would have shipped publicly pointing at nothing.

All relative links in tracked markdown now resolve: 0 broken.
audit-secrets.sh still 5/5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 10:10:25 -04:00

66 KiB
Raw Blame History

Entropy & Seed-Generation Security Audit — 2026-07-31

Trigger: the Coinkite COLDCARD entropy incident, disclosed 2026-07-30 (see .planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md, "T1"). That defect silently rebound seed generation to a non-cryptographic PRNG through a build-time macro guard, reducing effective seed entropy to ≤2^32 and enabling a ~1,082 BTC sweep. This audit asks the same question of Archipelago.

Why the stakes here are higher than a hardware wallet's. Archipelago derives its entire key hierarchy from one 24-word BIP-39 mnemonic (core/archipelago/src/seed.rs:1-18): the node Ed25519 did:key, the node Nostr key, the FIPS mesh transport key, per-identity keys, the BIP-84 Bitcoin wallet, the LND aezeed entropy — and the fleet release-root signing key (core/archipelago/src/seed.rs:143-146). A Coldcard-class entropy defect here would not merely drain wallets; it would let an attacker forge signed release manifests and catalogs for every node in the fleet.

Headline verdict: no Coldcard-class entropy defect exists in this codebase. Every first-party key-generation call site draws from a genuine CSPRNG, and the code does several things better than most implementations. The findings below are (a) one structural pattern that is the exact shape of T1 and should be closed cheaply, (b) one Critical access-control defect found while tracing the secret classes — unrelated to entropy but far more immediately exploitable than anything entropy-related — and (c) a set of Medium/Low hygiene items.


1. Scope and method

Directories covered

Path Coverage
core/*/src/**/*.rs full RNG-API grep sweep + call-graph trace of every secret class
neode-ui/src/**/*.{ts,vue} full browser-RNG grep sweep
scripts/**/*.{sh,py} RNG / secret-material grep sweep
image-recipe/** entropy, seed-file, machine-id, host-key and first-boot ordering evidence
~/.cargo/registry/src/*/bip39-2.1.0/, argon2-0.5.3/ vendored-dependency default-RNG / default-parameter reads
docs/adr/005-chacha20-backup-encryption.md Argon2 parameter cross-check

Explicitly excluded, and why

  • core/target/ — build output, not source. Excluded from every grep (the pipeline used core/*/src, which cannot reach it).
  • image-recipe/_archived/ — NOT excluded, contrary to the original scoping assumption. This is a correction the next auditor should not have to re-derive: image-recipe/build-debian-iso.sh:19-40 is a thin wrapper that copies image-recipe/_archived/build-auto-installer-iso.sh to a temp path, rewrites its relative paths, and execs it (image-recipe/build-debian-iso.sh:40). The "archived" auto-installer IS the live ISO build path. Treating _archived/ as dead code would have made [ARCHY-3] unanswerable. It is therefore in scope and is the primary [ARCHY-3] evidence surface.
  • image-recipe/_archived/build/auto-installer/installer-iso/... — a stale build output tree under _archived/, superseded by the generator above. Its /dev/urandom hits (image-recipe/_archived/build/auto-installer/installer-iso/archipelago/scripts/first-boot-containers.sh:182) are duplicates of the live scripts/first-boot-containers.sh and are not separately assessed.

Greps run

grep -rnE 'SmallRng|seed_from_u64|::from_seed\(|rand::rngs::mock|StdRng' core/*/src --include=*.rs
grep -rnE 'OsRng|thread_rng|rand::random|getrandom|SystemRandom' core/*/src --include=*.rs
grep -rn -B3 -A3 -E 'SystemTime::now|as_nanos|Instant::now' core/*/src --include=*.rs \
  | grep -iE 'key|seed|nonce|salt|token|secret|password|mnemonic'
grep -rn -B2 -A2 -E 'Math\.random|getRandomValues|crypto\.subtle|jsbn|SecureRandom\(' \
  neode-ui/src --include=*.ts --include=*.vue
grep -rnE '\$RANDOM|/dev/urandom|/dev/random|openssl rand|uuidgen|random\.random|random\.randint|shuf ' \
  scripts/ image-recipe/ --include=*.sh --include=*.py
grep -rniE 'random-seed|urandom|jitterentropy|haveged|rng-tools|rngd|crng' image-recipe/ \
  --include=*.sh --include=*.service --include=*.conf
find image-recipe -name 'random-seed' -o -name '*.seed'
grep -rniE '(info|warn|error|debug|trace)!\(.*(mnemonic|seed|privkey|private_key|passphrase|aezeed)' \
  core/*/src --include=*.rs
grep -rn 'derive(Debug' core/archipelago/src/seed.rs core/archipelago/src/identity.rs \
  core/archipelago/src/credentials/store.rs
cargo tree -i rand@0.8.5 -p archipelago ; cargo tree -i rand@0.9.2 -p archipelago

Results of the two negative greps, stated so they count as findings rather than silence:

  • find image-recipe -name 'random-seed' -o -name '*.seed' returned nothing. No seed file is checked into the image recipe.
  • grep -cE 'haveged|jitterentropy|rng-tools|rngd' image-recipe/_archived/build-auto-installer-iso.sh returned 0. No userspace entropy daemon is installed by the image.
  • grep -rnE 'SmallRng|seed_from_u64|rand::rngs::mock|StdRng' core/*/src returned no RNG hits at all — the four matches are NodeIdentity::from_seed(...) calls (core/archipelago/src/api/rpc/seed_rpc.rs:122, :255; core/archipelago/src/identity.rs:608, :634), which is Archipelago's own seed-to-identity function, not rand's from_seed. No non-cryptographic PRNG and no deterministic seeding exists anywhere in the Rust workspace.

Not performed

  • cargo auditcargo-audit is not installed on this host (command -v cargo-audit fails). No RustSec snapshot was taken. This is recorded as gap F-07; the research's recommendation stands that cargo audit/cargo deny belongs in CI rather than in a point-in-time audit.
  • Anything requiring real hardware — see §6, the UNVERIFIED on-node checklist.

Concurrent-work caveat

core/archipelago/src/container/secrets.rs and neode-ui/src/views/OnboardingSeedGenerate.vue had uncommitted third-party changes on disk at audit time (another agent working in the same tree). They were read as-is and not modified. Line numbers cited for those two files are against the working-tree state of 2026-07-31, not against HEAD.


2. Executive summary

Archipelago's entropy path is structurally sound. Every first-party call site that produces key material draws from rand::rngs::OsRng (a direct getrandom(2) wrapper) or from rand::random/rand::thread_rng on rand 0.8.5, which is ReseedingRng<ChaCha12Core, OsRng> — a real CSPRNG that still carries fork protection in the 0.8 series. There is no Mersenne Twister, no clock-seeded key, no SmallRng, no seed_from_u64, and no Math.random() in any browser key path. The master-seed function is preceded by a genuinely good, non-blocking CSPRNG-readiness probe (core/archipelago/src/seed.rs:52-91) that most implementations lack, and the derivation is domain-separated, zeroized, and pinned by known-answer tests.

Three things nonetheless warrant action, in this order:

  1. The most urgent finding is not about entropy at all. While tracing secret classes (3) and (4), the audit found that seed.generate and seed.restore are in the unauthenticated RPC allowlist (core/archipelago/src/api/rpc/middleware.rs:25-27), carry no onboarding-complete gate and no rate limit, and unconditionally overwrite a live node's Ed25519 identity, Nostr key and FIPS mesh key (core/archipelago/src/identity.rs:79-114). The endpoint is proxied to the LAN over plaintext HTTP (image-recipe/configs/nginx-archipelago.conf:11, :165, :192) and is also reachable by mesh peers (core/archipelago/src/server.rs:2080). A guard function for exactly this already exists and is simply never called (core/archipelago/src/identity.rs:117). Critical — F-01.
  2. The T1-shaped structural risk is real but currently benign. bip39::Mnemonic::generate(24) at core/archipelago/src/seed.rs:92 delegates its entropy source to a transitive dependency default. Not a vulnerability today; exactly the pattern that produced T1. Medium — F-02, and the one code change this audit applies.
  3. The one-ISO-many-nodes story is better than feared but has a fail-open hole. No random-seed file is baked, and per-device TLS/SSH regeneration exists — but the rootfs is a cached container export shared by every node, the regeneration is fail-open, and its completion marker is set even when regeneration failed, so a single failure leaves fleet-wide shared SSH host keys and TLS private key permanently. High — F-03.

Nothing in this audit suggests any existing Archipelago node has a weak master seed. No user action of the "your seed may be predictable, migrate now" kind is warranted — a point §7 of docs/security/PSBT-SIGNING-ARCHITECTURE.md depends on and must not overstate.


3. Findings

ID Severity Title Primary evidence
F-01 Critical Unauthenticated, unrated seed.generate/seed.restore overwrite a live node's identity keys core/archipelago/src/api/rpc/middleware.rs:25, core/archipelago/src/identity.rs:79
F-02 Medium Master mnemonic's entropy source is a transitive-dependency default, not a call-site argument (T1 shape) core/archipelago/src/seed.rs:92
F-03 High First-boot per-device secret regeneration is fail-open and never retried, over a fleet-shared cached rootfs image-recipe/_archived/build-auto-installer-iso.sh:1647, :1659, :1663
F-04 Medium Master mnemonic crosses the RPC boundary and is held in memory for 10 min, deliberately un-cleared, over plaintext-capable HTTP core/archipelago/src/api/rpc/seed_rpc.rs:147, :205-211
F-05 Medium Argon2::default() is 19 MiB / t=2, not ADR-005's stated 64 MB / 3 iterations core/archipelago/src/seed.rs:249, docs/adr/005-chacha20-backup-encryption.md:31
F-06 Medium Release master mnemonic is passed via env var / stdout in the signing ceremony core/archipelago/src/ceremony.rs:71-77, :149-160
F-07 Medium No cargo audit/cargo deny in CI; two rand majors coexist in the graph core/archipelago/Cargo.toml:68
F-08 Low 24-word master mnemonic persisted in browser sessionStorage during onboarding neode-ui/src/views/OnboardingSeedGenerate.vue:330
F-09 Low Modulo bias in TOTP backup-code generation core/archipelago/src/totp.rs:305
F-10 Lowsee F-10a Container generated_secrets use thread_rng() rather than an explicit OsRng (same T1 shape as F-02, smaller blast radius) core/archipelago/src/container/secrets.rs:92, :101
F-10a Medium Scope correction to F-10 (2026-08-02): the defaulted-RNG surface is crate-wide — 41 raw matches across 15 files, not 2 — and includes ecash key material and an AEAD nonce core/archipelago/src/wallet/bdhke.rs:133,:139 (Cashu secret + blinding factor), storage_crypto.rs:39 (nonce), session.rs (4 prod), +12 more files — full table in §F-10a
F-11 Informational Math.random() inside a seed-handling view (benign — UX challenge selection only) neode-ui/src/views/OnboardingSeedVerify.vue:159
F-12 Informational Identical default OS credentials on every flashed node image-recipe/archipelago-scripts/install-to-disk.sh:205
F-13 High BIP-84 account private key is imported into Bitcoin Core's wallet, duplicating the spending key outside the encrypted envelope core/archipelago/src/api/rpc/bitcoin.rs:203, :229-231

F-01 — Unauthenticated seed.generate / seed.restore overwrite a live node's identity keys — Critical

Evidence.

  • core/archipelago/src/api/rpc/middleware.rs:24-28 places seed.generate, seed.verify, seed.restore and seed.save-encrypted in UNAUTHENTICATED_METHODS, under the comment "Onboarding flow (before user has a session)".
  • core/archipelago/src/api/rpc/mod.rs:263-265 — membership in that list skips the entire session check; :295 skips RBAC; :326 skips CSRF.
  • core/archipelago/src/api/rpc/seed_rpc.rs:93-159 (handle_seed_generate) and :226-305 (handle_seed_restore) contain no check that onboarding is already complete or that a node key already exists.
  • core/archipelago/src/identity.rs:79-114 (NodeIdentity::from_seed) writes node_key, node_key.pub and, via write_fips_key_from_seed (:108), the FIPS mesh key — unconditionally, with no existence check. seed_rpc.rs:130-131 and :261-266 likewise overwrite nostr_secret / nostr_pubkey.
  • The guard already exists and is never called on this path: core/archipelago/src/identity.rs:117-119 (NodeIdentity::key_exists). Its only callers are core/archipelago/src/server.rs:63 and core/archipelago/src/api/rpc/seed_rpc.rs:343 (read-only status).
  • No rate limit: core/archipelago/src/rate_limit.rs:60-97 enumerates per-method limits and contains no seed.* entry, while explicitly acknowledging at :96 that "Inter-node federation RPCs (unauthenticated, need stricter limits)".
  • Reachability: image-recipe/configs/nginx-archipelago.conf:11 and :15 bind port 80 as default_server (plaintext, LAN); :165-175 proxies /rpc/v1 and :192-195 proxies /rpc/ to 127.0.0.1:5678. The FIPS mesh peer listener applies a path filter (core/archipelago/src/server.rs:1375, :1270) but that filter allows /rpc/v1 — asserted at core/archipelago/src/server.rs:2080.

Exploitability. No credentials, no session, no CSRF token, no rate limit. A single unauthenticated JSON-RPC POST from anywhere on the LAN — or from any peer that can reach the mesh listener — is sufficient. seed.restore is the worse of the two because the attacker supplies the mnemonic: they then hold the node's Ed25519 signing key, its Nostr node key and its FIPS transport key. seed.generate is a pure destructive primitive: it mints a mnemonic nobody ever sees and overwrites the node's identity with it.

Blast radius. Node identity takeover or permanent identity destruction. Downstream: the node's did:key changes, so every federation trust relationship keyed on that DID breaks; the FIPS mesh key changes, so mesh peering breaks; the Nostr node key changes, so discovery announcements are signed by a key the fleet does not recognise. This does not by itself expose the user's Bitcoin funds (the on-disk master_seed.enc envelope is not overwritten by these handlers) — but do not read that as reassurance: an attacker who controls the node's identity keys controls how that node presents itself to the federation.

This is not an entropy defect. It surfaced because Step B of this audit required tracing secret classes (3) and (4) end-to-end rather than only checking where their bits come from. It is reported here because it is the most serious thing found and suppressing it until a "more appropriate" document would be indefensible.

Remediation (concrete). In handle_seed_generate and handle_seed_restore, bail early when NodeIdentity::key_exists(&identity_dir) is true and the in-memory onboarding mnemonic is absent — i.e. this is a booted, already-provisioned node rather than an onboarding retry. Prefer additionally gating on auth_manager.is_onboarding_complete() (core/archipelago/src/auth.rs:182). Add seed.generate / seed.restore to rate_limit.rs's table at the strictness of auth.changePassword (3 per 300s). Consider removing /rpc/v1 from is_peer_allowed_path for seed methods specifically, or filtering by method rather than path. Needs its own plan — see Backlog R-01.


F-02 — Mnemonic entropy source is a transitive-dependency default — Medium — [ARCHY-1], FIXED IN THIS AUDIT

Evidence. core/archipelago/src/seed.rs:92:

let mnemonic = bip39::Mnemonic::generate(24)

Resolved against the vendored crate: ~/.cargo/registry/src/index.crates.io-.../bip39-2.1.0/src/lib.rs:311-313generate_in at :296-298, whose body is Mnemonic::generate_in_with(&mut rand::thread_rng(), language, word_count)generate_in_with at :267-283, which is generic over R: RngCore + CryptoRng and fills the entropy buffer at :281.

So the entropy backend for Archipelago's whole key hierarchy — including the release-root signing key — was selected by bip39's default, not stated at Archipelago's call site.

Exploitability. None today. rand::thread_rng() on rand 0.8.5 (core/archipelago/Cargo.toml:68) is ReseedingRng<ChaCha12Core, OsRng>: seeded from getrandom(2), reseeded every 64 KiB, CryptoRng, and still fork-protected in the 0.8 series. The mnemonic is genuinely 256-bit. This finding is about future exploitability, not present.

Blast radius (if it ever rebinds). Total. Every key in seed.rs:1-18, including the fleet release-root signing key at :143-146. That is strictly larger than a hardware wallet's, because it includes the ability to forge signed release manifests.

Why it is worth fixing anyway. This is the precise structural shape of T1: a call whose entropy backend is fixed by dependency/build configuration rather than by the calling code, with no compile error if it changes. bip39 is pinned =2.1.0 (core/archipelago/Cargo.toml:74) which contains the exposure today, and a future rand bump to 0.9+ removes fork protection (upstream changelog, 2025-01-27) without touching a line of Archipelago source.

Remediation — applied. seed.rs now routes generation through an internal helper that takes &mut (impl CryptoRng + RngCore) and calls bip39::Mnemonic::generate_in_with explicitly, with the production caller passing OsRng, plus a known-answer test that drives generation from a deterministic RNG and asserts the resulting words. That test is impossible to write against the pre-change code, because there was no seam to inject through. See §7.


F-03 — Fail-open, never-retried first-boot secret regeneration over a fleet-shared rootfs — High — part of [ARCHY-3]

Evidence.

  • The installed root filesystem is a container image exported to a tar (image-recipe/_archived/build-auto-installer-iso.sh:717-726), cached across builds (:267), shipped on the ISO (:1094) and extracted verbatim onto every target disk (:2303, tar -xf "$ROOTFS_TAR" -C /mnt/target). Every node flashed from one ISO therefore starts from a byte-identical filesystem.
  • That rootfs installs openssh-server (:345). Debian's openssh-server postinst generates host keys at install time — i.e. inside the container build — so SSH host keys are baked into the shared tar.
  • It also bakes a self-signed RSA-2048 TLS keypair at :463-469 (openssl req -x509 -nodes -days 3650 -newkey rsa:2048 ... /etc/archipelago/ssl/archipelago.key).
  • The mitigation exists and is correct in intent: archipelago-first-boot-secrets.service (:1599-1614) runs first-boot-secrets.sh (:1616-1665), which regenerates the TLS keypair (:1635-1648) and the full SSH host-key set via ssh-keygen -A into a staging dir and swaps on success (:1651-1662). It is installed at :2587-2593 and enabled at :3336.
  • The hole: both branches are fail-open — :1647 "WARNING: TLS regeneration failed, keeping baked key" and :1659 "WARNING: ssh-keygen -A failed, keeping baked host keys" — and touch "$MARKER" at :1663 runs unconditionally, outside both if blocks. The unit's ConditionPathExists=!/var/lib/archipelago/.secrets-regenerated (:1605) and the script's own [ -f "$MARKER" ] && exit 0 (:1625) then guarantee it never runs again.
  • Timing: the unit declares DefaultDependencies=no and only After=local-fs.target (:1603-1604), so it runs very early — precisely when a freshly-flashed headless machine has the least accumulated entropy, and it is the first consumer of the pool.

Exploitability. One transient failure at first boot (a full disk, a slow-to-seed pool causing a timeout, an openssl/ssh-keygen hiccup) permanently leaves that node running the image-wide shared SSH host key and TLS private key. An attacker who obtains one copy of the ISO — which is a published artifact — holds the SSH host key and TLS private key of every node that hit that failure path, enabling transparent MITM of the web UI and undetectable SSH host impersonation. The failure is logged only to /var/log/archipelago-first-boot-secrets.log and surfaces nowhere in the UI.

Blast radius. Per-node, but silently and permanently, and correlated fleet-wide by ISO build.

Remediation. Move touch "$MARKER" inside a success branch that requires both regenerations to have succeeded; on failure, leave the marker absent so the oneshot retries on the next boot, and surface the condition (a system.stats/doctor field, not just a log file). Additionally add After=systemd-random-seed.service — harmless today (no seed file is baked, see [ARCHY-3]) and correct if one is ever introduced. Independently, strip the baked SSH host keys and TLS key from the rootfs tar at build time so a regeneration failure degrades to "no key / service refuses to start" rather than "shared key, silently".


F-04 — Master mnemonic crosses the RPC boundary and lingers in memory — Medium — [ARCHY-4]

Evidence.

  • core/archipelago/src/api/rpc/seed_rpc.rs:147 builds words: Vec<String> from the mnemonic and :156-158 returns it as the JSON-RPC result.
  • Held server-side in a process-global LazyLock<Arc<Mutex<Option<OnboardingMnemonicState>>>> (:13-19) under a 10-minute TTL (:27).
  • Deliberately not cleared at verify time:205-211 documents the reasoning (the web client aborts at 15s and retries; clearing would make a retried verify fail). The rationale is sound; the residual risk is real and should be named rather than assumed away.
  • save_pending_seed_encrypted (:42-57) deliberately ignores the TTL, documented at :35-39.
  • Plaintext HTTP is a supported deployment: core/archipelago/src/api/rpc/mod.rs:227-241 sets the session cookie's Secure flag only when X-Forwarded-Proto: https is present, with the comment "On LAN HTTP, Secure flag prevents browsers from sending cookies back" — i.e. plaintext LAN is an expected mode, corroborated by image-recipe/configs/nginx-archipelago.conf:11 binding :80 as default_server.

Exploitability. Passive: anyone with LAN traffic visibility during the ~1-2 minutes of onboarding reads the 24 words in cleartext. This unlocks the Bitcoin wallet, the node identity, and — if the same mnemonic is ever used as a release master seed — the fleet signing key. Requires being on-path during onboarding, which bounds it.

Blast radius. Total for that node's key hierarchy.

Mitigating factors (real, and worth stating). OnboardingMnemonicState implements Drop with zeroize (:21-25); the words are never logged; and seed.reveal — the post-onboarding path — is properly gated (see §5). The exposure is confined to the onboarding window.

Remediation. Confine seed-bearing methods to loopback or require TLS for them specifically; shrink MNEMONIC_TTL; clear on a successful, acknowledged verify with a short grace window rather than never. Deferred to a plan — Backlog R-04.


F-05 — Argon2::default() does not match ADR-005 — Medium

Evidence. docs/adr/005-chacha20-backup-encryption.md:31 specifies "Argon2id with high memory cost (64MB) and iterations (3)". The code uses Argon2::default() at core/archipelago/src/seed.rs:249 and :285 (the master-seed and aezeed envelope), core/archipelago/src/backup/identity.rs:38 and :93, and core/archipelago/src/backup/full.rs:618 and :650.

From the vendored crate argon2-0.5.3: impl Default for Argon2 (src/lib.rs:176-180) uses Params::default(), whose constants are DEFAULT_M_COST = 19 * 1024 KiB = 19 MiB (src/params.rs:42), DEFAULT_T_COST = 2 (:52), DEFAULT_P_COST = 1 (:61).

Actual: Argon2id, v0x13, m=19456 KiB, t=2, p=1. ADR-005 states: 64 MB, 3 iterations. The algorithm choice (Argon2id) is correct; the cost parameters are roughly 3.4× weaker in memory and 1.5× weaker in time than the ADR claims. The defaults are the current OWASP minimum, so this is a documentation-vs-code divergence and a modest hardening gap, not a break.

Exploitability. Offline brute force of master_seed.enc / backup blobs by an attacker who already has file read access, at a lower cost than the ADR promises.

Remediation. Either construct Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::new(65536, 3, 1, None)?) in one shared helper and use it everywhere, or amend ADR-005 to state the real parameters. Do not silently change the parameters on the master-seed envelope without a migration path: an existing master_seed.enc was encrypted under the old parameters and would fail to decrypt. That constraint is what makes this a backlog item rather than a quick fix.


F-06 — Release master mnemonic passed by env var / printed to stdout — Medium

Evidence. core/archipelago/src/ceremony.rs:70-78 (cmd_gen) prints RELEASE_MASTER_MNEMONIC="<24 words>" to stdout via println!. :149-153 (load_release_root_key) reads the phrase via read_mnemonic(), which at :157-160 prefers the RELEASE_MASTER_MNEMONIC environment variable and falls back to stdin.

Exploitability. An environment variable is readable from /proc/<pid>/environ by the same user and lands in shell history if set inline; stdout lands in terminal scrollback, tmux buffers, CI logs and script/asciinema captures. This is the seed that derives the fleet release-root signing key (core/archipelago/src/seed.rs:143-146) — compromise means forging signed manifests for every node.

Mitigating factors. The ceremony is a deliberate, human-operated, offline procedure, the tool prints a prominent warning at ceremony.rs:73-75, and the stdin path exists and is the documented practice (project memory: "sign via user TTY"). The env-var path is a convenience affordance, not the intended default.

Remediation. Make stdin/TTY the only supported input for sign/pubkey and remove or feature-gate the env-var branch; for gen, write the mnemonic to a 0600 file on explicitly named removable media rather than stdout, or require an interactive confirmation. Low effort, but it touches the signing ceremony — schedule it deliberately, not opportunistically.


F-07 — No dependency-advisory gate in CI; two rand majors in the graph — Medium

Evidence. cargo-audit is not installed on this host, so no RustSec check was run. cargo tree -i rand@0.8.5 -p archipelago and -i rand@0.9.2 -p archipelago show both majors resolved into the same binary:

  • rand 0.8.5 — direct (core/archipelago/Cargo.toml:68), plus archipelago-security, bip39 2.1.0, mainline 2.0.1, secp256k1 0.29.1, tungstenite 0.20.1.
  • rand 0.9.2 — transitively via totp-rs 5.7.0 and tungstenite 0.26.2 (through tokio-tungsteniteasync-wsocketnostr-relay-poolnostr-sdk 0.44.1).

No Archipelago-authored key-generation call site uses rand 0.9.x — the direct dependency is pinned to 0.8.5 and every first-party OsRng/thread_rng/rand::random call resolves against it. But rand 0.9.0 removed fork protection from ThreadRng, and the orchestrator forks and spawns constantly, so the day a rand bump lands the T1 shape in F-02 and F-10 becomes materially worse. getrandom is likewise split across 0.2.17 and 0.3.4.

Remediation. Add cargo audit (or cargo deny check advisories bans) to CI, with a bans rule that fails on duplicate rand majors so the split is visible rather than silent. Before any rand 0.9+ bump, convert every key-generation site to explicit OsRng (F-02, F-10) — after which the fork-protection removal is irrelevant to Archipelago.


F-08 — 24-word master mnemonic persisted in browser sessionStorageLow

Evidence. neode-ui/src/views/OnboardingSeedGenerate.vue:330 writes the full word list: sessionStorage.setItem('_seed_words', JSON.stringify(words.value)); it is re-read at :297 and at neode-ui/src/views/OnboardingSeedVerify.vue:165. The mnemonic itself arrives from seed.generate at OnboardingSeedGenerate.vue:256-258.

Mitigating factors. It is removed on successful verify (neode-ui/src/views/OnboardingSeedVerify.vue:251), and its exclusion from the logout cache-purge is a deliberate, test-pinned decision (neode-ui/src/stores/__tests__/resourcesClear.test.ts:213, :231) — onboarding must survive a reload. So this is a considered trade-off, not an oversight.

Residual risk. A user who abandons onboarding mid-flow leaves the master mnemonic in plaintext sessionStorage for the lifetime of the tab. On the node's own kiosk browser, that tab may stay open indefinitely. Any XSS in the UI during that window reads it directly.

Remediation. Clear _seed_words on route-leave from the onboarding flow as well as on verify, and add a wall-clock expiry to the stored blob mirroring the server's MNEMONIC_TTL.


F-09 — Modulo bias in TOTP backup-code generation — Low — [ARCHY-5]

Evidence. core/archipelago/src/totp.rs:305:

let idx = (rand::random::<u8>() as usize) % charset.len();

with charset = 32 characters (:298). 32 divides 256 exactly, so in the current code the bias is zero — the research's [ARCHY-5] framing of "classic modulo bias" is correct as a pattern but the concrete instance is presently unbiased. The defect is latent: any future edit to the charset (adding a symbol, removing an ambiguous letter) silently introduces bias with no test to catch it. Reported as Low on that basis, not on present harm.

Remediation. Replace with rand::seq::SliceRandom::choose(&mut OsRng), which is unbiased for any charset length, and add an assertion or test that pins the property. Left to the backlog rather than applied here: the entropy source is already correct and the present bias is nil, so it does not meet this plan's bar for a code change.


F-10 — Container generated_secrets use thread_rng()Low

Evidence. core/archipelago/src/container/secrets.rs:90-93 (random_hex) and :98-102 (random_base64) both use rand::thread_rng().fill_bytes(&mut buf). These materialise manifest-declared generated_secrets for every app (Bitcoin RPC password, DB passwords, netbird store encryption key, the Fedimint gateway credential at :135-...).

Assessment. Cryptographically fine on rand 0.8.5 for the same reason as F-02, and the same T1-shaped structural objection applies with a smaller blast radius (per-app credentials rather than the master key hierarchy). File permissions were verified rather than assumed: core/archipelago/src/container/secrets.rs:207 sets .mode(0o600) on creation, and :269 and :307 are tests asserting mode == 0o600 for the written files. CLAUDE.md's "0600/rootless" invariant holds and is test-enforced.

(This file carried uncommitted third-party changes at audit time — line numbers are against the 2026-07-31 working tree.)

Remediation. Swap both helpers to rand::rngs::OsRng when F-02's pattern is generalised. One-line change each; batched into the same backlog item.


F-10a — Scope correction: the defaulted-RNG surface is crate-wide — Medium

Added 2026-08-02, after the original audit. F-10 above reported this defect as two call sites in one file. That was understated. This section records the true scope with evidence. F-10's own text and remediation are left unedited above so the correction is auditable rather than retroactive.

Evidence. grep -rn "rand::random\|thread_rng()" core/archipelago/src --include=*.rs returns 43 matches across 16 files. Two of those (seed.rs:87, :671) are comments in the already-remediated F-02 file, leaving 41 matches across 15 files:

File Matches Generates
core/archipelago/src/session.rs 16 → 4 prod + 12 test session tokens (#[cfg(test)] begins :470)
core/archipelago/src/api/rpc/package/pine_ha.rs 6 app credentials
core/archipelago/src/wallet/bdhke.rs 4 → 2 prod + 2 test Cashu proof secret (generate_secret, :133) and blinding factor (random_blinding_factor, :139) — genuine key material (#[cfg(test)] begins :143)
core/archipelago/src/mesh/x3dh.rs 2 X3DH key agreement — key material CORRECTED: u32 prekey identifiers (spk_id :100, otk_id :114). The X25519 secrets come from crypto::generate_x25519_ephemeral() at :99/:113 and are not in this table
core/archipelago/src/container/secrets.rs 2 generated_secrets (the original F-10)
core/archipelago/src/api/rpc/package/install.rs 2 install-time secrets
core/archipelago/src/storage_crypto.rs 1 ChaCha20-Poly1305 nonce — reuse breaks the AEAD
core/archipelago/src/credentials/store.rs 1 credential store material
core/archipelago/src/device_tokens.rs 1 device tokens
core/archipelago/src/federation/invites.rs 1 federation invites
core/archipelago/src/bitcoin_rpc.rs 1 Bitcoin RPC password
core/archipelago/src/totp.rs 1 TOTP backup codes (also F-09)
core/archipelago/src/transport/chunking.rs 1 chunk identifiers
core/archipelago/src/fips/dial.rs 1 dial jitter/identifiers
core/archipelago/src/api/rpc/auth.rs 1 auth-path material

Per-site production-vs-test classification is deliberately NOT asserted here — it is the first task of the remediation (KEY-05 Task 1), not an assumption of this correction. The counts above are raw matches.

Self-correction, 2026-08-02 (same day, before any KEY-05 work began). The first version of this table asserted semantics in its right-hand column that two entries did not support, and the KEY-05 planner caught it against the code. Both are fixed inline above, struck rather than silently rewritten:

  • mesh/x3dh.rs was wrong. Called "X3DH key agreement — key material"; the two sites are u32 prekey identifiers. The X25519 secrets are drawn elsewhere and were never in scope.
  • session.rs was misleading. 16 raw matches read as 16 production token sites; it is 4 production and 12 test.

The Medium rating still holds, on narrower grounds. It now rests on wallet/bdhke.rs's two production sites (the Cashu proof secret and the blinding factor — a predictable blinding factor breaks the ecash unlinkability guarantee and is genuinely key material) and on storage_crypto.rs:39's AEAD nonce. It does not rest on x3dh. Recorded because the correction to F-10 was made on the grounds that understatement misleads the next reader — overstatement does exactly the same, and this table did both within one day.

Assessment. Unchanged from F-10 in kind: rand::random() and thread_rng() are backed by ChaCha12 seeded from getrandom(2) on rand 0.8.5, so nothing in this table is broken today. What changes is the blast radius of the T1 structural objection. F-10 rated this Low on the basis of "per-app credentials rather than the master key hierarchy". That justification does not survive the true scope: wallet/bdhke.rs:133/:139 generate the Cashu proof secret and blinding factor — genuine key material, where a predictable blinding factor breaks ecash unlinkability — and storage_crypto.rs:39 draws an AEAD nonce, where a silent rebinding to a non-cryptographic PRNG would be catastrophic rather than merely undesirable. Re-rated Medium on those two grounds. (An earlier version of this sentence also cited mesh/x3dh.rs; that was incorrect and is struck in the table above — those sites are prekey identifiers.)

Why the original audit missed it. F-10 was reached by tracing the manifest secrets path (secret class 4). No step enumerated defaulted-RNG use across the whole crate independently of the traced paths — so files outside those traces were never in scope to be looked at. Recorded here because the same blind spot would recur in the next audit run under the same method.

Remediation → tracked as KEY-05 in Phase 10 (.planning/ROADMAP.md), which supersedes R-13: a sealed allowlist trait so only approved RNGs can be passed at key-generation seams; a clippy.toml disallowed-methods ban on rand::thread_rng / rand::random crate-wide so the default cannot be inherited by new code either; cargo-deny failing on duplicate rand majors (R-05, the mechanism by which a bump could silently rebind); a degenerate-entropy runtime check before key generation; and persisting the CSPRNG-readiness verdict (R-09) that seed.rs:59 already computes but discards.


F-11 — Math.random() inside a seed-handling view — Informational (benign)

Evidence. neode-ui/src/views/OnboardingSeedVerify.vue:157-163, pickRandomIndices uses Math.floor(Math.random() * max) to choose which of the 24 words the user is quizzed on.

Assessment: benign, and annotated here so the next auditor does not re-derive it. The indices select a UX challenge only. They are not key material, not a nonce, not a salt, and not a secret: an attacker who predicts perfectly which words will be quizzed learns nothing — the words themselves are what they would need, and those are already on the user's screen. The verification is a user-facing "did you write it down" check, not an authentication boundary (the server compares against its own held copy at core/archipelago/src/api/rpc/seed_rpc.rs:190-194).

Other Math.random() sites, all confirmed non-security: neode-ui/src/api/rpc-client.ts:183, :206, :215 (retry jitter); neode-ui/src/views/Login.vue:317 (progress bar); neode-ui/src/components/BootScreen.vue:112, :123 (starfield animation).

No remediation required. Optionally add a one-line comment at the call site so this stays annotated in the code rather than only in this document.


F-12 — Identical default OS credentials on every flashed node — Informational

Evidence. image-recipe/archipelago-scripts/install-to-disk.sh:205 sets archipelago:archipelago via chpasswd, and :367-371 prints the credentials with a "Please change the password after first login!" warning.

Assessment. Not an entropy defect and a known, documented alpha-stage default. Recorded here only because it belongs to the same one-image-many-nodes correlation theme as [ARCHY-3]: it is the one identity artefact that is deliberately identical across the fleet, and unlike the SSH host key and TLS key (F-03) there is no first-boot regeneration for it. Out of scope to fix; in scope to name.


F-13 — BIP-84 account private key is imported into Bitcoin Core — High

Evidence. core/archipelago/src/api/rpc/bitcoin.rs:161-294 (handle_bitcoin_init_wallet_from_seed):

  • :203 passes disable_private_keys = false to createwallet.
  • :188-189 derives the BIP-84 account xprv (crate::seed::derive_bitcoin_xprv, core/archipelago/src/seed.rs:207-224) and stringifies it.
  • :229-231 builds wpkh({xprv}/0/*) and wpkh({xprv}/1/*).
  • :278-281 imports those descriptors via importdescriptors.

Assessment. The node's Bitcoin spending key is therefore persisted twice: once in the daemon's Argon2 + ChaCha20-Poly1305 envelope (core/archipelago/src/seed.rs:238-269, 0600 via :318-324), and once in Bitcoin Core's wallet.dat, which has neither the Argon2 passphrase protection nor the same ownership story — it lives in the Bitcoin Core container's data volume. The wallet is created with an empty encryption passphrase (bitcoin.rs:205), so Core's own wallet encryption is not engaged either.

Not an entropy defect, and reported here because Step B required tracing secret class (1), the user Bitcoin/LND wallet seed, from generation to consumer — and this is where that trace ends up.

Credit where due: the in-memory handling of the xprv string is careful — it is zeroized on both the error path (bitcoin.rs:222) and the success path (:284) — and the wallet is a descriptor wallet (:207), which is the correct foundation. The defect is which key goes into it.

Secondary defect, same lines. The descriptors at :230-231 carry no key-origin annotation ([fingerprint/derivation]). Without it, no hardware signer can locate its key in a PSBT — so the current wallet could not be converted to an external-signer setup even if the private key were removed.

Exploitability. Requires read access to the Bitcoin Core data volume. That is a lower bar than the encrypted envelope: a container escape, a backup of the Bitcoin volume, or a misconfigured bind mount exposes it, whereas master_seed.enc additionally requires the user's password.

Blast radius. The node's entire on-chain Bitcoin balance at m/84'/0'/0'. It does not extend to the other key classes — the release-root key, node identity and FIPS keys are HKDF siblings, not children of the BIP-84 branch, so an attacker with the account xprv cannot climb back to the master seed.

Remediation. Pass disable_private_keys = true; import the account xpub with a key-origin annotation instead of the xprv; sign via the daemon (or an external signer) rather than via Core. This is Phase 1 of docs/security/PSBT-SIGNING-ARCHITECTURE.md §8, including the migration that verifies balance/UTXO parity before removing the private-key-bearing wallet.


4. [ARCHY-1] … [ARCHY-4] adjudication

[ARCHY-1] — CONFIRMED

The research's claim that bip39::Mnemonic::generate(24) at core/archipelago/src/seed.rs:92 resolves its entropy source through a transitive default is exactly right, and the citation is accurate: bip39-2.1.0/src/lib.rs:296-298 is generate_in, whose body is Mnemonic::generate_in_with(&mut rand::thread_rng(), language, word_count). The full chain is generate (:311-313) → generate_in (:296-298) → generate_in_with (:267-283).

The entropy source is chosen by the dependency, not at the call site. The injectable seam exists and is public — generate_in_with<R: RngCore + CryptoRng> — so closing this costs almost nothing. It is not a vulnerability today (rand 0.8.5's thread_rng is a fork-protected ChaCha12 CSPRNG seeded from getrandom(2)), but it is the structural shape of T1. Fixed in this audit — see §7 and F-02.

[ARCHY-2] — CONFIRMED (as a positive finding)

kernel_csprng_ready() at core/archipelago/src/seed.rs:58-75 calls libc::getrandom(..., libc::GRND_NONBLOCK) (:62-67), maps a 1-byte success to Some(true) (:68-69), EAGAIN to Some(false) (:70-71), and anything else to None (:73). The single byte it draws is discardedbyte is never read again. It is used only by MasterSeed::generate at :85-91 to emit info! or warn!.

No key material is drawn from the non-blocking path. The actual mnemonic entropy comes from bip39::Mnemonic::generate(24) at :92, i.e. getrandom(2) without GRND_NONBLOCK, which blocks until the pool is initialised. The doc comment at :52-57 states this reasoning correctly. The research's assessment — "exactly right and better than most implementations" — holds. The two hardening notes it raised also hold and are carried to the backlog: the invariant depends on the getrandom crate using the blocking syscall (worth a test, not just a comment), and the warn! should be persisted as a structured, durable event so a node can answer post-hoc "was the pool ready when this seed was born?" — the question Coldcard owners cannot answer today.

[ARCHY-3] — PARTIALLY CONFIRMED; the tree answers three of four sub-questions, the fourth is UNVERIFIED

First, a scoping correction the research could not have known: image-recipe/_archived/ is not dead. image-recipe/build-debian-iso.sh:19-40 execs image-recipe/_archived/build-auto-installer-iso.sh. That file is the ISO builder.

Sub-question Verdict Evidence
Does the build bake a populated seed file into the image? NO find image-recipe -name 'random-seed' -o -name '*.seed' → empty. The rootfs is a container export (build-auto-installer-iso.sh:717-726); systemd-random-seed.service never runs inside a container build, so /var/lib/systemd/random-seed is never created. The installer extracts that tar (:2303) and adds no seed file.
Is there a first-boot regeneration unit? YES, for TLS + SSH host keys — but it is fail-open and never retried archipelago-first-boot-secrets.service at :1599-1614, script at :1616-1665, installed :2587-2593, enabled :3336. Hole documented as F-03 (:1647, :1659, :1663). It does not touch /etc/machine-id or any random-seed file.
Does the image install jitterentropy-rngd / haveged / rng-tools? NO grep -cE 'haveged|jitterentropy|rng-tools|rngd' image-recipe/_archived/build-auto-installer-iso.sh0. The rootfs package list at :330-352 and following contains no entropy daemon. Kernel ≥5.6's in-kernel jitter source is therefore the only supplemental source on headless hardware.
Can onboarding key generation run before the kernel CSPRNG is initialised? NO — it can be delayed by it, but never weakened bip39 fills entropy via rand's OsRng/ThreadRng seeding, i.e. blocking getrandom(2). core/archipelago/src/seed.rs:52-57 documents exactly this and the probe at :85-91 makes the ordering visible in the logs. The failure mode is a hang, not a weak key — the correct trade.

What remains genuinely UNVERIFIED. Whether /etc/machine-id is empty (regenerated per node) or populated (shared) in the exported rootfs tar; whether SSH host keys are in fact present in that tar as the openssh-server install at :345 implies; the real crng init done timestamp relative to seed generation on freshly-flashed hardware; and whether N nodes flashed from one ISO actually produce N distinct seeds. None of these is answerable from this environment. They are the on-node checklist in §6 and must not be reported as verified.

Net assessment. The most-feared version of [ARCHY-3] — a baked, credited random-seed giving every node a correlated pool — does not exist. The real exposure is narrower and different from what the research predicted: fleet-shared SSH host keys and a fleet-shared TLS private key in the cached rootfs, protected by a regeneration step that fails open and never retries (F-03).

[ARCHY-4] — CONFIRMED, and worse than described

Every specific claim checks out:

  • The mnemonic is returned to the web client as words: Vec<String>core/archipelago/src/api/rpc/seed_rpc.rs:147, returned at :156-158. (The research cited "~line 147"; exact.)
  • 10-minute in-memory TTL — MNEMONIC_TTL at :27, state struct at :16-19.
  • Deliberately not cleared at verify time, with a documented rationale — :205-211. (Research cited :205-209; the comment block runs :205-211.)
  • Plaintext HTTP is a live mode — core/archipelago/src/api/rpc/mod.rs:227-241 conditions the cookie Secure flag on X-Forwarded-Proto: https and comments explicitly on "LAN HTTP"; image-recipe/configs/nginx-archipelago.conf:11, :15 bind :80 as default_server and :165-195 proxy /rpc/v1 and /rpc/ to the daemon.

Worse than described: the research treated this as a confidentiality exposure. It is also an integrity and availability exposure, because the same four seed methods are in UNAUTHENTICATED_METHODS (core/archipelago/src/api/rpc/middleware.rs:24-28) with no onboarding gate and no rate limit, and the handlers overwrite live identity keys unconditionally. That is F-01, severity Critical.

[ARCHY-5] — CONFIRMED as a pattern, REFUTED as a present defect

The line is exactly as cited (core/archipelago/src/totp.rs:305) but the charset at :298 is 32 characters, and 32 divides 256 exactly, so the current distribution is uniform — there is no bias today. The research's characterisation ("classic modulo bias whenever charset.len() does not divide 256") is technically precise; its implied conclusion that this instance is biased is not. Recorded as F-09, Low, on latent-defect grounds only. Stated plainly rather than quietly dropped, per this audit's honesty rule.

Open question 9 (Argon2 parameters) — DIVERGENCE CONFIRMED

Argon2::default() = Argon2id, v0x13, m=19456 KiB (19 MiB), t=2, p=1 (argon2-0.5.3/src/params.rs:42, :52, :61; src/lib.rs:176-180). docs/adr/005-chacha20-backup-encryption.md:31 states 64 MB and 3 iterations. The code does not match the ADR. Full detail and the migration constraint are in F-05.

Also noted from the research, confirmed benign

core/archipelago/src/storage_crypto.rs:39 and core/archipelago/src/credentials/store.rs:69 draw 96-bit ChaCha20-Poly1305 nonces via rand::random(). CSPRNG-backed; fine. The random-nonce birthday bound (~2^32 messages per key) is not approached by either use. Same for core/archipelago/src/mesh/crypto.rs:70 (explicit OsRng, with a correct explanatory comment at :64), core/archipelago/src/fips/dial.rs:75 (a 16-bit dial ID, not a secret), and core/archipelago/src/wallet/bdhke.rs:133, :139.


5. What we do right

Credit where the code is correct — each with evidence, so a future refactor that removes any of these is visibly a regression.

  1. The CSPRNG-readiness probe. core/archipelago/src/seed.rs:52-91. Uses GRND_NONBLOCK as a probe only, discards the byte, and logs the pool state immediately before generating the master seed. The doc comment reasons correctly about why blocking getrandom(2) makes a weak seed impossible. This is better than most wallet implementations and is precisely the audit trail Coldcard owners now wish they had.
  2. Zeroization is real, not decorative. MasterSeed is #[derive(Zeroize, ZeroizeOnDrop)] (core/archipelago/src/seed.rs:47-50); the Argon2-derived key is explicitly zeroized on both the encrypt and decrypt paths (:262, :292); the aezeed plaintext join is zeroized after use (:384, :401); the in-memory onboarding mnemonic zeroizes on Drop (core/archipelago/src/api/rpc/seed_rpc.rs:21-25); the reveal path zeroizes the password on every exit (:396, :430, :441, :465).
  3. No #[derive(Debug)] on any secret-bearing type. grep -rn 'derive(Debug' core/archipelago/src/seed.rs core/archipelago/src/identity.rs core/archipelago/src/credentials/store.rs returns nothing — the classic accidental-log escape is closed by construction.
  4. No secret is logged. The secret-logging grep across core/*/src returned only non-secret status lines. The most sensitive one, core/archipelago/src/seed.rs:86 ("kernel CSPRNG initialized; generating master seed"), contains no material. core/archipelago/src/identity.rs:103-106 logs only the first 16 hex chars of a public key. The file-level invariant at core/archipelago/src/seed.rs:18 ("Never log mnemonic or seed material at any level") is actually honoured.
  5. Encrypted-at-rest envelope with per-blob salt and nonce from OsRng. core/archipelago/src/seed.rs:243-246, AEAD at :253-260, and every identity blob written 0600 via a single shared helper (:318-324). One implementation, not five.
  6. 24-word enforcement on restore. core/archipelago/src/seed.rs:111-114 rejects any word count other than 24, so a 12-word (128-bit) mnemonic cannot be smuggled into a hierarchy that assumes 256 bits.
  7. Domain-separated derivation, pinned by known-answer tests. Distinct HKDF info strings per key class (core/archipelago/src/seed.rs:37-41), with KATs that pin the exact bytes: :764-779 (node key, cross-checked against scripts/verify-seed-derivation.py) and :800-816 (release-root private and public key). A derivation change cannot land silently.
  8. An existing non-determinism regression guard. core/archipelago/src/seed.rs:597-622 generates 64 mnemonics and asserts both uniqueness and word-distribution spread, with a comment naming exactly the failure it guards against. This is a genuinely good instinct that predates the Coldcard incident — it would have caught a Yasmarang-class collapse.
  9. seed.reveal is properly gated. core/archipelago/src/api/rpc/seed_rpc.rs:360-369: authenticated session required (it is deliberately not in the unauthenticated allowlist), password re-verification, replay-protected TOTP when 2FA is on, and separate backup-passphrase decryption. The contrast with F-01's ungated seed.generate/seed.restore is what makes F-01 look like an oversight rather than a design position.
  10. Correct browser RNG at the call sites that matter. neode-ui/src/views/OnboardingVerify.vue:105-109 uses crypto.getRandomValues for the 32-byte signing challenge; neode-ui/src/views/web5/Web5.vue:183-185 does the same, and guards on crypto.subtle being absent — which is exactly right, because subtle is undefined in an insecure context while getRandomValues keeps working over plain HTTP.
  11. Container secret file modes are test-enforced, not assumed. core/archipelago/src/container/secrets.rs:207 sets 0o600; :269 and :307 are tests asserting it. CLAUDE.md's invariant is mechanically defended.
  12. The release-root key is derived, not stored, and nodes hold only the public half. core/archipelago/src/seed.rs:133-146 documents the publisher-only derivation; core/archipelago/src/trust/anchor.rs:34 pins the public key. Fleet nodes never hold the signing key.
  13. The FIPS mesh peer listener is path-filtered. core/archipelago/src/server.rs:1375, :1270. The mechanism is right even though its current allowlist is too permissive for seed methods (F-01).

6. On-node verification checklist — UNVERIFIED

Every item below is UNVERIFIED. None was executed. Real hardware — a freshly-flashed node, .228, or the dev-box — is not reachable from the environment this audit ran in. Do not treat any of these as checked until an operator has run them and recorded the output.

Run on a freshly flashed node, before completing onboarding, unless noted.

C-1 — Was the kernel CSPRNG ready when keys were generated? ([ARCHY-3])

journalctl -b | grep -iE 'crng init|random: '
journalctl -b -u archipelago | grep -i 'kernel CSPRNG'
cat /proc/sys/kernel/random/entropy_avail
systemd-analyze blame | grep -iE 'random|archipelago-first-boot-secrets'

Pass: crng init done timestamp strictly precedes the kernel CSPRNG initialized; generating master seed line from core/archipelago/src/seed.rs:86. A not yet initialized warn line from :87-89 is the signal to escalate.

C-2 — Is a seed file present, and is machine-id unique? ([ARCHY-3])

ls -l /var/lib/systemd/random-seed /var/lib/urandom/random-seed 2>&1
cat /etc/machine-id

Pass: either no seed file at first boot, or one created after first boot with a current mtime. machine-id must differ between two nodes flashed from the same ISO — run on both and compare.

C-3 — Are SSH host keys and the TLS key per-node? (F-03, the highest-value check here)

On two nodes flashed from the same ISO:

for f in /etc/ssh/ssh_host_*_key.pub; do echo "$f: $(ssh-keygen -lf "$f")"; done
openssl x509 -in /etc/archipelago/ssl/archipelago.crt -noout -fingerprint -sha256
cat /var/lib/archipelago/.secrets-regenerated 2>&1; ls -l /var/lib/archipelago/.secrets-regenerated
grep -i warning /var/log/archipelago-first-boot-secrets.log

Fail: any fingerprint matching between the two nodes, or any WARNING: line in the log alongside an existing .secrets-regenerated marker (that combination is exactly the fail-open path at image-recipe/_archived/build-auto-installer-iso.sh:1647/:1659/:1663).

C-4 — Does the shipped rootfs tar contain identity artefacts? (F-03, run on the build host)

tar -tvf <build-dir>/archipelago-rootfs.tar | grep -E 'etc/ssh/ssh_host|etc/machine-id|var/lib/systemd/random-seed|archipelago/ssl/archipelago.key'

Expected: SSH host keys and the TLS key present (they are baked — see build-auto-installer-iso.sh:345, :463-469), random-seed absent, machine-id absent or zero-length. Anything else changes F-03's severity.

C-5 — Cross-node same-ISO seed collision test (the empirical proof that would have caught T1)

Flash N ≥ 3 nodes from one ISO. On each, without user interaction:

curl -s -X POST http://127.0.0.1:5678/rpc/v1 \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"seed.generate","params":null}' \
  | sha256sum

Pass: N distinct digests. Handle the output as key material — these are real mnemonics; compare digests only, never the words, and re-provision every node used for this test. Do not run this against a node in real use — per F-01 it overwrites the node's identity.

C-6 — Is the RPC endpoint reachable unauthenticated from the LAN? (F-01)

From a different machine on the same LAN, against a disposable node:

curl -s -o /dev/null -w '%{http_code}\n' -X POST http://<node-ip>/rpc/v1 \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"seed.status","params":null}'

Fail: 200. Use seed.status (read-only), never seed.generate/seed.restore, to probe this. Repeat over the Tor onion address and over the FIPS mesh ULA to establish the full exposure surface.

C-7 — Is the daemon's memory swappable?

systemctl cat archipelago.service | grep -E 'MemoryDenyWriteExecute|LimitMEMLOCK'
swapon --show

Informational: the onboarding mnemonic lives in process memory for up to 10 minutes (F-04) and image-recipe/archipelago-scripts/install-to-disk.sh:226-236 creates a 2-8 GB swapfile on every install.


7. ARCHY-1 remediation status — APPLIED

The injectable-RNG-seam refactor described in F-02 was applied to core/archipelago/src/seed.rs. Scope, precisely:

  • A new private helper generate_mnemonic_with<R: rand::CryptoRng + rand::RngCore>(rng: &mut R) calls bip39::Mnemonic::generate_in_with(rng, Language::English, 24) — the injectable bip39 entry point — instead of the defaulting Mnemonic::generate(24).
  • MasterSeed::generate() passes &mut rand::rngs::OsRng explicitly.
  • A doc comment at the helper pins the rationale to this audit and to T1, so a future rand/bip39 bump cannot rebind the entropy source without someone reading why it matters.
  • Two tests added to the existing module.

Nothing else changed. The derivation paths, the 24-word count, the empty-BIP-39-passphrase decision, the at-rest encryption envelope, and every existing test are untouched.

Tests. cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago seed::25 passed, 0 failed.

  • mnemonic_generation_uses_injected_rng — drives generation from a deterministic CryptoRng + RngCore test RNG and asserts (a) the result equals bip39::Mnemonic::from_entropy(<the exact bytes that RNG emitted>), which is the direct proof that the injected RNG — not bip39's transitive rand::thread_rng() default — is the one actually consumed; (b) a known-answer word list; (c) determinism across two identical RNG states. This test is impossible to write against the pre-change code, because Mnemonic::generate(24) exposes no seam through which the RNG can be observed or substituted.
  • mnemonic_generation_is_256_bit — the production OsRng path yields 24 words and two successive productions differ.

The residual risk this does not close. Making the source explicit does not make the fix retroactive: mnemonics generated before this change came from rand::thread_rng(). That was and remains a genuine CSPRNG (F-02, "Exploitability: none today"), so no existing seed is weakened — but the guarantee for those seeds rests on rand 0.8.5's behaviour, not on this call site. Reviewers should read this as removing a future failure mode, not as repairing a past one.

Everything else in this document is queued in §8, not implemented.


8. Remediation Backlog

Prioritised by severity × effort, most valuable per unit of work first. Only R-00 was implemented by this audit. Everything else is queued here and mirrored into the issue tracker so it is not stranded in a document nobody re-reads.

Effort scale: S = under an hour; M = half a day; L = a day or more; PHASE = needs its own /gsd-plan-phase, not an opportunistic edit.

# Closes Change Files Effort Hardware?
R-00 F-02 / [ARCHY-1] DONE in this audit. Route mnemonic generation through an injectable-RNG helper; production passes OsRng; known-answer test proves the injected RNG is consumed core/archipelago/src/seed.rs S no
R-01 F-01 (Critical) Gate seed.generate / seed.restore on onboarding being incomplete; add rate limits; narrow the mesh peer path filter core/archipelago/src/api/rpc/seed_rpc.rs, .../middleware.rs, .../rate_limit.rs, server.rs M yes (re-onboard + federation re-verify)
R-02 F-03 (High) Move touch "$MARKER" inside a both-succeeded branch so a failed regeneration retries next boot; surface the failure beyond a log file image-recipe/_archived/build-auto-installer-iso.sh S yes (ISO rebuild + fresh flash)
R-03 F-03 (High) Strip baked SSH host keys and the TLS keypair from the rootfs tar at build time, so a regeneration failure degrades to "no key" not "shared key" image-recipe/_archived/build-auto-installer-iso.sh M yes
R-04 F-13 (High) disable_private_keys=true; import the xpub with a [fingerprint/derivation] key origin; migrate with balance/UTXO parity verification core/archipelago/src/api/rpc/bitcoin.rs PHASE yes (node with real UTXO history)
R-05 F-07 (Medium) Add cargo audit / cargo deny check advisories bans to CI, with a bans rule failing on duplicate rand majors CI config S no
R-06 F-05 (Medium) Reconcile Argon2::default() (19 MiB / t=2) with ADR-005 (64 MB / 3) — either raise the params behind a versioned envelope with a migration, or amend the ADR core/archipelago/src/seed.rs, backup/full.rs, backup/identity.rs, docs/adr/005-... M no
R-07 F-04 (Medium) Confine seed-bearing RPCs to loopback/TLS; shrink MNEMONIC_TTL; clear on acknowledged verify with a short grace window core/archipelago/src/api/rpc/seed_rpc.rs, .../mod.rs, nginx config PHASE yes
R-08 F-06 (Medium) Make stdin/TTY the only mnemonic input for ceremony sign/pubkey; stop printing the mnemonic to stdout in ceremony gen core/archipelago/src/ceremony.rs S no (but schedule deliberately — it is the signing ceremony)
R-09 [ARCHY-2] hardening Persist the CSPRNG-readiness verdict as a durable structured event, so any node can answer post-hoc "was the pool ready when this seed was born?" core/archipelago/src/seed.rs S no
R-10 [ARCHY-2] hardening Add a test asserting the getrandom crate uses the blocking syscall, so the invariant is mechanical rather than a comment core/archipelago/src/seed.rs S no
R-11 F-08 (Low) Clear _seed_words on route-leave from onboarding, not only on successful verify; add a wall-clock expiry mirroring MNEMONIC_TTL neode-ui/src/views/OnboardingSeedGenerate.vue, OnboardingSeedVerify.vue S no
R-12 F-09 (Low) Replace % charset.len() with SliceRandom::choose(&mut OsRng) and pin the uniformity property with a test core/archipelago/src/totp.rs S no
R-13 F-10 (Low) Swap random_hex / random_base64 from thread_rng() to explicit OsRngSUPERSEDED 2026-08-02 by R-16; this file is 2 of 41 sites core/archipelago/src/container/secrets.rs S no
R-16 F-10a (Medium) Crate-wide enforcement so a defaulted RNG cannot be inherited anywhere: sealed allowlist trait at key-generation seams; clippy.toml disallowed-methods ban on rand::thread_rng/rand::random (compile-time, CI-enforced); cargo-deny on duplicate rand majors; degenerate-entropy runtime check; persist the CSPRNG-readiness verdict (absorbs R-05, R-09, R-13) 15 files — see §F-10a PHASE — tracked as KEY-05, Phase 10 no
R-14 F-11 (Informational) One-line comment at pickRandomIndices recording that the Math.random() is a UX challenge selector, not key material neode-ui/src/views/OnboardingSeedVerify.vue S no
R-15 §6 checklist Run the on-node verification checklist — especially C-3 (per-node SSH/TLS keys) and C-5 (cross-node collision test) M yes (2+ nodes from one ISO)

Explicitly NOT implemented in this task, and why

  • R-01, R-04, R-07 need their own phase. R-01 changes an authentication boundary on a live fleet; R-04 moves the spending key out of a wallet holding real funds; R-07 changes the onboarding transport. Each needs a migration story and real-node verification that an audit-and-spec task cannot provide.
  • R-02, R-03 require rebuilding the ISO and flashing at least two machines to verify. Not reachable from this environment.
  • R-13 is blocked purely by tree hygiene: core/archipelago/src/container/secrets.rs had another agent's uncommitted changes at audit time and this plan's invariant is that no commit it authors touches their files. Trivial once that work lands.
  • The whole of docs/security/PSBT-SIGNING-ARCHITECTURE.md is a rollout, not a fix. It is queued as a spec for /gsd-plan-phase, not implemented anywhere.

  • docs/security/PSBT-SIGNING-ARCHITECTURE.md — the signing architecture this audit's conclusions feed into (watch-only descriptors, PSBT, multisig, honest LND limits).
  • docs/hardware-signer-design.md — exploratory TROPIC01 air-gapped signer.
  • docs/adr/005-chacha20-backup-encryption.md — the ADR that F-05 diverges from.
  • .planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md — the incident analysis, the T1-T7 catalogue, and the audit checklist this document executed.