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