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");
|
||||
|
||||
Reference in New Issue
Block a user