# Phase 10: Key-Material Hardening - Context **Gathered:** 2026-08-01 **Status:** Ready for planning ## Phase Boundary Every path that creates, restores, or persists node key material proves the caller is authorized and the material is per-node. Closes the three exploitable findings from `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`: - An already-onboarded node must refuse to have its identity replaced (F-01, Critical). - A node flashed from the fleet-shared rootfs must never share another node's host keys (F-03, High). - The wallet spending key must not exist in cleartext outside the encrypted envelope (F-13, High). **Not in scope:** the remaining audit findings F-04..F-12 (tracked as R-05..R-14 in `docs/UNIFIED-TASK-TRACKER.md`), the PSBT air-gap *implementation*, and any change to derivation paths, word counts, or the at-rest encryption envelope. ## Implementation Decisions ### KEY-01 — Re-key policy (F-01, Critical) - **D-01:** An already-onboarded node **hard refuses** `seed.restore` and `seed.generate`. These endpoints are permanently closed once the node holds identity keys. No authenticated-session variant, no physical-presence window — the pre-auth path carries no authorization decision at all, which is what keeps its attack surface at zero. — **Reversibility:** costly — the refusal becomes an observable API contract that the onboarding UI, the companion app, and any restore tooling will be written against; loosening it later is safe, but tightening a looser rule after release would break callers. - **D-02:** The legitimate re-key path is the **existing authenticated `system.factory-reset`** (`api/rpc/dispatcher.rs:469`, `system/handlers.rs:575`), after which the node is un-onboarded and the normal onboarding restore flow works. Verified during discussion that both `system.factory-reset` and `auth.resetOnboarding` are already authenticated — they are absent from `UNAUTHENTICATED_METHODS` — so this path does not need to be built, and it is not itself a bypass. - **D-03:** The gate **refuses if *either* signal says onboarded** — never one signal trusted alone. Fails safe when signals disagree, which is a real state: `auth.rs:196-207` already carries auto-heal logic for exactly that drift. - **D-03a (mechanism correction, 2026-08-01 — intent of D-03 unchanged):** D-03 originally named `NodeIdentity::key_exists` (`identity.rs:117`) as one of the two signals. **That signal is unusable and would have bricked onboarding on every fresh node.** `Server::new` (`server.rs:63-72`) calls `NodeIdentity::load_or_create` on *both* branches of its fresh-vs-existing check, and `load_or_create` (`identity.rs:48-51`) generates and writes a random temporary node key when none exists. So `key_exists` is `true` on every node that has booted even once, onboarded or not — a gate keyed on it refuses `seed.generate` on a node that has never been onboarded. This flaw was inherited from the audit's own suggested remediation (`ENTROPY-SEED-AUDIT-2026-07-31.md:214-221`) and repeated in the planning brief; the planner caught it against the code. The corrected signal set is `is_setup()` / `is_onboarding_complete()` / `seed_exists()` — all false on a fresh node and during the seed steps, all true afterwards. `key_exists` and `fips_key_exists` are rejected with recorded verdicts, and the correction is pinned by a test (`allows_on_fresh_temp_dir_even_though_node_key_exists`), not a comment. - **D-04:** 10-01 covers **every method in `UNAUTHENTICATED_METHODS` that can mutate identity or credentials**, behind the same gate and one shared test suite — not just the two endpoints F-01 names. Explicitly in the audit sweep: `seed.generate`, `seed.restore`, `seed.save-encrypted`, `backup.restore-identity`, `auth.setup`, and `auth.onboardingComplete`. Fixing `seed.restore` while `backup.restore-identity` reaches the same identity-overwrite primitive would move the door, not close it. For endpoints that turn out not to mutate (e.g. possibly `seed.verify`), record an explicit evidence-backed verdict rather than changing behaviour. ### KEY-02 — First-boot secret regeneration (F-03, High) - **D-05:** On failure, **retry with backoff, then fail closed** — refuse to bring the service up and surface a loud console/screen error. Chosen over fail-immediately (a transient first-boot condition would brick a new node with no self-recovery) and over boot-locked-with- warning (a dismissable warning means running on shared keys). The current behaviour is the opposite of all three: fail-open with the completion marker set even on failure (`build-auto-installer-iso.sh:1647`, `:1659`, `:1663`). - **D-06:** Scope is **fix the ISO builder AND remediate already-deployed nodes** — boot-time detection plus one-time regeneration, reaching the fleet via OTA. Builder-only would stop the exposure growing without ending it, on exactly the nodes that are already live. — **Reversibility:** one-way — rotating SSH host keys on live nodes invalidates existing `known_hosts` entries fleet-wide and changes host identity for any tooling pinned to it; once rotated there is no going back to the old key. The plan must sequence this so remote access is not lost mid-rotation, and this decision earns a checkpoint before the task that implements it. ### KEY-03 — Wallet spending key (F-13, High) - **D-07:** **Migrate existing wallets** to watch-only (`disable_private_keys=true`, xpub imported with a `[fingerprint/derivation]` key origin), with **balance and UTXO-set parity verified before and after**, keeping the old `wallet.dat` as a rollback. Matches the audit's R-04 and CLAUDE.md's "migrations never destroy data" invariant. New-wallets-only was rejected because it leaves the exposure precisely on nodes holding real funds. — **Reversibility:** one-way — this rewrites a wallet that may hold user funds. Rollback depends entirely on the retained `wallet.dat` and the parity proof; a migration that loses UTXO visibility is a funds-visibility incident. Earns a checkpoint before execution. - **D-07a (scoping correction, 2026-08-01, after D-07 was recorded):** the user states Bitcoin Core's wallet is no longer used by anything and is very old. Verified: 1. `bitcoin.init-wallet-from-seed` has **no caller anywhere** — the only occurrence outside the handler is its dispatcher registration (`dispatcher.rs:122`). Nothing in `neode-ui/src`, `core/`, or scripts. 2. **LND is the wallet.** `Web5Wallet.vue` and `SendBitcoinModal.vue` call `lnd.sendcoins`, `lnd.estimatefee`, `lnd.getinfo`. Across all of `neode-ui/src` the only `bitcoin.*` calls are `bitcoin.getinfo`, `bitcoin.prune-status`, `bitcoin.onion` — no wallet operations. 3. archi-dev-box has **no `/var/lib/archipelago/bitcoin/wallets/` directory**, so the named descriptor wallet this handler creates (default `wallet_name` = `"archipelago"`, `bitcoin.rs:172-174`) has never been created there. The `wallet.dat` at the datadir root is Core's legacy default-wallet location, not this handler's output. 4. The endpoint is authenticated **and** requires the user's password (`verify_password`, `bitcoin.rs:177-180`); it is absent from `UNAUTHENTICATED_METHODS`. F-13 was never remotely reachable — it is key-at-rest duplication, not an exposed endpoint. **Therefore F-13 is latent, not live**, and D-07's premise (funded wallets out there carrying the xprv) is unproven. KEY-03 is re-scoped **discovery-first**: a fleet-wide check for any wallet this handler created, and whether it holds balance/UTXO history, runs *before* any migration. The migration and its checkpoint stay in the plan but become **conditional on discovery finding a real wallet**. If discovery is empty fleet-wide, the correct fix is the cheap one — make the handler watch-only by construction (xpub + key origin per D-09), or delete the endpoint as dead code, recording the evidence either way. Deleting is to be presented as a decision, not taken unilaterally. Only archi-dev-box is known-negative; the rest of the fleet is UNVERIFIED. Discovery commands must never use `listdescriptors true` (it returns private keys); `listwallets` / `getwalletinfo` / `listdescriptors` suffice. *Consequence for D-08:* if Core's wallet is genuinely dead, LND is the only wallet that matters, and `PSBT-SIGNING-ARCHITECTURE.md`'s honest LND limits (channel, revocation and HTLC keys cannot be air-gapped) become the governing constraint on the phase's signing story. The plan must say so plainly rather than implying a watch-only Core wallet delivers air-gapped custody. - **D-07b (final KEY-03 scope, supersedes D-07 and D-07a's conditional migration):** Core's wallet is out entirely — it is outdated and used by nothing. **Delete `bitcoin.init-wallet-from-seed`** (handler `bitcoin.rs:161-294` + its `dispatcher.rs:122` registration): an uncalled, authenticated, password-gated endpoint whose only job is to derive and stringify the master BIP-84 xprv. Pure liability, zero benefit. Deleted outright, not deprecated — nothing in the repo or frontend calls it, no unattended caller can reach it (password-gated), and archi-dev-box shows it never ran. **No migration is planned.** D-07's parity-proof migration and its `one-way` checkpoint are withdrawn — there is no wallet to migrate. If the KEY-03 discovery sweep unexpectedly finds a descriptor wallet this handler created on some fleet node, that is a **finding to surface and stop on**, not a trigger to auto-migrate: it would mean the endpoint was invoked manually and the node's spending key is duplicated in Core, which deserves a human decision. **PSBT is already solved by LND, not Core.** Verified in-repo: - `lnd.create-psbt` (`api/rpc/lnd/wallet.rs:605`) → LND WalletKit `/v2/wallet/psbt/fund`; its own doc comment says "Create an unsigned PSBT for hardware wallet signing". - `lnd.finalize-psbt` (`:711`) takes `signed_psbt_base64` → `/v2/wallet/psbt/finalize` → broadcasts via `/v2/wallet/tx`. - Both already rate-limited (`rate_limit.rs:68-69`, 5/300s). LND is pinned to **v0.18.4-beta**. KEY-03 therefore becomes: **delete the Core path, and make the existing LND PSBT flow a first-class, tested, documented path** — including an acceptance criterion that the PSBT produced by `lnd.create-psbt` carries the BIP-32 derivation / key-origin data a hardware signer needs to locate its key (this is what D-09 was really protecting; it no longer applies to Core descriptors, which are being deleted). — **Reversibility:** reversible — deleting an uncalled endpoint is recoverable from git, and the LND flow already exists. The `one-way` rating from D-07 no longer applies. - **D-07c (deferred, not chosen now):** a true cold vault independent of both Core and LND — BDK descriptor wallet in the daemon with the node's own **ElectrumX** app as chain source (already shipped: `apps/electrumx`, `electrs_status.rs`). Considered and deliberately deferred out of Phase 10; it needs its own phase (new dependency, new UI surface). Recorded so the option is not lost. The alternative shape — LND watch-only via `importaccount` + remote signing — was also considered and rejected for coupling cold storage to LND's upgrade path. **Standing honesty constraint for any signing docs this phase touches:** on-chain funds held by LND *are* air-gappable today via the create→sign-offline→finalize flow. Lightning channel, revocation and HTLC keys are **not air-gappable at all** — they must sign in real time to answer counterparty commitments. LND remote signing relocates those keys; it does not cool them. No document produced by this phase may imply otherwise. - **D-08:** Default signing stays **daemon-side PSBT signing** using the seed already held in the encrypted envelope, with the air-gapped/external-signer path from `docs/security/PSBT-SIGNING-ARCHITECTURE.md` available as **opt-in**. Send UX is unchanged; the win is that the spending key exists in exactly one place instead of two. Requiring an external signer was rejected as a UX change needing hardware users may not have. - **D-09:** The missing key-origin annotation is in scope, not a follow-up. Today's descriptors (`bitcoin.rs:230-231`) carry none, which is why the current wallet could not be converted to an external-signer setup even if the private key were removed — fixing the key without the origin would leave D-08's opt-in path unreachable. ### Rollout - **D-10:** The KEY-01 fix **rides the next scheduled OTA** rather than an emergency point release. *Recorded consequence:* F-01 is remotely reachable on every live fleet node until that OTA ships, so the exposure window is set by the OTA cadence, not by when 10-01 is verified. Per CLAUDE.md the dev pair (archi-dev-box + x250-dev) is deployed and verified before any OTA regardless. - **D-11:** 10-01 (KEY-01) is still planned as **wave 1, empty `depends_on`, independently shippable** — so the release decision stays a scheduling choice rather than a technical constraint. If the OTA slips, 10-01 must remain cuttable on its own. ### Claude's Discretion - Exact error code / JSON-RPC response shape for a refused call (must not leak whether the node is onboarded to an unauthenticated caller beyond what `auth.isOnboardingComplete` already discloses — that method is itself unauthenticated, so the information is not new). - Rate-limit shape and thresholds, subject to the constraint in the traps below. - Test organisation and file placement. - Whether the shared gate is a middleware-layer check, a helper called by each handler, or both. ## Canonical References **Downstream agents MUST read these before planning or implementing.** ### The findings themselves (primary source — read first, in full) - `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` — the audit this phase exists to close. F-01 at §166, F-03 at §267, F-13 at §528. Remediation register R-01..R-15 near §890. On-node UNVERIFIED checklist C-3 §779, C-4 §792, C-6 §814. 103 file:line references. - `docs/security/PSBT-SIGNING-ARCHITECTURE.md` — target architecture for KEY-03: watch-only descriptors, `wsh(sortedmulti)`, air-gap transport, and the honest LND limits (channel, revocation and HTLC keys cannot be air-gapped). ### Project invariants - `CLAUDE.md` — rootless Podman only; secrets are manifest-declared; **migrations never destroy data**; verify on a real node before any tag; commit+push every unit of work. - `docs/UNIFIED-TASK-TRACKER.md` — carries R-01..R-15; the 9 items added by quick task 260731-upz are the out-of-scope remainder of this audit. ### Code that is the subject of the phase - `core/archipelago/src/api/rpc/middleware.rs:5-40` — `UNAUTHENTICATED_METHODS`, the list D-04 sweeps. - `core/archipelago/src/identity.rs:79-114` (`from_seed`, the unconditional overwrite) and `:117` (`key_exists`, the guard that exists and is never called on this path). - `core/archipelago/src/auth.rs:182-210` — `is_onboarding_complete` and its auto-heal drift logic. - `core/archipelago/src/api/rpc/seed_rpc.rs:93-120` (generate, with the lock + TTL fast-path) and `:226-265` (restore). - `core/archipelago/src/api/rpc/bitcoin.rs:161-294` — `handle_bitcoin_init_wallet_from_seed`. - `image-recipe/_archived/build-auto-installer-iso.sh:1647`,`:1659`,`:1663` — the fail-open regeneration and its marker. - `image-recipe/build-debian-iso.sh:40` — **proves `_archived/` is live**, not dead code. ## Existing Code Insights ### Reusable Assets - `NodeIdentity::key_exists` (`identity.rs:117`) — the guard D-03 needs, already written and already correct; it is simply never called on the seed path. - `system.factory-reset` (`system/handlers.rs:575`) and `auth.resetOnboarding` (`auth.rs:272`) — both already authenticated; D-02's recovery path is existing behaviour, not new code. - The wallet is already a **descriptor** wallet (`bitcoin.rs:207` passes `descriptors=true`), which is the correct foundation for D-07 — the defect is which key goes into it, not the wallet type. - The xprv string is already zeroized on both the error path (`bitcoin.rs:222`) and the success path (`:284`) — in-memory handling is careful and should be preserved by the migration. ### Established Patterns - Pre-auth onboarding endpoints are an intentional design, not an oversight — the node has no user account until `auth.setup` runs. Any fix must preserve first-boot onboarding on a fresh node; this is the single biggest way to get KEY-01 wrong. - `handle_seed_generate`'s `ONBOARDING_MNEMONIC` lock + `MNEMONIC_TTL` idempotent fast-path (`seed_rpc.rs:93-120`) is **retry-storm protection, not authorization** — written because the web client retries every 4s on slow first-boot hardware and aborts at 15s. A new rate limit must not reintroduce the "error at the DID-creation screen" failure it was added to prevent. ### Integration Points - The gate sits between `middleware.rs`'s dispatch decision and the `seed_rpc.rs` / `backup` / `auth` handlers. - KEY-02 spans the ISO builder (build host) and node boot (systemd), not the Rust daemon — a different verification surface from KEY-01/KEY-03. - KEY-03 touches the Bitcoin Core container's wallet, so it interacts with the app lifecycle, not just the daemon. ## Specific Ideas - **The regression test is the deliverable, not the patch.** For KEY-01 there must be a test that fails against today's code: an already-onboarded node rejects `seed.restore` with attacker-supplied words, and its `node_key` and `nostr_secret` are byte-identical afterwards. This mirrors the standard the entropy fix in `8b51b7e2` was held to (a known-answer test that could not exist before the change). - `image-recipe/_archived/` must **not** be "tidied up" or relocated as part of KEY-02. It is live — `build-debian-iso.sh:40` execs it — and the audit notes that treating it as dead would have hidden F-03 entirely. - Concurrent agents share this git tree and push to `main`: stage explicitly by path, never `git add -A` / `git commit -a`. ## Deferred Ideas - **F-04 (Medium)** — mnemonic crosses the RPC boundary and is held in memory 10 minutes over plaintext-capable HTTP. Tracked as R-07, itself marked PHASE-sized by the audit. Its natural home is a follow-up phase alongside the loopback/TLS confinement question, not here. - **F-05 (Medium)** — `Argon2::default()` (19 MiB / t=2) contradicts ADR-005's stated 64 MB / 3 iterations. Needs either a versioned envelope migration or an ADR amendment (R-06). - **F-06 (Medium)** — release master mnemonic passed via env var / stdout in the signing ceremony (R-08). Deliberately scheduled separately: it *is* the signing ceremony. - **F-07 (Medium)** — no `cargo audit` / `cargo deny` in CI; two `rand` majors coexist (R-05). - **F-09 / F-10 / F-11 (Low / Informational)** — TOTP modulo bias (R-12), container `generated_secrets` using `thread_rng()` (R-13, blocked on another agent's uncommitted work in `container/secrets.rs`), and the `Math.random()` comment (R-14). - **The archi-dev-box test node** (shapes A and B) — `.planning/todos/pending/2026-08-01-archi-dev-box-as-fresh-test-node-without-iso.md`. Sequenced after this phase; shape A is the natural harness for KEY-04, since a second instance boots un-onboarded, which is exactly the state D-03's gate must distinguish. ### Reviewed Todos (not folded) - *Fedimint gateway must not install with a pre-set password* — matched on `area: security`, but already fixed by a concurrent agent in commit `42652547` (FED-07, Phase 1). Not folded. - *Connected-nodes list scroll height*, *FIPS/Tor pills on cloud files* — matched only on incidental keywords (`must`, `2026`, `fips`); both are UI work belonging to Phase 1's UIFIX series. Not folded. --- *Phase: 10-key-material-hardening* *Context gathered: 2026-08-01*