diff --git a/Cargo.lock b/Cargo.lock index 4eea7a7..926e48d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -266,6 +266,7 @@ dependencies = [ "clap", "futures", "hex", + "miniscript", "rand_core 0.6.4", "reqwest", "serde", @@ -343,9 +344,9 @@ dependencies = [ [[package]] name = "bitcoin" -version = "0.32.2" +version = "0.32.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea507acc1cd80fc084ace38544bbcf7ced7c2aa65b653b102de0ce718df668f6" +checksum = "1e499f9fc0407f50fe98af744ab44fa67d409f76b6772e1689ec8485eb0c0f66" dependencies = [ "base58ck", "bech32", @@ -353,9 +354,10 @@ dependencies = [ "bitcoin-io", "bitcoin-units", "bitcoin_hashes", - "hex-conservative", + "hex-conservative 0.2.2", "hex_lit", "secp256k1", + "serde", ] [[package]] @@ -363,6 +365,9 @@ name = "bitcoin-internals" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" +dependencies = [ + "serde", +] [[package]] name = "bitcoin-io" @@ -377,6 +382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2" dependencies = [ "bitcoin-internals", + "serde", ] [[package]] @@ -386,7 +392,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb18c03d0db0247e147a21a6faafd5a7eb851c743db062de72018b6b7e8e4d16" dependencies = [ "bitcoin-io", - "hex-conservative", + "hex-conservative 0.2.2", + "serde", ] [[package]] @@ -1021,13 +1028,19 @@ dependencies = [ [[package]] name = "hex-conservative" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5313b072ce3c597065a808dbf612c4c8e8590bdbf8b579508bf7a762c5eae6cd" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" dependencies = [ "arrayvec", ] +[[package]] +name = "hex-conservative" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366fa3443ac84474447710ec17bb00b05dfbd096137817981e86f992f21a2793" + [[package]] name = "hex_lit" version = "0.1.1" @@ -1430,6 +1443,18 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniscript" +version = "13.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "867b1f11e0545ad5ebbddd8a9f18756d9657a6758bf10d96cf15ddd0b726b650" +dependencies = [ + "bech32", + "bitcoin", + "hex-conservative 1.0.1", + "serde", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1957,6 +1982,7 @@ checksum = "0e0cc0f1cf93f4969faf3ea1c7d8a9faed25918d96affa959720823dfe86d4f3" dependencies = [ "bitcoin_hashes", "secp256k1-sys", + "serde", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index df95033..08ba701 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ bhwi-async = { path = "./bhwi-async", version = "0.0.1" } futures = "0.3" hex = "0.4.3" log = "0.4" +miniscript = "13.0.0" rand_core = "0.6.4" reqwest = "0.13.2" serde = "1.0.228" diff --git a/bhwi-cli/Cargo.toml b/bhwi-cli/Cargo.toml index 0e52efd..7f61542 100644 --- a/bhwi-cli/Cargo.toml +++ b/bhwi-cli/Cargo.toml @@ -19,6 +19,7 @@ bitcoin.workspace = true bhwi-async = { workspace = true, features = ["emulators"] } futures.workspace = true hex = { workspace = true, features = ["serde"] } +miniscript = { workspace = true, features = ["serde"] } rand_core.workspace = true reqwest = { workspace = true, features = ["json"] } serde.workspace = true diff --git a/bhwi-cli/src/bin/bhwi.rs b/bhwi-cli/src/bin/bhwi.rs index 145223b..217ef85 100644 --- a/bhwi-cli/src/bin/bhwi.rs +++ b/bhwi-cli/src/bin/bhwi.rs @@ -1,11 +1,11 @@ use anyhow::Result; -use bhwi_cli::{DeviceManager, config::Config}; +use bhwi_cli::{DeviceManager, OutputFormat, config::Config}; use bitcoin::{ Network, bip32::{DerivationPath, Fingerprint}, }; -use clap::{Parser, Subcommand, ValueEnum}; +use clap::{Parser, Subcommand}; #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] @@ -19,6 +19,9 @@ struct Args { /// default will be the Bitcoin mainnet network. #[arg(long, short, value_parser = clap::value_parser!(bitcoin::Network), default_value_t = bitcoin::Network::Bitcoin)] network: Network, + /// output formatting + #[arg(long, short)] + format: Option, } impl From for Config { @@ -26,17 +29,21 @@ impl From for Config { let Args { network, fingerprint, + format, .. } = args; Self { network, fingerprint, + format, } } } #[derive(Debug, Clone, Subcommand)] enum Commands { + #[command(subcommand)] + Descriptor(DescriptorCommands), #[command(subcommand)] Device(DeviceCommands), #[command(subcommand)] @@ -47,10 +54,7 @@ enum Commands { enum DeviceCommands { /// List all available devices #[command(alias = "enumerate")] - List { - #[arg(long, short)] - format: Option, - }, + List, } #[derive(Debug, Clone, Subcommand)] @@ -61,28 +65,35 @@ enum XpubCommands { }, } -#[derive(Debug, Clone, Copy, ValueEnum)] -enum ListFormat { - Pretty, - Json, +#[derive(Debug, Clone, Copy, Subcommand)] +enum DescriptorCommands { + /// Get pubkey descriptors from device + #[command()] + Pubkeys { + #[arg(long, short)] + account: Option, + }, } #[tokio::main] async fn main() -> Result<()> { let args = Args::parse(); let command = args.command.to_owned(); + let format = args.format; let config: Config = args.into(); let dev_man = DeviceManager::new(config); match command { - Commands::Device(DeviceCommands::List { format }) => { + Commands::Descriptor(DescriptorCommands::Pubkeys { account }) => { + dev_man.get_pubkey_descriptors(account).await? + } + Commands::Device(DeviceCommands::List) => { let mut devices = dev_man.enumerate().await?; for (i, device) in devices.iter_mut().enumerate() { - device.device().unlock(dev_man.config.network).await?; let fingerprint = device.fingerprint().await?; let name = device.name(); let is_emulated = device.is_emulated(); match format { - Some(ListFormat::Pretty) => { + Some(OutputFormat::Pretty) => { if i == 0 { println!("{:<18} | {:<8} | {:<15}", "Name", "Emulated", "Fingerprint"); } @@ -90,11 +101,11 @@ async fn main() -> Result<()> { println!("{name:<18} | {is_emulated:<8} | {fingerprint:<15}"); println!("{}", "-".repeat(55)); } - Some(ListFormat::Json) => {} + Some(OutputFormat::Json) => {} None => println!("{fingerprint}"), } } - if let Some(ListFormat::Json) = format { + if let Some(OutputFormat::Json) = format { println!("{}", serde_json::json![devices]) } } diff --git a/bhwi-cli/src/config.rs b/bhwi-cli/src/config.rs index 6432440..8f1b532 100644 --- a/bhwi-cli/src/config.rs +++ b/bhwi-cli/src/config.rs @@ -1,8 +1,11 @@ use bitcoin::{Network, bip32::Fingerprint}; +use crate::OutputFormat; + // TODO: eventually have this be parsable by toml/yaml, env vars #[derive(Debug)] pub struct Config { pub network: Network, pub fingerprint: Option, + pub format: Option, } diff --git a/bhwi-cli/src/get_descriptors.rs b/bhwi-cli/src/get_descriptors.rs new file mode 100644 index 0000000..82a8c6b --- /dev/null +++ b/bhwi-cli/src/get_descriptors.rs @@ -0,0 +1,204 @@ +use anyhow::Result; +use bhwi_async::HWIDevice; +use bitcoin::{ + Network, + bip32::{ChildNumber, DerivationPath, Fingerprint}, +}; +use miniscript::{ + Descriptor, DescriptorPublicKey, + descriptor::{DescriptorType, DescriptorXKey, Wildcard, Wpkh}, +}; + +use crate::{DeviceManager, OutputFormat}; + +#[derive(Debug, Clone)] +pub struct GetDescriptorOptions { + /// The device's master fingerprint to use in the descriptor + pub master_fingerprint: Fingerprint, + /// The method used to derive the keys for the descriptor + pub target: DescriptorTarget, + /// Is this descriptor used for a change address? + pub is_change: bool, + /// The address type to use for the descriptor + pub descriptor_type: DescriptorType, + /// The Bitcoin network to use in descriptor paths + pub network: Network, +} + +#[derive(Debug, Clone)] +/// The method used to derive the keys for the descriptor +pub enum DescriptorTarget { + /// Derivation path to derive keys + Path(DerivationPath), + /// BIP-44 account index + Account(u32), +} + +impl GetDescriptorOptions { + pub fn with_path( + master_fingerprint: Fingerprint, + path: DerivationPath, + is_change: bool, + descriptor_type: DescriptorType, + network: Network, + ) -> Self { + Self { + master_fingerprint, + target: DescriptorTarget::Path(path), + is_change, + descriptor_type, + network, + } + } + + pub fn with_account( + master_fingerprint: Fingerprint, + account: u32, + is_change: bool, + descriptor_type: DescriptorType, + network: Network, + ) -> Self { + Self { + master_fingerprint, + target: DescriptorTarget::Account(account), + is_change, + descriptor_type, + network, + } + } +} + +impl DeviceManager { + /// Gets a descriptor with the given parameters + // reference: https://github.com/bitcoin-core/HWI/blob/master/hwilib/commands.py#L274 + pub async fn get_descriptor( + &self, + device: &mut Box, + options: GetDescriptorOptions, + ) -> Result> { + let GetDescriptorOptions { + master_fingerprint, + target, + is_change, + descriptor_type, + network, + } = options; + let path = match target { + DescriptorTarget::Path(path) => path, + DescriptorTarget::Account(account) => { + let purpose = ChildNumber::from_hardened_idx(bip44_purpose(descriptor_type)?)?; + let chain = ChildNumber::from_hardened_idx(bip44_chain(network))?; + let account = ChildNumber::from_hardened_idx(account)?; + let change = ChildNumber::from_normal_idx(is_change.into())?; + + [purpose, chain, account, change].as_ref().into() + } + }; + + let split = path + .into_iter() + .rposition(ChildNumber::is_hardened) + .map(|i| i + 1) + .unwrap_or(0); + let (origin, suffix) = (&path[..split], &path[split..]); + + let xpub = device.get_extended_pubkey(origin.into(), false).await?; + let pk = DescriptorPublicKey::XPub(DescriptorXKey { + origin: Some((master_fingerprint, origin.into())), + xkey: xpub, + derivation_path: suffix.into(), + wildcard: Wildcard::Unhardened, + }); + Ok(match descriptor_type { + DescriptorType::Pkh => Descriptor::new_pkh(pk)?, + DescriptorType::Wpkh => Descriptor::new_wpkh(pk)?, + DescriptorType::ShWpkh => Descriptor::new_sh_with_wpkh(Wpkh::new(pk)?), + // TODO: check if device supports Taproot + DescriptorType::Tr => Descriptor::new_tr(pk, None)?, + _ => anyhow::bail!("Unsupported descriptor type {descriptor_type:?}"), + }) + } + + /// Output all supported pubkey output descriptors for a device. Analogous + /// to the Python HWI when uses with JSON formatting. + // TODO: Python HWI outputs using h instead of ' for hardened paths but + // rust-miniscript doesn't allow this when displaying an entire Descriptor. + pub async fn get_pubkey_descriptors(&self, account: Option) -> Result<()> { + let Some(mut device) = self.get_device_with_fingerprint().await? else { + return Ok(()); + }; + let network = self.config.network; + let format = self.config.format; + let fingerprint = device.fingerprint().await?; + let dev = device.device(); + let mut receive = vec![]; + let mut internal = vec![]; + for desc_type in [ + DescriptorType::Pkh, + DescriptorType::Wpkh, + DescriptorType::ShWpkh, + DescriptorType::Tr, + ] { + let opts_receive = GetDescriptorOptions::with_account( + fingerprint, + account.unwrap_or(0), + false, + desc_type, + network, + ); + let opts_internal = GetDescriptorOptions::with_account( + fingerprint, + account.unwrap_or(0), + true, + desc_type, + network, + ); + receive.push(self.get_descriptor(dev, opts_receive).await?); + internal.push(self.get_descriptor(dev, opts_internal).await?); + } + match format { + Some(OutputFormat::Pretty) => { + let header = format!("{:<10} | {:<120}", "Purpose", "Descriptor"); + println!("{}", header); + println!("{}", "-".repeat(header.len())); + for (purpose, items) in [("internal", internal), ("receive", receive)] { + for item in items { + println!("{:<10} | {:<120}", purpose, item); + } + println!("{}", "-".repeat(header.len())); + } + } + Some(OutputFormat::Json) => { + println!( + "{}", + serde_json::json!( + { + "receive": receive, + "internal": internal + }) + ); + } + None => { + receive + .iter() + .chain(internal.iter()) + .for_each(|d| println!("{d:#}")); + } + } + Ok(()) + } +} + +fn bip44_purpose(desc_type: DescriptorType) -> Result { + Ok(match desc_type { + DescriptorType::Sh | DescriptorType::ShSortedMulti | DescriptorType::Pkh => 44, + DescriptorType::Wpkh | DescriptorType::Wsh | DescriptorType::WshSortedMulti => 84, + DescriptorType::ShWsh | DescriptorType::ShWpkh | DescriptorType::ShWshSortedMulti => 49, + DescriptorType::Tr => 86, + DescriptorType::Bare => anyhow::bail!("Bare PK descriptors aren't supported"), + }) +} + +fn bip44_chain(network: Network) -> u32 { + if let Network::Bitcoin = network { 0 } else { 1 } +} diff --git a/bhwi-cli/src/lib.rs b/bhwi-cli/src/lib.rs index 09c9d92..1529943 100644 --- a/bhwi-cli/src/lib.rs +++ b/bhwi-cli/src/lib.rs @@ -2,6 +2,7 @@ use anyhow::Result; use async_trait::async_trait; use bhwi_async::HWIDevice; use bitcoin::bip32::Fingerprint; +use clap::ValueEnum; use futures::future::join_all; use serde::{Serialize, Serializer}; use strum::{EnumIter, IntoEnumIterator}; @@ -10,6 +11,7 @@ use crate::{coldcard::ColdcardDevice, config::Config, jade::JadeDevice, ledger:: pub mod coldcard; pub mod config; +pub mod get_descriptors; pub mod hid; pub mod jade; pub mod ledger; @@ -111,6 +113,12 @@ pub trait DeviceEnumerator { async fn enumerate(config: &Config) -> Result>; } +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum OutputFormat { + Pretty, + Json, +} + fn option_fingerprint(value: &Option, ser: S) -> Result where S: Serializer,