26 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 10-key-material-hardening | 05 | execute | 1 |
|
false |
|
|
Purpose: handle_bitcoin_init_wallet_from_seed derives the BIP-84 account xprv, stringifies
it, and imports wpkh(xprv/0/*) and wpkh(xprv/1/*) into Bitcoin Core's wallet.dat
(core/archipelago/src/api/rpc/bitcoin.rs:188-189, :203, :229-231, :278-281). It has no
caller anywhere in the repo, LND is the wallet the UI actually drives, and archi-dev-box shows
the wallet it creates has never existed there. Its entire function is to put a second copy of the
spending key somewhere with weaker protection than the Argon2 envelope. D-07b: delete it.
Output: the Core path gone, lnd.create-psbt reporting whether its PSBT carries the BIP-32
derivation data a hardware signer needs, and a signing-posture document that states plainly what
is and is not air-gappable.
D-07's parity-proof migration and its one-way checkpoint are withdrawn (D-07b). There is no wallet to migrate. Do not plan or build migration machinery. If Task 3's census unexpectedly finds a wallet this handler created, that is a finding to stop on — it would mean the endpoint was invoked by hand and that node's spending key is duplicated in Core, which deserves a human decision, not an automated rewrite.
What deletion does to D-08 and D-09. D-08 asked that the spending key exist in exactly one
place, with an opt-in air-gapped path; deleting the Core import achieves the first half outright
(the only remaining on-node copy of the BIP-84 key is the Argon2 envelope), and the opt-in path
is LND's existing PSBT round trip rather than a Core watch-only wallet. D-09 required a
[fingerprint/derivation] key origin on emitted descriptors so a hardware signer can locate its
key; with Core's descriptors deleted there are no Archipelago-emitted descriptors left to
annotate, so D-09's actual protection moves to the PSBT itself — Task 2 inspects and reports
whether the PSBT lnd.create-psbt returns carries the BIP-32 key-origin data a signer needs.
Neither decision is dropped; both are satisfied by a different mechanism, and the plan says so
rather than letting them lapse.
<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/STATE.md @.planning/phases/10-key-material-hardening/10-CONTEXT.md @docs/security/PSBT-SIGNING-ARCHITECTURE.md @CLAUDE.md Task 1: Delete the Core wallet path end to end, and record why (D-07b) Deleting an uncalled, password-gated endpoint is recoverable from git in one revert; nothing consumes its output and no on-disk state depends on it (D-07b). core/archipelago/src/api/rpc/bitcoin.rs, core/archipelago/src/api/rpc/dispatcher.rs, core/archipelago/src/seed.rs, docs/security/KEY-03-SIGNING-POSTURE.md - core/archipelago/src/api/rpc/bitcoin.rs (lines 155-300 — the whole handler being deleted, including the zeroize calls at :222 and :284 whose careful in-memory handling is worth naming in the record) - core/archipelago/src/api/rpc/dispatcher.rs (lines 105-130 — the bitcoin.* dispatch arms, one of which is being removed) - core/archipelago/src/seed.rs (lines 225-250 — derive_bitcoin_xprv, which loses its only non-test caller) - docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (finding F-13 and remediation R-04 — the record this task closes) - .planning/phases/10-key-material-hardening/10-CONTEXT.md (D-07a and D-07b — the evidence chain, and D-07c which must stay visible) Before deleting anything, re-establish the evidence yourself rather than inheriting it, and paste the raw command output into the SUMMARY. Run a repo-wide search for the method name `bitcoin.init-wallet-from-seed` and for the handler symbol `handle_bitcoin_init_wallet_from_seed` across `core/`, `neode-ui/src`, `scripts/`, `web/`, `apps/` and `tests/`, excluding `core/target`, `node_modules` and `.git`. The expected result is exactly two occurrences of the method name (the dispatcher arm and, if present, a docs mention) and two of the symbol (its definition and the dispatcher call). If the search finds a third caller, STOP and raise a checkpoint — the deletion's premise is that nothing calls it.Then:
- Delete
handle_bitcoin_init_wallet_from_seedfromcore/archipelago/src/api/rpc/bitcoin.rs(the whole function including its doc comment) and remove the"bitcoin.init-wallet-from-seed"arm fromcore/archipelago/src/api/rpc/dispatcher.rs. Remove any import that becomes unused as a result —zeroize::Zeroizeis the likely one; let the compiler tell you rather than guessing. crate::seed::derive_bitcoin_xprvloses its only non-test caller. Do NOT delete it: it is covered by existing tests (seed.rs:601-602,:856) and it is the derivation D-07c's deferred BDK cold vault will need. Add#[allow(dead_code)]with a doc line naming D-07c as the reason it is retained, so the next reader does not delete it as cruft.archipelagois a binary crate with nolib.rs, so an uncalledpub fndoes warn andclippy -D warningswould fail without this.- Create
docs/security/KEY-03-SIGNING-POSTURE.mdwith a first section## Bitcoin Core wallet path — deleted (D-07b)recording: the four evidence points from D-07a with theirfile:line; the search output from this task; that the endpoint was authenticated AND password-gated (bitcoin.rs:176-180) so F-13 was never remotely reachable — key-at-rest duplication, not an exposed endpoint; that the in-memory handling of the xprv string was careful (zeroized on both paths) and the defect was which key went into the wallet, not how it was held; and that F-13 is closed by removal rather than by conversion to watch-only. Reference D-07c explicitly so the deferred cold-vault option stays visible rather than being quietly lost with the code. cd core && CARGO_INCREMENTAL=0 cargo build -p archipelago && CARGO_INCREMENTAL=0 cargo clippy -p archipelago -- -D warnings <acceptance_criteria>cd core && CARGO_INCREMENTAL=0 cargo build -p archipelagosucceeds andcargo clippy -p archipelago -- -D warningsis clean.grep -rn 'handle_bitcoin_init_wallet_from_seed' core/ --include=*.rsreturns no matches.grep -rn 'init-wallet-from-seed' core/archipelago/src/api/rpc/dispatcher.rsreturns only thelnd.init-wallet-from-seedarm, which is a different endpoint and stays.cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago seed::still passes, provingderive_bitcoin_xprv's coverage survived the deletion.docs/security/KEY-03-SIGNING-POSTURE.mdexists, cites D-07b and D-07c by name, and contains the pasted no-caller search output. </acceptance_criteria> Nothing in the daemon can write the BIP-84 account private key into Bitcoin Core, the derivation function survives with its tests and a stated reason, and the deletion is documented with its evidence.
fn psbt_key_origin_report(psbt_base64: &str) -> anyhow::Result<PsbtKeyOriginReport> returning a
struct with input_count: usize, inputs_with_key_origin: usize and
all_inputs_have_key_origin: bool. Decode with base64 and parse with
bitcoin::psbt::Psbt::deserialize; count an input as carrying key origin when either its
bip32_derivation map or its tap_key_origins map is non-empty. Use only the already-present
bitcoin and base64 crates — adding a dependency is out of scope for this plan and the
executor must halt rather than add one.
Wire it into handle_lnd_create_psbt after funded_psbt is extracted: compute the report
best-effort (a decode failure must degrade to null, never to an error — a user's send must not
fail because an inspection helper could not parse something), add a key_origin object to the
JSON response carrying the three fields, and tracing::warn! with the counts when
all_inputs_have_key_origin is false, because that is the exact condition under which a hardware
signer will refuse the PSBT. Do not change handle_lnd_finalize_psbt and do not touch
handle_lnd_create_raw_tx — the latter deliberately auto-signs with LND's hot keys and is a
different flow.
Add tests in the existing mod tests, building the fixtures programmatically with the bitcoin
crate rather than pasting opaque base64 so the tests explain themselves:
psbt_without_derivations_reports_no_key_origin (a PSBT built from an unsigned transaction with
one input, no bip32_derivation) and psbt_with_derivations_reports_key_origin (the same PSBT
with a bip32_derivation entry inserted on input 0). Also
malformed_psbt_is_an_error_not_a_panic for a non-base64 and a truncated input.
Then answer, with evidence, the question the report exists to raise, and put the answer in
docs/security/KEY-03-SIGNING-POSTURE.md under ## LND PSBT round trip — what is covered:
- Which steps of fund -> export -> sign offline -> import -> finalize -> broadcast exist in this
repo today, with
file:linefor each (lnd.create-psbtatlnd/wallet.rs:605,lnd.finalize-psbtat:711including its broadcast to/v2/wallet/tx, the client bindings atneode-ui/src/api/rpc-client.ts:410-435, the 5/300s limits atrate_limit.rs:68-69). - Which of those steps has automated test coverage after this task and which does not. State the uncovered ones plainly; do not describe an untested path as verified.
- The question that decides whether this is a real air gap: on a default node, LND holds the
keys for the inputs
lnd.create-psbtselects. Determine and record whether an externally-held signer can sign such a PSBT at all without LND first being provisioned watch-only against that signer (remotesigner.*/createwatchonly, PSBT-SIGNING-ARCHITECTURE §5.1-5.2), and whether any fleet node is so provisioned today. Record the verdict either way with its evidence. The PSBT transport being present is not the same claim as custody being air-gapped, and this document must not let the two blur. - The standing honesty statement, in its own subsection: Lightning channel, revocation and HTLC keys are not air-gappable at all — they must sign in real time to answer counterparty commitments; remote signing relocates them to a hardened host, it does not cool them. No wording anywhere in the document may imply otherwise.
Finally, add a short status banner at the top of docs/security/PSBT-SIGNING-ARCHITECTURE.md
recording that its Phase 1 ("Descriptor watch-only read path", §8) was superseded by D-07b:
the Core wallet path was deleted rather than converted, so §0's "single highest-value change" and
§2.1's invariant now read against a code path that no longer exists. Point the reader at
docs/security/KEY-03-SIGNING-POSTURE.md for the current state. Change nothing else in that
document — §5.4's honesty table is correct and stays exactly as written.
cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago psbt -- --nocapture
<acceptance_criteria>
- cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago psbt passes with at least three new tests, including the with-derivations and without-derivations pair.
- cd core && CARGO_INCREMENTAL=0 cargo clippy -p archipelago -- -D warnings is clean.
- git diff core/archipelago/Cargo.toml is empty — no dependency was added.
- grep -c 'key_origin' core/archipelago/src/api/rpc/lnd/wallet.rs is at least 4 (struct, function, response field, warn line).
- handle_lnd_create_raw_tx is unchanged: git diff shows no hunk inside it.
- docs/security/KEY-03-SIGNING-POSTURE.md contains a per-step coverage table with file:line and an explicit tested/untested column, the recorded watch-only verdict, and the Lightning-keys-are-not-air-gappable subsection.
- docs/security/PSBT-SIGNING-ARCHITECTURE.md's diff is confined to the added status banner; section 5.4 is byte-identical.
</acceptance_criteria>
lnd.create-psbt reports whether its PSBT carries the BIP-32 key-origin data an external signer needs, backed by tests, and the repository states honestly what the round trip does and does not deliver today.
Hard constraint on every command in this task: never run listdescriptors true. The true
argument makes Core return the descriptors including private keys, which would print an xprv to a
terminal and into a transcript. listwallets, getwalletinfo and listdescriptors without
arguments answer the question completely. If any output unexpectedly contains a string starting
with xprv, stop immediately, do not paste it, and report only that it occurred.
If a wallet with private_keys_enabled: true is found on any node, that is a finding: stop,
record it, and raise it as a blocker. It would mean the endpoint was invoked manually before this
plan deleted it, and that node's spending key is duplicated outside the Argon2 envelope. Do not
migrate, unload or modify it — D-07b withdrew the migration deliberately, and rewriting a wallet
that might hold funds is exactly the kind of decision that belongs to a human.
Deletion of the code path that would create such a wallet, plus the signing-posture record this census completes.
On each reachable fleet node, for the Bitcoin Core (or Knots) container:
1. ls -la /var/lib/archipelago/bitcoin/wallets/ 2>&1 — an absent directory is itself a complete answer for that node; paste it as-is.
2. bitcoin-cli listwallets (via podman exec into the container, matching however that node runs it).
3. For each wallet name returned: bitcoin-cli -rpcwallet=<name> getwalletinfo and record private_keys_enabled, descriptors, walletname and balance.
4. For any wallet with private_keys_enabled: true: bitcoin-cli -rpcwallet=<name> listdescriptors — with NO second argument. Record only the descriptor prefixes (wpkh(...), never a full key string.
5. Record the node label, the Bitcoin app in use (Core or Knots) and its version from bitcoin-cli getnetworkinfo | head.
6. Paste the output for every node checked, and list explicitly any fleet node that was NOT checked and why.
<acceptance_criteria>
- Every reachable fleet node has a row in the census table with wallets_dir_present, listwallets output and, per wallet, private_keys_enabled. Unreachable nodes are listed as UNCHECKED with the reason, never omitted.
- No command run in this task included listdescriptors true, and no output containing a key string was pasted. The operator confirms this explicitly.
- If any wallet reported private_keys_enabled: true, the phase raises a blocker naming the node label and the wallet name, and the plan does NOT proceed to close KEY-03 until that is decided by a human.
- If no such wallet is found, docs/security/KEY-03-SIGNING-POSTURE.md records F-13 as closed by deletion with no migration required, and names the nodes that evidence it.
</acceptance_criteria>
Paste the per-node census output, then type "approved" — or name the node and wallet if a private-key-bearing wallet was found.
The fleet's Core wallet state is recorded rather than assumed, and any private-key-bearing wallet is surfaced as a blocker instead of being silently migrated.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
Daemon encrypted envelope -> Bitcoin Core wallet.dat |
The boundary F-13 crosses. wallet.dat lives in the Bitcoin container's data volume with no Argon2 passphrase (the wallet was created with an empty one, bitcoin.rs:205). Task 1 removes the only code that crosses it. |
Daemon -> LND REST (/v2/wallet/psbt/*) |
Macaroon-authenticated, loopback. The PSBT that crosses it is public data; the keys that sign it are LND's. |
| Node -> external signer (offline) | The air-gap boundary. Whether it can be crossed at all depends on which party holds the input keys — the question Task 2 must answer with evidence. |
| Operator terminal -> census output | listdescriptors true would print an xprv into a transcript; the census must not create the exposure it is measuring. |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-10-41 | Information disclosure | BIP-84 account xprv persisted in Core's wallet.dat outside the Argon2 envelope (F-13) |
high | mitigate | Task 1 deletes the only code path that writes it; Task 3 confirms no node already holds such a wallet |
| T-10-42 | Information disclosure | A census command prints a private key into a terminal and a transcript | high | mitigate | listdescriptors true is banned by name in the task; the acceptance criterion requires the operator to confirm it was not run; a key-looking string aborts the paste |
| T-10-43 | Tampering | An automated migration rewrites a wallet that may hold real funds | high | mitigate | D-07b withdrew the migration; Task 3 stops on a finding and escalates to a human instead |
| T-10-44 | Spoofing | An external signer refuses a PSBT because it carries no key origin, and the failure surfaces as an opaque error | medium | mitigate | Task 2 inspects the PSBT and reports all_inputs_have_key_origin on the response plus a warn log, so the condition is named before the user reaches the signer |
| T-10-45 | Repudiation | Documentation claims air-gapped custody the implementation does not deliver | high | mitigate | Task 2 requires a recorded, evidence-backed verdict on whether an external signer can sign a default node's PSBT at all, plus the Lightning-keys honesty subsection; the PSBT-SIGNING-ARCHITECTURE banner records that its Phase 1 was superseded rather than delivered |
| T-10-46 | Denial of service | The new PSBT inspection breaks a user's on-chain send | medium | mitigate | The report is computed best-effort and degrades to null; handle_lnd_finalize_psbt and handle_lnd_create_raw_tx are untouched, asserted by a diff-scoped acceptance criterion |
| T-10-47 | Elevation of privilege | Deleting a dispatcher arm changes the reachable RPC surface unexpectedly | low | accept | The removed method was authenticated and additionally password-gated and had no caller; the no-caller search is re-run as an acceptance criterion rather than inherited |
| T-10-SC | Tampering | npm/pip/cargo installs | low | accept | No dependency is added; an empty git diff core/archipelago/Cargo.toml is an acceptance criterion. Executor MUST halt and raise a checkpoint rather than adding a crate to satisfy the PSBT parsing. |
| </threat_model> |
<artifacts_this_phase_produces>
Artifacts this plan produces
Removed:
| Symbol | Kind | Location |
|---|---|---|
handle_bitcoin_init_wallet_from_seed |
async fn | core/archipelago/src/api/rpc/bitcoin.rs:161-295 |
"bitcoin.init-wallet-from-seed" |
dispatch arm | core/archipelago/src/api/rpc/dispatcher.rs:122-124 |
New in core/archipelago/src/api/rpc/lnd/wallet.rs:
| Symbol | Kind | Signature |
|---|---|---|
PsbtKeyOriginReport |
struct | { input_count: usize, inputs_with_key_origin: usize, all_inputs_have_key_origin: bool } |
psbt_key_origin_report |
fn | fn psbt_key_origin_report(psbt_base64: &str) -> anyhow::Result<PsbtKeyOriginReport> |
Changed RPC response contract: lnd.create-psbt gains
key_origin: { input_count, inputs_with_key_origin, all_inputs_have_key_origin } | null.
Existing fields (psbt_base64, change_output_index, total_amount_sats,
fee_rate_sat_per_vbyte) are unchanged; the field is additive.
Changed attribute: crate::seed::derive_bitcoin_xprv gains #[allow(dead_code)] with a
doc line naming D-07c as the reason it is retained.
New file: docs/security/KEY-03-SIGNING-POSTURE.md — headings
## Bitcoin Core wallet path — deleted (D-07b), ## LND PSBT round trip — what is covered,
## Fleet census — Core descriptor wallets.
Modified: docs/security/PSBT-SIGNING-ARCHITECTURE.md — status banner only.
</artifacts_this_phase_produces>
<success_criteria>
- No code path in the daemon writes the BIP-84 account private key into Bitcoin Core.
derive_bitcoin_xprvsurvives with its tests and a written reason (D-07c).lnd.create-psbtreports key-origin presence, backed by programmatically-built test fixtures.- The signing-posture document states, with
file:line, which round-trip steps exist, which are tested, whether an external signer can sign a default node's PSBT at all, and that Lightning channel/revocation/HTLC keys are not air-gappable. - The fleet census is recorded per node, with any private-key-bearing wallet raised as a blocker. </success_criteria>