Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit 6d567c8517
1877 changed files with 416132 additions and 0 deletions
@@ -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.
+592
View File
@@ -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.