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:
archipelago
2026-08-02 10:08:42 -04:00
co-authored by Claude Opus 5
parent 40b77e392a
commit 262998747e
3 changed files with 406 additions and 0 deletions
+161
View File
@@ -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");