From: Vihiga Tyonum Date: Wed, 5 Aug 2026 22:12:53 +0000 (+0100) Subject: feat(hwi): Add merging ext and int desc into policy X-Git-Url: http://internal-gitweb-vhost/-script/src/example_cli/database/struct.AddrsOutput.html?a=commitdiff_plain;h=3e3c0be3b1c8ca63beb831f087b9c5c263c80987;p=bdk-cli feat(hwi): Add merging ext and int desc into policy --- diff --git a/README.md b/README.md index 98c21ff..9b81ace 100644 --- a/README.md +++ b/README.md @@ -209,9 +209,7 @@ cargo run -- wallet --wallet payjoin_history ## Hardware wallet (HWI) -Compile with the `hwi` feature to interact with a connected hardware wallet -(Ledger, Coldcard, BitBox02, Jade or Specter). On Linux you need the USB/HID -system libraries first: +Compile with the `hwi` feature to interact with a connected hardware wallet (Ledger, Coldcard, BitBox02, Jade or Specter). On Linux you need the USB/HID system libraries first: ```shell sudo apt-get install -y libudev-dev libusb-1.0-0-dev pkg-config @@ -221,22 +219,22 @@ List every connected device (fingerprint and model): ```shell cargo run --features hwi -- hwi devices ``` -Register a wallet policy on the device. This returns an HMAC that some devices (e.g. Ledger) require to reuse the policy on later calls: +Register a wallet policy on the device. ```shell -cargo run --features hwi -- hwi --wallet my_wallet --ext-descriptor register +cargo run --features hwi -- wallet --wallet my_wallet hwi register ``` -Display a receive address on the device for verification (defaults to external keychain, index 0): +Display a receive address on the device: ```shell -cargo run --features hwi -- hwi --ext-descriptor address +cargo run --features hwi -- wallet --wallet my_wallet hwi address ``` Sign a PSBT with the device: ```shell -cargo run --features hwi -- hwi --wallet my_wallet --ext-descriptor --hmac sign +cargo run --features hwi -- wallet --wallet my_wallet hwi sign ``` ## Justfile diff --git a/src/handlers/hwi.rs b/src/handlers/hwi.rs index 8553172..657c323 100644 --- a/src/handlers/hwi.rs +++ b/src/handlers/hwi.rs @@ -5,22 +5,14 @@ //! - Device-only utilities live under the top-level `hwi` command //! ([`HwiCommand`]): `hwi xpub` reads a device extended public key and //! `hwi devices` lists connected devices. Neither needs a wallet — `hwi xpub` -//! is the bootstrap step used to build a descriptor in the first place. +//! is the bootstrap step used to build a descriptor. //! - Wallet-scoped operations live under `wallet hwi …` //! ([`WalletHwiCommand`]): `register`, `address` and `sign`. These take the //! wallet policy from the loaded wallet's descriptors, so no descriptor needs //! to be passed on the command line. -//! -//! `async-hwi` expects a *wallet policy*: a single, public (xpub) descriptor -//! whose keys carry origin info and use multipath (`/<0;1>/*`) notation so the -//! policy covers both the receive and change chains. BDK exposes the external -//! and internal chains as two single-path public descriptors, so -//! [`build_device_policy`] combines them into the multipath form the device -//! needs. use async_hwi::AddressScript; use bdk_wallet::KeychainKind; -use bdk_wallet::bitcoin::{Psbt, bip32::DerivationPath, hex::DisplayHex}; use clap::{Parser, Subcommand}; use serde_json::json; use std::str::FromStr; @@ -29,6 +21,13 @@ use crate::error::BDKCliError as Error; use crate::handlers::{AppContext, AsyncAppCommand, Init, OfflineOperations}; use crate::utils::hwi::{HwiWallet, enumerate_hwi_devices, first_hwi_device}; +use bdk_wallet::bitcoin::{ + Psbt, + bip32::{ChildNumber, DerivationPath}, + hex::DisplayHex, +}; +use bdk_wallet::miniscript::{Descriptor, DescriptorPublicKey, descriptor::ShInner}; + /// Device-only HWI utilities that do not require a wallet. #[derive(Debug, Parser, Clone, PartialEq, Eq)] pub struct HwiCommand { @@ -42,13 +41,15 @@ pub struct HwiCommand { pub enum HwiSubCommand { /// Read an extended public key from the device at a derivation path. /// - /// Use this first to obtain the key material needed to build a wallet - /// descriptor for the device. The output includes a ready-to-use key - /// expression (`[fingerprint/path]xpub`) to drop into a descriptor. + /// Needed to build a wallet descriptor for the device. Xpub { /// Derivation path, e.g. `m/84'/1'/0'`. #[arg(default_value = "m/84'/1'/0'")] path: String, + + /// Output descriptor script type. + #[arg(long = "type", value_parser = ["wpkh", "tr", "pkh"])] + script_type: Option, }, /// List all connected hardware wallet devices. Devices, @@ -63,7 +64,7 @@ impl AsyncAppCommand> for HwiCommand { let hwi_wallet = HwiWallet::default(); match &self.subcommand { - HwiSubCommand::Xpub { path } => { + HwiSubCommand::Xpub { path, script_type } => { let derivation = DerivationPath::from_str(path) .map_err(|e| Error::Generic(format!("Invalid derivation path: {e}")))?; let device = first_hwi_device(network, &hwi_wallet).await?; @@ -71,12 +72,32 @@ impl AsyncAppCommand> for HwiCommand { let fingerprint = device.get_master_fingerprint().await?.to_string(); let origin = path.trim_start_matches('m').trim_start_matches('/'); let key_expression = format!("[{fingerprint}/{origin}]{xpub}"); - Ok(json!({ + + let mut out = json!({ "path": path, "xpub": xpub.to_string(), "master_fingerprint": fingerprint, "key_expression": key_expression, - })) + }); + + match script_type + .clone() + .or_else(|| infer_script_type(path).map(str::to_string)) + { + Some(stype) => { + out["script_type"] = json!(stype); + out["external_descriptor"] = + json!(build_descriptor(&stype, &key_expression, 0)?); + out["internal_descriptor"] = + json!(build_descriptor(&stype, &key_expression, 1)?); + } + None => { + out["note"] = json!( + "Non-standard or multisig path; pass --type to emit descriptors, or use key_expression" + ); + } + } + Ok(out) } HwiSubCommand::Devices => { @@ -143,24 +164,31 @@ impl WalletHwiCommand { let network = ctx.network; // Derive the multipath policy from the wallet's public descriptors. - let ext = ctx + let ext_desc = ctx .state .wallet .public_descriptor(KeychainKind::External) - .to_string(); - let int = ctx + .clone(); + let int_desc = ctx .state .wallet .public_descriptor(KeychainKind::Internal) - .to_string(); - // A single-descriptor wallet returns the same descriptor for both - // keychains. + .clone(); + + let kind = WalletKind::classify(&ext_desc); + + // Only multisig/miniscript wallets get a registered device policy; + // single-sig wallets are signed straight from the PSBT, so attaching a + // policy (which BitBox/Ledger reject for single-sig) is skipped. + + let ext = ext_desc.to_string(); + let int = int_desc.to_string(); let int = if int == ext { None } else { Some(int) }; let policy = build_device_policy(&ext, int.as_deref())?; let hwi_wallet = HwiWallet { name: Some(wallet_name), - policy: Some(&policy), + policy: matches!(kind, WalletKind::Policy).then_some(policy.as_str()), hmac: self.hmac.as_deref(), }; @@ -173,29 +201,16 @@ impl WalletHwiCommand { "success": true, "hmac": hmac.map(|h| h.to_lower_hex_string()), })), - // Devices like Specter don't use BIP-388 policy registration. + // Some devices don't implement registration. Err(async_hwi::Error::UnimplementedMethod) => Ok(json!({ "success": true, "hmac": null, - "message": "This device does not require policy registration", + "message": "This device does not support wallet registration", })), Err(e) => Err(Error::HwiError(e)), } } - WalletHwiSubCommand::Address { index, change } => { - let device = first_hwi_device(network, &hwi_wallet).await?; - let script = AddressScript::Miniscript { - index: *index, - change: *change, - }; - // Not every device can show an address on screen (e.g. Specter); - // fall back to the locally derived address instead of failing. - let displayed = match device.display_address(&script).await { - Ok(()) => true, - Err(async_hwi::Error::UnimplementedMethod) => false, - Err(e) => return Err(Error::HwiError(e)), - }; let keychain = if *change { KeychainKind::Internal } else { @@ -207,18 +222,45 @@ impl WalletHwiCommand { .peek_address(keychain, *index) .address .to_string(); - let message = if displayed { - "Verify this address matches the one shown on your device" - } else { - "This device cannot display an address on screen; derived locally" + + // Attempt to derive and display the address on the device and on the cli. + // If it fails to derive from the device, it falls back on the wallet to generate and display + let script = match kind { + WalletKind::TaprootSingleSig => { + let desc = if *change { &int_desc } else { &ext_desc }; + taproot_address_path(desc, *index).map(AddressScript::P2TR) + } + _ => Some(AddressScript::Miniscript { + index: *index, + change: *change, + }), + }; + + let device = first_hwi_device(network, &hwi_wallet).await?; + let displayed = match script { + Some(script) => match device.display_address(&script).await { + Ok(()) => ( + true, + "Verify this address matches the one shown on your device".to_string(), + ), + // The device attempted but declined (e.g. unsupported + // for this script type, or no registered policy). + Err(e) => ( + false, + format!("Device did not display the address ({e}); showing the locally derived address"), + ), + }, + None => ( + false, + "Could not build a device address request; showing the locally derived address".to_string(), + ), }; + Ok(json!({ - "success": true, "index": index, "change": change, "address": address, "displayed_on_device": displayed, - "message": message, })) } @@ -238,16 +280,92 @@ fn strip_checksum(descriptor: &str) -> &str { descriptor.split('#').next().unwrap_or(descriptor) } -/// Build the multipath wallet policy async-hwi expects from a wallet's external -/// and (optional) internal single-path public descriptors. +/// How a wallet's descriptor maps onto a hardware device's capabilities. +#[derive(Clone, Copy, PartialEq, Eq)] +enum WalletKind { + /// BIP-86 taproot single-sig — shown on device via the simple P2TR path. + TaprootSingleSig, + /// Other single-sig (wpkh/pkh/sh-wpkh) — signable, but no on-device address + /// display path in async-hwi. + SingleSig, + /// Multisig/miniscript — requires policy registration. + Policy, +} + +impl WalletKind { + fn classify(desc: &Descriptor) -> Self { + match desc { + Descriptor::Tr(tr) if tr.tap_tree().is_none() => WalletKind::TaprootSingleSig, + Descriptor::Wpkh(_) | Descriptor::Pkh(_) => WalletKind::SingleSig, + Descriptor::Sh(sh) if matches!(sh.as_inner(), ShInner::Wpkh(_)) => { + WalletKind::SingleSig + } + _ => WalletKind::Policy, + } + } +} + +/// Build the full BIP-32 path (from the master key) to the taproot single-sig +/// address at `index`, e.g. `86'/1'/0'/0/index`. Returns `None` unless the +/// descriptor is a keyspend-only taproot over an xpub key. +fn taproot_address_path( + desc: &Descriptor, + index: u32, +) -> Option { + let Descriptor::Tr(tr) = desc else { + return None; + }; + if tr.tap_tree().is_some() { + return None; + } + let DescriptorPublicKey::XPub(xkey) = tr.internal_key() else { + return None; + }; + // Full path from the master key: [origin]/[xpub derivation]/index. + let base = match &xkey.origin { + Some((_, origin_path)) => origin_path.extend(&xkey.derivation_path), + None => xkey.derivation_path.clone(), + }; + let index = ChildNumber::from_normal_idx(index).ok()?; + Some(base.extend([index])) +} + +/// Infer the single-sig descriptor script type from a derivation path's. +fn infer_script_type(path: &str) -> Option<&'static str> { + let purpose = path + .split('/') + .filter(|s| !s.is_empty() && *s != "m") + .find_map(|s| s.trim_end_matches(['\'', 'h']).parse::().ok())?; + match purpose { + 44 => Some("pkh"), + 49 => Some("sh-wpkh"), + 84 => Some("wpkh"), + 86 => Some("tr"), + _ => None, + } +} + +/// Wrap a key expression into a checksummed single-sig descriptor for the given +/// keychain branch (0 = external, 1 = internal). +fn build_descriptor(script_type: &str, key_expr: &str, branch: u32) -> Result { + let raw = match script_type { + "tr" => format!("tr({key_expr}/{branch}/*)"), + "pkh" => format!("pkh({key_expr}/{branch}/*)"), + "sh-wpkh" => format!("sh(wpkh({key_expr}/{branch}/*))"), + _ => format!("wpkh({key_expr}/{branch}/*)"), + }; + let descriptor = Descriptor::::from_str(&raw) + .map_err(|e| Error::Generic(format!("Failed to build descriptor: {e}")))?; + Ok(descriptor.to_string()) +} + +/// Build the wallet policy /// -/// - Rejects private-key material — a device must only ever see public keys. +/// - Rejects private-key. /// - Passes an already-multipath descriptor (`/**` or `/<..>/*`) through /// unchanged. /// - Combines a standard `/0/*` external and `/1/*` internal pair into a single /// `/<0;1>/*` multipath descriptor. -/// - With no internal descriptor, returns the external descriptor and warns -/// that only the receive chain is covered. fn build_device_policy(ext: &str, int: Option<&str>) -> Result { let ext = strip_checksum(ext).trim().to_string(); @@ -271,8 +389,7 @@ fn build_device_policy(ext: &str, int: Option<&str>) -> Result { .to_string(), )); } - // `/0/*` and `/1/*` only ever appear as a key's wildcard branch (key - // origins inside `[..]` are hardened and carry no `*`). + // `/0/*` and `/1/*` only ever appear as a key's wildcard branch. if ext.replace("/0/*", "/1/*") != int { return Err(Error::Generic( "Cannot derive a multipath policy: the wallet's external and internal \ diff --git a/src/utils/hwi.rs b/src/utils/hwi.rs index 9877845..94b1568 100644 --- a/src/utils/hwi.rs +++ b/src/utils/hwi.rs @@ -7,9 +7,11 @@ //! that new devices supported upstream become available here with minimal //! changes. +use async_hwi::bitbox::api::BitBox as BitBoxApi; +use async_hwi::bitbox::api::runtime::TokioRuntime; use async_hwi::{ HWI, - bitbox::{BitBox02, PairingBitbox02WithLocalCache, api::runtime}, + bitbox::{BitBox02, NoiseConfigNoCache, PairingBitbox02WithLocalCache, api::runtime}, coldcard, jade::{self, Jade}, ledger::{HidApi, Ledger, LedgerSimulator, TransportHID}, @@ -77,6 +79,38 @@ pub async fn enumerate_hwi_devices( devices.push(device.into()); } + // BitBox02 simulator (TCP, defaults to 127.0.0.1:15423). async-hwi only + // connects a physical BitBox over USB, so the simulator is reached directly + // through the re-exported bitbox-api. A connection error just means the + // simulator isn't running, so we ignore it and keep probing other devices. + { + if let Ok(bitbox) = + BitBoxApi::::from_simulator(None, Box::new(NoiseConfigNoCache {})).await + { + match bitbox.unlock_and_pair().await { + Ok(pairing) => match pairing.wait_confirm().await { + Ok(paired) => { + // A freshly started simulator is unseeded, so key operations fail. + // Applicable to only simulators + if let Ok(info) = paired.device_info().await + && !info.initialized + && let Err(e) = paired.restore_from_mnemonic().await + { + warn!("BitBox simulator seeding failed: {e:?}"); + } + let mut bb02 = BitBox02::from(paired).with_network(network); + if let Some(policy) = wallet.policy { + bb02 = bb02.with_policy(policy).map_err(map_device_err)?; + } + devices.push(bb02.into()); + } + Err(e) => warn!("BitBox simulator pairing confirmation failed: {e:?}"), + }, + Err(e) => warn!("BitBox simulator unlock/pair failed: {e:?}"), + } + } + } + // USB (HID) devices: BitBox02 and Coldcard. let api = Box::new(HidApi::new().map_err(|e| Error::Generic(e.to_string()))?);