`handle_bitcoin_init_wallet_from_seed` derived the BIP-84 account extended *private* key, stringified it, and imported `wpkh(xprv/0/*)` / `wpkh(xprv/1/*)` into a Bitcoin Core descriptor wallet created with `disable_private_keys=false` and an empty passphrase. That put a second copy of the node's spending key in Core's `wallet.dat`, outside the daemon's Argon2 + ChaCha20-Poly1305 envelope. That duplication into weaker protection was audit finding F-13 (High). Deleted rather than rewritten watch-only (D-07b supersedes D-07/D-07a): - No caller anywhere. Repo-wide search leaves exactly one occurrence of the method name (its own dispatcher registration) and two of the symbol in code (definition + dispatch call); every other hit is prose in docs. - LND is the wallet the product drives. Across neode-ui/src every `bitcoin.*` call is read-only status (getinfo/prune-status/onion); the wallet UI sends via `lnd.sendcoins`. - It never ran on archi-dev-box: no wallet named `archipelago` exists there, and the one loaded wallet reports blank=true, keypoolsize=0, txcount=0. - It was authenticated AND password-gated, so F-13 was key-at-rest duplication, not an exposed endpoint. No migration is performed and none is planned. This removes code, not wallets: nothing on disk is touched, no funds move, no wallet.dat is modified. If a node is ever found holding a wallet this handler created, that is a finding to surface and stop on, not a trigger to auto-migrate. `seed::derive_bitcoin_xprv` loses its only non-test caller and is retained deliberately with `#[allow(dead_code)]` and a stated reason: it keeps its existing test coverage and it is the derivation D-07c's deferred BDK cold vault will need. Records the evidence, the D-08/D-09 consequences and the D-07c deferral in docs/security/KEY-03-SIGNING-POSTURE.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
10 KiB
KEY-03 — Signing posture after the Bitcoin Core wallet deletion
What this document is. The evidence-backed record of how Archipelago's Bitcoin signing posture stands after Phase 10 KEY-03. It supersedes, for the Bitcoin Core wallet specifically, the target state described in
docs/security/PSBT-SIGNING-ARCHITECTURE.md§8 Phase 1 — that phase planned to convert Core's wallet to watch-only; D-07b deleted the path instead.Governing decisions:
.planning/phases/10-key-material-hardening/10-CONTEXT.mdD-07b (final KEY-03 scope — delete, do not migrate) and D-07c (the deferred BDK cold vault, recorded so it is not lost with the code). D-07b supersedes D-07 and D-07a's conditional migration.Audit finding closed: F-13 (High) —
docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md:604, remediation register R-04.
Bitcoin Core wallet path — deleted (D-07b)
What was deleted
| Symbol | Kind | Location before deletion |
|---|---|---|
handle_bitcoin_init_wallet_from_seed |
async fn |
core/archipelago/src/api/rpc/bitcoin.rs:161-295 |
"bitcoin.init-wallet-from-seed" |
JSON-RPC dispatch arm | core/archipelago/src/api/rpc/dispatcher.rs:122-124 |
The defect (F-13)
The handler loaded the encrypted seed, derived the BIP-84 account extended private key
(crate::seed::derive_bitcoin_xprv, bitcoin.rs:188), stringified it (:189), and imported
wpkh(xprv/0/*) and wpkh(xprv/1/*) (:230-231) into a Bitcoin Core descriptor wallet created
with disable_private_keys = false (:203) and an empty wallet passphrase (:205).
The result was a second copy of the node's spending key, persisted in Core's wallet.dat
inside the Bitcoin container's data volume, with no Argon2 passphrase — while the first copy sits
in the daemon's Argon2 + ChaCha20-Poly1305 envelope written 0600
(core/archipelago/src/seed.rs:238-269, :318-324). That duplication, into weaker protection,
was the entire finding.
Evidence that deletion was the right close (re-established for this task, not inherited)
The four D-07a evidence points, verified again against the tree before anything was removed:
1. No caller anywhere. Repo-wide search across core/, neode-ui/src, scripts/, web/,
apps/, tests/ and docs/, excluding core/target, node_modules and .git:
$ 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 one occurrence of the method name (its own dispatcher registration) and two of the symbol in code (its definition and the dispatcher call). The three remaining symbol hits are prose in documentation — the audit, the task tracker, and the PSBT architecture spec — not callers. No frontend, script, test or other Rust module invoked it.
2. LND is the wallet the product actually drives. Across all of neode-ui/src, every
bitcoin.* RPC call is read-only status: bitcoin.getinfo (14 call sites),
bitcoin.prune-status (3), bitcoin.onion (1). There are no bitcoin.* wallet operations.
The wallet UI (Web5Wallet.vue, SendBitcoinModal.vue) sends via lnd.sendcoins, estimates via
lnd.estimatefee, and reads balance via lnd.getinfo.
3. The wallet it creates never existed on the reference node. Verified live on
archi-dev-box, 2026-08-02, against the running bitcoin-knots container (read-only RPCs
only — see the census section for the exact commands and the standing ban on
listdescriptors true):
listwalletdir → { "wallets": [ "gatewayd-02004b91…", "gatewayd-03443c0c…", "" ] }
listwallets → [ "" ]
There is no wallet named archipelago — the handler's default wallet_name
(bitcoin.rs:170-173). It has never run on this node. getwalletinfo on the one loaded wallet
(the unnamed default) reports:
walletname: "" blank: true keypoolsize: 0
txcount: 0 balance: 0.00000000
descriptors: true private_keys_enabled: true
blank: true with keypoolsize: 0 and txcount: 0 is Bitcoin Core's own statement that no
key was ever imported into it and no transaction ever touched it. The two gatewayd-* entries
are Fedimint gateway wallets, unrelated to the BIP-84 path. The wallet.dat at the datadir root
is Core's own legacy default-wallet location, not this handler's output.
This is one node. archi-dev-box is verified; the rest of the fleet is UNVERIFIED pending the census below.
Supporting history evidence: git log -S "init-wallet-from-seed" scoped to
core/archipelago/src/api/rpc/dispatcher.rs and neode-ui/src returns exactly one commit —
19dcfd4f feat: BIP-39 master seed for unified key derivation, the commit that added it. No
frontend wrapper was ever written: it was built and never wired up.
4. It was never remotely reachable. The endpoint is absent from UNAUTHENTICATED_METHODS
(core/archipelago/src/api/rpc/middleware.rs:5-40) — so it required an authenticated session —
and it additionally re-verified the user's password before touching the seed
(self.auth_manager.verify_password(password), bitcoin.rs:176-179). F-13 was therefore
key-at-rest duplication, not an exposed endpoint. That is why it was rated High rather than
Critical, and why deleting it is a hardening measure rather than an incident response.
What was not wrong with it
Worth stating so the record is fair, and so the next reader does not mistake the lesson. The
in-memory handling of the xprv string was careful: it was zeroized on the error path
(bitcoin.rs:222) and on the success path (:284), matching the standard set elsewhere in
seed.rs. The wallet type was also correct — createwallet already passed descriptors = true
(:207), which is the right foundation.
The defect was which key went into the wallet, not how the key was held in memory or what kind
of wallet it was. A watch-only rewrite (xpub + [fingerprint/derivation] key origin) would
have been a legitimate fix. Deletion was chosen over rewrite because the endpoint had no caller,
no consumer, and no product role: rewriting it would have produced a correct implementation of
something nothing uses, and left a wallet-creating code path to be maintained and re-audited
forever.
How F-13 is closed
By removal, not by conversion to watch-only. After this change there is no code path in the daemon that writes the BIP-84 account private key into Bitcoin Core. The only on-node copy of that key is the daemon's Argon2 + ChaCha20-Poly1305 envelope.
No migration was performed and none is planned. D-07's parity-proof migration and its one-way checkpoint are withdrawn (D-07b) — there is no wallet to migrate. If a fleet node is ever found holding a descriptor wallet this handler created, that is a finding to surface and stop on, not a trigger to auto-migrate: it would mean the endpoint was invoked by hand and that node's spending key is duplicated in Core, which deserves a human decision rather than an automated rewrite of a wallet that may hold funds.
This deletion removes code, not wallets
Stated explicitly so nobody reading the change later has to wonder whether it was destructive:
Nothing on disk is touched. No
wallet.datis modified, unloaded or removed. No funds move. No LND state, secret, descriptor or seed is altered. The change removes a Rust function and amatcharm — the path by which a private key could be imported into Bitcoin Core — and nothing else.
This holds even on a hypothetical node where the endpoint had been invoked by hand: deleting the handler destroys nothing there either. It closes the door; it does not clean the room. Cleaning up such a wallet, if one is ever found, is a separate human decision (see the census below), and CLAUDE.md's "migrations never destroy data" invariant is not engaged by this change because there is no migration.
What deletion does to D-08 and D-09
Neither decision lapses; both are satisfied by a different mechanism.
- 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 opt-in path is LND's existing PSBT round trip, not a Core watch-only wallet — see the next section, including the recorded verdict on how far that actually goes today.
- 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. That is whylnd.create-psbtnow inspects and reports the key-origin data its PSBT carries (psbt_key_origin_report,core/archipelago/src/api/rpc/lnd/wallet.rs).
derive_bitcoin_xprv is retained deliberately (D-07c)
crate::seed::derive_bitcoin_xprv (core/archipelago/src/seed.rs:231) lost its only non-test
caller and was kept, marked #[allow(dead_code)] with the reason in its doc comment. It is
covered by existing tests (seed.rs:601-602, :856) and it is the derivation D-07c's deferred
BDK cold vault — a descriptor wallet in the daemon using the node's own ElectrumX app
(apps/electrumx, electrs_status.rs) as chain source — will need.
D-07c was considered and deliberately deferred out of Phase 10 (it needs its own phase: a new
dependency and a new UI surface). It is recorded here, and in the function's doc comment, so the
option is not quietly lost along with the code that was deleted. The alternative shape — LND
watch-only via importaccount plus remote signing — was considered and rejected for coupling
cold storage to LND's upgrade path.