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

22 KiB
Raw Blame History

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
security
key-material
psbt
lnd
bitcoin-core
F-13
KEY-03
complete
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
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
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
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
created modified
docs/security/KEY-03-SIGNING-POSTURE.md
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
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
duration completed tasks_completed tasks_total
~3h15m (dominated by cargo target-dir contention with three concurrent agents) 2026-08-02 3 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 PASS145: "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.* blockcontainer/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 comparisonall 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) Mitigatedlistdescriptors 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) Mitigatedkey_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).