# 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 `exec`s 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 audit` — **`cargo-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` — 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 | **Low** | 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-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`: ```rust 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-313` → `generate_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`: 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` from the mnemonic and `:156-158` returns it as the JSON-RPC result. - Held server-side in a process-global `LazyLock>>>` (`: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//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-tungstenite` → `async-wsocket` → `nostr-relay-pool` → `nostr-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 `sessionStorage` — **Low** **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`: ```rust let idx = (rand::random::() 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-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` — 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 **discarded** — `byte` 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.sh` → `0`. 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` — `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]) ```bash 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]) ```bash 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: ```bash 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*) ```bash tar -tvf /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: ```bash 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: ```bash curl -s -o /dev/null -w '%{http_code}\n' -X POST http:///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? ```bash 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(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()`, 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 `docs/UNIFIED-TASK-TRACKER.md` 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 `OsRng` | `core/archipelago/src/container/secrets.rs` | S | no — **blocked**: file has another agent's uncommitted work | | **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. --- ## 9. Related documents - `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.