docs(quick-260731-upz): entropy-audit remediation backlog + tracker items

- Adds F-13 (High) to the audit: the BIP-84 account PRIVATE key is imported
  into Bitcoin Core (bitcoin.rs:203 disable_private_keys=false, :229-231
  wpkh(xprv/...)), so the spending key is persisted outside the Argon2
  envelope in a wallet with an empty passphrase; the descriptors also carry
  no [fingerprint/derivation] key origin, so no hardware signer could ever
  use them. Found by tracing secret class (1) end-to-end
- Fills the Remediation Backlog: R-00..R-15, prioritised severity x effort,
  each with the finding it closes, files, effort, and hardware gating; plus
  an explicit "not implemented here, and why" section
- Records ARCHY-1 as APPLIED with the exact test evidence and an honest note
  that making the source explicit removes a future failure mode rather than
  repairing a past one
- Wires the resulting open items into docs/UNIFIED-TASK-TRACKER.md in its
  existing tier/checkbox format: Tier 0 (cargo audit/deny CI, ceremony
  mnemonic input, a five-item hygiene batch, secrets.rs OsRng), Tier 1 (ISO
  fail-open first-boot secrets, Argon2 vs ADR-005, the on-node checklist),
  Tier 2 (the Critical unauthenticated seed RPCs, PSBT Phase 1, PSBT phases
  2-7, seed-RPC transport confinement)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-01 06:02:31 -04:00
