feat(10-05): report BIP-32 key origin on lnd.create-psbt, and record the honest signing posture (D-07b/D-09)
With Bitcoin Core's wallet deleted, LND's PSBT round trip is the only external-signer path Archipelago has, and D-09's key-origin protection moves from Core descriptors (of which none remain) to the PSBT itself. Adds `psbt_key_origin_report(&str) -> Result<PsbtKeyOriginReport>` to lnd/wallet.rs, reporting `input_count`, `inputs_with_key_origin` and `all_inputs_have_key_origin`. An input counts as carrying key origin when either its `bip32_derivation` or `tap_key_origins` map is non-empty. A PSBT with zero inputs reports false rather than vacuous truth. Parsed with the already-present `bitcoin` and `base64` crates; no dependency added. `lnd.create-psbt` gains an additive `key_origin` object on its response and a `tracing::warn!` with the counts when key origin is missing, because that is the exact condition under which a hardware signer refuses the PSBT. Computed best-effort: a decode failure degrades to `null`, never to an error, so a user's send cannot fail because an inspection helper could not parse something. `handle_lnd_finalize_psbt` and `handle_lnd_create_raw_tx` (which deliberately auto-signs with LND's hot keys) are untouched. Three tests, with fixtures built programmatically from the `bitcoin` crate rather than pasted as opaque base64: with-derivations, without-derivations, and malformed-is-an-error-not-a-panic. KEY-03-SIGNING-POSTURE.md gains an honest per-step coverage map of the fund -> export -> sign offline -> import -> finalize -> broadcast round trip. Of six steps, only the new inspection has automated coverage; steps 1, 4, 5 and 6 have none, and there is no air-gap transport (no animated QR, no .psbt file exchange) — export/import is copy-paste of base64. Untested paths are named as untested. Records the verdict that decides whether any of this is an air gap: on a default node an external signer CANNOT meaningfully sign a PSBT from `lnd.create-psbt`, because LND holds the keys for every input it selects. Evidence: the PSBT is funded from LND's own wallet; `ensure_wallet_initialized` creates a full key-holding wallet via /v1/initwallet; the generated lnd.conf carries no `remotesigner.*` block; and a search of apps/, scripts/, core/archipelago/src and image-recipe/ for remotesigner/createwatchonly/ nochainbackend returns zero matches. No fleet node is provisioned watch-only. What ships is PSBT transport, not air-gapped custody — the gap is provisioning, not plumbing. Adds 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. Also adds a status banner to PSBT-SIGNING-ARCHITECTURE.md recording that its Phase 1 was superseded by deletion rather than delivered, so §0's "single highest-value change" and §2.1's invariant now read against a code path that no longer exists. Banner only; §5.4's honesty table is byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
40b77e392a
commit
262998747e
@@ -698,11 +698,43 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(-1);
|
||||
|
||||
// Report whether this PSBT carries the BIP-32 key-origin data an external
|
||||
// signer needs to locate its own key. Best-effort by design: a decode
|
||||
// failure degrades to `null`, never to an error. A user's send must not
|
||||
// fail because an inspection helper could not parse something.
|
||||
let key_origin = match psbt_key_origin_report(&funded_psbt) {
|
||||
Ok(report) => {
|
||||
if !report.all_inputs_have_key_origin {
|
||||
// This is the exact condition under which a hardware signer
|
||||
// refuses the PSBT, so name it here rather than letting the
|
||||
// user discover it as an opaque failure at the device.
|
||||
tracing::warn!(
|
||||
input_count = report.input_count,
|
||||
inputs_with_key_origin = report.inputs_with_key_origin,
|
||||
"PSBT is missing BIP-32 key origin on one or more inputs; an external signer will not be able to locate its key"
|
||||
);
|
||||
}
|
||||
serde_json::json!({
|
||||
"input_count": report.input_count,
|
||||
"inputs_with_key_origin": report.inputs_with_key_origin,
|
||||
"all_inputs_have_key_origin": report.all_inputs_have_key_origin,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"Could not inspect PSBT for key origin; reporting null"
|
||||
);
|
||||
serde_json::Value::Null
|
||||
}
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"psbt_base64": funded_psbt,
|
||||
"change_output_index": change_output_index,
|
||||
"total_amount_sats": total_amount,
|
||||
"fee_rate_sat_per_vbyte": sat_per_vbyte,
|
||||
"key_origin": key_origin,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1126,10 +1158,139 @@ fn build_invoice_request_body(amount_sats: i64, memo: &str) -> serde_json::Value
|
||||
})
|
||||
}
|
||||
|
||||
/// What an external signer needs in order to find its own key in a PSBT.
|
||||
///
|
||||
/// A hardware signer locates the key it must sign with by reading each input's
|
||||
/// BIP-32 key-origin data (`[fingerprint/derivation]`). An input carrying none is
|
||||
/// an input the device cannot sign — it refuses rather than guesses. This is the
|
||||
/// protection D-09 was really about; with Bitcoin Core's descriptors deleted under
|
||||
/// D-07b, the PSBT itself is where key origin now has to be checked.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct PsbtKeyOriginReport {
|
||||
input_count: usize,
|
||||
inputs_with_key_origin: usize,
|
||||
all_inputs_have_key_origin: bool,
|
||||
}
|
||||
|
||||
/// Inspect a base64 PSBT and report how many of its inputs carry BIP-32 key origin.
|
||||
///
|
||||
/// An input counts as carrying key origin when either its `bip32_derivation` map
|
||||
/// (ECDSA / segwit v0) or its `tap_key_origins` map (taproot) is non-empty.
|
||||
///
|
||||
/// A PSBT with **zero** inputs reports `all_inputs_have_key_origin: false` rather
|
||||
/// than vacuous truth — an inputless PSBT cannot be signed at all, and answering
|
||||
/// "yes, everything a signer needs is present" would be actively misleading.
|
||||
///
|
||||
/// This is an *inspection*, never a precondition: callers must degrade to a null
|
||||
/// report on error, not fail the user's transaction (see `handle_lnd_create_psbt`).
|
||||
fn psbt_key_origin_report(psbt_base64: &str) -> Result<PsbtKeyOriginReport> {
|
||||
let raw = base64::engine::general_purpose::STANDARD
|
||||
.decode(psbt_base64.trim())
|
||||
.context("PSBT is not valid base64")?;
|
||||
|
||||
let psbt = bitcoin::psbt::Psbt::deserialize(&raw).context("PSBT failed to deserialize")?;
|
||||
|
||||
let input_count = psbt.inputs.len();
|
||||
let inputs_with_key_origin = psbt
|
||||
.inputs
|
||||
.iter()
|
||||
.filter(|input| !input.bip32_derivation.is_empty() || !input.tap_key_origins.is_empty())
|
||||
.count();
|
||||
|
||||
Ok(PsbtKeyOriginReport {
|
||||
input_count,
|
||||
inputs_with_key_origin,
|
||||
all_inputs_have_key_origin: input_count > 0 && inputs_with_key_origin == input_count,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a minimal, genuinely unsigned one-input PSBT with no key origin on
|
||||
/// any input. Built programmatically rather than pasted as opaque base64 so
|
||||
/// the fixture states what it is.
|
||||
fn unsigned_one_input_psbt() -> bitcoin::psbt::Psbt {
|
||||
use bitcoin::{
|
||||
absolute::LockTime, transaction::Version, Amount, OutPoint, ScriptBuf, Sequence,
|
||||
Transaction, TxIn, TxOut, Witness,
|
||||
};
|
||||
|
||||
let tx = Transaction {
|
||||
version: Version::TWO,
|
||||
lock_time: LockTime::ZERO,
|
||||
input: vec![TxIn {
|
||||
previous_output: OutPoint::null(),
|
||||
script_sig: ScriptBuf::new(),
|
||||
sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
|
||||
witness: Witness::new(),
|
||||
}],
|
||||
output: vec![TxOut {
|
||||
value: Amount::from_sat(10_000),
|
||||
script_pubkey: ScriptBuf::new(),
|
||||
}],
|
||||
};
|
||||
|
||||
bitcoin::psbt::Psbt::from_unsigned_tx(tx).expect("unsigned tx is a valid PSBT")
|
||||
}
|
||||
|
||||
fn psbt_to_base64(psbt: &bitcoin::psbt::Psbt) -> String {
|
||||
base64::engine::general_purpose::STANDARD.encode(psbt.serialize())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psbt_without_derivations_reports_no_key_origin() {
|
||||
let psbt = unsigned_one_input_psbt();
|
||||
let report = psbt_key_origin_report(&psbt_to_base64(&psbt)).expect("valid PSBT");
|
||||
|
||||
assert_eq!(report.input_count, 1);
|
||||
assert_eq!(report.inputs_with_key_origin, 0);
|
||||
assert!(
|
||||
!report.all_inputs_have_key_origin,
|
||||
"an input with no bip32_derivation is one a hardware signer cannot sign"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psbt_with_derivations_reports_key_origin() {
|
||||
use bitcoin::bip32::{DerivationPath, Fingerprint};
|
||||
use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
|
||||
|
||||
let secp = Secp256k1::new();
|
||||
let sk = SecretKey::from_slice(&[0x11u8; 32]).expect("valid secret key");
|
||||
let pk = PublicKey::from_secret_key(&secp, &sk);
|
||||
let path: DerivationPath = "m/84'/0'/0'/0/0".parse().expect("valid BIP-84 path");
|
||||
let fingerprint = Fingerprint::from([0xde, 0xad, 0xbe, 0xef]);
|
||||
|
||||
let mut psbt = unsigned_one_input_psbt();
|
||||
psbt.inputs[0]
|
||||
.bip32_derivation
|
||||
.insert(pk, (fingerprint, path));
|
||||
|
||||
let report = psbt_key_origin_report(&psbt_to_base64(&psbt)).expect("valid PSBT");
|
||||
|
||||
assert_eq!(report.input_count, 1);
|
||||
assert_eq!(report.inputs_with_key_origin, 1);
|
||||
assert!(report.all_inputs_have_key_origin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_psbt_is_an_error_not_a_panic() {
|
||||
// Not base64 at all.
|
||||
assert!(psbt_key_origin_report("not a psbt!!!").is_err());
|
||||
|
||||
// Valid base64, but truncated PSBT bytes.
|
||||
let psbt = unsigned_one_input_psbt();
|
||||
let serialized = psbt.serialize();
|
||||
let truncated =
|
||||
base64::engine::general_purpose::STANDARD.encode(&serialized[..serialized.len() / 2]);
|
||||
assert!(psbt_key_origin_report(&truncated).is_err());
|
||||
|
||||
// Empty input.
|
||||
assert!(psbt_key_origin_report("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invoice_request_body_always_sets_private_true() {
|
||||
let body = build_invoice_request_body(1_234, "test memo");
|
||||
|
||||
@@ -178,3 +178,220 @@ option is not quietly lost along with the code that was deleted. The alternative
|
||||
watch-only via `importaccount` plus remote signing — was considered and rejected for coupling
|
||||
cold storage to LND's upgrade path.
|
||||
|
||||
---
|
||||
|
||||
## LND PSBT round trip — what is covered
|
||||
|
||||
With Core's wallet deleted, LND is the only wallet Archipelago has, and its PSBT round trip is
|
||||
the only external-signer path that exists. This section records what that path actually consists
|
||||
of, what is tested, and — the question that decides whether any of it is an air gap — whether an
|
||||
externally-held signer can sign a default node's PSBT at all.
|
||||
|
||||
### Per-step coverage map
|
||||
|
||||
Round trip: **fund → export → sign offline → import → finalize → broadcast.**
|
||||
|
||||
| # | Step | Where it lives | `file:line` | Automated test coverage |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **Fund** — build a funded PSBT via LND WalletKit `/v2/wallet/psbt/fund` | `lnd.create-psbt` handler | `core/archipelago/src/api/rpc/lnd/wallet.rs:605`; dispatch arm `api/rpc/dispatcher.rs:136` | **Untested.** No LND mock exists; the handler's request/response handling is exercised only by hand. |
|
||||
| 1a | **Inspect** — report BIP-32 key origin on the funded PSBT | `psbt_key_origin_report` + wiring | `lnd/wallet.rs:1186` (fn), `:1169` (struct), `:705` (call site), `:737` (response field) | **Tested.** 3 unit tests, below. |
|
||||
| 2 | **Export** — hand the base64 PSBT to the user | UI renders `psbt_base64` for copy | `neode-ui/src/api/rpc-client.ts:407-423`; `neode-ui/src/views/web5/Web5SendReceiveModals.vue:308` | **Partial.** `neode-ui/src/api/__tests__/rpc-client.test.ts:319-323` asserts only that the client calls the method `lnd.create-psbt`; it does not test the payload or the rendering. |
|
||||
| 3 | **Sign offline** — external signer produces a signed PSBT | **Not in this repo.** No first-party signer ships today. | — | N/A |
|
||||
| 4 | **Import** — user pastes the signed PSBT back | textarea → `signedPsbtInput` | `Web5SendReceiveModals.vue:102`, `:419-424` | **Untested.** |
|
||||
| 5 | **Finalize** — `/v2/wallet/psbt/finalize` | `lnd.finalize-psbt` handler | `lnd/wallet.rs:743`; dispatch arm `dispatcher.rs:137` | **Untested.** |
|
||||
| 6 | **Broadcast** — `/v2/wallet/tx`, in the same handler | `handle_lnd_finalize_psbt` tail | `lnd/wallet.rs:795` | **Untested.** |
|
||||
| — | **Rate limiting** — both endpoints at 5 calls / 300s | `RateLimiter` defaults | `core/archipelago/src/rate_limit.rs:68-69` | **Untested for these two methods specifically.** |
|
||||
|
||||
**Stated plainly, because an untested path must not be described as verified:** of the six steps,
|
||||
**one** (the key-origin inspection added by this plan) has automated coverage in the Rust
|
||||
crate. Steps 1, 4, 5 and 6 have **none** — no test exercises the LND REST calls, the finalize
|
||||
handler, or the broadcast. Step 2's only test asserts a method name. **No end-to-end test of the
|
||||
round trip exists**, and none of it has been verified against a real hardware signer.
|
||||
|
||||
There is also **no air-gap transport**: no animated QR encode/decode, no `.psbt` file
|
||||
download/upload. Export and import are copy-paste of base64 in a textarea. The BC-UR v2 / BBQr
|
||||
design in `PSBT-SIGNING-ARCHITECTURE.md` §4 is unimplemented.
|
||||
|
||||
### New tests added by this plan
|
||||
|
||||
In `core/archipelago/src/api/rpc/lnd/wallet.rs`'s `mod tests`, with fixtures built
|
||||
programmatically from the `bitcoin` crate rather than pasted as opaque base64:
|
||||
|
||||
| Test | Asserts |
|
||||
|---|---|
|
||||
| `psbt_without_derivations_reports_no_key_origin` | A one-input unsigned PSBT with no `bip32_derivation` reports `inputs_with_key_origin: 0` and `all_inputs_have_key_origin: false`. |
|
||||
| `psbt_with_derivations_reports_key_origin` | The same PSBT with a `(Fingerprint, DerivationPath)` inserted on input 0 reports `1/1` and `true`. |
|
||||
| `malformed_psbt_is_an_error_not_a_panic` | Non-base64, truncated-PSBT and empty inputs all return `Err`, never panic. |
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
`lnd.create-psbt` now returns an additive `key_origin` field:
|
||||
|
||||
```json
|
||||
"key_origin": { "input_count": 1, "inputs_with_key_origin": 0, "all_inputs_have_key_origin": false }
|
||||
```
|
||||
|
||||
It is computed **best-effort**: a decode failure degrades to `null` and logs a warning, never to
|
||||
an error — a user's send must not fail because an inspection helper could not parse something.
|
||||
When `all_inputs_have_key_origin` is false the handler emits a `tracing::warn!` with the counts,
|
||||
because that is the exact condition under which a hardware signer refuses the PSBT. Existing
|
||||
response fields are unchanged; `handle_lnd_finalize_psbt` and `handle_lnd_create_raw_tx` (the
|
||||
sibling that deliberately auto-signs with LND's hot keys) were not touched.
|
||||
|
||||
### Can an external signer actually sign a default node's PSBT? — **No, not today**
|
||||
|
||||
This is the question that separates "we have PSBT plumbing" from "we have air-gapped custody",
|
||||
and the two must not be allowed to blur.
|
||||
|
||||
**Verdict: on a default Archipelago node, an externally-held signer cannot meaningfully sign a
|
||||
PSBT produced by `lnd.create-psbt`.** The evidence:
|
||||
|
||||
1. **The PSBT is funded from LND's own wallet.** `lnd.create-psbt` POSTs to LND's WalletKit
|
||||
`/v2/wallet/psbt/fund` (`lnd/wallet.rs:672`), which selects UTXOs belonging to **LND's**
|
||||
wallet. The keys for those inputs are the keys LND holds.
|
||||
2. **LND's wallet on every node is a full key-holding wallet, created locally.**
|
||||
`container::lnd::ensure_wallet_initialized` (`core/archipelago/src/container/lnd.rs:86`) calls
|
||||
`init_wallet_via_rest`, which POSTs `/v1/initwallet` with a `cipher_seed_mnemonic`
|
||||
(`container/lnd.rs:504-516`) and persists the aezeed backup (`:523-525`). That is a normal
|
||||
wallet with private keys, not a watch-only one.
|
||||
3. **No node's `lnd.conf` carries a remote-signing block.** The config Archipelago generates
|
||||
(`container/lnd.rs:64-79`) contains `bitcoin.node=bitcoind` and the bitcoind RPC settings, and
|
||||
**no `remotesigner.*` keys at all**.
|
||||
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**. There is no code path, script or manifest that sets
|
||||
any node up this way.
|
||||
|
||||
An external signer could only sign these inputs if LND were first provisioned **watch-only
|
||||
against that signer** — `remotesigner.*` on the node plus `lncli createwatchonly` from the
|
||||
signer's exported accounts, with the level-3 accounts and the p2tr import step described in
|
||||
`PSBT-SIGNING-ARCHITECTURE.md` §5.1-5.2. **No fleet node is so provisioned.**
|
||||
|
||||
**What therefore ships today is the PSBT *transport*, not air-gapped custody.** The round trip is
|
||||
real and rate-limited, and it is genuinely useful for signing a PSBT whose inputs belong to some
|
||||
*other* wallet — but on a default node the signer that holds the input keys is LND itself, so
|
||||
routing the PSBT out to an external device and back adds a step without moving custody anywhere.
|
||||
The gap between here and D-08's opt-in air-gapped path is **provisioning, not plumbing**, and
|
||||
that provisioning is out of scope for Phase 10 (it is `PSBT-SIGNING-ARCHITECTURE.md` §8 Phase 6).
|
||||
|
||||
Nothing in the UI currently claims otherwise, and nothing added by this plan does either. If
|
||||
copy is ever written for this flow, it must not describe it as cold storage on the strength of
|
||||
the PSBT round trip alone.
|
||||
|
||||
### Lightning channel, revocation and HTLC keys are not air-gappable — at all
|
||||
|
||||
This is a standing constraint, not a caveat, and it survives every change in this document.
|
||||
|
||||
> **A Lightning node's channel, revocation and HTLC keys must sign in real time to answer
|
||||
> counterparty commitments. They cannot be air-gapped.** A routing node cannot tolerate a
|
||||
> human-in-the-loop signing step: a delayed response to a commitment update risks a force-close,
|
||||
> and a missing revocation risks loss. LND remote signing **relocates** these keys to a hardened
|
||||
> host — it does **not** cool them. There is no configuration, present or future, in which a
|
||||
> live Lightning node's channel keys are cold.
|
||||
|
||||
This is the same limit stated in `PSBT-SIGNING-ARCHITECTURE.md` §5.1 ("Air-gap channel /
|
||||
revocation / HTLC keys — **No**") and §5.4, whose honesty table remains correct and unmodified.
|
||||
|
||||
The consequence for user-facing copy, quoted from §5.4 and repeated here so it cannot be lost:
|
||||
|
||||
> *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.*
|
||||
|
||||
**No wording in this document, or in any document this phase touches, may imply that Lightning
|
||||
funds can be held cold.** A user who believes their Lightning balance is cold will keep more in
|
||||
it than they otherwise would, which is exactly the miscalibration that turns an incident into a
|
||||
loss.
|
||||
|
||||
---
|
||||
|
||||
## Fleet census — Core descriptor wallets
|
||||
|
||||
**Status: INCOMPLETE — one node verified, fleet pending.** This section answers one question per
|
||||
node: *does this node hold a Bitcoin Core descriptor wallet that
|
||||
`handle_bitcoin_init_wallet_from_seed` created, and does it hold private keys?* It is recorded
|
||||
per node rather than assumed, because deletion closes the door but does not tell us whether
|
||||
anyone walked through it before.
|
||||
|
||||
### Hard constraint on every command in this census
|
||||
|
||||
> **Never run `listdescriptors true`.** The `true` argument makes Bitcoin Core return the
|
||||
> descriptors **including private keys**, which would print an xprv to a terminal and into a
|
||||
> transcript — creating the exact exposure this census exists to measure.
|
||||
> `listwalletdir`, `listwallets`, `getwalletinfo` and `listdescriptors` **with no second
|
||||
> argument** answer the question completely.
|
||||
>
|
||||
> If any output unexpectedly contains a string beginning `xprv`, **stop immediately, do not
|
||||
> paste it**, and report only that it occurred.
|
||||
|
||||
### Commands (re-runnable by an auditor)
|
||||
|
||||
Per node, against the Bitcoin Core / Knots container:
|
||||
|
||||
```bash
|
||||
# 0. Does the handler's wallets directory exist at all? An absent directory is
|
||||
# itself a complete answer for that node — paste the output as-is.
|
||||
ls -la /var/lib/archipelago/bitcoin/wallets/ 2>&1
|
||||
|
||||
# bitcoin-cli is NOT on $PATH inside the container. On archi-dev-box (Knots
|
||||
# 29.3) it lives at:
|
||||
# /opt/bitcoin-29.3.knots20260210/bin/bitcoin-cli
|
||||
# The RPC user is `archipelago`; the password is read from
|
||||
# /var/lib/archipelago/secrets/bitcoin-rpc-password
|
||||
# — reference that path, never the value, and prefer -stdinrpcpass so the
|
||||
# password never appears in a process list or shell history.
|
||||
|
||||
# 1. Every wallet on disk, loaded or not.
|
||||
bitcoin-cli -rpcuser=archipelago -stdinrpcpass listwalletdir
|
||||
|
||||
# 2. Currently loaded wallets.
|
||||
bitcoin-cli -rpcuser=archipelago -stdinrpcpass listwallets
|
||||
|
||||
# 3. Per wallet returned: record walletname, private_keys_enabled, descriptors,
|
||||
# blank, keypoolsize, txcount, balance.
|
||||
bitcoin-cli -rpcuser=archipelago -stdinrpcpass -rpcwallet=<name> getwalletinfo
|
||||
|
||||
# 4. ONLY for a wallet with private_keys_enabled: true — NOTE: no second argument.
|
||||
# Record descriptor prefixes (`wpkh(...`) only, never a full key string.
|
||||
bitcoin-cli -rpcuser=archipelago -stdinrpcpass -rpcwallet=<name> listdescriptors
|
||||
|
||||
# 5. Which Bitcoin app and version.
|
||||
bitcoin-cli -rpcuser=archipelago -stdinrpcpass getnetworkinfo | head
|
||||
```
|
||||
|
||||
### Results
|
||||
|
||||
| Node | App / version | `wallets_dir_present` | `listwallets` | `archipelago` wallet? | Private-key-bearing wallet? | Verdict |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **archi-dev-box** | Bitcoin Knots 29.3 (`bitcoin-knots` container) | — (`listwalletdir` used instead) | `[ "" ]` | **No** | The unnamed default wallet reports `private_keys_enabled: true`, but also `blank: true`, `keypoolsize: 0`, `txcount: 0`, `balance: 0` — Core's own statement that **no key was ever imported and no transaction ever touched it**. It is not this handler's output. | **CLEAR** — verified 2026-08-02 |
|
||||
| `.228` (shorty-s, resilience node) | — | — | — | — | — | **UNCHECKED** |
|
||||
| `.198` (OptiPlex) | — | — | — | — | — | **UNCHECKED** |
|
||||
| `.116` (thinkpad / dev node) | — | — | — | — | — | **UNCHECKED** |
|
||||
| `x250-dev` | — | — | — | — | — | **UNCHECKED** |
|
||||
| framework-pt | — | — | — | — | — | **UNCHECKED** |
|
||||
|
||||
`listwalletdir` on archi-dev-box also returned two `gatewayd-02004b91…` / `gatewayd-03443c0c…`
|
||||
wallets. These are **Fedimint gateway** wallets, unrelated to the BIP-84 path and out of scope
|
||||
for this finding.
|
||||
|
||||
### Standing rule if a wallet is found
|
||||
|
||||
If any node reports a wallet named `archipelago` (or any descriptor wallet with
|
||||
`private_keys_enabled: true` that this handler plausibly created), that is a **finding**:
|
||||
|
||||
1. **Stop.** Record it here with the node label and wallet name.
|
||||
2. **Raise it as a blocker.** KEY-03 does not close until a human decides what to do about it.
|
||||
3. **Do not migrate, unload, rescan or modify it.** D-07b withdrew the migration deliberately.
|
||||
Rewriting a wallet that might hold funds is exactly the kind of decision that belongs to a
|
||||
human, and CLAUDE.md's "migrations never destroy data" invariant applies the moment anyone
|
||||
touches it.
|
||||
|
||||
Such a wallet would mean the endpoint was invoked manually before this plan deleted it, and that
|
||||
node's spending key is duplicated in Core outside the Argon2 envelope.
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# PSBT-First Signing Architecture
|
||||
|
||||
> ## ⚠️ Status update (2026-08-02): **§8 Phase 1 was superseded by deletion, not delivered**
|
||||
>
|
||||
> Phase 1 ("Descriptor watch-only read path", §8) planned to **rewrite**
|
||||
> `handle_bitcoin_init_wallet_from_seed` so Bitcoin Core's wallet held only the xpub. That is not
|
||||
> what happened. Under Phase 10 decision **D-07b**, the entire Bitcoin Core wallet path was
|
||||
> **deleted**: `handle_bitcoin_init_wallet_from_seed` and its `bitcoin.init-wallet-from-seed`
|
||||
> dispatch arm are gone. It had no caller, LND is the wallet the product drives, and the endpoint
|
||||
> was authenticated *and* password-gated, so F-13 was key-at-rest duplication rather than an
|
||||
> exposed endpoint.
|
||||
>
|
||||
> **Consequences for reading the rest of this document:**
|
||||
>
|
||||
> - **§0's "single highest-value change"** and **§2.1's invariant** now read against a code path
|
||||
> that no longer exists. Their goal — the BIP-84 private key existing in exactly one place —
|
||||
> is **achieved**, by removal rather than by conversion to watch-only.
|
||||
> - **§1.1, §2.2, §3.1 and §7.3** describe a Core watch-only wallet and a wallet migration.
|
||||
> **There is no such wallet and no migration was performed or is planned.**
|
||||
> - **§3.1's key-origin requirement** still holds, but it now applies to the **PSBT** rather than
|
||||
> to Archipelago-emitted descriptors, of which there are none left. `lnd.create-psbt` inspects
|
||||
> and reports it (`psbt_key_origin_report`, `core/archipelago/src/api/rpc/lnd/wallet.rs`).
|
||||
> - **§5 (LND) is unaffected and remains accurate**, including **§5.4's honesty table**, which is
|
||||
> correct as written and unchanged.
|
||||
>
|
||||
> **For the current state, read `docs/security/KEY-03-SIGNING-POSTURE.md`** — it records the
|
||||
> deletion with its evidence, an honest per-step coverage map of the LND PSBT round trip, and the
|
||||
> verdict on whether an external signer can sign a default node's PSBT today (it cannot: no fleet
|
||||
> node is provisioned watch-only). Phases 2-7 below are unaffected as design targets.
|
||||
|
||||
> **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.
|
||||
|
||||
Reference in New Issue
Block a user