docs(quick-260731-upz): entropy & seed-generation security audit

Evidence-backed audit of every secret class against the real tree, prompted
by the 2026-07-30 Coinkite COLDCARD entropy incident.

- No Coldcard-class entropy defect exists: no non-cryptographic PRNG, no
  clock-seeded key, no Math.random() in any browser key path
- F-01 (Critical, NOT entropy): seed.generate/seed.restore are unauthenticated,
  unrated, and unconditionally overwrite a live node's Ed25519/Nostr/FIPS keys
- F-02 [ARCHY-1] CONFIRMED: mnemonic entropy source is a bip39 transitive
  default, not a call-site argument — the exact structural shape of T1
- F-03 (High) [ARCHY-3]: first-boot TLS/SSH regeneration is fail-open and its
  completion marker is set even on failure, over a fleet-shared cached rootfs
- ARCHY-2 confirmed good; ARCHY-5 refuted as a present defect (32 | 256)
- Argon2::default() is 19MiB/t=2, not ADR-005's stated 64MB/3
- Corrects the scoping assumption that image-recipe/_archived/ is dead: it is
  the live ISO builder, exec'd by build-debian-iso.sh
- Adds a "What we do right" section and an UNVERIFIED on-node checklist

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-31 23:03:26 -04:00
co-authored by Claude Opus 5
parent cc52719e81
commit f11db4ea1d
@@ -0,0 +1,809 @@
# 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<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 | **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-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<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-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::<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-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.
---
## 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 **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<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])
```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 <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:
```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://<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?
```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
<!-- filled by Task 3 -->
Everything else in this document is queued in §8, not implemented.
---
## 8. Remediation Backlog
<!-- filled by Task 3 -->
---
## 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.