docs(quick-260731-upz): PSBT-first signing architecture spec

Watch-only descriptor wallets, external signers, multisig, air-gap transport
and honest LND limits — a spec a future /gsd-plan-phase can consume.

- Names the current gap: bitcoin.rs:203 passes disable_private_keys=false and
  bitcoin.rs:229-231 imports wpkh(xprv/...), so Core holds the BIP-84 account
  PRIVATE key today. Closing that is Phase 1 and unblocks everything else
- Full Core RPC loop with wallet- vs node-scoped RPCs; analyzepsbt drives UI
- Tier 1 single-sig with mandatory [fingerprint/derivation] key origin; Tier 2
  wsh(sortedmulti) on BIP-48; taproot/MuSig2 deferred as UNVERIFIED
- BC-UR v2 primary (fountain-coded, degrades gracefully), BBQr for Coldcard,
  file fallback always; animated multi-frame is mandatory, not optional
- LND: channel/revocation/HTLC keys CANNOT be air-gapped; funding tx must
  NEVER be self-broadcast (type-level refusal, not a boolean)
- On-chain (PSBT-protectable) vs lightning (necessarily hot) split, with the
  exact user-facing sentence the UI must use
- Hot wallet kept as explicitly-secondary with server-enforced limits; safe
  path is the DEFAULT, per T1's survivors
- Migration section refuses to over-alarm: the audit found no entropy defect,
  so no Archipelago user needs to rotate a seed
