--- phase: 10-key-material-hardening plan: 05 subsystem: bitcoin-signing tags: [security, key-material, psbt, lnd, bitcoin-core, F-13, KEY-03] status: complete requires: - "10-CONTEXT.md D-07b (delete, do not migrate) and D-07c (deferred BDK cold vault)" - "docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md F-13 / R-04" provides: - "No daemon code path writes the BIP-84 account private key into Bitcoin Core" - "psbt_key_origin_report + the key_origin field on lnd.create-psbt" - "docs/security/KEY-03-SIGNING-POSTURE.md — the evidence-backed signing-posture record" affects: - "core/archipelago/src/api/rpc/bitcoin.rs" - "core/archipelago/src/api/rpc/dispatcher.rs" - "core/archipelago/src/api/rpc/lnd/wallet.rs" - "core/archipelago/src/seed.rs" - "docs/security/PSBT-SIGNING-ARCHITECTURE.md" tech-stack: added: [] patterns: - "Best-effort inspection that degrades to null, never to an error, on a funds path" - "Programmatically-built PSBT test fixtures instead of pasted opaque base64" - "Tombstone comments that deliberately omit the deleted symbol name so grep-based regression checks stay durable" key-files: created: - "docs/security/KEY-03-SIGNING-POSTURE.md" modified: - "core/archipelago/src/api/rpc/bitcoin.rs" - "core/archipelago/src/api/rpc/dispatcher.rs" - "core/archipelago/src/api/rpc/lnd/wallet.rs" - "core/archipelago/src/seed.rs" - "docs/security/PSBT-SIGNING-ARCHITECTURE.md" decisions: - "F-13 closed by deleting the Core wallet path outright rather than rewriting it watch-only (D-07b)" - "derive_bitcoin_xprv retained with #[allow(dead_code)] and a stated D-07c reason rather than deleted as cruft" - "Verdict recorded: no fleet node is provisioned watch-only, so an external signer cannot meaningfully sign a default node's PSBT today" - "Census conclusion scoped to examined nodes only — not generalised to the fleet while 6 nodes are unreachable" metrics: duration: "~3h15m (dominated by cargo target-dir contention with three concurrent agents)" completed: 2026-08-02 tasks_completed: 3 tasks_total: 3 --- # Phase 10 Plan 05: Key-Material Hardening (KEY-03) Summary Deleted the uncalled Bitcoin Core wallet handler that imported the BIP-84 account **xprv** into `wallet.dat` (F-13), and made LND's PSBT round trip report the BIP-32 key-origin data an external signer needs — with an honest, evidence-backed record of what that round trip does and does not deliver. **Status: 3 of 3 tasks complete.** Task 3's blocking `checkpoint:human-verify` was satisfied by operator-run verification (the plan is `autonomous: false`; the checkpoint was not self-approved — execution stopped, the operator ran the census, and the result was recorded). ## Commits | # | SHA | Task | Message | |---|---|---|---| | 1 | `96229268` | Task 1 (tracer) | `fix(10-05): delete the Bitcoin Core wallet path that duplicated the spending key (F-13, D-07b)` | | 2 | `26299874` | Task 2 | `feat(10-05): report BIP-32 key origin on lnd.create-psbt, and record the honest signing posture (D-07b/D-09)` | | 3 | `0d513a0e` | Task 3 | `docs(10-05): record the Core-wallet fleet census — 4 nodes clear, 6 unchecked (D-07b)` | Not pushed, not tagged, not deployed, per the execution brief. The SUMMARY itself is deliberately uncommitted. ## Task 1 — Core wallet path deleted ### No-caller search output (re-established, not inherited) ``` $ grep -rn 'bitcoin\.init-wallet-from-seed' core/ neode-ui/src scripts/ web/ apps/ tests/ docs/ core/archipelago/src/api/rpc/dispatcher.rs:122: "bitcoin.init-wallet-from-seed" => { $ grep -rn 'handle_bitcoin_init_wallet_from_seed' core/ neode-ui/src scripts/ web/ apps/ tests/ docs/ core/archipelago/src/api/rpc/bitcoin.rs:161: pub(super) async fn handle_bitcoin_init_wallet_from_seed( core/archipelago/src/api/rpc/dispatcher.rs:123: self.handle_bitcoin_init_wallet_from_seed(params).await docs/UNIFIED-TASK-TRACKER.md:208: §8 Phase 1). `handle_bitcoin_init_wallet_from_seed` passes docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md:607:(`handle_bitcoin_init_wallet_from_seed`): docs/security/PSBT-SIGNING-ARCHITECTURE.md:147: `handle_bitcoin_init_wallet_from_seed`, `core/archipelago/src/api/rpc/bitcoin.rs:161-294`). ``` Exactly the expected result: one occurrence of the method name (its own dispatcher registration), two of the symbol in code (definition + dispatch call). The three remaining symbol hits are prose in documentation, not callers. **No third caller — the deletion's premise held**, so no checkpoint was raised. Also re-verified independently: - **Across all of `neode-ui/src`, every `bitcoin.*` RPC call is read-only status**: `bitcoin.getinfo` (14 sites), `bitcoin.prune-status` (3), `bitcoin.onion` (1). Zero `bitcoin.*` wallet operations. - **The endpoint is absent from `UNAUTHENTICATED_METHODS`** (`middleware.rs:5-40`) and additionally called `verify_password` (`bitcoin.rs:176-179`) — authenticated *and* password-gated, so F-13 was key-at-rest duplication, never a remotely reachable endpoint. ### What changed - Deleted `handle_bitcoin_init_wallet_from_seed` (`bitcoin.rs:161-295`) and the `"bitcoin.init-wallet-from-seed"` dispatch arm (`dispatcher.rs:122-124`). - Removed the now-unused `use zeroize::Zeroize;` from `bitcoin.rs`. - `seed::derive_bitcoin_xprv` retained with `#[allow(dead_code)]` and a doc line naming **D-07c** as the reason (deferred BDK cold vault), so the next reader does not remove it as cruft. - Created `docs/security/KEY-03-SIGNING-POSTURE.md`. ### Acceptance criteria | Criterion | Result | |---|---| | `cargo build -p archipelago` succeeds | **PASS** (1m47s, 3 pre-existing warnings, none in this plan's files) | | `grep -rn 'handle_bitcoin_init_wallet_from_seed' core/ --include=*.rs` → no matches | **PASS** (see deviation 1) | | `grep -n 'init-wallet-from-seed' dispatcher.rs` → only the `lnd.` arm | **PASS** — `145: "lnd.init-wallet-from-seed"` only | | `cargo test -p archipelago seed::` still passes | **PASS** — 25 passed, 0 failed, incl. `test_bitcoin_xprv_deterministic` and `test_full_derivation_from_known_mnemonic` | | Doc exists, cites D-07b and D-07c, carries the search output | **PASS** — 397 lines; 6× D-07b, 4× D-07c | | `cargo clippy -p archipelago -- -D warnings` clean | **PARTIAL** — see deviation 2 | ## Task 2 — LND PSBT key-origin reporting ### What was added `core/archipelago/src/api/rpc/lnd/wallet.rs`: | Symbol | Line | Kind | |---|---|---| | `PsbtKeyOriginReport` | `:1169` | struct `{ input_count, inputs_with_key_origin, all_inputs_have_key_origin }` | | `psbt_key_origin_report` | `:1186` | `fn(&str) -> Result` | | call site + warn | `:705` | best-effort, degrades to `null` | | response field | `:737` | `"key_origin": { … } \| null` | An input counts as carrying key origin when either `bip32_derivation` or `tap_key_origins` is non-empty. A zero-input PSBT reports `all_inputs_have_key_origin: false` rather than vacuous truth, since an inputless PSBT cannot be signed and "yes, a signer has everything it needs" would be actively misleading. ### Tests (new, 3 passing) ``` running 3 tests test api::rpc::lnd::wallet::tests::psbt_with_derivations_reports_key_origin ... ok test api::rpc::lnd::wallet::tests::psbt_without_derivations_reports_no_key_origin ... ok test api::rpc::lnd::wallet::tests::malformed_psbt_is_an_error_not_a_panic ... ok test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 1014 filtered out; finished in 0.00s ``` Fixtures are built programmatically with the `bitcoin` crate (`Psbt::from_unsigned_tx` over a one-input `Transaction`, then a `(Fingerprint, DerivationPath)` inserted on input 0) rather than pasted as opaque base64, so the tests explain themselves. ### Round-trip coverage map (recorded in the doc) | # | Step | `file:line` | Tested? | |---|---|---|---| | 1 | Fund — `lnd.create-psbt` → `/v2/wallet/psbt/fund` | `lnd/wallet.rs:605`, `dispatcher.rs:136` | **No** | | 1a | Inspect — key origin | `lnd/wallet.rs:1186`, `:1169`, `:705`, `:737` | **Yes** (3 tests) | | 2 | Export — base64 to UI | `rpc-client.ts:407-423`, `Web5SendReceiveModals.vue:308` | **Partial** (`rpc-client.test.ts:319-323` asserts the method name only) | | 3 | Sign offline | not in this repo | N/A | | 4 | Import — paste signed PSBT | `Web5SendReceiveModals.vue:102`, `:419-424` | **No** | | 5 | Finalize — `/v2/wallet/psbt/finalize` | `lnd/wallet.rs:743`, `dispatcher.rs:137` | **No** | | 6 | Broadcast — `/v2/wallet/tx` | `lnd/wallet.rs:795` | **No** | | — | Rate limits 5/300s | `rate_limit.rs:68-69` | **No** | **One of six steps has automated coverage.** There is also **no air-gap transport** — no animated QR, no `.psbt` file exchange; export/import is copy-paste of base64 in a textarea. Nothing has been verified against real signing hardware. The doc states all of this plainly rather than describing an untested path as verified. ### The watch-only verdict (the question that decides whether this is an air gap) **Verdict: NO — on a default Archipelago node an externally-held signer cannot meaningfully sign a PSBT from `lnd.create-psbt`, because LND holds the private keys for every input it selects.** Evidence: 1. The PSBT is funded from **LND's own wallet** — `/v2/wallet/psbt/fund` (`lnd/wallet.rs:672`) selects LND's UTXOs. 2. **LND's wallet on every node is a full key-holding wallet, created locally.** `container::lnd::ensure_wallet_initialized` (`container/lnd.rs:86`) → `init_wallet_via_rest` POSTs `/v1/initwallet` with a `cipher_seed_mnemonic` (`container/lnd.rs:504-516`) and persists the aezeed backup (`:523-525`). 3. **The generated `lnd.conf` carries no `remotesigner.*` block** — `container/lnd.rs:64-79` writes `bitcoin.node=bitcoind` plus bitcoind RPC settings and nothing else. 4. **Nothing in the repo provisions watch-only LND.** A search of `apps/`, `scripts/`, `core/archipelago/src` and `image-recipe/` for `remotesigner`, `createwatchonly` and `nochainbackend` returns **zero matches**. So what ships today is the PSBT **transport**, complete and rate-limited, **not air-gapped custody**. The gap between here and D-08's opt-in path is **provisioning, not plumbing** (PSBT-SIGNING-ARCHITECTURE §8 Phase 6, out of scope for Phase 10). ### Honesty statement (its own subsection in the doc) Lightning channel, revocation and HTLC keys are **not air-gappable at all** — they must sign in real time to answer counterparty commitments; a routing node cannot tolerate human-in-the-loop signing. LND remote signing **relocates** them to a hardened host; it does **not** cool them. No wording in either document implies otherwise. ### Acceptance criteria | Criterion | Result | |---|---| | ≥3 new tests including the with/without pair | **PASS** — 3 passed | | `cargo clippy -p archipelago` clean for this plan's files | **PASS** — zero diagnostics in `bitcoin.rs`, `dispatcher.rs`, `seed.rs`, `lnd/wallet.rs` | | `git diff core/archipelago/Cargo.toml` empty | **PASS** — no dependency added | | `grep -c 'key_origin' lnd/wallet.rs` ≥ 4 | **PASS** — 26 | | `handle_lnd_create_raw_tx` unchanged | **PASS** — diff hunks at `+701`, `+737`, `+1161`, `+1211`; `create_raw_tx` starts at `:825` and `finalize_psbt` spans `:743-823`, so no hunk falls inside either | | PSBT-SIGNING-ARCHITECTURE diff confined to the banner; §5.4 byte-identical | **PASS** — single hunk `@@ -2,0 +3,28 @@`; `diff` of §5.4 against HEAD reports IDENTICAL | ## Task 3 — Fleet census: **RUN 2026-08-02, no escalation** `type="checkpoint:human-verify" gate="blocking"`, plan `autonomous: false`. Execution stopped at the checkpoint; the operator ran the read-only procedure across the Tailscale fleet and supplied the results, which are recorded in `docs/security/KEY-03-SIGNING-POSTURE.md` § *Fleet census*. ### Examined — 4 nodes, all CLEAR | Node | Tailscale IP | Container | `listwalletdir` | `archipelago` wallet? | Default wallet | |---|---|---|---|---|---| | archi-dev-box | `100.69.68.39` | `bitcoin-knots` | 2× `gatewayd-*`, `""` | **No** | `blank: true`, keypool 0, txcount 0, balance 0 | | shorty-s (`.228`) | `100.64.204.114` | `bitcoin-knots` | 1× `gatewayd-*`, `""` | **No** | same | | archy-x250-beta | `100.72.136.5` | `bitcoin-core` | 1× `gatewayd-*`, `""` | **No** | same | | archy-x250-pa | `100.89.209.89` | `bitcoin-core` | 1× `gatewayd-*`, `""` | **No** | same | `listwallets` → `[""]` on every node. The only named wallets are Fedimint `gatewayd-*`. The one loaded (unnamed, default) wallet does report `private_keys_enabled: true`, but also `blank: true`, `keypoolsize: 0`, `txcount: 0`, `balance: 0.00000000`, `descriptors: true` — Core's own statement that **no key was ever imported into it and no transaction ever touched it.** **The result holds across two container vintages** (`bitcoin-knots` ×2, `bitcoin-core` ×2), so it is a property of the fleet rather than four copies of one image behaving identically. **No key material appeared in any output; `listdescriptors true` was never run.** Supporting history: `git log -S "init-wallet-from-seed"` scoped to `dispatcher.rs` and `neode-ui/src` returns exactly one commit — `19dcfd4f`, the commit that **added** it. No frontend wrapper was ever written. ### Not examined — 6 nodes, recorded with reasons | Node | Tailscale IP | Why | |---|---|---| | framework-pt | `100.65.115.109` | `Permission denied (publickey,password)` — SSH password rotated, not held | | archipelago-1 | `100.82.34.38` | `Permission denied (publickey,password)` | | archipelago | `100.70.96.88` | `Permission denied (publickey,password)` | | archy-dev-pa | `100.64.83.15` | `Permission denied (publickey,password)` | | archipelago-5 | `100.114.134.21` | Timed out during SSH banner exchange | | archy-x250-dev | `100.113.100.55` | Offline — Tailscale last seen 2 days prior | Password auth was **deliberately not attempted** on any of these: several fleet nodes lock PAM quickly on a wrong password, and locking out an in-use production node is a worse outcome than an incomplete census. ### Conclusion, at the strength the evidence supports > **No examined node holds a wallet created by the deleted handler, and no examined node holds any > wallet with keys or funds.** Deliberately **not** generalised to "the fleet is clear" while six nodes are unknown — an unexamined node is unknown, not safe. F-13 is closed **by deletion** regardless: the code that could create such a wallet is gone from every future build. The census adds that no such wallet was found anywhere anyone could look. **Nothing to escalate; the stop-on-finding rule stands** for the remaining nodes. ### Standing item The six unchecked nodes are homed in **`docs/UNIFIED-TASK-TRACKER.md`** as *"Finish the Core-wallet fleet census — 6 nodes unchecked"*, not only in the security doc, so it is visible to someone who is not already reading one. Flagged there as a natural fold-in for **KEY-04's on-node work** (which needs node access anyway) but tracked independently so it does not vanish if KEY-04 is re-scoped. That file's stale R-04/F-13 entry — which still described the deleted handler and a watch-only migration as pending work — was corrected to done-by-deletion in the same commit. ## Deviations from Plan ### 1. [Rule 2 — preserved a durable regression check] Tombstone comments reworded to omit the deleted symbol name **Found during:** Task 1 acceptance verification. **Issue:** I first wrote tombstone comments in `bitcoin.rs`, `dispatcher.rs` and `seed.rs` that named `handle_bitcoin_init_wallet_from_seed` / `bitcoin.init-wallet-from-seed` verbatim. That broke two acceptance criteria (`grep … → no matches`) — and, more importantly, it would have **permanently defeated the greps as a regression check**: any future reintroduction of the symbol would be masked by the comment that warns against reintroducing it. **Fix:** Reworded all three to describe the deleted thing ("the Bitcoin Core wallet-init handler that used to live here") and point at `docs/security/KEY-03-SIGNING-POSTURE.md`, which carries the full symbol name. Guidance preserved, greps clean, regression check durable. **Commit:** `96229268` ### 2. [Out of scope — pre-existing] `cargo clippy -- -D warnings` fails in `archipelago-openwrt` **Found during:** Task 1 verification. **Issue:** `cargo clippy -p archipelago -- -D warnings` fails with 4 lint errors — 2× `consider using sort_by_key`, 1× `str::trim` before `str::split_whitespace`, 1× `creates an owned instance just for comparison` — **all in `archipelago-openwrt`**, a crate this plan does not touch. **Assessment:** Pre-existing and out of scope under the scope boundary rule. Neither of my commits touches that crate (`git log 96229268^..HEAD -- core/archipelago-openwrt` → 0 commits), and my crate is not a dependency of it, so the failure is independent of this work by construction. **Action:** Not fixed. `cargo clippy -p archipelago --message-format=short` reports **zero diagnostics** in this plan's four files, which is the criterion that speaks to this work. **Recommend:** a separate cleanup task for `archipelago-openwrt`'s lints so `-D warnings` can be used as a gate again. ### 3. [Process — atomicity preserved] SUMMARY not committed, and the doc split across commits `docs/security/KEY-03-SIGNING-POSTURE.md` is a single file carrying all three tasks' content. To keep the commits genuinely atomic, it was staged truncated to its Task 1 sections for commit `96229268`, restored in full for `26299874`, and extended with the census for `0d513a0e`. `.planning/phases/10-key-material-hardening/10-05-SUMMARY.md` is left uncommitted per the execution brief. ### 4. [Rule 2 — corrected a record this change invalidated] Updated `docs/UNIFIED-TASK-TRACKER.md` **Found during:** Task 3 write-up. **Issue:** the tracker's R-04/F-13 entry still described `handle_bitcoin_init_wallet_from_seed`, its `disable_private_keys = false` and a watch-only migration with balance/UTXO parity as pending work — all of which now describe code that does not exist. A stale open item pointing at deleted line numbers actively misleads the next reader. **Fix:** marked it done-by-deletion with a pointer to `KEY-03-SIGNING-POSTURE.md`, and added the six unchecked census nodes as a new standing item. **Scope note:** `docs/UNIFIED-TASK-TRACKER.md` is not in the plan's `files_modified`. It was verified clean (`git status --porcelain`) before editing, and staged by path. **Commit:** `0d513a0e` ## Known Stubs None. No placeholder values, mock data or unwired components were introduced. Two *absences* are documented rather than stubbed, because they are honest statements of scope rather than placeholders: there is no air-gap transport (QR / file exchange) and no automated coverage for round-trip steps 1, 4, 5 and 6. Both are recorded in `docs/security/KEY-03-SIGNING-POSTURE.md` as untested/unimplemented, and neither is presented as working. ## Threat Flags None. No new network endpoint, auth path, file-access pattern or schema change at a trust boundary was introduced. The plan's threat register is addressed as follows: | Threat | Disposition | |---|---| | T-10-41 (xprv in `wallet.dat`) | **Mitigated** — the only code path that wrote it is deleted; census found no pre-existing wallet on 4 examined nodes, 6 remain unknown and are tracked | | T-10-42 (census prints a private key) | **Mitigated** — `listdescriptors true` banned by name in the doc and never run; only read-only RPCs used; no key material appeared in any output. Password auth was not attempted on locked-out nodes, so the census also avoided locking a production node out | | T-10-43 (automated migration rewrites a funded wallet) | **Mitigated** — no migration built, none run; stop-on-finding rule recorded and never triggered | | T-10-44 (opaque signer refusal) | **Mitigated** — `key_origin` on the response plus a `warn!` names the condition before the user reaches the device | | T-10-45 (docs claim custody they don't deliver) | **Mitigated** — watch-only verdict recorded with 4 evidence points; Lightning-keys subsection added; PSBT-SIGNING-ARCHITECTURE banner records Phase 1 superseded | | T-10-46 (inspection breaks a send) | **Mitigated** — best-effort, degrades to `null`; finalize and `create_raw_tx` untouched, asserted by diff scope | | T-10-47 (RPC surface change) | **Accepted** — no-caller search re-run, not inherited | | T-10-SC (dependency install) | **Accepted** — no dependency added; `Cargo.toml` diff empty | ## Notes for the next agent - **The tree is shared with three other agents.** All staging was explicit by path; `.planning/STATE.md` (another agent's uncommitted edit) was never staged. `cargo` runs contended heavily (load average 25-30, one test build took 34 minutes); one intermediate test build failed with 16 errors in `federation/*` from another agent's mid-edit state, which resolved on its own. - **`STATE.md` / `ROADMAP.md` / `REQUIREMENTS.md` were deliberately not updated.** Another agent holds an uncommitted edit to `STATE.md` throughout, and the execution brief scoped this run to commits only. KEY-03's requirement should be marked complete by whoever reconciles phase state, noting that the census's six unchecked nodes are tracked separately and are not a blocker on KEY-03 itself (F-13 is closed by deletion, which is build-wide and does not depend on the census). ## Self-Check: PASSED - Files verified present: `docs/security/KEY-03-SIGNING-POSTURE.md`, `docs/security/PSBT-SIGNING-ARCHITECTURE.md`, `docs/UNIFIED-TASK-TRACKER.md`, `core/archipelago/src/api/rpc/lnd/wallet.rs`, this SUMMARY. - Commits verified in git: `96229268`, `26299874`, `0d513a0e`. - No file belonging to this plan is left uncommitted (the SUMMARY is uncommitted deliberately, per the execution brief).