Files
archy/docs/security/KEY-03-SIGNING-POSTURE.md
T
archipelagoandClaude Opus 5 9622926868 fix(10-05): delete the Bitcoin Core wallet path that duplicated the spending key (F-13, D-07b)
`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>
2026-08-02 10:08:14 -04:00

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.md D-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.dat is modified, unloaded or removed. No funds move. No LND state, secret, descriptor or seed is altered. The change removes a Rust function and a match arm — 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 why lnd.create-psbt now 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.