- 7 phases with dependencies, candidate requirements, and hardware gating
- Answers two open items in docs/hardware-signer-design.md
- Flags bitcoin-knots:latest as an unpinned tag vs ADR-009

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-31 23:09:17 -04:00
co-authored by Claude Opus 5
parent f11db4ea1d
commit 5faf1a3c5f
+592
View File
@@ -0,0 +1,592 @@
# PSBT-First Signing Architecture
> **Status: specification.** No implementation. This document defines a target architecture and
> a phased rollout that a future `/gsd-plan-phase` can consume directly. It deliberately
> contains no code, adds no dependencies, and changes no wallet or signing behaviour.
>
> **Companion document:** `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` — the entropy and
> seed-generation audit that motivated this spec. **Cross-linked design:**
> `docs/hardware-signer-design.md` — the exploratory TROPIC01 air-gapped signer, which this
> architecture treats as the future *first-party* signer, not as a competing design.
**Provenance rules used throughout.** Every architectural claim is grounded in either (a) a
`file:line` from this tree, or (b) RESEARCH.md Part C
(`.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md`,
which cites Bitcoin Core `doc/psbt.md`, `doc/descriptors.md`, `doc/multisig-tutorial.md`, the
Core 30.0 release notes, LND `docs/remote-signing.md` and `docs/psbt.md`). Anything from
neither is marked `[UNVERIFIED]`.
---
## 0. Why this document exists
The 2026-07-30 Coinkite COLDCARD entropy incident swept ~1,082 BTC from ~1,195 addresses. The
Archipelago-specific reading is in the audit; the design-relevant lesson is narrower and is the
organising principle of this spec:
> **T1's survivors were the users who took the *optional* extra step.** Users who rolled dice
> contributed ≥128 bits independently of the broken RNG and were not at risk. The safe path
> existed the whole time; it just was not the default.
Everything below follows from that. The safe path (watch-only + external signer + PSBT) must be
the **default** and must feel like the normal way to use Archipelago, not an expert mode buried
behind a warning. The hot wallet is retained, deliberately, as an explicitly-secondary tier —
because a safe path users route around is not a safe path.
**Where the tree stands today (important, and not what the target says).**
`core/archipelago/src/api/rpc/bitcoin.rs:161-294` already creates a **descriptor** wallet
(`createwallet ... descriptors=true`, `:207`) — which is the right foundation — but it passes
`disable_private_keys = false` (`:203`) and imports `wpkh(xprv/0/*)` and `wpkh(xprv/1/*)`
(`:229-231`), i.e. **the BIP-84 account extended *private* key is imported into Bitcoin Core's
`wallet.dat`.** The node's spending key therefore lives in two places: the daemon's Argon2 +
ChaCha20-Poly1305 envelope (`core/archipelago/src/seed.rs:238-269`) *and* Core's wallet
database. The code is careful with the string in memory (`bitcoin.rs:189`, zeroized at `:222`
and `:284`), but the key itself is persisted by Core. Closing that gap is Phase 1 of the
rollout in §8, and it is the single highest-value change in this document.
---
## 1. Target architecture
### 1.1 Watch-only descriptor wallet on the node
The node runs a Bitcoin Core wallet that is **structurally incapable of signing**:
- Created with `createwallet` passing **`disable_private_keys = true`** and
`descriptors = true`. Note the ordering already used at
`core/archipelago/src/api/rpc/bitcoin.rs:200-208` — the second positional argument is
`disable_private_keys`, currently `false`.
- Populated with `importdescriptors`, using **public** descriptors only
(`wpkh([fingerprint/84h/0h/0h]xpub.../0/*)` and `.../1/*`).
Unsignability comes from the *absence of private key material*, not from a flag that could be
flipped. That is the correct construction and is why "watch-only" here means "descriptor wallet
with no private keys", not "a wallet we promise not to sign with".
**Descriptor-only from day one.** Bitcoin Core 30.0 removed the ability to create *or load* BDB
legacy wallets (RESEARCH §C.1). Nothing in this design may depend on a legacy wallet, on
`importmulti`, or on any of the 11 removed legacy RPCs. Archipelago is already descriptor-based
(`bitcoin.rs:207`), so this costs nothing to preserve and would be expensive to lose.
### 1.2 The loop, with the actual RPCs
| Step | RPC | Scope | Notes |
|---|---|---|---|
| 1. Construct + fund | `walletcreatefundedpsbt` | **wallet** | Runs on the watch-only wallet. Selects inputs, adds change, attaches the metadata the signer needs. |
| 2. Fill UTXO data (optional) | `utxoupdatepsbt` | node | Useful when the PSBT was built elsewhere or is missing witness UTXO data. |
| 3. Inspect | `analyzepsbt` | node | **Drive all UI state from this** — see §1.3. |
| 4. Export | — | — | Serialise to base64 / file / QR (§4). |
| 5. Sign (offline) | external signer | — | Hardware device, or `descriptorprocesspsbt` on an offline machine holding the descriptors. |
| 6. Import | — | — | Scan / upload the signed PSBT back. |
| 7. Merge signatures | `combinepsbt` | node | Multisig only: merges signatures for the **same** transaction from multiple signers. |
| 8. Merge transactions | `joinpsbts` | node | Different transactions into one. **Not** the multisig merge — a common and expensive confusion. |
| 9. Finalize | `finalizepsbt` | node | Produces the network-serialized transaction. |
| 10. Broadcast | `sendrawtransaction` | node | Except for LND channel funding — see §5. |
`walletprocesspsbt` (wallet-scoped) and `descriptorprocesspsbt` (node-scoped, takes a descriptor
list, **needs no wallet**) are the two signing entry points. `descriptorprocesspsbt` is the
right primitive for an offline signing machine that has descriptors but no wallet.
**Wallet-scoped vs node-scoped matters operationally**: wallet-scoped RPCs must be addressed to
the specific wallet endpoint (`/wallet/<name>`), node-scoped ones must not. Archipelago's
existing `bitcoin_rpc_call` helper (`core/archipelago/src/api/rpc/bitcoin.rs:191-210` usage)
will need an explicit wallet-scoping parameter rather than one global endpoint.
### 1.3 `analyzepsbt` drives the UI — do not infer state
`analyzepsbt` reports, per input, what is still missing and **which role must act next**
(updater / signer / finalizer). The UI must render from that, not from Archipelago's own guess
about how many signatures a 2-of-3 needs. Rationale: role inference is where coordinators get
multisig wrong, and the node already has an authoritative answer one RPC away. It also makes
the "what do I do now" screen correct for free in partial-signature states.
### 1.4 Versions this runs against
From the manifests, so the spec is not written against an imaginary node:
| App | Manifest version | Image |
|---|---|---|
| Bitcoin Core | `28.4.0` (`apps/bitcoin-core/manifest.yml:4`) | `bitcoin:28.4` (`:10`) |
| Bitcoin Knots | `28.1.0` (`apps/bitcoin-knots/manifest.yml:4`) | **`bitcoin-knots:latest`** (`:10`) |
| LND | `0.18.4` (`apps/lnd/manifest.yml:4`) | `lnd:v0.18.4-beta` (`:8`), requires Bitcoin `>=26.0` (`:25`) |
**Flagged, in scope to name and out of scope to fix:** `bitcoin-knots:latest`
(`apps/bitcoin-knots/manifest.yml:10`) is an **unpinned tag**, at odds with ADR-009's
pinned-tag mandate and with every other image in these three manifests. For a wallet-bearing
component, an unpinned tag means the descriptor/PSBT RPC surface underneath a user's funds can
change on a `podman pull`. Fixing it belongs to whoever owns ADR-009 enforcement.
**PSBTv2 / BIP-370** is merged into Bitcoin Core (RESEARCH §C.1). **`[UNVERIFIED]`** — which
released version first exposes it at the RPC surface, and how broadly hardware signers accept
it, was not confirmed. **Build against PSBTv1 as the interop baseline**; treat v2 as
opportunistic and never as a requirement for a user to spend their money.
---
## 2. Where each step lives
Three surfaces, one non-negotiable invariant.
### 2.1 The invariant
> **The BIP-84 private key stays in the daemon's encrypted store. Only the xpub goes into the
> Core descriptor wallet. The private key is never imported into Core.**
Today this is violated (`core/archipelago/src/api/rpc/bitcoin.rs:229-231`, §0). The at-rest
envelope that should hold it exclusively already exists and is sound: Argon2 + ChaCha20-Poly1305
with per-blob salt and nonce from `OsRng`, written `0600`
(`core/archipelago/src/seed.rs:238-269`, `:243-246`, `:318-324`).
### 2.2 Rust orchestrator — `core/archipelago`
Owns everything that touches keys or Core:
- Derives the BIP-84 account key (`core/archipelago/src/seed.rs:207-224`, path `m/84'/0'/0'`)
and exports **only** the account-level xpub plus its key-origin fingerprint into descriptors.
- Creates and maintains the watch-only wallet (rewrite of
`handle_bitcoin_init_wallet_from_seed`, `core/archipelago/src/api/rpc/bitcoin.rs:161-294`).
- Owns the PSBT lifecycle RPCs: construct, analyze, combine, finalize, broadcast.
- Owns the *internal* software-signer path used by the hot tier (§6), which decrypts the seed
under the user's password exactly as `bitcoin.rs:182-185` does today, signs, and zeroizes.
- Enforces spend limits server-side (§6). **Limits enforced in the UI are not limits.**
### 2.3 `neode-ui`
Owns presentation and transport only. It must never see a private key, an xprv, or a mnemonic
outside the onboarding flow the audit already scopes (F-04, F-08).
- Renders the PSBT review screen: inputs, outputs, fee, change, and the `analyzepsbt` "next
role" state.
- Renders the export payload as animated QR (§4) and offers file download.
- Accepts the signed PSBT by camera scan or file upload.
- Renders the cold / warm / hot tier badges (§6) and the honest Lightning copy (§5.4).
### 2.4 Companion app
Owns the air-gap camera path. It already has the two pieces this needs:
- A working QR scanner (project memory: native scan shipped in companion 0.5.22; dense-QR fix
`07772b56`).
- SeedQR encode/decode (`neode-ui/src/utils/seedqr.ts:11`), with a correct, honest note at
`:9` that the LND aezeed is **not** BIP-39 and must never be SeedQR-encoded.
The companion is the natural home for scan-heavy multi-frame PSBT transport, because the node's
own browser may be a TV kiosk with no camera.
---
## 3. Tiers
### 3.1 Tier 1 — single-sig with an external hardware signer
- Descriptor: `wpkh([<fingerprint>/84h/0h/0h]xpub.../0/*)` and `.../1/*`.
- **Key-origin annotation `[fingerprint/derivation]` is mandatory, not cosmetic.** Without it a
hardware signer cannot locate its own key in the PSBT and will refuse to sign (RESEARCH §C.2).
Every descriptor Archipelago emits must carry it. The current code emits descriptors with **no
key-origin prefix** (`core/archipelago/src/api/rpc/bitcoin.rs:230-231`) — a second concrete
reason Phase 1 must rewrite that function.
- Descriptor checksums: obtain via `getdescriptorinfo` before `importdescriptors`, as the
existing code correctly already does (`bitcoin.rs:234-259`). Core rejects a wrong checksum.
### 3.2 Tier 2 — `wsh(sortedmulti(k, ...))` multisig
- Script: `wsh(sortedmulti(k, xpub1/…, xpub2/…, xpub3/…))`.
- **Why `sortedmulti` over ordered `multi`:** `sortedmulti` (BIP-67) lexicographically sorts the
keys in the resulting script, so the wallet can be **recreated without preserving xpub order**.
With ordered `multi`, losing the order loses the wallet even though every key survives — a
recovery failure mode that is entirely avoidable. Use `sortedmulti` unless a specific
cosigner demands ordered `multi`.
- **BIP-48 derivation** for multisig accounts: `m/48'/<coin>'/<account>'/<script_type>'`, with
`2'` = P2WSH. Every coordinator (Sparrow, Nunchuk, Caravan, Specter) expects this path; using
anything else means users cannot import their Archipelago multisig anywhere else.
- Descriptor exchange: each cosigner contributes an xpub **with key origin**; the coordinator
assembles the descriptor and every participant imports the identical descriptor string. All
participants must be able to export the descriptor for backup — a multisig backup is the
descriptor plus each seed, and users who back up only seeds lose funds.
- Reference to copy rather than re-derive: Bitcoin Core's `doc/multisig-tutorial.md` and the
functional test `test/functional/wallet_multisig_descriptor_psbt.py`, which is the exact RPC
sequence in executable form (RESEARCH §C.3).
### 3.3 Taproot / MuSig2 multisig — future work, deliberately
`tr(...)` descriptors exist, but **`[UNVERIFIED]`** — the 2026 state of MuSig2 key-aggregation
support in Core's descriptor wallets and across hardware signers was not confirmed (RESEARCH
§C.3, Open Question 4). Shipping a multisig scheme whose recovery depends on unconfirmed
signer support is how users lose money years later. **Ship `wsh(sortedmulti(...))`.** Revisit
taproot multisig when Core's support and at least two independent hardware signers can be
verified against a real device.
---
## 4. Air-gapped transport
### 4.1 The format decision
| Format | Mechanism | Verdict |
|---|---|---|
| **BC-UR v2** (Blockchain Commons) | **Fountain-coded** (rateless erasure). Any sufficient subset of frames reconstructs the payload; order-independent. | **Recommended primary.** |
| **BBQr** (Coinkite) | Payload split across sequential frames; receiver accumulates and must obtain each missing frame. | Support for Coldcard interop; not the primary. |
| microSD / file (`.psbt`) | Plain file exchange. | **Mandatory fallback, always offered.** |
| SeedQR | Static QR of mnemonic word indices. | **Seed transport only, not PSBT.** Already shipped (`neode-ui/src/utils/seedqr.ts:11`). |
**Recommendation: BC-UR v2 as primary, BBQr for Coldcard interop, file always available.**
The justification is specific to Archipelago's hardware reality rather than generic. The
companion app scans QR from a phone camera, frequently at a TV or in a rack cupboard, in poor
light. BBQr's sequential model means a single missed frame stalls the user until that exact
frame comes round again — the failure mode is "keep pointing the camera and hope". BC-UR's
fountain coding means *any* sufficient number of frames reconstructs the payload, so a bad
scanning environment degrades into "takes longer" instead of "gets stuck". That difference is
what makes an air-gap workflow tolerable enough that users keep using it — which, per §0, is
the whole point.
**`[UNVERIFIED]`** — device support matrix. Confirmed from RESEARCH §C.4: Coldcard → BBQr
(native) + microSD + NFC; Foundation Passport and Keystone → UR; SeedSigner → BC-UR v2. Jade,
Krux, BitBox, Ledger and Trezor support was **not** confirmed and must be verified against real
hardware before any of them is listed as supported in the UI.
### 4.2 QR density — animated is mandatory, not a nice-to-have
A QR code maxes out around ~2,953 bytes at the largest version with the lowest error correction,
and far less at densities a phone camera can actually read across a room. **A real multi-input
multisig PSBT routinely exceeds that.** Therefore:
- **Multi-frame animated QR is mandatory.** Single-QR PSBT export must not be the only path.
- **A file fallback must always be offered**, on every export screen, with equal visual weight.
microSD/file has no density limit and is the most reliable route for large PSBTs.
- The UI must show frame progress (e.g. "142 of 210 frames received") so a stalled scan is
visibly stalled rather than mysteriously slow.
### 4.3 Consistency with the first-party signer
`docs/hardware-signer-design.md` specifies a QR-only, camera-in/screen-out air-gapped signer
(TROPIC01 + ESP32-S3), and lists "Animated/multi-part QR strategy for large PSBTs" as an open
item (`docs/hardware-signer-design.md:167`) and "Define QR payload formats for both roles" at
`:165`. **This document answers both for Bitcoin: BC-UR v2 primary, BBQr for Coldcard interop.**
That signer, when built, should implement the same format so the same node-side transport code
serves third-party signers and the first-party device identically. Its dual Nostr-signing role
(`docs/hardware-signer-design.md:110-148`) is out of scope here but shares the transport layer,
which is an argument for implementing transport as a payload-agnostic module.
---
## 5. LND — what is and is not achievable
### 5.1 Decision table
| Capability | Achievable? | Detail |
|---|---|---|
| Watch-only `lnd` + separate signer instance | **Yes** | `remotesigner.*` on the watch-only node; the signer needs no chain backend (`bitcoin.node=nochainbackend`). |
| Signer fully offline | **No** | The signer must accept a **live inbound gRPC connection**. "Offline except for one connection" is not an air-gap. |
| Air-gap channel / revocation / HTLC keys | **No** | These live in the signer and must sign **on demand, at protocol speed**. A routing node cannot tolerate human-in-the-loop signing. **This is the hard limit of the entire design.** |
| PSBT funding of channels | **Yes** | `lncli openchannel --psbt`; `PsbtShim` via `FundingStateStep`; batch by passing the returned PSBT as `base_psbt`. |
| Open a channel with zero LND wallet balance | **Yes** | The `--psbt` flow explicitly supports funding from an external wallet. |
| **Self-broadcast the funding transaction** | **NEVER** | LND must publish it "in the proper funding flow order **or the funds can be lost**". Encode as a hard UI rule — see §5.3. |
| Sign arbitrary messages / on-chain txs externally | **Yes** | `signrpc` / `walletrpc` (`signer:generate`, `onchain:write`). |
| Move private keys between instances after init | **No** | Not supported. |
| Add accounts dynamically without wallet reconstruction | **No** | Not supported. |
Source: RESEARCH §C.5, from LND `docs/remote-signing.md` and `docs/psbt.md`.
### 5.2 Required accounts and the taproot gotcha
Remote signing requires xpubs for level-3 derivation accounts: purpose **49** (NP2WKH), **84**
(P2WKH), **86** (P2TR), and **1017** accounts 0-255 (node identity, channels, watchtower,
HTLCs). Setup is `lncli wallet accounts list > accounts-signer.json` on the signer, then
`lncli createwatchonly accounts-signer.json` on the watch-only node. A minimal signer macaroon
is `lncli bakemacaroon --save_to signer.custom.macaroon message:write signer:generate
address:read onchain:write`.
**Taproot gotcha:** requires LND v0.15.3-beta+ and a manual
`lncli wallet accounts import --address_type p2tr <xpub> default` on upgrade, or the node fails
with `"account 0 not found"`. Archipelago pins LND `0.18.4` (`apps/lnd/manifest.yml:4`), so the
version floor is satisfied; the manual import step is not automatic and must be part of any
migration runbook.
Migrating an existing node is `remotesigner.migrate-wallet-to-watch-only=true`, which **purges
private key material in place** — one-way, and therefore gated behind a verified backup.
### 5.3 The self-broadcast rule is a hard UI constraint
Archipelago already exposes `lnd.create-psbt` and `lnd.finalize-psbt`
(`core/archipelago/src/api/rpc/dispatcher.rs:136-137`,
implemented in `core/archipelago/src/api/rpc/lnd/wallet.rs:605` and `:711`), and the finalize
handler already broadcasts (`core/archipelago/src/api/rpc/lnd/wallet.rs:757`). That is correct
for an **on-chain** send and **catastrophic** for a channel-funding PSBT.
**Rule:** any PSBT produced by the channel-funding flow must be tagged as such end-to-end, and
every broadcast path must refuse to broadcast a channel-funding PSBT. The refusal belongs in the
Rust orchestrator, not in the UI, and it should be a type-level distinction (a distinct
`ChannelFundingPsbt` wrapper) rather than a boolean anyone can forget to check. This is the one
place in this document where a mistake destroys funds rather than exposing them.
### 5.4 On-chain vs Lightning — two genuinely different tiers
The design splits cleanly, and the split must be visible to users:
| | **On-chain balance** | **Lightning balance** |
|---|---|---|
| Key exposure | Can be fully cold — key never on the node | **Necessarily hot** — channel/revocation/HTLC keys must sign at protocol speed |
| Protection mechanism | Watch-only descriptors + PSBT + external signer | Remote signing *relocates* keys to a hardened host; it does not remove hot exposure |
| Honest claim | "Cold storage" is accurate | "Cold storage" is **false** |
**The exact sentence the UI should use:**
> *A Lightning routing node's channel keys are necessarily hot. Remote signing moves them to a
> hardened machine; it does not make them cold. Only your on-chain balance can be genuinely
> protected by an offline signer.*
**Any copy implying a routing node's channel keys are cold is misleading and must not ship.**
This is not pedantry: a user who believes their Lightning balance is cold will keep more in it
than they would otherwise, which is precisely the miscalibration that turns an incident into a
loss. The Coldcard incident is a good reason to be conservative in this copy rather than
optimistic.
---
## 6. The hot wallet as the explicitly-secondary option
The hot wallet stays. Removing it would push users to worse tools. It is framed, limited, and
labelled as secondary.
1. **Hard separation of on-chain and Lightning balances** in the data model and in the UI.
**Never one blended number.** They have different key exposure (§5.4), different recovery
stories, and different risk. A single "balance" figure silently averages a cold number with a
hot one, which is a lie of composition.
2. **Server-enforced spend limits.** Per-transaction and rolling-daily, enforced in the Rust
orchestrator. Anything above the limit is **forced onto the PSBT path** — not blocked, not
warned-and-allowed: routed. Archipelago already rate-limits financial RPCs
(`core/archipelago/src/rate_limit.rs:62-69`: `wallet.send` 5/300s, `lnd.sendcoins` 5/300s,
`lnd.openchannel` 3/300s), so the enforcement point exists; value limits are the addition.
3. **Reuse the existing at-rest envelope.** Argon2 + ChaCha20-Poly1305, per-blob salt and nonce
from `OsRng`, `0600` (`core/archipelago/src/seed.rs:238-269`, `:318-324`). Do not invent a
second envelope. See audit finding **F-05** on aligning the Argon2 parameters with ADR-005
before this tier carries meaningful value.
4. **Zeroization on every path.** The existing code is the standard to match:
`core/archipelago/src/seed.rs:262`, `:292`, `:384`, `:401`;
`core/archipelago/src/api/rpc/bitcoin.rs:222`, `:284`.
5. **Explicit tiering in the UI**, named rather than hidden:
- **Cold** — watch-only + external signer. On-chain only. The default for new wallets.
- **Warm** — hot on-chain key in the daemon's envelope, under spend limits.
- **Hot** — Lightning. Unavoidably hot; labelled as such.
### 6.1 Nudging toward PSBT without punishing the hot path
The failure mode to avoid is a safe path so tedious that users disable it, and a hot path so
nagged-at that users stop reading warnings. Concretely:
- **Default new wallets to cold.** Do not make the user opt in to safety. This is the direct
lesson of §0.
- **One-time framing, not per-transaction nagging.** Explain the tiers once, at setup, and then
show a small persistent tier badge. Repeated modal warnings train users to dismiss modals.
- **Make the limit the teacher.** When a spend exceeds the warm limit, route it to the PSBT
flow with a neutral explanation ("this amount uses your signing device") rather than an error.
The user learns the tier boundary by using it.
- **Never make the hot path feel broken.** A small Lightning payment should be one tap. If
everyday use is painful, users move their funds to software that does not have any of this.
- **Let the user raise limits, deliberately.** A limit the user cannot adjust gets worked around
entirely; a limit they must consciously raise is a decision they remember making.
---
## 7. Migration for existing users
### 7.1 What the incident does and does not imply here
**Be precise, because both errors are costly.**
- **A software fix does not repair an already-generated seed.** If a seed was produced by a
defective RNG, updating the software leaves it exactly as guessable. This is why Coinkite told
users to migrate rather than merely update.
- **The audit found no such defect in Archipelago.** `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`
§2 and §4 record that every first-party key-generation call site draws from a genuine CSPRNG,
that the mnemonic is a real 256-bit value, and that `[ARCHY-1]` is a *structural* risk with no
present exploitability.
**Therefore: no Archipelago user needs to rotate their seed because of the COLDCARD incident.**
Do not ship a banner implying otherwise. Over-alarming has a real cost — it triggers unnecessary
fund movements, which have their own fee, privacy, and fat-finger risks, and it burns the
credibility needed for a real advisory later.
**Who this section *does* apply to:**
1. **Users whose seed was generated on a Coldcard and imported into Archipelago**, on affected
firmware. Their seed is at risk from T1, independent of Archipelago's own code quality. They
should follow Coinkite's guidance and the sequence in §7.2.
2. **Every user, at the point Phase 1 lands** — because the account xprv is currently imported
into Bitcoin Core (`core/archipelago/src/api/rpc/bitcoin.rs:229-231`, §0). Moving to
watch-only does not require a new seed; it requires re-creating the Core wallet without
private keys. That is a *wallet* migration, not a *key* migration, and it must be presented
as such — see §7.3.
### 7.2 Seed-rotation sequence (only when a seed is actually suspect)
Order matters; each step de-risks the next.
1. **Generate a new key** on trusted, fixed hardware or software.
2. **Verify the backup** — restore it into a second wallet and confirm it reproduces the same
first receive address before sending anything.
3. **Verify a receive address** on the signing device's own screen, not only on the host.
4. **Send a small test transaction** to the new wallet and confirm it arrives and is spendable.
5. **Migrate the funds** from the old wallet to the new one.
6. **Retain the old backup** until every output is confirmed spent and the new wallet's balance
is verified. Destroying the old backup early is the most common way this sequence loses money.
If Lightning is in use, closing channels is part of step 5 and is slow (force-closes carry
timelocks). Budget for it; do not present channel migration as instantaneous.
### 7.3 Wallet migration to watch-only (Phase 1) — *not* a seed rotation
For every existing user, when Phase 1 lands:
1. Confirm the encrypted seed backup exists and is decryptable
(`core/archipelago/src/seed.rs:341-357`, `seed_exists` at `:360-362`).
2. Derive the account xpub and build the key-origin-annotated descriptors.
3. Create a **new** wallet with `disable_private_keys = true` and import the public descriptors.
4. Rescan, and confirm the new watch-only wallet reports the **same balance and the same UTXO
set** as the old one. Do not proceed on any mismatch.
5. Only then unload and remove the private-key-bearing wallet from Core.
**The user's seed does not change and their funds do not move.** Say that plainly in the UI —
the natural user fear on seeing any wallet-migration prompt is that their money is being touched.
---
## 8. Phased rollout
Each phase names a goal, its dependencies, candidate requirements, and whether it needs real
hardware. This section is the input a future `/gsd-plan-phase` consumes.
### Phase 1 — Descriptor watch-only read path
**Goal:** the node's Bitcoin Core wallet holds no private keys; the daemon's encrypted store is
the only place the BIP-84 key exists.
**Dependencies:** none. **This is the highest-value change in the document and it unblocks
everything else** — no external-signer flow is meaningful while Core holds the xprv.
**Candidate requirements:**
- `createwallet` is called with `disable_private_keys = true` (currently `false`,
`core/archipelago/src/api/rpc/bitcoin.rs:203`).
- Imported descriptors carry the **xpub** and a key-origin annotation
`[fingerprint/84h/0h/0h]` (currently a bare xprv with no origin, `bitcoin.rs:229-231`).
- A migration path re-creates the wallet watch-only and verifies balance/UTXO parity before
removing the old wallet (§7.3).
- The account xprv is never written to Core and never leaves the Argon2 envelope except in
memory, zeroized.
- Regression test: the wallet cannot sign — a signing attempt against it fails structurally.
**Real hardware:** yes, for the migration — verify on a node with real UTXO history (`.228`).
### Phase 2 — PSBT construct and export
**Goal:** the node can build a funded PSBT from the watch-only wallet and hand it out.
**Dependencies:** Phase 1.
**Candidate requirements:**
- `walletcreatefundedpsbt` wired with explicit fee control, reusing the existing fee-preset UI.
- `analyzepsbt` exposed and used as the single source of UI state (§1.3).
- Export as base64 and as a `.psbt` file download.
- A PSBT review screen showing inputs, outputs, fee, change, and destination — the human check
the whole air-gap model depends on.
**Real hardware:** no (regtest/testnet sufficient).
### Phase 3 — External-signer import and finalize
**Goal:** a signed PSBT from a third-party signer completes the loop and broadcasts.
**Dependencies:** Phase 2.
**Candidate requirements:**
- Import a signed PSBT by file upload; `combinepsbt` where multiple parts arrive.
- `finalizepsbt` + `sendrawtransaction`, with the channel-funding refusal of §5.3 in place from
day one — not retrofitted.
- Clear error surfacing when `analyzepsbt` says signatures are still missing.
**Real hardware:** **yes** — must be verified end-to-end against at least one real signer
(Coldcard or Passport) before it is offered to users.
### Phase 4 — Air-gap transport (BC-UR v2 + BBQr)
**Goal:** the loop closes over QR, with a file fallback, in the companion app.
**Dependencies:** Phase 3.
**Candidate requirements:**
- BC-UR v2 encode (node) and decode (companion), fountain-coded, with visible frame progress.
- BBQr decode for Coldcard interop.
- File fallback offered with equal weight on every export and import screen (§4.2).
- Payload-agnostic transport module, so `docs/hardware-signer-design.md`'s Nostr role can reuse
it later without a rewrite.
**Real hardware:** **yes** — QR density and scan reliability cannot be evaluated in an emulator.
Verify at realistic distance and lighting, including the TV-kiosk case.
### Phase 5 — Multisig
**Goal:** `wsh(sortedmulti(k, ...))` wallets with BIP-48 paths and descriptor exchange.
**Dependencies:** Phase 4 (large multisig PSBTs are exactly the case that needs robust transport).
**Candidate requirements:**
- Create/import a `wsh(sortedmulti(...))` descriptor with per-key origin annotations.
- BIP-48 `m/48'/0'/<account>'/2'` derivation for Archipelago's own key.
- Descriptor export/backup UX that states plainly that the descriptor is part of the backup.
- `combinepsbt` across N signers with `analyzepsbt`-driven progress.
- Interop test against at least one external coordinator (Sparrow or Nunchuk).
**Real hardware:** **yes** — two independent signers minimum.
### Phase 6 — LND remote signing
**Goal:** LND runs watch-only with a separate signer instance, with honest UI copy.
**Dependencies:** Phase 1 (the on-chain story must be settled first; doing Lightning first would
teach users the wrong mental model).
**Candidate requirements:**
- Signer instance provisioning (`bitcoin.node=nochainbackend`, minimal macaroon) and watch-only
setup via `createwatchonly`.
- Explicit p2tr account import step (§5.2), or a documented failure with a fix-it action.
- `remotesigner.migrate-wallet-to-watch-only=true` migration, gated behind a verified backup —
it purges key material in place and is one-way.
- UI copy carrying the §5.4 sentence verbatim, and no copy anywhere claiming Lightning funds are
cold.
**Real hardware:** **yes** — two hosts, and a real channel.
### Phase 7 — Hot-wallet limits and tiering
**Goal:** the hot path is bounded, labelled, and routes large spends to PSBT.
**Dependencies:** Phase 3 (there must be a PSBT path to route *to*).
**Candidate requirements:**
- Server-enforced per-transaction and rolling-daily limits, with over-limit spends routed to the
PSBT flow rather than rejected (§6.1).
- On-chain and Lightning balances separated in the data model and never summed in the UI.
- Cold / warm / hot tier badges.
- New wallets default to cold.
**Real hardware:** no, beyond normal on-node verification.
### Sequencing note
Phases 1-4 are the spine and should run in order. Phase 6 (LND) and Phase 7 (limits) can run in
parallel with Phase 5 (multisig) once Phase 3 lands. Phase 1 alone materially improves the
current security posture and should not wait for the rest.
---
## 9. Related documents
- `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` — the audit motivating this spec; see F-05
(Argon2 parameters) and the F-13 addendum on the xprv-in-Core issue.
- `docs/hardware-signer-design.md` — the first-party TROPIC01 air-gapped signer; §4.3 above
answers two of its open items.
- `docs/adr/005-chacha20-backup-encryption.md` — the at-rest envelope §6 reuses.
- `.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md`
— Part C is the source for the Core RPC table, the LND capability matrix, and the air-gap
format comparison.