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:
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.
|
||||||
Reference in New Issue
Block a user