co-authored by Claude Opus 5
parent 5f366f7589
commit 5ba80e49b7
2 changed files with 261 additions and 3 deletions
+138
View File
@@ -62,8 +62,89 @@ those are marked ✅ below with the commit that did it, so we stop re-litigating
installed on any of .116/.198/.228 to check directly; low priority until someone
actually needs it installed.
- [ ] **Add `cargo audit` / `cargo deny` to CI, failing on duplicate `rand` majors**
(entropy audit R-05, finding F-07 —
`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`). `cargo-audit` is not installed
anywhere, so no RustSec check has ever run against this tree. Separately,
`cargo tree` shows **both** `rand 0.8.5` (direct, all first-party key generation)
and `rand 0.9.2` (transitive via `totp-rs` and `tungstenite 0.26.2`) resolved into
one binary. `rand 0.9.0` removed `ThreadRng` fork protection and the orchestrator
forks constantly, so a future bump must be visible rather than silent — add a
`bans` rule so the duplicate majors show up in CI, not in an incident.
- [ ] **Harden the release signing ceremony's mnemonic input** (entropy audit R-08,
finding F-06). `ceremony gen` prints the release master mnemonic to **stdout**
(`core/archipelago/src/ceremony.rs:71-77`) and `load_release_root_key` prefers the
`RELEASE_MASTER_MNEMONIC` **environment variable** over stdin (`:157-160`) — both
leak into shell history, `/proc/<pid>/environ`, tmux scrollback and terminal
recordings. This is the seed that derives the fleet release-root signing key, so a
leak means forged signed manifests fleet-wide. Make stdin/TTY the only supported
input for `sign`/`pubkey`; write `gen`'s output to a `0600` file rather than the
terminal. Small change, but schedule it deliberately — it is the signing ceremony.
- [ ] **Small entropy-audit hygiene batch** (entropy audit R-09 R-12, R-14 —
`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`). Five independent one-liners,
each closing a Low/Informational finding:
- Persist the CSPRNG-readiness verdict (`seed.rs:85-91`) as a durable structured
event, so any node can answer post-hoc "was the entropy pool ready when this seed
was born?" — the question Coldcard owners cannot answer today.
- Add a test asserting the `getrandom` crate uses the **blocking** syscall, making
`seed.rs:52-57`'s invariant mechanical instead of a comment.
- Clear `_seed_words` from `sessionStorage` on route-leave from onboarding, not only
on successful verify (`OnboardingSeedVerify.vue:251`), plus a wall-clock expiry
mirroring the server's 10-minute `MNEMONIC_TTL`.
- Replace `% charset.len()` in `totp.rs:305` with `SliceRandom::choose(&mut OsRng)`.
(No bias today — 32 divides 256 — but any future charset edit introduces one
silently. The audit refutes the research's claim that this is currently biased.)
- Comment `pickRandomIndices` (`OnboardingSeedVerify.vue:157`) to record that its
`Math.random()` picks a UX challenge, not key material, so the next auditor does
not re-derive that it is benign.
- [ ] **Swap container `generated_secrets` to explicit `OsRng`** (entropy audit R-13,
finding F-10) — `random_hex`/`random_base64` in
`core/archipelago/src/container/secrets.rs:90-102` use `thread_rng()`. Correct on
`rand 0.8.5`, but the same implicit-source pattern as [ARCHY-1]. Two-line change.
**Currently blocked only on tree hygiene** — that file had another agent's
uncommitted work when the audit ran.
## Tier 1 — Medium effort, unblocked
- [ ] **Fix the fail-open first-boot secret regeneration in the ISO** (entropy audit
R-02 + R-03, finding F-03 — `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`).
The installed rootfs is a **cached container export shared by every node**
(`image-recipe/_archived/build-auto-installer-iso.sh:717-726`, extracted at
`:2303`), and it bakes SSH host keys (via the `openssh-server` install at `:345`)
and a TLS keypair (`:463-469`). `archipelago-first-boot-secrets.service` correctly
regenerates both per device — but both branches are **fail-open** (`:1647`,
`:1659`) and `touch "$MARKER"` at `:1663` runs **unconditionally**, so a single
transient failure permanently leaves that node on the image-wide shared SSH host
key and TLS private key, with the failure visible only in a log file. Fix:
(a) set the marker only when both regenerations succeeded, so it retries next
boot; (b) surface the failure in the UI/doctor, not just the log; (c) strip the
baked keys from the rootfs tar so a failure degrades to "no key" rather than
"shared key". Needs an ISO rebuild and two fresh flashes to verify.
- [ ] **Reconcile `Argon2::default()` with ADR-005** (entropy audit R-06, finding F-05).
ADR-005 states 64 MB / 3 iterations
(`docs/adr/005-chacha20-backup-encryption.md:31`); `Argon2::default()` in
argon2 0.5.3 is Argon2id at **19 MiB / t=2 / p=1**. Used at
`core/archipelago/src/seed.rs:249` and `:285`, `backup/identity.rs:38`/`:93`,
`backup/full.rs:618`/`:650`. Either raise the parameters behind a versioned
envelope **with a migration** (an existing `master_seed.enc` was encrypted under
the old parameters and will not decrypt under new ones) or amend the ADR to state
the real numbers. Do not change them silently.
- [ ] **Run the on-node entropy verification checklist** (entropy audit R-15, §6 of
`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`). Everything in that section is
explicitly **UNVERIFIED** — it needs real hardware this environment cannot reach.
Highest value first: **C-3** (are SSH host-key and TLS fingerprints actually
different across two nodes flashed from the same ISO?) and **C-5** (the cross-node
same-ISO seed collision test — the empirical check that would have caught the
Coldcard defect). Also C-1 (`crng init done` vs seed-generation timestamp), C-2
(`machine-id` uniqueness), C-4 (what the rootfs tar actually contains, run on the
build host), C-6 (is `/rpc/v1` reachable unauthenticated from the LAN). Use a
disposable node — C-5 overwrites node identity.
- [x] ~~immich → Quadlet migration~~ — investigated 2026-07-01, turned out already done:
immich uses the same `install_stack_via_orchestrator` primitive as netbird/btcpay
(`immich_stack_app_ids()` in `stacks.rs:690`), and is confirmed running as real
@@ -86,6 +167,63 @@ those are marked ✅ below with the commit that did it, so we stop re-litigating
## Tier 2 — High effort, mostly unblocked (the actual next exit criteria)
- [ ] **🔴 Gate the unauthenticated seed RPCs** (entropy audit R-01, finding **F-01,
Critical** — `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`). `seed.generate`,
`seed.verify`, `seed.restore` and `seed.save-encrypted` are in
`UNAUTHENTICATED_METHODS` (`core/archipelago/src/api/rpc/middleware.rs:24-28`),
which skips session, RBAC **and** CSRF (`api/rpc/mod.rs:263`, `:295`, `:326`).
Neither handler checks whether onboarding is already complete
(`api/rpc/seed_rpc.rs:93-159`, `:226-305`), and `NodeIdentity::from_seed`
overwrites `node_key`, `nostr_secret` and the FIPS mesh key **unconditionally**
(`identity.rs:79-114`). There is no rate limit (`rate_limit.rs:60-97` has no
`seed.*` entry). The endpoint is proxied to the LAN over plaintext HTTP
(`image-recipe/configs/nginx-archipelago.conf:11`, `:165`, `:192`) and mesh peers
can reach it too (`server.rs:2080` asserts `/rpc/v1` passes the peer path filter).
Net: **one unauthenticated POST can take over or destroy a live node's identity**,
and `seed.restore` lets the attacker choose the mnemonic. The guard already exists
and is simply never called — `NodeIdentity::key_exists` (`identity.rs:117`).
Fix: bail when a node key exists and no onboarding mnemonic is pending; prefer
also gating on `auth_manager.is_onboarding_complete()`; add rate limits at
`auth.changePassword` strictness; narrow the peer path filter. Changes an
authentication boundary on a live fleet — **needs its own `/gsd-plan-phase` with a
federation re-verify**, not an opportunistic patch.
- [ ] **PSBT-first signing: Phase 1 — move the Bitcoin private key out of Core**
(entropy audit R-04, finding **F-13** + `docs/security/PSBT-SIGNING-ARCHITECTURE.md`
§8 Phase 1). `handle_bitcoin_init_wallet_from_seed` passes
`disable_private_keys = false` (`core/archipelago/src/api/rpc/bitcoin.rs:203`) and
imports `wpkh(xprv/0/*)` / `wpkh(xprv/1/*)` (`:229-231`), so the BIP-84 account
**private** key is persisted in Bitcoin Core's `wallet.dat` — with an empty wallet
passphrase (`:205`) — in addition to the daemon's Argon2 envelope. The descriptors
also carry **no `[fingerprint/derivation]` key origin**, so no hardware signer
could ever locate its key even if the private key were removed. Fix: watch-only
(`disable_private_keys=true`) + xpub-with-origin descriptors + a migration that
verifies balance and UTXO parity **before** removing the old wallet. This is the
single highest-value change in the PSBT spec and unblocks every later phase.
Needs a node with real UTXO history to verify.
- [ ] **PSBT-first signing: Phases 2-7 rollout**
(`docs/security/PSBT-SIGNING-ARCHITECTURE.md` §8) — the spec is written to be
consumed directly by `/gsd-plan-phase`, with per-phase goals, dependencies,
candidate requirements and hardware gating. Sequence: PSBT construct/export →
external-signer import + finalize → air-gap transport (BC-UR v2 primary, BBQr for
Coldcard, file fallback always) → `wsh(sortedmulti)` multisig on BIP-48 → LND
remote signing → hot-wallet spend limits and cold/warm/hot tiering. Two hard rules
the spec fixes in place: a channel-funding PSBT must **never** be self-broadcast
(funds can be lost), and no UI copy may imply a routing node's Lightning channel
keys are cold — they are necessarily hot. Phases 3-6 need real hardware.
- [ ] **Confine the seed-bearing RPCs to loopback/TLS** (entropy audit R-07, finding
F-04 / [ARCHY-4]). The 24-word master mnemonic is returned to the browser over
JSON-RPC (`core/archipelago/src/api/rpc/seed_rpc.rs:147`, `:156-158`), held in
process memory under a 10-minute TTL (`:27`) and deliberately **not** cleared at
verify time (`:205-211`, with a documented and defensible rationale about client
retries) — over a transport that is plaintext HTTP on LAN by design
(`api/rpc/mod.rs:227-241`). Anyone with LAN traffic visibility during onboarding
reads the phrase that unlocks the wallet and the node identity. Fix: force TLS or
loopback for seed methods, shrink the TTL, and clear on an acknowledged verify
with a short grace window. Touches the onboarding transport — needs a phase.
- [~] **Multinode test pass** (`docs/multinode-testing-plan.md`) — worked the
preconditions on .198 2026-07-01:
- ✅ cleared 2 stale failed-unit records (`archy-mempool-db.service`,
+123 -3
View File
@@ -159,6 +159,7 @@ action of the "your seed may be predictable, migrate now" kind is warranted —
| F-10 | **Low** | Container `generated_secrets` use `thread_rng()` rather than an explicit `OsRng` (same T1 shape as F-02, smaller blast radius) | `core/archipelago/src/container/secrets.rs:92`, `:101` |
| F-11 | **Informational** | `Math.random()` inside a seed-handling view (benign — UX challenge selection only) | `neode-ui/src/views/OnboardingSeedVerify.vue:159` |
| F-12 | **Informational** | Identical default OS credentials on every flashed node | `image-recipe/archipelago-scripts/install-to-disk.sh:205` |
| F-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` |
---
@@ -524,6 +525,55 @@ 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**
@@ -785,9 +835,41 @@ every install.
---
## 7. `ARCHY-1` remediation status
## 7. `ARCHY-1` remediation status — **APPLIED**
<!-- filled by Task 3 -->
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.
@@ -795,7 +877,45 @@ Everything else in this document is queued in §8, not implemented.
## 8. Remediation Backlog
<!-- filled by Task 3 -->
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` | `core/archipelago/src/container/secrets.rs` | S | no — **blocked**: file has another agent's uncommitted work |
| **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.
---