Untracking the ops docs broke every reference to them. Repoints or removes those references across README, architecture, ROADMAP, the archive index, lifecycle TESTING, the hardening plan, and the security docs — pointing at the issue tracker where a live task list was meant, and dropping the entry entirely where it only existed to link an internal file. Also fixes two pre-existing broken links found by validating every relative link in the tracked docs: - README linked docs/OPEN_SOURCE_READINESS.md, which never existed (underscores vs hyphens). - reticulum-daemon/README.md linked a local Claude session plan at ../../.claude/plans/enchanted-strolling-rocket.md — outside the repo, and a path that would have shipped publicly pointing at nothing. All relative links in tracked markdown now resolve: 0 broken. audit-secrets.sh still 5/5. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1007 lines
66 KiB
Markdown
1007 lines
66 KiB
Markdown
# 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** → **see F-10a** | Container `generated_secrets` use `thread_rng()` rather than an explicit `OsRng` (same T1 shape as F-02, smaller blast radius) | `core/archipelago/src/container/secrets.rs:92`, `:101` |
|
||
| F-10a | **Medium** | **Scope correction to F-10 (2026-08-02):** the defaulted-RNG surface is crate-wide — **41 raw matches across 15 files**, not 2 — and includes ecash key material and an AEAD nonce | `core/archipelago/src/wallet/bdhke.rs:133`,`:139` (Cashu secret + blinding factor), `storage_crypto.rs:39` (nonce), `session.rs` (4 prod), +12 more files — full table in §F-10a |
|
||
| F-11 | **Informational** | `Math.random()` inside a seed-handling view (benign — UX challenge selection only) | `neode-ui/src/views/OnboardingSeedVerify.vue:159` |
|
||
| F-12 | **Informational** | Identical default OS credentials on every flashed node | `image-recipe/archipelago-scripts/install-to-disk.sh:205` |
|
||
| F-13 | **High** | BIP-84 account **private** key is imported into Bitcoin Core's wallet, duplicating the spending key outside the encrypted envelope | `core/archipelago/src/api/rpc/bitcoin.rs:203`, `:229-231` |
|
||
|
||
---
|
||
|
||
### F-01 — Unauthenticated `seed.generate` / `seed.restore` overwrite a live node's identity keys — **Critical**
|
||
|
||
**Evidence.**
|
||
- `core/archipelago/src/api/rpc/middleware.rs:24-28` places `seed.generate`, `seed.verify`,
|
||
`seed.restore` and `seed.save-encrypted` in `UNAUTHENTICATED_METHODS`, under the comment
|
||
"Onboarding flow (before user has a session)".
|
||
- `core/archipelago/src/api/rpc/mod.rs:263-265` — membership in that list skips the entire
|
||
session check; `:295` skips RBAC; `:326` skips CSRF.
|
||
- `core/archipelago/src/api/rpc/seed_rpc.rs:93-159` (`handle_seed_generate`) and `:226-305`
|
||
(`handle_seed_restore`) contain **no** check that onboarding is already complete or that a
|
||
node key already exists.
|
||
- `core/archipelago/src/identity.rs:79-114` (`NodeIdentity::from_seed`) writes `node_key`,
|
||
`node_key.pub` and, via `write_fips_key_from_seed` (`:108`), the FIPS mesh key —
|
||
**unconditionally, with no existence check.** `seed_rpc.rs:130-131` and `:261-266` likewise
|
||
overwrite `nostr_secret` / `nostr_pubkey`.
|
||
- The guard already exists and is never called on this path:
|
||
`core/archipelago/src/identity.rs:117-119` (`NodeIdentity::key_exists`). Its only callers are
|
||
`core/archipelago/src/server.rs:63` and `core/archipelago/src/api/rpc/seed_rpc.rs:343`
|
||
(read-only status).
|
||
- No rate limit: `core/archipelago/src/rate_limit.rs:60-97` enumerates per-method limits and
|
||
contains **no `seed.*` entry**, while explicitly acknowledging at `:96` that
|
||
"Inter-node federation RPCs (unauthenticated, need stricter limits)".
|
||
- Reachability: `image-recipe/configs/nginx-archipelago.conf:11` and `:15` bind port 80 as
|
||
`default_server` (plaintext, LAN); `:165-175` proxies `/rpc/v1` and `:192-195` proxies
|
||
`/rpc/` to `127.0.0.1:5678`. The FIPS mesh peer listener applies a path filter
|
||
(`core/archipelago/src/server.rs:1375`, `:1270`) but that filter **allows** `/rpc/v1` —
|
||
asserted at `core/archipelago/src/server.rs:2080`.
|
||
|
||
**Exploitability.** No credentials, no session, no CSRF token, no rate limit. A single
|
||
unauthenticated JSON-RPC POST from anywhere on the LAN — or from any peer that can reach the
|
||
mesh listener — is sufficient. `seed.restore` is the worse of the two because the attacker
|
||
supplies the mnemonic: they then hold the node's Ed25519 signing key, its Nostr node key and
|
||
its FIPS transport key. `seed.generate` is a pure destructive primitive: it mints a mnemonic
|
||
nobody ever sees and overwrites the node's identity with it.
|
||
|
||
**Blast radius.** Node identity takeover or permanent identity destruction. Downstream: the
|
||
node's `did:key` changes, so every federation trust relationship keyed on that DID breaks; the
|
||
FIPS mesh key changes, so mesh peering breaks; the Nostr node key changes, so discovery
|
||
announcements are signed by a key the fleet does not recognise. This does **not** by itself
|
||
expose the user's Bitcoin funds (the on-disk `master_seed.enc` envelope is not overwritten by
|
||
these handlers) — but do not read that as reassurance: an attacker who controls the node's
|
||
identity keys controls how that node presents itself to the federation.
|
||
|
||
**This is not an entropy defect.** It surfaced because Step B of this audit required tracing
|
||
secret classes (3) and (4) end-to-end rather than only checking where their bits come from.
|
||
It is reported here because it is the most serious thing found and suppressing it until a
|
||
"more appropriate" document would be indefensible.
|
||
|
||
**Remediation (concrete).** In `handle_seed_generate` and `handle_seed_restore`, bail early
|
||
when `NodeIdentity::key_exists(&identity_dir)` is true *and* the in-memory onboarding mnemonic
|
||
is absent — i.e. this is a booted, already-provisioned node rather than an onboarding retry.
|
||
Prefer additionally gating on `auth_manager.is_onboarding_complete()`
|
||
(`core/archipelago/src/auth.rs:182`). Add `seed.generate` / `seed.restore` to
|
||
`rate_limit.rs`'s table at the strictness of `auth.changePassword` (3 per 300s). Consider
|
||
removing `/rpc/v1` from `is_peer_allowed_path` for seed methods specifically, or filtering by
|
||
method rather than path. Needs its own plan — see Backlog R-01.
|
||
|
||
---
|
||
|
||
### F-02 — Mnemonic entropy source is a transitive-dependency default — **Medium** — [ARCHY-1], **FIXED IN THIS AUDIT**
|
||
|
||
**Evidence.** `core/archipelago/src/seed.rs:92`:
|
||
|
||
```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-10a — Scope correction: the defaulted-RNG surface is crate-wide — **Medium**
|
||
|
||
> **Added 2026-08-02, after the original audit.** F-10 above reported this defect as two call
|
||
> sites in one file. That was **understated**. This section records the true scope with evidence.
|
||
> F-10's own text and remediation are left unedited above so the correction is auditable rather
|
||
> than retroactive.
|
||
|
||
**Evidence.** `grep -rn "rand::random\|thread_rng()" core/archipelago/src --include=*.rs` returns
|
||
**43 matches across 16 files**. Two of those (`seed.rs:87`, `:671`) are comments in the
|
||
already-remediated F-02 file, leaving **41 matches across 15 files**:
|
||
|
||
| File | Matches | Generates |
|
||
|---|---|---|
|
||
| `core/archipelago/src/session.rs` | 16 → **4 prod + 12 test** | session tokens (`#[cfg(test)]` begins `:470`) |
|
||
| `core/archipelago/src/api/rpc/package/pine_ha.rs` | 6 | app credentials |
|
||
| `core/archipelago/src/wallet/bdhke.rs` | 4 → **2 prod + 2 test** | **Cashu proof secret (`generate_secret`, `:133`) and blinding factor (`random_blinding_factor`, `:139`) — genuine key material** (`#[cfg(test)]` begins `:143`) |
|
||
| `core/archipelago/src/mesh/x3dh.rs` | 2 | ~~X3DH key agreement — key material~~ **CORRECTED: `u32` prekey *identifiers*** (`spk_id` `:100`, `otk_id` `:114`). The X25519 secrets come from `crypto::generate_x25519_ephemeral()` at `:99`/`:113` and are **not** in this table |
|
||
| `core/archipelago/src/container/secrets.rs` | 2 | `generated_secrets` (the original F-10) |
|
||
| `core/archipelago/src/api/rpc/package/install.rs` | 2 | install-time secrets |
|
||
| `core/archipelago/src/storage_crypto.rs` | 1 | **ChaCha20-Poly1305 nonce — reuse breaks the AEAD** |
|
||
| `core/archipelago/src/credentials/store.rs` | 1 | credential store material |
|
||
| `core/archipelago/src/device_tokens.rs` | 1 | device tokens |
|
||
| `core/archipelago/src/federation/invites.rs` | 1 | federation invites |
|
||
| `core/archipelago/src/bitcoin_rpc.rs` | 1 | Bitcoin RPC password |
|
||
| `core/archipelago/src/totp.rs` | 1 | TOTP backup codes (also F-09) |
|
||
| `core/archipelago/src/transport/chunking.rs` | 1 | chunk identifiers |
|
||
| `core/archipelago/src/fips/dial.rs` | 1 | dial jitter/identifiers |
|
||
| `core/archipelago/src/api/rpc/auth.rs` | 1 | auth-path material |
|
||
|
||
**Per-site production-vs-test classification is deliberately NOT asserted here** — it is the
|
||
first task of the remediation (KEY-05 Task 1), not an assumption of this correction. The counts
|
||
above are raw matches.
|
||
|
||
> **Self-correction, 2026-08-02 (same day, before any KEY-05 work began).** The first version of
|
||
> this table asserted semantics in its right-hand column that two entries did not support, and
|
||
> the KEY-05 planner caught it against the code. Both are fixed inline above, struck rather than
|
||
> silently rewritten:
|
||
> - **`mesh/x3dh.rs` was wrong.** Called "X3DH key agreement — key material"; the two sites are
|
||
> `u32` prekey *identifiers*. The X25519 secrets are drawn elsewhere and were never in scope.
|
||
> - **`session.rs` was misleading.** 16 raw matches read as 16 production token sites; it is 4
|
||
> production and 12 test.
|
||
>
|
||
> **The Medium rating still holds, on narrower grounds.** It now rests on `wallet/bdhke.rs`'s two
|
||
> production sites (the Cashu proof secret and the blinding factor — a predictable blinding
|
||
> factor breaks the ecash unlinkability guarantee and is genuinely key material) and on
|
||
> `storage_crypto.rs:39`'s AEAD nonce. It does **not** rest on x3dh. Recorded because the
|
||
> correction to F-10 was made on the grounds that understatement misleads the next reader —
|
||
> overstatement does exactly the same, and this table did both within one day.
|
||
|
||
**Assessment.** Unchanged from F-10 in kind: `rand::random()` and `thread_rng()` are backed by
|
||
ChaCha12 seeded from `getrandom(2)` on `rand 0.8.5`, so **nothing in this table is broken
|
||
today**. What changes is the *blast radius* of the T1 structural objection. F-10 rated this Low
|
||
on the basis of "per-app credentials rather than the master key hierarchy". That justification
|
||
does not survive the true scope: `wallet/bdhke.rs:133`/`:139` generate the Cashu proof secret and
|
||
blinding factor — genuine key material, where a predictable blinding factor breaks ecash
|
||
unlinkability — and `storage_crypto.rs:39` draws an AEAD nonce, where a silent rebinding to a
|
||
non-cryptographic PRNG would be catastrophic rather than merely undesirable. Re-rated **Medium**
|
||
on those two grounds. (An earlier version of this sentence also cited `mesh/x3dh.rs`; that was
|
||
incorrect and is struck in the table above — those sites are prekey identifiers.)
|
||
|
||
**Why the original audit missed it.** F-10 was reached by tracing the *manifest secrets* path
|
||
(secret class 4). No step enumerated defaulted-RNG use across the whole crate independently of
|
||
the traced paths — so files outside those traces were never in scope to be looked at. Recorded
|
||
here because the same blind spot would recur in the next audit run under the same method.
|
||
|
||
**Remediation → tracked as KEY-05 in Phase 10** (`.planning/ROADMAP.md`), which supersedes R-13:
|
||
a sealed allowlist trait so only approved RNGs can be passed at key-generation seams; a
|
||
`clippy.toml` `disallowed-methods` ban on `rand::thread_rng` / `rand::random` crate-wide so the
|
||
default cannot be inherited by *new* code either; `cargo-deny` failing on duplicate `rand`
|
||
majors (R-05, the mechanism by which a bump could silently rebind); a degenerate-entropy runtime
|
||
check before key generation; and persisting the CSPRNG-readiness verdict (R-09) that
|
||
`seed.rs:59` already computes but discards.
|
||
|
||
---
|
||
|
||
### F-11 — `Math.random()` inside a seed-handling view — **Informational (benign)**
|
||
|
||
**Evidence.** `neode-ui/src/views/OnboardingSeedVerify.vue:157-163`, `pickRandomIndices` uses
|
||
`Math.floor(Math.random() * max)` to choose which of the 24 words the user is quizzed on.
|
||
|
||
**Assessment: benign, and annotated here so the next auditor does not re-derive it.** The
|
||
indices select a UX challenge only. They are not key material, not a nonce, not a salt, and not
|
||
a secret: an attacker who predicts perfectly which words will be quizzed learns nothing — the
|
||
words themselves are what they would need, and those are already on the user's screen. The
|
||
verification is a *user*-facing "did you write it down" check, not an authentication boundary
|
||
(the server compares against its own held copy at
|
||
`core/archipelago/src/api/rpc/seed_rpc.rs:190-194`).
|
||
|
||
Other `Math.random()` sites, all confirmed non-security:
|
||
`neode-ui/src/api/rpc-client.ts:183`, `:206`, `:215` (retry jitter);
|
||
`neode-ui/src/views/Login.vue:317` (progress bar);
|
||
`neode-ui/src/components/BootScreen.vue:112`, `:123` (starfield animation).
|
||
|
||
**No remediation required.** Optionally add a one-line comment at the call site so this stays
|
||
annotated in the code rather than only in this document.
|
||
|
||
---
|
||
|
||
### F-12 — Identical default OS credentials on every flashed node — **Informational**
|
||
|
||
**Evidence.** `image-recipe/archipelago-scripts/install-to-disk.sh:205` sets
|
||
`archipelago:archipelago` via `chpasswd`, and `:367-371` prints the credentials with a
|
||
"Please change the password after first login!" warning.
|
||
|
||
**Assessment.** Not an entropy defect and a known, documented alpha-stage default. Recorded here
|
||
only because it belongs to the same one-image-many-nodes correlation theme as [ARCHY-3]: it is
|
||
the one identity artefact that is *deliberately* identical across the fleet, and unlike the SSH
|
||
host key and TLS key (F-03) there is no first-boot regeneration for it. Out of scope to fix;
|
||
in scope to name.
|
||
|
||
---
|
||
|
||
### F-13 — BIP-84 account **private** key is imported into Bitcoin Core — **High**
|
||
|
||
**Evidence.** `core/archipelago/src/api/rpc/bitcoin.rs:161-294`
|
||
(`handle_bitcoin_init_wallet_from_seed`):
|
||
|
||
- `:203` passes `disable_private_keys = false` to `createwallet`.
|
||
- `:188-189` derives the BIP-84 account **xprv** (`crate::seed::derive_bitcoin_xprv`,
|
||
`core/archipelago/src/seed.rs:207-224`) and stringifies it.
|
||
- `:229-231` builds `wpkh({xprv}/0/*)` and `wpkh({xprv}/1/*)`.
|
||
- `:278-281` imports those descriptors via `importdescriptors`.
|
||
|
||
**Assessment.** The node's Bitcoin spending key is therefore persisted **twice**: once in the
|
||
daemon's Argon2 + ChaCha20-Poly1305 envelope (`core/archipelago/src/seed.rs:238-269`, `0600` via
|
||
`:318-324`), and once in Bitcoin Core's `wallet.dat`, which has neither the Argon2 passphrase
|
||
protection nor the same ownership story — it lives in the Bitcoin Core container's data volume.
|
||
The wallet is created with an **empty** encryption passphrase (`bitcoin.rs:205`), so Core's own
|
||
wallet encryption is not engaged either.
|
||
|
||
**Not an entropy defect**, and reported here because Step B required tracing secret class (1),
|
||
the user Bitcoin/LND wallet seed, from generation to consumer — and this is where that trace
|
||
ends up.
|
||
|
||
**Credit where due:** the in-memory handling of the xprv string is careful — it is zeroized on
|
||
both the error path (`bitcoin.rs:222`) and the success path (`:284`) — and the wallet is a
|
||
*descriptor* wallet (`:207`), which is the correct foundation. The defect is which key goes
|
||
into it.
|
||
|
||
**Secondary defect, same lines.** The descriptors at `:230-231` carry **no key-origin
|
||
annotation** (`[fingerprint/derivation]`). Without it, no hardware signer can locate its key in
|
||
a PSBT — so the current wallet could not be converted to an external-signer setup even if the
|
||
private key were removed.
|
||
|
||
**Exploitability.** Requires read access to the Bitcoin Core data volume. That is a lower bar
|
||
than the encrypted envelope: a container escape, a backup of the Bitcoin volume, or a
|
||
misconfigured bind mount exposes it, whereas `master_seed.enc` additionally requires the user's
|
||
password.
|
||
|
||
**Blast radius.** The node's entire on-chain Bitcoin balance at `m/84'/0'/0'`. It does **not**
|
||
extend to the other key classes — the release-root key, node identity and FIPS keys are HKDF
|
||
siblings, not children of the BIP-84 branch, so an attacker with the account xprv cannot climb
|
||
back to the master seed.
|
||
|
||
**Remediation.** Pass `disable_private_keys = true`; import the account **xpub** with a
|
||
key-origin annotation instead of the xprv; sign via the daemon (or an external signer) rather
|
||
than via Core. This is Phase 1 of `docs/security/PSBT-SIGNING-ARCHITECTURE.md` §8, including
|
||
the migration that verifies balance/UTXO parity before removing the private-key-bearing wallet.
|
||
|
||
---
|
||
|
||
## 4. [ARCHY-1] … [ARCHY-4] adjudication
|
||
|
||
### [ARCHY-1] — **CONFIRMED**
|
||
|
||
The research's claim that `bip39::Mnemonic::generate(24)` at `core/archipelago/src/seed.rs:92`
|
||
resolves its entropy source through a transitive default is **exactly right**, and the citation
|
||
is accurate: `bip39-2.1.0/src/lib.rs:296-298` is `generate_in`, whose body is
|
||
`Mnemonic::generate_in_with(&mut rand::thread_rng(), language, word_count)`. The full chain is
|
||
`generate` (`:311-313`) → `generate_in` (`:296-298`) → `generate_in_with` (`:267-283`).
|
||
|
||
**The entropy source is chosen by the dependency, not at the call site.** The injectable seam
|
||
exists and is public — `generate_in_with<R: RngCore + CryptoRng>` — so closing this costs
|
||
almost nothing. It is **not** a vulnerability today (`rand 0.8.5`'s `thread_rng` is a
|
||
fork-protected ChaCha12 CSPRNG seeded from `getrandom(2)`), but it is the structural shape of
|
||
T1. **Fixed in this audit — see §7 and F-02.**
|
||
|
||
### [ARCHY-2] — **CONFIRMED (as a positive finding)**
|
||
|
||
`kernel_csprng_ready()` at `core/archipelago/src/seed.rs:58-75` calls
|
||
`libc::getrandom(..., libc::GRND_NONBLOCK)` (`:62-67`), maps a 1-byte success to `Some(true)`
|
||
(`:68-69`), `EAGAIN` to `Some(false)` (`:70-71`), and anything else to `None` (`:73`). The
|
||
single byte it draws is **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 — **APPLIED**
|
||
|
||
The injectable-RNG-seam refactor described in F-02 was applied to
|
||
`core/archipelago/src/seed.rs`. Scope, precisely:
|
||
|
||
- A new private helper `generate_mnemonic_with<R: rand::CryptoRng + rand::RngCore>(rng: &mut R)`
|
||
calls `bip39::Mnemonic::generate_in_with(rng, Language::English, 24)` — the **injectable**
|
||
bip39 entry point — instead of the defaulting `Mnemonic::generate(24)`.
|
||
- `MasterSeed::generate()` passes `&mut rand::rngs::OsRng` explicitly.
|
||
- A doc comment at the helper pins the rationale to this audit and to T1, so a future
|
||
`rand`/`bip39` bump cannot rebind the entropy source without someone reading why it matters.
|
||
- Two tests added to the existing module.
|
||
|
||
**Nothing else changed.** The derivation paths, the 24-word count, the empty-BIP-39-passphrase
|
||
decision, the at-rest encryption envelope, and every existing test are untouched.
|
||
|
||
**Tests.** `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago seed::` →
|
||
**25 passed, 0 failed.**
|
||
|
||
- `mnemonic_generation_uses_injected_rng` — drives generation from a deterministic
|
||
`CryptoRng + RngCore` test RNG and asserts (a) the result equals
|
||
`bip39::Mnemonic::from_entropy(<the exact bytes that RNG emitted>)`, which is the direct proof
|
||
that the **injected** RNG — not bip39's transitive `rand::thread_rng()` default — is the one
|
||
actually consumed; (b) a known-answer word list; (c) determinism across two identical RNG
|
||
states. **This test is impossible to write against the pre-change code**, because
|
||
`Mnemonic::generate(24)` exposes no seam through which the RNG can be observed or substituted.
|
||
- `mnemonic_generation_is_256_bit` — the production `OsRng` path yields 24 words and two
|
||
successive productions differ.
|
||
|
||
**The residual risk this does not close.** Making the source explicit does not make the *fix*
|
||
retroactive: mnemonics generated before this change came from `rand::thread_rng()`. That was
|
||
and remains a genuine CSPRNG (F-02, "Exploitability: none today"), so no existing seed is
|
||
weakened — but the guarantee for those seeds rests on `rand 0.8.5`'s behaviour, not on this
|
||
call site. Reviewers should read this as *removing a future failure mode*, not as repairing a
|
||
past one.
|
||
|
||
Everything else in this document is queued in §8, not implemented.
|
||
|
||
---
|
||
|
||
## 8. Remediation Backlog
|
||
|
||
Prioritised by severity × effort, most valuable per unit of work first. **Only R-00 was
|
||
implemented by this audit.** Everything else is queued here and mirrored into
|
||
the issue tracker so it is not stranded in a document nobody re-reads.
|
||
|
||
**Effort scale:** S = under an hour; M = half a day; L = a day or more; **PHASE** = needs its
|
||
own `/gsd-plan-phase`, not an opportunistic edit.
|
||
|
||
| # | Closes | Change | Files | Effort | Hardware? |
|
||
|---|---|---|---|---|---|
|
||
| **R-00** | F-02 / [ARCHY-1] | **DONE in this audit.** Route mnemonic generation through an injectable-RNG helper; production passes `OsRng`; known-answer test proves the injected RNG is consumed | `core/archipelago/src/seed.rs` | S | no |
|
||
| **R-01** | **F-01 (Critical)** | Gate `seed.generate` / `seed.restore` on onboarding being incomplete; add rate limits; narrow the mesh peer path filter | `core/archipelago/src/api/rpc/seed_rpc.rs`, `.../middleware.rs`, `.../rate_limit.rs`, `server.rs` | M | yes (re-onboard + federation re-verify) |
|
||
| **R-02** | F-03 (High) | Move `touch "$MARKER"` inside a both-succeeded branch so a failed regeneration retries next boot; surface the failure beyond a log file | `image-recipe/_archived/build-auto-installer-iso.sh` | S | **yes** (ISO rebuild + fresh flash) |
|
||
| **R-03** | F-03 (High) | Strip baked SSH host keys and the TLS keypair from the rootfs tar at build time, so a regeneration failure degrades to "no key" not "shared key" | `image-recipe/_archived/build-auto-installer-iso.sh` | M | **yes** |
|
||
| **R-04** | **F-13 (High)** | `disable_private_keys=true`; import the **xpub** with a `[fingerprint/derivation]` key origin; migrate with balance/UTXO parity verification | `core/archipelago/src/api/rpc/bitcoin.rs` | **PHASE** | **yes** (node with real UTXO history) |
|
||
| **R-05** | F-07 (Medium) | Add `cargo audit` / `cargo deny check advisories bans` to CI, with a `bans` rule failing on duplicate `rand` majors | CI config | S | no |
|
||
| **R-06** | F-05 (Medium) | Reconcile `Argon2::default()` (19 MiB / t=2) with ADR-005 (64 MB / 3) — either raise the params behind a versioned envelope with a migration, or amend the ADR | `core/archipelago/src/seed.rs`, `backup/full.rs`, `backup/identity.rs`, `docs/adr/005-...` | M | no |
|
||
| **R-07** | F-04 (Medium) | Confine seed-bearing RPCs to loopback/TLS; shrink `MNEMONIC_TTL`; clear on acknowledged verify with a short grace window | `core/archipelago/src/api/rpc/seed_rpc.rs`, `.../mod.rs`, nginx config | **PHASE** | yes |
|
||
| **R-08** | F-06 (Medium) | Make stdin/TTY the only mnemonic input for `ceremony sign`/`pubkey`; stop printing the mnemonic to stdout in `ceremony gen` | `core/archipelago/src/ceremony.rs` | S | no (but schedule deliberately — it is the signing ceremony) |
|
||
| **R-09** | [ARCHY-2] hardening | Persist the CSPRNG-readiness verdict as a durable structured event, so any node can answer post-hoc "was the pool ready when this seed was born?" | `core/archipelago/src/seed.rs` | S | no |
|
||
| **R-10** | [ARCHY-2] hardening | Add a test asserting the `getrandom` crate uses the **blocking** syscall, so the invariant is mechanical rather than a comment | `core/archipelago/src/seed.rs` | S | no |
|
||
| **R-11** | F-08 (Low) | Clear `_seed_words` on route-leave from onboarding, not only on successful verify; add a wall-clock expiry mirroring `MNEMONIC_TTL` | `neode-ui/src/views/OnboardingSeedGenerate.vue`, `OnboardingSeedVerify.vue` | S | no |
|
||
| **R-12** | F-09 (Low) | Replace `% charset.len()` with `SliceRandom::choose(&mut OsRng)` and pin the uniformity property with a test | `core/archipelago/src/totp.rs` | S | no |
|
||
| **R-13** | F-10 (Low) | ~~Swap `random_hex` / `random_base64` from `thread_rng()` to explicit `OsRng`~~ — **SUPERSEDED 2026-08-02 by R-16**; this file is 2 of 41 sites | `core/archipelago/src/container/secrets.rs` | S | no |
|
||
| **R-16** | **F-10a (Medium)** | Crate-wide enforcement so a defaulted RNG cannot be inherited anywhere: sealed allowlist trait at key-generation seams; `clippy.toml` `disallowed-methods` ban on `rand::thread_rng`/`rand::random` (compile-time, CI-enforced); `cargo-deny` on duplicate `rand` majors; degenerate-entropy runtime check; persist the CSPRNG-readiness verdict (absorbs R-05, R-09, R-13) | 15 files — see §F-10a | **PHASE** — tracked as **KEY-05**, Phase 10 | no |
|
||
| **R-14** | F-11 (Informational) | One-line comment at `pickRandomIndices` recording that the `Math.random()` is a UX challenge selector, not key material | `neode-ui/src/views/OnboardingSeedVerify.vue` | S | no |
|
||
| **R-15** | §6 checklist | Run the on-node verification checklist — especially C-3 (per-node SSH/TLS keys) and C-5 (cross-node collision test) | — | M | **yes** (2+ nodes from one ISO) |
|
||
|
||
### Explicitly NOT implemented in this task, and why
|
||
|
||
- **R-01, R-04, R-07** need their own phase. R-01 changes an authentication boundary on a live
|
||
fleet; R-04 moves the spending key out of a wallet holding real funds; R-07 changes the
|
||
onboarding transport. Each needs a migration story and real-node verification that an
|
||
audit-and-spec task cannot provide.
|
||
- **R-02, R-03** require rebuilding the ISO and flashing at least two machines to verify. Not
|
||
reachable from this environment.
|
||
- **R-13** is blocked purely by tree hygiene: `core/archipelago/src/container/secrets.rs` had
|
||
another agent's uncommitted changes at audit time and this plan's invariant is that no commit
|
||
it authors touches their files. Trivial once that work lands.
|
||
- **The whole of `docs/security/PSBT-SIGNING-ARCHITECTURE.md`** is a rollout, not a fix. It is
|
||
queued as a spec for `/gsd-plan-phase`, not implemented anywhere.
|
||
|
||
---
|
||
|
||
## 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.
|