Files
archy/.planning/phases/10-key-material-hardening/10-05-SUMMARY.md

371 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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<PsbtKeyOriginReport>` |
| 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).