22 KiB
phase, plan, subsystem, tags, status, requires, provides, affects, tech-stack, key-files, decisions, metrics
| phase | plan | subsystem | tags | status | requires | provides | affects | tech-stack | key-files | decisions | metrics | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 10-key-material-hardening | 05 | bitcoin-signing |
|
complete |
|
|
|
|
|
|
|
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, everybitcoin.*RPC call is read-only status:bitcoin.getinfo(14 sites),bitcoin.prune-status(3),bitcoin.onion(1). Zerobitcoin.*wallet operations. - The endpoint is absent from
UNAUTHENTICATED_METHODS(middleware.rs:5-40) and additionally calledverify_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;frombitcoin.rs. seed::derive_bitcoin_xprvretained 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:
- The PSBT is funded from LND's own wallet —
/v2/wallet/psbt/fund(lnd/wallet.rs:672) selects LND's UTXOs. - 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_restPOSTs/v1/initwalletwith acipher_seed_mnemonic(container/lnd.rs:504-516) and persists the aezeed backup (:523-525). - The generated
lnd.confcarries noremotesigner.*block —container/lnd.rs:64-79writesbitcoin.node=bitcoindplus bitcoind RPC settings and nothing else. - Nothing in the repo provisions watch-only LND. A search of
apps/,scripts/,core/archipelago/srcandimage-recipe/forremotesigner,createwatchonlyandnochainbackendreturns 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.cargoruns contended heavily (load average 25-30, one test build took 34 minutes); one intermediate test build failed with 16 errors infederation/*from another agent's mid-edit state, which resolved on its own. STATE.md/ROADMAP.md/REQUIREMENTS.mdwere deliberately not updated. Another agent holds an uncommitted edit toSTATE.mdthroughout, 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).