From: Vihiga Tyonum Date: Fri, 9 Oct 2026 13:15:17 +0000 (+0100) Subject: feat(hwi): Add fingerprint to "hwi xpub" X-Git-Url: http://internal-gitweb-vhost/-client-interface/%22https:/encode/struct.WScriptHash.html?a=commitdiff_plain;h=4131510db77240500d1df55be06b3da86bc11b52;p=bdk-cli feat(hwi): Add fingerprint to "hwi xpub" - add --fingerprint to `hwi xpub` and `wallet hwi`, select the matching device, and enumerate physical devices before simulators. --- diff --git a/src/handlers/hwi.rs b/src/handlers/hwi.rs index 657c323..1db0070 100644 --- a/src/handlers/hwi.rs +++ b/src/handlers/hwi.rs @@ -22,7 +22,7 @@ use crate::handlers::{AppContext, AsyncAppCommand, Init, OfflineOperations}; use crate::utils::hwi::{HwiWallet, enumerate_hwi_devices, first_hwi_device}; use bdk_wallet::bitcoin::{ - Psbt, + Network, Psbt, bip32::{ChildNumber, DerivationPath}, hex::DisplayHex, }; @@ -44,12 +44,16 @@ pub enum HwiSubCommand { /// 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, + // #[arg(default_value = "m/84'/1'/0'")] + path: Option, /// Output descriptor script type. - #[arg(long = "type", value_parser = ["wpkh", "tr", "pkh"])] + #[arg(long = "type", value_parser = ["wpkh", "tr", "sh-wpkh", "pkh"])] script_type: Option, + + /// Target a specific connected device by master fingerprint (hex). + #[arg(long)] + fingerprint: Option, }, /// List all connected hardware wallet devices. Devices, @@ -64,25 +68,37 @@ impl AsyncAppCommand> for HwiCommand { let hwi_wallet = HwiWallet::default(); match &self.subcommand { - HwiSubCommand::Xpub { path, script_type } => { - let derivation = DerivationPath::from_str(path) + HwiSubCommand::Xpub { + path, + script_type, + fingerprint, + } => { + let path = match path { + Some(path) => path.clone(), + None => { + let coin = if network == Network::Bitcoin { 0 } else { 1 }; + format!("m/84'/{coin}'/0'") + } + }; + + 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?; + let device = first_hwi_device(network, &hwi_wallet, fingerprint.as_deref()).await?; let xpub = device.get_extended_pubkey(&derivation).await?; - let fingerprint = device.get_master_fingerprint().await?.to_string(); + let master_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}"); + let key_expression = format!("[{master_fingerprint}/{origin}]{xpub}"); let mut out = json!({ "path": path, "xpub": xpub.to_string(), - "master_fingerprint": fingerprint, + "master_fingerprint": master_fingerprint, "key_expression": key_expression, }); match script_type .clone() - .or_else(|| infer_script_type(path).map(str::to_string)) + .or_else(|| infer_script_type(&path).map(str::to_string)) { Some(stype) => { out["script_type"] = json!(stype); @@ -123,6 +139,9 @@ pub struct WalletHwiCommand { /// policy on a Ledger for `address`/`sign`. #[arg(env = "HWI_HMAC", long)] pub hmac: Option, + /// Target a specific connected device by master fingerprint (hex). + #[arg(long)] + pub fingerprint: Option, #[command(subcommand)] pub subcommand: WalletHwiSubCommand, } @@ -133,7 +152,10 @@ pub struct WalletHwiCommand { pub enum WalletHwiSubCommand { /// Register the wallet's policy on the device, returning its HMAC. Register, - /// Display a receive address on the device. + /// Display the receive address at an index on the device for verification. + /// + /// It derives and shows the address at `--index` but does NOT + /// advance the wallet's receive index. Address { /// Address index to derive. #[arg(long, default_value_t = 0)] @@ -163,6 +185,8 @@ impl WalletHwiCommand { ) -> Result { let network = ctx.network; + let target_fp = self.fingerprint.as_deref(); + // Derive the multipath policy from the wallet's public descriptors. let ext_desc = ctx .state @@ -195,12 +219,17 @@ impl WalletHwiCommand { match &self.subcommand { WalletHwiSubCommand::Register => { tracing::debug!("Registering wallet '{wallet_name}' with policy '{policy}'"); - let device = first_hwi_device(network, &hwi_wallet).await?; + let device = first_hwi_device(network, &hwi_wallet, target_fp).await?; match device.register_wallet(wallet_name, &policy).await { - Ok(hmac) => Ok(json!({ - "success": true, - "hmac": hmac.map(|h| h.to_lower_hex_string()), - })), + Ok(hmac) => { + let hmac = hmac.map(|h| h.to_lower_hex_string()); + let next_step = hmac.as_ref().map(|h| { + format!( + "Save this HMAC and pass it to `address`/`sign` with --hmac {h} (or set HWI_HMAC)." + ) + }); + Ok(json!({ "success": true, "hmac": hmac, "next_step": next_step })) + } // Some devices don't implement registration. Err(async_hwi::Error::UnimplementedMethod) => Ok(json!({ "success": true, @@ -236,8 +265,8 @@ impl WalletHwiCommand { }), }; - let device = first_hwi_device(network, &hwi_wallet).await?; - let displayed = match script { + let device = first_hwi_device(network, &hwi_wallet, target_fp).await?; + let (displayed, message) = match script { Some(script) => match device.display_address(&script).await { Ok(()) => ( true, @@ -261,13 +290,14 @@ impl WalletHwiCommand { "change": change, "address": address, "displayed_on_device": displayed, + "message": message, })) } WalletHwiSubCommand::Sign { psbt } => { let mut psbt = Psbt::from_str(psbt) .map_err(|e| Error::Generic(format!("Failed to parse PSBT: {e}")))?; - let device = first_hwi_device(network, &hwi_wallet).await?; + let device = first_hwi_device(network, &hwi_wallet, target_fp).await?; device.sign_tx(&mut psbt).await?; Ok(json!({ "psbt": psbt.to_string() })) } @@ -349,10 +379,11 @@ fn infer_script_type(path: &str) -> Option<&'static str> { /// keychain branch (0 = external, 1 = internal). fn build_descriptor(script_type: &str, key_expr: &str, branch: u32) -> Result { let raw = match script_type { + "wpkh" => format!("wpkh({key_expr}/{branch}/*)"), "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}/*)"), + other => return Err(Error::Generic(format!("Unsupported script type: {other}"))), }; let descriptor = Descriptor::::from_str(&raw) .map_err(|e| Error::Generic(format!("Failed to build descriptor: {e}")))?; @@ -367,44 +398,104 @@ fn build_descriptor(script_type: &str, key_expr: &str, branch: u32) -> Result/*` multipath descriptor. fn build_device_policy(ext: &str, int: Option<&str>) -> Result { - let ext = strip_checksum(ext).trim().to_string(); - - if ext.contains("xprv") || ext.contains("tprv") { + let ext = strip_checksum(ext).trim(); + if contains_private_key(ext) { return Err(Error::Generic( "Hardware wallet policy must use public keys (xpub), not private keys".to_string(), )); } - // Already a multipath descriptor: use as-is. - if ext.contains("/**") || ext.contains('<') { - return Ok(ext); - } - - match int { - Some(int) => { - let int = strip_checksum(int).trim(); - if int.contains("xprv") || int.contains("tprv") { - return Err(Error::Generic( - "Hardware wallet policy must use public keys (xpub), not private keys" - .to_string(), - )); + let raw = if ext.contains("/**") || ext.contains('<') { + // Already a multipath descriptor. + ext.to_string() + } else { + match int { + Some(int) => { + let int = strip_checksum(int).trim(); + if contains_private_key(int) { + return Err(Error::Generic( + "Hardware wallet policy must use public keys (xpub), not private keys" + .to_string(), + )); + } + // `/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 \ + descriptors are not a standard `/0/*` and `/1/*` pair." + .to_string(), + )); + } + ext.replace("/0/*", "/<0;1>/*") } - // `/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 \ - descriptors are not a standard `/0/*` and `/1/*` pair." - .to_string(), - )); + None => { + tracing::warn!( + "Wallet has no separate internal (change) descriptor; the device policy will \ + only cover receive addresses." + ); + ext.to_string() } - Ok(ext.replace("/0/*", "/<0;1>/*")) - } - None => { - tracing::warn!( - "Wallet has no separate internal (change) descriptor; the device policy will \ - only cover receive addresses." - ); - Ok(ext) } + }; + + // Canonicalize so the device receives one well-formed, checksummed form. + let descriptor = Descriptor::::from_str(&raw) + .map_err(|e| Error::Generic(format!("Invalid wallet policy descriptor: {e}")))?; + Ok(descriptor.to_string()) +} + +fn contains_private_key(descriptor: &str) -> bool { + descriptor.contains("xprv") || descriptor.contains("tprv") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::CliOpts; + use clap::CommandFactory; + + const EXT: &str = "wpkh([f5acc2fd/84'/1'/0']tpubDCbK3Ysvk8HjcF6mPyrgMu3KgLiaaP19RjKpNezd8GrbAbNg6v5BtWLaCt8FNm6QkLseopKLf5MNYQFtochDTKHdfgG6iqJ8cqnLNAwtXuP/0/*)"; + const INT: &str = "wpkh([f5acc2fd/84'/1'/0']tpubDCbK3Ysvk8HjcF6mPyrgMu3KgLiaaP19RjKpNezd8GrbAbNg6v5BtWLaCt8FNm6QkLseopKLf5MNYQFtochDTKHdfgG6iqJ8cqnLNAwtXuP/1/*)"; + + #[test] + fn cli_definition_is_valid() { + CliOpts::command().debug_assert(); + } + + #[test] + fn infers_script_type_from_purpose() { + assert_eq!(infer_script_type("m/44'/1'/0'"), Some("pkh")); + assert_eq!(infer_script_type("m/49'/1'/0'"), Some("sh-wpkh")); + assert_eq!(infer_script_type("m/84'/1'/0'"), Some("wpkh")); + assert_eq!(infer_script_type("m/86h/0h/0h"), Some("tr")); + assert_eq!(infer_script_type("m/48'/1'/0'/2'"), None); + } + + #[test] + fn build_descriptor_rejects_unknown_type() { + assert!(build_descriptor("foo", "key", 0).is_err()); + } + + #[test] + fn policy_combines_and_canonicalizes() { + let policy = build_device_policy(EXT, Some(INT)).unwrap(); + // Combined into a single multipath descriptor... + assert!(policy.contains("<0;1>")); + assert!(!policy.contains("/0/*")); + assert!(!policy.contains("/1/*")); + // ...and re-serialized with a checksum. + assert!(policy.contains('#')); + } + + #[test] + fn policy_rejects_private_keys() { + let xprv = "wpkh([f5acc2fd/84'/1'/0']tprv8ZgxMBicQKsPd.../0/*)"; + assert!(build_device_policy(xprv, None).is_err()); + } + + #[test] + fn policy_rejects_mismatched_pair() { + let other = "wpkh([f5acc2fd/84'/1'/0']tpubDDtb2WPYwEWw2WWDV7reLV348iJHw2HmhzvPysKKrJw3hYmvrd4jasyoioVPdKGQqjyaBMEvTn1HvHWDSVqQ6amyyxRZ5YjpPBBGjJ8yu8S/1/*)"; + assert!(build_device_policy(EXT, Some(other)).is_err()); } } diff --git a/src/utils/hwi.rs b/src/utils/hwi.rs index 94b1568..296eabd 100644 --- a/src/utils/hwi.rs +++ b/src/utils/hwi.rs @@ -43,10 +43,7 @@ pub async fn enumerate_hwi_devices( ) -> Result>, Error> { let mut devices: Vec> = Vec::new(); - // Specter simulator (TCP) then physical Specter devices (serial). - if let Ok(device) = SpecterSimulator::try_connect().await { - devices.push(device.into()); - } + // Specter (serial). match Specter::enumerate().await { Ok(specters) => devices.extend(specters.into_iter().map(Into::into)), Err(e) => warn!("Specter enumeration failed: {e:?}"), @@ -74,130 +71,208 @@ pub async fn enumerate_hwi_devices( Err(e) => warn!("Jade enumeration failed: {e:?}"), } - // Ledger simulator (Speculos). - if let Ok(device) = LedgerSimulator::try_connect().await { - devices.push(device.into()); - } + // USB (HID): BitBox02, Coldcard and Ledger. A HID init failure is non-fatal + match HidApi::new() { + Ok(api) => { + let api = Box::new(api); - // 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)?; + for device_info in api.device_list() { + // BitBox02 + if async_hwi::bitbox::is_bitbox02(device_info) + && let Ok(handle) = device_info.open_device(&api) + && let Ok(pairing) = + PairingBitbox02WithLocalCache::::connect( + handle, None, + ) + .await + && let Ok((device, _)) = pairing.wait_confirm().await + { + push_bitbox( + &mut devices, + BitBox02::from(device).with_network(network), + wallet.policy, + ); + } + + // Coldcard + if device_info.vendor_id() == coldcard::api::COINKITE_VID + && device_info.product_id() == coldcard::api::CKCC_PID + && let Some(sn) = device_info.serial_number() + { + match coldcard::api::Coldcard::open(&api, sn, None) { + Ok((cc, _)) => { + let mut hw = coldcard::Coldcard::from(cc); + if let Some(name) = wallet.name { + hw = hw.with_wallet_name(name.to_string()); + } + devices.push(hw.into()); } - devices.push(bb02.into()); + Err(e) => warn!("Failed to open Coldcard (SN {sn}): {e:?}"), } - 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()))?); - - for device_info in api.device_list() { - // BitBox02 - if async_hwi::bitbox::is_bitbox02(device_info) - && let Ok(handle) = device_info.open_device(&api) - && let Ok(pairing) = - PairingBitbox02WithLocalCache::::connect(handle, None).await - && let Ok((device, _)) = pairing.wait_confirm().await - { - let mut bb02 = BitBox02::from(device).with_network(network); - if let Some(policy) = wallet.policy { - bb02 = bb02.with_policy(policy).map_err(map_device_err)?; + } } - devices.push(bb02.into()); - } - // Coldcard - if device_info.vendor_id() == coldcard::api::COINKITE_VID - && device_info.product_id() == coldcard::api::CKCC_PID - && let Some(sn) = device_info.serial_number() - { - match coldcard::api::Coldcard::open(&api, sn, None) { - Ok((cc, _)) => { - let mut hw = coldcard::Coldcard::from(cc); - if let Some(name) = wallet.name { - hw = hw.with_wallet_name(name.to_string()); - } - devices.push(hw.into()); + // Ledger (HID). + for detected in Ledger::::enumerate(&api) { + if let Ok(device) = Ledger::::connect(&api, detected) { + push_ledger(&mut devices, device, wallet)?; } - Err(e) => warn!("Failed to open Coldcard (SN {sn}): {e:?}"), } } + Err(e) => warn!("HID API init failed; skipping USB devices: {e}"), } - // Ledger (HID). - for detected in Ledger::::enumerate(&api) { - if let Ok(mut device) = Ledger::::connect(&api, detected) { - // A registered wallet policy (and its HMAC) is required before a - // Ledger will operate on anything other than default single-sig. - if let (Some(name), Some(policy)) = (wallet.name, wallet.policy) { - let hmac = match wallet.hmac { - Some(s) => { - let bytes = Vec::from_hex(s) - .map_err(|e| Error::Generic(format!("Invalid HMAC hex: {e}")))?; - let mut h = [0u8; 32]; - if bytes.len() != h.len() { - return Err(Error::Generic( - "HMAC must be 32 bytes (64 hex chars)".to_string(), - )); - } - h.copy_from_slice(&bytes); - Some(h) + // Simulators + + if let Ok(device) = SpecterSimulator::try_connect().await { + devices.push(device.into()); + } + if let Ok(device) = LedgerSimulator::try_connect().await { + devices.push(device.into()); + } + + // BitBox02 simulator + 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. Create a seed to carry out operations + if let Ok(info) = paired.device_info().await + && !info.initialized + && let Err(e) = paired.restore_from_mnemonic().await + { + warn!("BitBox simulator seeding failed: {e:?}"); } - None => None, - }; - device = device - .with_wallet(name, policy, hmac) - .map_err(map_device_err)?; - } - devices.push(device.into()); + push_bitbox( + &mut devices, + BitBox02::from(paired).with_network(network), + wallet.policy, + ); + } + Err(e) => warn!("BitBox simulator pairing confirmation failed: {e:?}"), + }, + Err(e) => warn!("BitBox simulator unlock/pair failed: {e:?}"), } } Ok(devices) } -/// Enumerate devices and return the first one, erroring if none are found. +/// Select a single connected device, by `target_fingerprint` when given. /// -/// Used by the single-device subcommands (register/address/sign). If more than -/// one device is connected a warning is logged and the first is used. +/// With no target and several devices connected, the first (physical devices +/// rank before simulators) is used and a warning lists the alternatives. The +/// chosen device's fingerprint and model are always logged. pub async fn first_hwi_device( network: Network, wallet: &HwiWallet<'_>, + target_fingerprint: Option<&str>, ) -> Result, Error> { let devices = enumerate_hwi_devices(network, wallet).await?; - let count = devices.len(); - if count > 1 { - warn!("{count} hardware wallets detected; using the first one."); + if devices.is_empty() { + return Err(Error::Generic("No hardware wallet detected".to_string())); + } + + // Label devices with their master fingerprint for selection and logging. + let mut labeled: Vec<(String, Box)> = Vec::with_capacity(devices.len()); + for device in devices { + match device.get_master_fingerprint().await { + Ok(fp) => labeled.push((fp.to_string(), device)), + Err(e) => warn!("Ignoring a device with an unreadable fingerprint: {e:?}"), + } + } + if labeled.is_empty() { + return Err(Error::Generic( + "No usable hardware wallet detected".to_string(), + )); + } + + let (fingerprint, device) = match target_fingerprint { + Some(target) => labeled + .into_iter() + .find(|(fp, _)| fp.eq_ignore_ascii_case(target)) + .ok_or_else(|| { + Error::Generic(format!("No connected device matches fingerprint {target}")) + })?, + None => { + if labeled.len() > 1 { + let fingerprints: Vec<&str> = labeled.iter().map(|(fp, _)| fp.as_str()).collect(); + warn!( + "{} hardware wallets connected ({}); using the first. Select one with --fingerprint.", + labeled.len(), + fingerprints.join(", ") + ); + } + labeled + .into_iter() + .next() + .expect("labeled is non-empty here") + } + }; + + warn!( + "Using hardware wallet {fingerprint} ({})", + device.device_kind() + ); + Ok(device) +} + +/// Attach an optional policy to a BitBox and push it. A policy failure skips +/// the device with a warning instead of aborting the whole scan. +fn push_bitbox( + devices: &mut Vec>, + bb02: BitBox02, + policy: Option<&str>, +) { + match policy { + Some(policy) => match bb02.with_policy(policy) { + Ok(device) => devices.push(device.into()), + Err(e) => warn!("BitBox with_policy failed; skipping device: {e:?}"), + }, + None => devices.push(bb02.into()), } - devices - .into_iter() - .next() - .ok_or_else(|| Error::Generic("No hardware wallet detected".to_string())) } -fn map_device_err(e: E) -> Error { - Error::Generic(e.to_string()) +/// Attach the wallet (name/policy/hmac) to a Ledger and push it. A `with_wallet` +/// failure skips the device with a warning; a malformed `--hmac` is a user error +/// and is returned. +fn push_ledger( + devices: &mut Vec>, + device: Ledger, + wallet: &HwiWallet<'_>, +) -> Result<(), Error> { + let device = match (wallet.name, wallet.policy) { + (Some(name), Some(policy)) => { + let hmac = parse_registration_hmac(wallet.hmac)?; + match device.with_wallet(name, policy, hmac) { + Ok(device) => device, + Err(e) => { + warn!("Ledger with_wallet failed; skipping device: {e:?}"); + return Ok(()); + } + } + } + _ => device, + }; + devices.push(device.into()); + Ok(()) +} + +/// Parse a 32-byte registration HMAC from its hex representation. +fn parse_registration_hmac(hmac: Option<&str>) -> Result, Error> { + let Some(s) = hmac else { + return Ok(None); + }; + let bytes = Vec::from_hex(s).map_err(|e| Error::Generic(format!("Invalid HMAC hex: {e}")))?; + let mut h = [0u8; 32]; + if bytes.len() != h.len() { + return Err(Error::Generic( + "HMAC must be 32 bytes (64 hex chars)".to_string(), + )); + } + h.copy_from_slice(&bytes); + Ok(Some(h)) }