Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,987 @@
|
||||
# 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 call sites across 15 files**, not 2 — and includes ecash and X3DH key material | `core/archipelago/src/session.rs` (16), `wallet/bdhke.rs` (4), `mesh/x3dh.rs` (2), `storage_crypto.rs`, +11 more — 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 | session tokens |
|
||||
| `core/archipelago/src/api/rpc/package/pine_ha.rs` | 6 | app credentials |
|
||||
| `core/archipelago/src/wallet/bdhke.rs` | 4 | **Cashu blinded-key-exchange values — key material** |
|
||||
| `core/archipelago/src/mesh/x3dh.rs` | 2 | **X3DH key agreement — key material** |
|
||||
| `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, not an assumption of this correction. The counts above are raw
|
||||
matches.
|
||||
|
||||
**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` and `mesh/x3dh.rs` generate key material, 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**.
|
||||
|
||||
**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
|
||||
`docs/UNIFIED-TASK-TRACKER.md` so it is not stranded in a document nobody re-reads.
|
||||
|
||||
**Effort scale:** S = under an hour; M = half a day; L = a day or more; **PHASE** = needs its
|
||||
own `/gsd-plan-phase`, not an opportunistic edit.
|
||||
|
||||
| # | Closes | Change | Files | Effort | Hardware? |
|
||||
|---|---|---|---|---|---|
|
||||
| **R-00** | F-02 / [ARCHY-1] | **DONE in this audit.** Route mnemonic generation through an injectable-RNG helper; production passes `OsRng`; known-answer test proves the injected RNG is consumed | `core/archipelago/src/seed.rs` | S | no |
|
||||
| **R-01** | **F-01 (Critical)** | Gate `seed.generate` / `seed.restore` on onboarding being incomplete; add rate limits; narrow the mesh peer path filter | `core/archipelago/src/api/rpc/seed_rpc.rs`, `.../middleware.rs`, `.../rate_limit.rs`, `server.rs` | M | yes (re-onboard + federation re-verify) |
|
||||
| **R-02** | F-03 (High) | Move `touch "$MARKER"` inside a both-succeeded branch so a failed regeneration retries next boot; surface the failure beyond a log file | `image-recipe/_archived/build-auto-installer-iso.sh` | S | **yes** (ISO rebuild + fresh flash) |
|
||||
| **R-03** | F-03 (High) | Strip baked SSH host keys and the TLS keypair from the rootfs tar at build time, so a regeneration failure degrades to "no key" not "shared key" | `image-recipe/_archived/build-auto-installer-iso.sh` | M | **yes** |
|
||||
| **R-04** | **F-13 (High)** | `disable_private_keys=true`; import the **xpub** with a `[fingerprint/derivation]` key origin; migrate with balance/UTXO parity verification | `core/archipelago/src/api/rpc/bitcoin.rs` | **PHASE** | **yes** (node with real UTXO history) |
|
||||
| **R-05** | F-07 (Medium) | Add `cargo audit` / `cargo deny check advisories bans` to CI, with a `bans` rule failing on duplicate `rand` majors | CI config | S | no |
|
||||
| **R-06** | F-05 (Medium) | Reconcile `Argon2::default()` (19 MiB / t=2) with ADR-005 (64 MB / 3) — either raise the params behind a versioned envelope with a migration, or amend the ADR | `core/archipelago/src/seed.rs`, `backup/full.rs`, `backup/identity.rs`, `docs/adr/005-...` | M | no |
|
||||
| **R-07** | F-04 (Medium) | Confine seed-bearing RPCs to loopback/TLS; shrink `MNEMONIC_TTL`; clear on acknowledged verify with a short grace window | `core/archipelago/src/api/rpc/seed_rpc.rs`, `.../mod.rs`, nginx config | **PHASE** | yes |
|
||||
| **R-08** | F-06 (Medium) | Make stdin/TTY the only mnemonic input for `ceremony sign`/`pubkey`; stop printing the mnemonic to stdout in `ceremony gen` | `core/archipelago/src/ceremony.rs` | S | no (but schedule deliberately — it is the signing ceremony) |
|
||||
| **R-09** | [ARCHY-2] hardening | Persist the CSPRNG-readiness verdict as a durable structured event, so any node can answer post-hoc "was the pool ready when this seed was born?" | `core/archipelago/src/seed.rs` | S | no |
|
||||
| **R-10** | [ARCHY-2] hardening | Add a test asserting the `getrandom` crate uses the **blocking** syscall, so the invariant is mechanical rather than a comment | `core/archipelago/src/seed.rs` | S | no |
|
||||
| **R-11** | F-08 (Low) | Clear `_seed_words` on route-leave from onboarding, not only on successful verify; add a wall-clock expiry mirroring `MNEMONIC_TTL` | `neode-ui/src/views/OnboardingSeedGenerate.vue`, `OnboardingSeedVerify.vue` | S | no |
|
||||
| **R-12** | F-09 (Low) | Replace `% charset.len()` with `SliceRandom::choose(&mut OsRng)` and pin the uniformity property with a test | `core/archipelago/src/totp.rs` | S | no |
|
||||
| **R-13** | F-10 (Low) | ~~Swap `random_hex` / `random_base64` from `thread_rng()` to explicit `OsRng`~~ — **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.
|
||||
@@ -0,0 +1,592 @@
|
||||
# PSBT-First Signing Architecture
|
||||
|
||||
> **Status: specification.** No implementation. This document defines a target architecture and
|
||||
> a phased rollout that a future `/gsd-plan-phase` can consume directly. It deliberately
|
||||
> contains no code, adds no dependencies, and changes no wallet or signing behaviour.
|
||||
>
|
||||
> **Companion document:** `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` — the entropy and
|
||||
> seed-generation audit that motivated this spec. **Cross-linked design:**
|
||||
> `docs/hardware-signer-design.md` — the exploratory TROPIC01 air-gapped signer, which this
|
||||
> architecture treats as the future *first-party* signer, not as a competing design.
|
||||
|
||||
**Provenance rules used throughout.** Every architectural claim is grounded in either (a) a
|
||||
`file:line` from this tree, or (b) RESEARCH.md Part C
|
||||
(`.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md`,
|
||||
which cites Bitcoin Core `doc/psbt.md`, `doc/descriptors.md`, `doc/multisig-tutorial.md`, the
|
||||
Core 30.0 release notes, LND `docs/remote-signing.md` and `docs/psbt.md`). Anything from
|
||||
neither is marked `[UNVERIFIED]`.
|
||||
|
||||
---
|
||||
|
||||
## 0. Why this document exists
|
||||
|
||||
The 2026-07-30 Coinkite COLDCARD entropy incident swept ~1,082 BTC from ~1,195 addresses. The
|
||||
Archipelago-specific reading is in the audit; the design-relevant lesson is narrower and is the
|
||||
organising principle of this spec:
|
||||
|
||||
> **T1's survivors were the users who took the *optional* extra step.** Users who rolled dice
|
||||
> contributed ≥128 bits independently of the broken RNG and were not at risk. The safe path
|
||||
> existed the whole time; it just was not the default.
|
||||
|
||||
Everything below follows from that. The safe path (watch-only + external signer + PSBT) must be
|
||||
the **default** and must feel like the normal way to use Archipelago, not an expert mode buried
|
||||
behind a warning. The hot wallet is retained, deliberately, as an explicitly-secondary tier —
|
||||
because a safe path users route around is not a safe path.
|
||||
|
||||
**Where the tree stands today (important, and not what the target says).**
|
||||
`core/archipelago/src/api/rpc/bitcoin.rs:161-294` already creates a **descriptor** wallet
|
||||
(`createwallet ... descriptors=true`, `:207`) — which is the right foundation — but it passes
|
||||
`disable_private_keys = false` (`:203`) and imports `wpkh(xprv/0/*)` and `wpkh(xprv/1/*)`
|
||||
(`:229-231`), i.e. **the BIP-84 account extended *private* key is imported into Bitcoin Core's
|
||||
`wallet.dat`.** The node's spending key therefore lives in two places: the daemon's Argon2 +
|
||||
ChaCha20-Poly1305 envelope (`core/archipelago/src/seed.rs:238-269`) *and* Core's wallet
|
||||
database. The code is careful with the string in memory (`bitcoin.rs:189`, zeroized at `:222`
|
||||
and `:284`), but the key itself is persisted by Core. Closing that gap is Phase 1 of the
|
||||
rollout in §8, and it is the single highest-value change in this document.
|
||||
|
||||
---
|
||||
|
||||
## 1. Target architecture
|
||||
|
||||
### 1.1 Watch-only descriptor wallet on the node
|
||||
|
||||
The node runs a Bitcoin Core wallet that is **structurally incapable of signing**:
|
||||
|
||||
- Created with `createwallet` passing **`disable_private_keys = true`** and
|
||||
`descriptors = true`. Note the ordering already used at
|
||||
`core/archipelago/src/api/rpc/bitcoin.rs:200-208` — the second positional argument is
|
||||
`disable_private_keys`, currently `false`.
|
||||
- Populated with `importdescriptors`, using **public** descriptors only
|
||||
(`wpkh([fingerprint/84h/0h/0h]xpub.../0/*)` and `.../1/*`).
|
||||
|
||||
Unsignability comes from the *absence of private key material*, not from a flag that could be
|
||||
flipped. That is the correct construction and is why "watch-only" here means "descriptor wallet
|
||||
with no private keys", not "a wallet we promise not to sign with".
|
||||
|
||||
**Descriptor-only from day one.** Bitcoin Core 30.0 removed the ability to create *or load* BDB
|
||||
legacy wallets (RESEARCH §C.1). Nothing in this design may depend on a legacy wallet, on
|
||||
`importmulti`, or on any of the 11 removed legacy RPCs. Archipelago is already descriptor-based
|
||||
(`bitcoin.rs:207`), so this costs nothing to preserve and would be expensive to lose.
|
||||
|
||||
### 1.2 The loop, with the actual RPCs
|
||||
|
||||
| Step | RPC | Scope | Notes |
|
||||
|---|---|---|---|
|
||||
| 1. Construct + fund | `walletcreatefundedpsbt` | **wallet** | Runs on the watch-only wallet. Selects inputs, adds change, attaches the metadata the signer needs. |
|
||||
| 2. Fill UTXO data (optional) | `utxoupdatepsbt` | node | Useful when the PSBT was built elsewhere or is missing witness UTXO data. |
|
||||
| 3. Inspect | `analyzepsbt` | node | **Drive all UI state from this** — see §1.3. |
|
||||
| 4. Export | — | — | Serialise to base64 / file / QR (§4). |
|
||||
| 5. Sign (offline) | external signer | — | Hardware device, or `descriptorprocesspsbt` on an offline machine holding the descriptors. |
|
||||
| 6. Import | — | — | Scan / upload the signed PSBT back. |
|
||||
| 7. Merge signatures | `combinepsbt` | node | Multisig only: merges signatures for the **same** transaction from multiple signers. |
|
||||
| 8. Merge transactions | `joinpsbts` | node | Different transactions into one. **Not** the multisig merge — a common and expensive confusion. |
|
||||
| 9. Finalize | `finalizepsbt` | node | Produces the network-serialized transaction. |
|
||||
| 10. Broadcast | `sendrawtransaction` | node | Except for LND channel funding — see §5. |
|
||||
|
||||
`walletprocesspsbt` (wallet-scoped) and `descriptorprocesspsbt` (node-scoped, takes a descriptor
|
||||
list, **needs no wallet**) are the two signing entry points. `descriptorprocesspsbt` is the
|
||||
right primitive for an offline signing machine that has descriptors but no wallet.
|
||||
|
||||
**Wallet-scoped vs node-scoped matters operationally**: wallet-scoped RPCs must be addressed to
|
||||
the specific wallet endpoint (`/wallet/<name>`), node-scoped ones must not. Archipelago's
|
||||
existing `bitcoin_rpc_call` helper (`core/archipelago/src/api/rpc/bitcoin.rs:191-210` usage)
|
||||
will need an explicit wallet-scoping parameter rather than one global endpoint.
|
||||
|
||||
### 1.3 `analyzepsbt` drives the UI — do not infer state
|
||||
|
||||
`analyzepsbt` reports, per input, what is still missing and **which role must act next**
|
||||
(updater / signer / finalizer). The UI must render from that, not from Archipelago's own guess
|
||||
about how many signatures a 2-of-3 needs. Rationale: role inference is where coordinators get
|
||||
multisig wrong, and the node already has an authoritative answer one RPC away. It also makes
|
||||
the "what do I do now" screen correct for free in partial-signature states.
|
||||
|
||||
### 1.4 Versions this runs against
|
||||
|
||||
From the manifests, so the spec is not written against an imaginary node:
|
||||
|
||||
| App | Manifest version | Image |
|
||||
|---|---|---|
|
||||
| Bitcoin Core | `28.4.0` (`apps/bitcoin-core/manifest.yml:4`) | `bitcoin:28.4` (`:10`) |
|
||||
| Bitcoin Knots | `28.1.0` (`apps/bitcoin-knots/manifest.yml:4`) | **`bitcoin-knots:latest`** (`:10`) |
|
||||
| LND | `0.18.4` (`apps/lnd/manifest.yml:4`) | `lnd:v0.18.4-beta` (`:8`), requires Bitcoin `>=26.0` (`:25`) |
|
||||
|
||||
**Flagged, in scope to name and out of scope to fix:** `bitcoin-knots:latest`
|
||||
(`apps/bitcoin-knots/manifest.yml:10`) is an **unpinned tag**, at odds with ADR-009's
|
||||
pinned-tag mandate and with every other image in these three manifests. For a wallet-bearing
|
||||
component, an unpinned tag means the descriptor/PSBT RPC surface underneath a user's funds can
|
||||
change on a `podman pull`. Fixing it belongs to whoever owns ADR-009 enforcement.
|
||||
|
||||
**PSBTv2 / BIP-370** is merged into Bitcoin Core (RESEARCH §C.1). **`[UNVERIFIED]`** — which
|
||||
released version first exposes it at the RPC surface, and how broadly hardware signers accept
|
||||
it, was not confirmed. **Build against PSBTv1 as the interop baseline**; treat v2 as
|
||||
opportunistic and never as a requirement for a user to spend their money.
|
||||
|
||||
---
|
||||
|
||||
## 2. Where each step lives
|
||||
|
||||
Three surfaces, one non-negotiable invariant.
|
||||
|
||||
### 2.1 The invariant
|
||||
|
||||
> **The BIP-84 private key stays in the daemon's encrypted store. Only the xpub goes into the
|
||||
> Core descriptor wallet. The private key is never imported into Core.**
|
||||
|
||||
Today this is violated (`core/archipelago/src/api/rpc/bitcoin.rs:229-231`, §0). The at-rest
|
||||
envelope that should hold it exclusively already exists and is sound: Argon2 + ChaCha20-Poly1305
|
||||
with per-blob salt and nonce from `OsRng`, written `0600`
|
||||
(`core/archipelago/src/seed.rs:238-269`, `:243-246`, `:318-324`).
|
||||
|
||||
### 2.2 Rust orchestrator — `core/archipelago`
|
||||
|
||||
Owns everything that touches keys or Core:
|
||||
|
||||
- Derives the BIP-84 account key (`core/archipelago/src/seed.rs:207-224`, path `m/84'/0'/0'`)
|
||||
and exports **only** the account-level xpub plus its key-origin fingerprint into descriptors.
|
||||
- Creates and maintains the watch-only wallet (rewrite of
|
||||
`handle_bitcoin_init_wallet_from_seed`, `core/archipelago/src/api/rpc/bitcoin.rs:161-294`).
|
||||
- Owns the PSBT lifecycle RPCs: construct, analyze, combine, finalize, broadcast.
|
||||
- Owns the *internal* software-signer path used by the hot tier (§6), which decrypts the seed
|
||||
under the user's password exactly as `bitcoin.rs:182-185` does today, signs, and zeroizes.
|
||||
- Enforces spend limits server-side (§6). **Limits enforced in the UI are not limits.**
|
||||
|
||||
### 2.3 `neode-ui`
|
||||
|
||||
Owns presentation and transport only. It must never see a private key, an xprv, or a mnemonic
|
||||
outside the onboarding flow the audit already scopes (F-04, F-08).
|
||||
|
||||
- Renders the PSBT review screen: inputs, outputs, fee, change, and the `analyzepsbt` "next
|
||||
role" state.
|
||||
- Renders the export payload as animated QR (§4) and offers file download.
|
||||
- Accepts the signed PSBT by camera scan or file upload.
|
||||
- Renders the cold / warm / hot tier badges (§6) and the honest Lightning copy (§5.4).
|
||||
|
||||
### 2.4 Companion app
|
||||
|
||||
Owns the air-gap camera path. It already has the two pieces this needs:
|
||||
|
||||
- A working QR scanner (project memory: native scan shipped in companion 0.5.22; dense-QR fix
|
||||
`07772b56`).
|
||||
- SeedQR encode/decode (`neode-ui/src/utils/seedqr.ts:11`), with a correct, honest note at
|
||||
`:9` that the LND aezeed is **not** BIP-39 and must never be SeedQR-encoded.
|
||||
|
||||
The companion is the natural home for scan-heavy multi-frame PSBT transport, because the node's
|
||||
own browser may be a TV kiosk with no camera.
|
||||
|
||||
---
|
||||
|
||||
## 3. Tiers
|
||||
|
||||
### 3.1 Tier 1 — single-sig with an external hardware signer
|
||||
|
||||
- Descriptor: `wpkh([<fingerprint>/84h/0h/0h]xpub.../0/*)` and `.../1/*`.
|
||||
- **Key-origin annotation `[fingerprint/derivation]` is mandatory, not cosmetic.** Without it a
|
||||
hardware signer cannot locate its own key in the PSBT and will refuse to sign (RESEARCH §C.2).
|
||||
Every descriptor Archipelago emits must carry it. The current code emits descriptors with **no
|
||||
key-origin prefix** (`core/archipelago/src/api/rpc/bitcoin.rs:230-231`) — a second concrete
|
||||
reason Phase 1 must rewrite that function.
|
||||
- Descriptor checksums: obtain via `getdescriptorinfo` before `importdescriptors`, as the
|
||||
existing code correctly already does (`bitcoin.rs:234-259`). Core rejects a wrong checksum.
|
||||
|
||||
### 3.2 Tier 2 — `wsh(sortedmulti(k, ...))` multisig
|
||||
|
||||
- Script: `wsh(sortedmulti(k, xpub1/…, xpub2/…, xpub3/…))`.
|
||||
- **Why `sortedmulti` over ordered `multi`:** `sortedmulti` (BIP-67) lexicographically sorts the
|
||||
keys in the resulting script, so the wallet can be **recreated without preserving xpub order**.
|
||||
With ordered `multi`, losing the order loses the wallet even though every key survives — a
|
||||
recovery failure mode that is entirely avoidable. Use `sortedmulti` unless a specific
|
||||
cosigner demands ordered `multi`.
|
||||
- **BIP-48 derivation** for multisig accounts: `m/48'/<coin>'/<account>'/<script_type>'`, with
|
||||
`2'` = P2WSH. Every coordinator (Sparrow, Nunchuk, Caravan, Specter) expects this path; using
|
||||
anything else means users cannot import their Archipelago multisig anywhere else.
|
||||
- Descriptor exchange: each cosigner contributes an xpub **with key origin**; the coordinator
|
||||
assembles the descriptor and every participant imports the identical descriptor string. All
|
||||
participants must be able to export the descriptor for backup — a multisig backup is the
|
||||
descriptor plus each seed, and users who back up only seeds lose funds.
|
||||
- Reference to copy rather than re-derive: Bitcoin Core's `doc/multisig-tutorial.md` and the
|
||||
functional test `test/functional/wallet_multisig_descriptor_psbt.py`, which is the exact RPC
|
||||
sequence in executable form (RESEARCH §C.3).
|
||||
|
||||
### 3.3 Taproot / MuSig2 multisig — future work, deliberately
|
||||
|
||||
`tr(...)` descriptors exist, but **`[UNVERIFIED]`** — the 2026 state of MuSig2 key-aggregation
|
||||
support in Core's descriptor wallets and across hardware signers was not confirmed (RESEARCH
|
||||
§C.3, Open Question 4). Shipping a multisig scheme whose recovery depends on unconfirmed
|
||||
signer support is how users lose money years later. **Ship `wsh(sortedmulti(...))`.** Revisit
|
||||
taproot multisig when Core's support and at least two independent hardware signers can be
|
||||
verified against a real device.
|
||||
|
||||
---
|
||||
|
||||
## 4. Air-gapped transport
|
||||
|
||||
### 4.1 The format decision
|
||||
|
||||
| Format | Mechanism | Verdict |
|
||||
|---|---|---|
|
||||
| **BC-UR v2** (Blockchain Commons) | **Fountain-coded** (rateless erasure). Any sufficient subset of frames reconstructs the payload; order-independent. | **Recommended primary.** |
|
||||
| **BBQr** (Coinkite) | Payload split across sequential frames; receiver accumulates and must obtain each missing frame. | Support for Coldcard interop; not the primary. |
|
||||
| microSD / file (`.psbt`) | Plain file exchange. | **Mandatory fallback, always offered.** |
|
||||
| SeedQR | Static QR of mnemonic word indices. | **Seed transport only, not PSBT.** Already shipped (`neode-ui/src/utils/seedqr.ts:11`). |
|
||||
|
||||
**Recommendation: BC-UR v2 as primary, BBQr for Coldcard interop, file always available.**
|
||||
|
||||
The justification is specific to Archipelago's hardware reality rather than generic. The
|
||||
companion app scans QR from a phone camera, frequently at a TV or in a rack cupboard, in poor
|
||||
light. BBQr's sequential model means a single missed frame stalls the user until that exact
|
||||
frame comes round again — the failure mode is "keep pointing the camera and hope". BC-UR's
|
||||
fountain coding means *any* sufficient number of frames reconstructs the payload, so a bad
|
||||
scanning environment degrades into "takes longer" instead of "gets stuck". That difference is
|
||||
what makes an air-gap workflow tolerable enough that users keep using it — which, per §0, is
|
||||
the whole point.
|
||||
|
||||
**`[UNVERIFIED]`** — device support matrix. Confirmed from RESEARCH §C.4: Coldcard → BBQr
|
||||
(native) + microSD + NFC; Foundation Passport and Keystone → UR; SeedSigner → BC-UR v2. Jade,
|
||||
Krux, BitBox, Ledger and Trezor support was **not** confirmed and must be verified against real
|
||||
hardware before any of them is listed as supported in the UI.
|
||||
|
||||
### 4.2 QR density — animated is mandatory, not a nice-to-have
|
||||
|
||||
A QR code maxes out around ~2,953 bytes at the largest version with the lowest error correction,
|
||||
and far less at densities a phone camera can actually read across a room. **A real multi-input
|
||||
multisig PSBT routinely exceeds that.** Therefore:
|
||||
|
||||
- **Multi-frame animated QR is mandatory.** Single-QR PSBT export must not be the only path.
|
||||
- **A file fallback must always be offered**, on every export screen, with equal visual weight.
|
||||
microSD/file has no density limit and is the most reliable route for large PSBTs.
|
||||
- The UI must show frame progress (e.g. "142 of 210 frames received") so a stalled scan is
|
||||
visibly stalled rather than mysteriously slow.
|
||||
|
||||
### 4.3 Consistency with the first-party signer
|
||||
|
||||
`docs/hardware-signer-design.md` specifies a QR-only, camera-in/screen-out air-gapped signer
|
||||
(TROPIC01 + ESP32-S3), and lists "Animated/multi-part QR strategy for large PSBTs" as an open
|
||||
item (`docs/hardware-signer-design.md:167`) and "Define QR payload formats for both roles" at
|
||||
`:165`. **This document answers both for Bitcoin: BC-UR v2 primary, BBQr for Coldcard interop.**
|
||||
That signer, when built, should implement the same format so the same node-side transport code
|
||||
serves third-party signers and the first-party device identically. Its dual Nostr-signing role
|
||||
(`docs/hardware-signer-design.md:110-148`) is out of scope here but shares the transport layer,
|
||||
which is an argument for implementing transport as a payload-agnostic module.
|
||||
|
||||
---
|
||||
|
||||
## 5. LND — what is and is not achievable
|
||||
|
||||
### 5.1 Decision table
|
||||
|
||||
| Capability | Achievable? | Detail |
|
||||
|---|---|---|
|
||||
| Watch-only `lnd` + separate signer instance | **Yes** | `remotesigner.*` on the watch-only node; the signer needs no chain backend (`bitcoin.node=nochainbackend`). |
|
||||
| Signer fully offline | **No** | The signer must accept a **live inbound gRPC connection**. "Offline except for one connection" is not an air-gap. |
|
||||
| Air-gap channel / revocation / HTLC keys | **No** | These live in the signer and must sign **on demand, at protocol speed**. A routing node cannot tolerate human-in-the-loop signing. **This is the hard limit of the entire design.** |
|
||||
| PSBT funding of channels | **Yes** | `lncli openchannel --psbt`; `PsbtShim` via `FundingStateStep`; batch by passing the returned PSBT as `base_psbt`. |
|
||||
| Open a channel with zero LND wallet balance | **Yes** | The `--psbt` flow explicitly supports funding from an external wallet. |
|
||||
| **Self-broadcast the funding transaction** | **NEVER** | LND must publish it "in the proper funding flow order **or the funds can be lost**". Encode as a hard UI rule — see §5.3. |
|
||||
| Sign arbitrary messages / on-chain txs externally | **Yes** | `signrpc` / `walletrpc` (`signer:generate`, `onchain:write`). |
|
||||
| Move private keys between instances after init | **No** | Not supported. |
|
||||
| Add accounts dynamically without wallet reconstruction | **No** | Not supported. |
|
||||
|
||||
Source: RESEARCH §C.5, from LND `docs/remote-signing.md` and `docs/psbt.md`.
|
||||
|
||||
### 5.2 Required accounts and the taproot gotcha
|
||||
|
||||
Remote signing requires xpubs for level-3 derivation accounts: purpose **49** (NP2WKH), **84**
|
||||
(P2WKH), **86** (P2TR), and **1017** accounts 0-255 (node identity, channels, watchtower,
|
||||
HTLCs). Setup is `lncli wallet accounts list > accounts-signer.json` on the signer, then
|
||||
`lncli createwatchonly accounts-signer.json` on the watch-only node. A minimal signer macaroon
|
||||
is `lncli bakemacaroon --save_to signer.custom.macaroon message:write signer:generate
|
||||
address:read onchain:write`.
|
||||
|
||||
**Taproot gotcha:** requires LND v0.15.3-beta+ and a manual
|
||||
`lncli wallet accounts import --address_type p2tr <xpub> default` on upgrade, or the node fails
|
||||
with `"account 0 not found"`. Archipelago pins LND `0.18.4` (`apps/lnd/manifest.yml:4`), so the
|
||||
version floor is satisfied; the manual import step is not automatic and must be part of any
|
||||
migration runbook.
|
||||
|
||||
Migrating an existing node is `remotesigner.migrate-wallet-to-watch-only=true`, which **purges
|
||||
private key material in place** — one-way, and therefore gated behind a verified backup.
|
||||
|
||||
### 5.3 The self-broadcast rule is a hard UI constraint
|
||||
|
||||
Archipelago already exposes `lnd.create-psbt` and `lnd.finalize-psbt`
|
||||
(`core/archipelago/src/api/rpc/dispatcher.rs:136-137`,
|
||||
implemented in `core/archipelago/src/api/rpc/lnd/wallet.rs:605` and `:711`), and the finalize
|
||||
handler already broadcasts (`core/archipelago/src/api/rpc/lnd/wallet.rs:757`). That is correct
|
||||
for an **on-chain** send and **catastrophic** for a channel-funding PSBT.
|
||||
|
||||
**Rule:** any PSBT produced by the channel-funding flow must be tagged as such end-to-end, and
|
||||
every broadcast path must refuse to broadcast a channel-funding PSBT. The refusal belongs in the
|
||||
Rust orchestrator, not in the UI, and it should be a type-level distinction (a distinct
|
||||
`ChannelFundingPsbt` wrapper) rather than a boolean anyone can forget to check. This is the one
|
||||
place in this document where a mistake destroys funds rather than exposing them.
|
||||
|
||||
### 5.4 On-chain vs Lightning — two genuinely different tiers
|
||||
|
||||
The design splits cleanly, and the split must be visible to users:
|
||||
|
||||
| | **On-chain balance** | **Lightning balance** |
|
||||
|---|---|---|
|
||||
| Key exposure | Can be fully cold — key never on the node | **Necessarily hot** — channel/revocation/HTLC keys must sign at protocol speed |
|
||||
| Protection mechanism | Watch-only descriptors + PSBT + external signer | Remote signing *relocates* keys to a hardened host; it does not remove hot exposure |
|
||||
| Honest claim | "Cold storage" is accurate | "Cold storage" is **false** |
|
||||
|
||||
**The exact sentence the UI should use:**
|
||||
|
||||
> *A Lightning routing node's channel keys are necessarily hot. Remote signing moves them to a
|
||||
> hardened machine; it does not make them cold. Only your on-chain balance can be genuinely
|
||||
> protected by an offline signer.*
|
||||
|
||||
**Any copy implying a routing node's channel keys are cold is misleading and must not ship.**
|
||||
This is not pedantry: a user who believes their Lightning balance is cold will keep more in it
|
||||
than they would otherwise, which is precisely the miscalibration that turns an incident into a
|
||||
loss. The Coldcard incident is a good reason to be conservative in this copy rather than
|
||||
optimistic.
|
||||
|
||||
---
|
||||
|
||||
## 6. The hot wallet as the explicitly-secondary option
|
||||
|
||||
The hot wallet stays. Removing it would push users to worse tools. It is framed, limited, and
|
||||
labelled as secondary.
|
||||
|
||||
1. **Hard separation of on-chain and Lightning balances** in the data model and in the UI.
|
||||
**Never one blended number.** They have different key exposure (§5.4), different recovery
|
||||
stories, and different risk. A single "balance" figure silently averages a cold number with a
|
||||
hot one, which is a lie of composition.
|
||||
2. **Server-enforced spend limits.** Per-transaction and rolling-daily, enforced in the Rust
|
||||
orchestrator. Anything above the limit is **forced onto the PSBT path** — not blocked, not
|
||||
warned-and-allowed: routed. Archipelago already rate-limits financial RPCs
|
||||
(`core/archipelago/src/rate_limit.rs:62-69`: `wallet.send` 5/300s, `lnd.sendcoins` 5/300s,
|
||||
`lnd.openchannel` 3/300s), so the enforcement point exists; value limits are the addition.
|
||||
3. **Reuse the existing at-rest envelope.** Argon2 + ChaCha20-Poly1305, per-blob salt and nonce
|
||||
from `OsRng`, `0600` (`core/archipelago/src/seed.rs:238-269`, `:318-324`). Do not invent a
|
||||
second envelope. See audit finding **F-05** on aligning the Argon2 parameters with ADR-005
|
||||
before this tier carries meaningful value.
|
||||
4. **Zeroization on every path.** The existing code is the standard to match:
|
||||
`core/archipelago/src/seed.rs:262`, `:292`, `:384`, `:401`;
|
||||
`core/archipelago/src/api/rpc/bitcoin.rs:222`, `:284`.
|
||||
5. **Explicit tiering in the UI**, named rather than hidden:
|
||||
- **Cold** — watch-only + external signer. On-chain only. The default for new wallets.
|
||||
- **Warm** — hot on-chain key in the daemon's envelope, under spend limits.
|
||||
- **Hot** — Lightning. Unavoidably hot; labelled as such.
|
||||
|
||||
### 6.1 Nudging toward PSBT without punishing the hot path
|
||||
|
||||
The failure mode to avoid is a safe path so tedious that users disable it, and a hot path so
|
||||
nagged-at that users stop reading warnings. Concretely:
|
||||
|
||||
- **Default new wallets to cold.** Do not make the user opt in to safety. This is the direct
|
||||
lesson of §0.
|
||||
- **One-time framing, not per-transaction nagging.** Explain the tiers once, at setup, and then
|
||||
show a small persistent tier badge. Repeated modal warnings train users to dismiss modals.
|
||||
- **Make the limit the teacher.** When a spend exceeds the warm limit, route it to the PSBT
|
||||
flow with a neutral explanation ("this amount uses your signing device") rather than an error.
|
||||
The user learns the tier boundary by using it.
|
||||
- **Never make the hot path feel broken.** A small Lightning payment should be one tap. If
|
||||
everyday use is painful, users move their funds to software that does not have any of this.
|
||||
- **Let the user raise limits, deliberately.** A limit the user cannot adjust gets worked around
|
||||
entirely; a limit they must consciously raise is a decision they remember making.
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration for existing users
|
||||
|
||||
### 7.1 What the incident does and does not imply here
|
||||
|
||||
**Be precise, because both errors are costly.**
|
||||
|
||||
- **A software fix does not repair an already-generated seed.** If a seed was produced by a
|
||||
defective RNG, updating the software leaves it exactly as guessable. This is why Coinkite told
|
||||
users to migrate rather than merely update.
|
||||
- **The audit found no such defect in Archipelago.** `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`
|
||||
§2 and §4 record that every first-party key-generation call site draws from a genuine CSPRNG,
|
||||
that the mnemonic is a real 256-bit value, and that `[ARCHY-1]` is a *structural* risk with no
|
||||
present exploitability.
|
||||
|
||||
**Therefore: no Archipelago user needs to rotate their seed because of the COLDCARD incident.**
|
||||
Do not ship a banner implying otherwise. Over-alarming has a real cost — it triggers unnecessary
|
||||
fund movements, which have their own fee, privacy, and fat-finger risks, and it burns the
|
||||
credibility needed for a real advisory later.
|
||||
|
||||
**Who this section *does* apply to:**
|
||||
|
||||
1. **Users whose seed was generated on a Coldcard and imported into Archipelago**, on affected
|
||||
firmware. Their seed is at risk from T1, independent of Archipelago's own code quality. They
|
||||
should follow Coinkite's guidance and the sequence in §7.2.
|
||||
2. **Every user, at the point Phase 1 lands** — because the account xprv is currently imported
|
||||
into Bitcoin Core (`core/archipelago/src/api/rpc/bitcoin.rs:229-231`, §0). Moving to
|
||||
watch-only does not require a new seed; it requires re-creating the Core wallet without
|
||||
private keys. That is a *wallet* migration, not a *key* migration, and it must be presented
|
||||
as such — see §7.3.
|
||||
|
||||
### 7.2 Seed-rotation sequence (only when a seed is actually suspect)
|
||||
|
||||
Order matters; each step de-risks the next.
|
||||
|
||||
1. **Generate a new key** on trusted, fixed hardware or software.
|
||||
2. **Verify the backup** — restore it into a second wallet and confirm it reproduces the same
|
||||
first receive address before sending anything.
|
||||
3. **Verify a receive address** on the signing device's own screen, not only on the host.
|
||||
4. **Send a small test transaction** to the new wallet and confirm it arrives and is spendable.
|
||||
5. **Migrate the funds** from the old wallet to the new one.
|
||||
6. **Retain the old backup** until every output is confirmed spent and the new wallet's balance
|
||||
is verified. Destroying the old backup early is the most common way this sequence loses money.
|
||||
|
||||
If Lightning is in use, closing channels is part of step 5 and is slow (force-closes carry
|
||||
timelocks). Budget for it; do not present channel migration as instantaneous.
|
||||
|
||||
### 7.3 Wallet migration to watch-only (Phase 1) — *not* a seed rotation
|
||||
|
||||
For every existing user, when Phase 1 lands:
|
||||
|
||||
1. Confirm the encrypted seed backup exists and is decryptable
|
||||
(`core/archipelago/src/seed.rs:341-357`, `seed_exists` at `:360-362`).
|
||||
2. Derive the account xpub and build the key-origin-annotated descriptors.
|
||||
3. Create a **new** wallet with `disable_private_keys = true` and import the public descriptors.
|
||||
4. Rescan, and confirm the new watch-only wallet reports the **same balance and the same UTXO
|
||||
set** as the old one. Do not proceed on any mismatch.
|
||||
5. Only then unload and remove the private-key-bearing wallet from Core.
|
||||
|
||||
**The user's seed does not change and their funds do not move.** Say that plainly in the UI —
|
||||
the natural user fear on seeing any wallet-migration prompt is that their money is being touched.
|
||||
|
||||
---
|
||||
|
||||
## 8. Phased rollout
|
||||
|
||||
Each phase names a goal, its dependencies, candidate requirements, and whether it needs real
|
||||
hardware. This section is the input a future `/gsd-plan-phase` consumes.
|
||||
|
||||
### Phase 1 — Descriptor watch-only read path
|
||||
|
||||
**Goal:** the node's Bitcoin Core wallet holds no private keys; the daemon's encrypted store is
|
||||
the only place the BIP-84 key exists.
|
||||
|
||||
**Dependencies:** none. **This is the highest-value change in the document and it unblocks
|
||||
everything else** — no external-signer flow is meaningful while Core holds the xprv.
|
||||
|
||||
**Candidate requirements:**
|
||||
- `createwallet` is called with `disable_private_keys = true` (currently `false`,
|
||||
`core/archipelago/src/api/rpc/bitcoin.rs:203`).
|
||||
- Imported descriptors carry the **xpub** and a key-origin annotation
|
||||
`[fingerprint/84h/0h/0h]` (currently a bare xprv with no origin, `bitcoin.rs:229-231`).
|
||||
- A migration path re-creates the wallet watch-only and verifies balance/UTXO parity before
|
||||
removing the old wallet (§7.3).
|
||||
- The account xprv is never written to Core and never leaves the Argon2 envelope except in
|
||||
memory, zeroized.
|
||||
- Regression test: the wallet cannot sign — a signing attempt against it fails structurally.
|
||||
|
||||
**Real hardware:** yes, for the migration — verify on a node with real UTXO history (`.228`).
|
||||
|
||||
### Phase 2 — PSBT construct and export
|
||||
|
||||
**Goal:** the node can build a funded PSBT from the watch-only wallet and hand it out.
|
||||
|
||||
**Dependencies:** Phase 1.
|
||||
|
||||
**Candidate requirements:**
|
||||
- `walletcreatefundedpsbt` wired with explicit fee control, reusing the existing fee-preset UI.
|
||||
- `analyzepsbt` exposed and used as the single source of UI state (§1.3).
|
||||
- Export as base64 and as a `.psbt` file download.
|
||||
- A PSBT review screen showing inputs, outputs, fee, change, and destination — the human check
|
||||
the whole air-gap model depends on.
|
||||
|
||||
**Real hardware:** no (regtest/testnet sufficient).
|
||||
|
||||
### Phase 3 — External-signer import and finalize
|
||||
|
||||
**Goal:** a signed PSBT from a third-party signer completes the loop and broadcasts.
|
||||
|
||||
**Dependencies:** Phase 2.
|
||||
|
||||
**Candidate requirements:**
|
||||
- Import a signed PSBT by file upload; `combinepsbt` where multiple parts arrive.
|
||||
- `finalizepsbt` + `sendrawtransaction`, with the channel-funding refusal of §5.3 in place from
|
||||
day one — not retrofitted.
|
||||
- Clear error surfacing when `analyzepsbt` says signatures are still missing.
|
||||
|
||||
**Real hardware:** **yes** — must be verified end-to-end against at least one real signer
|
||||
(Coldcard or Passport) before it is offered to users.
|
||||
|
||||
### Phase 4 — Air-gap transport (BC-UR v2 + BBQr)
|
||||
|
||||
**Goal:** the loop closes over QR, with a file fallback, in the companion app.
|
||||
|
||||
**Dependencies:** Phase 3.
|
||||
|
||||
**Candidate requirements:**
|
||||
- BC-UR v2 encode (node) and decode (companion), fountain-coded, with visible frame progress.
|
||||
- BBQr decode for Coldcard interop.
|
||||
- File fallback offered with equal weight on every export and import screen (§4.2).
|
||||
- Payload-agnostic transport module, so `docs/hardware-signer-design.md`'s Nostr role can reuse
|
||||
it later without a rewrite.
|
||||
|
||||
**Real hardware:** **yes** — QR density and scan reliability cannot be evaluated in an emulator.
|
||||
Verify at realistic distance and lighting, including the TV-kiosk case.
|
||||
|
||||
### Phase 5 — Multisig
|
||||
|
||||
**Goal:** `wsh(sortedmulti(k, ...))` wallets with BIP-48 paths and descriptor exchange.
|
||||
|
||||
**Dependencies:** Phase 4 (large multisig PSBTs are exactly the case that needs robust transport).
|
||||
|
||||
**Candidate requirements:**
|
||||
- Create/import a `wsh(sortedmulti(...))` descriptor with per-key origin annotations.
|
||||
- BIP-48 `m/48'/0'/<account>'/2'` derivation for Archipelago's own key.
|
||||
- Descriptor export/backup UX that states plainly that the descriptor is part of the backup.
|
||||
- `combinepsbt` across N signers with `analyzepsbt`-driven progress.
|
||||
- Interop test against at least one external coordinator (Sparrow or Nunchuk).
|
||||
|
||||
**Real hardware:** **yes** — two independent signers minimum.
|
||||
|
||||
### Phase 6 — LND remote signing
|
||||
|
||||
**Goal:** LND runs watch-only with a separate signer instance, with honest UI copy.
|
||||
|
||||
**Dependencies:** Phase 1 (the on-chain story must be settled first; doing Lightning first would
|
||||
teach users the wrong mental model).
|
||||
|
||||
**Candidate requirements:**
|
||||
- Signer instance provisioning (`bitcoin.node=nochainbackend`, minimal macaroon) and watch-only
|
||||
setup via `createwatchonly`.
|
||||
- Explicit p2tr account import step (§5.2), or a documented failure with a fix-it action.
|
||||
- `remotesigner.migrate-wallet-to-watch-only=true` migration, gated behind a verified backup —
|
||||
it purges key material in place and is one-way.
|
||||
- UI copy carrying the §5.4 sentence verbatim, and no copy anywhere claiming Lightning funds are
|
||||
cold.
|
||||
|
||||
**Real hardware:** **yes** — two hosts, and a real channel.
|
||||
|
||||
### Phase 7 — Hot-wallet limits and tiering
|
||||
|
||||
**Goal:** the hot path is bounded, labelled, and routes large spends to PSBT.
|
||||
|
||||
**Dependencies:** Phase 3 (there must be a PSBT path to route *to*).
|
||||
|
||||
**Candidate requirements:**
|
||||
- Server-enforced per-transaction and rolling-daily limits, with over-limit spends routed to the
|
||||
PSBT flow rather than rejected (§6.1).
|
||||
- On-chain and Lightning balances separated in the data model and never summed in the UI.
|
||||
- Cold / warm / hot tier badges.
|
||||
- New wallets default to cold.
|
||||
|
||||
**Real hardware:** no, beyond normal on-node verification.
|
||||
|
||||
### Sequencing note
|
||||
|
||||
Phases 1-4 are the spine and should run in order. Phase 6 (LND) and Phase 7 (limits) can run in
|
||||
parallel with Phase 5 (multisig) once Phase 3 lands. Phase 1 alone materially improves the
|
||||
current security posture and should not wait for the rest.
|
||||
|
||||
---
|
||||
|
||||
## 9. Related documents
|
||||
|
||||
- `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` — the audit motivating this spec; see F-05
|
||||
(Argon2 parameters) and the F-13 addendum on the xprv-in-Core issue.
|
||||
- `docs/hardware-signer-design.md` — the first-party TROPIC01 air-gapped signer; §4.3 above
|
||||
answers two of its open items.
|
||||
- `docs/adr/005-chacha20-backup-encryption.md` — the at-rest envelope §6 reuses.
|
||||
- `.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md`
|
||||
— Part C is the source for the Core RPC table, the LND capability matrix, and the air-gap
|
||||
format comparison.
|
||||
Reference in New Issue
Block a user