From ec3ee74a88c186bd8b2fc99294825f5195363bc3 Mon Sep 17 00:00:00 2001 From: Trevor Arjeski Date: Fri, 10 Apr 2026 05:13:26 +0000 Subject: [PATCH 1/7] bhwi-cli: fix error when coldcard emulator isn't reachable When the Coldcard emulator isn't running there was an IO error that prevented other devices from being enumerated. --- bhwi-cli/src/coldcard.rs | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/bhwi-cli/src/coldcard.rs b/bhwi-cli/src/coldcard.rs index e6145db..48dd3da 100644 --- a/bhwi-cli/src/coldcard.rs +++ b/bhwi-cli/src/coldcard.rs @@ -39,17 +39,16 @@ impl ColdcardDevice { #[cfg(unix)] async fn emulator_device(path: &str, rng: &mut OsRng) -> Result> { if std::fs::exists(path)? { - Ok(Some( - Device::new( - "Coldcard Emulator", - Box::new(Coldcard::new( - ColdcardTransportHID::new(emulator::EmulatorClient::new(path).await?), - rng, - )), - true, - ) - .await?, - )) + let Ok(client) = emulator::EmulatorClient::new(path).await else { + return Ok(None); + }; + let device = Device::new( + "Coldcard Emulator", + Box::new(Coldcard::new(ColdcardTransportHID::new(client), rng)), + true, + ) + .await?; + Ok(Some(device)) } else { Ok(None) } From 27f19507ae90f65b4d28b277955e073c1510f648 Mon Sep 17 00:00:00 2001 From: Trevor Arjeski Date: Fri, 10 Apr 2026 05:36:57 +0000 Subject: [PATCH 2/7] bhwi: add context string to UnexpectedResult errors Perhaps not the best way to handle errors, but for now it's much more useful during development. --- bhwi/src/coldcard/mod.rs | 7 +++---- bhwi/src/common.rs | 10 ++++++++-- bhwi/src/jade/mod.rs | 2 +- bhwi/src/ledger/mod.rs | 22 ++++++++++++++-------- 4 files changed, 26 insertions(+), 15 deletions(-) diff --git a/bhwi/src/coldcard/mod.rs b/bhwi/src/coldcard/mod.rs index 84d1f95..58127ff 100644 --- a/bhwi/src/coldcard/mod.rs +++ b/bhwi/src/coldcard/mod.rs @@ -233,10 +233,9 @@ impl From for Error { ColdcardError::MissingCommandInfo(e) => Error::MissingCommandInfo(e), ColdcardError::NoErrorOrResult => Error::NoErrorOrResult, ColdcardError::Serialization(s) => Error::Serialization(s), - ColdcardError::UnexpectedResponseMessage { got, expected } => Error::UnexpectedResult( - format!("unexpected device response message. got: {got}, expected: {expected:?}") - .as_bytes() - .to_vec(), + ColdcardError::UnexpectedResponseMessage { got, expected } => Error::unexpected_result( + format!("{got:?}").into_bytes(), + format!("coldcard unexpected response: expected {expected:?}, got {got:?}"), ), } } diff --git a/bhwi/src/common.rs b/bhwi/src/common.rs index b012e42..0fbef77 100644 --- a/bhwi/src/common.rs +++ b/bhwi/src/common.rs @@ -55,8 +55,8 @@ pub enum Error { #[error("missing command info: {0}")] MissingCommandInfo(&'static str), - #[error("unexpected result: {0:x?}")] - UnexpectedResult(Vec), + #[error("unexpected result for {1}: {0:x?}")] + UnexpectedResult(Vec, String), #[error("rpc error {0}: {1:?}")] Rpc(i32, Option), @@ -71,6 +71,12 @@ pub enum Error { AuthenticationRefused, } +impl Error { + pub fn unexpected_result(data: Vec, context: impl Into) -> Self { + Error::UnexpectedResult(data, context.into()) + } +} + pub type ColdcardInterpreter<'a> = coldcard::ColdcardInterpreter<'a, Command, Transmit, Response, Error>; pub type JadeInterpreter = jade::JadeInterpreter; diff --git a/bhwi/src/jade/mod.rs b/bhwi/src/jade/mod.rs index 8691789..9914a46 100644 --- a/bhwi/src/jade/mod.rs +++ b/bhwi/src/jade/mod.rs @@ -307,7 +307,7 @@ impl From for Error { JadeError::NoErrorOrResult => Error::NoErrorOrResult, JadeError::Rpc(api_error) => Error::Rpc(api_error.code, api_error.message), JadeError::Serialization(s) => Error::Serialization(s), - JadeError::UnexpectedResult(msg) => Error::UnexpectedResult(msg.into_bytes()), + JadeError::UnexpectedResult(msg) => Error::unexpected_result(msg.clone().into_bytes(), format!("jade unexpected result: {msg}")), JadeError::HandshakeRefused => Error::AuthenticationRefused, } } diff --git a/bhwi/src/ledger/mod.rs b/bhwi/src/ledger/mod.rs index 27e2e14..d0203a1 100644 --- a/bhwi/src/ledger/mod.rs +++ b/bhwi/src/ledger/mod.rs @@ -41,13 +41,19 @@ pub enum LedgerError { #[error("operation interrupted")] Interrupted, - #[error("unexpected result: {0:x?}")] - UnexpectedResult(Vec), + #[error("unexpected result for {1}: {0:x?}")] + UnexpectedResult(Vec, String), #[error("failed to open app: {0:x?}")] FailedToOpenApp(Vec), } +impl LedgerError { + pub fn unexpected_result(data: Vec, context: impl Into) -> Self { + LedgerError::UnexpectedResult(data, context.into()) + } +} + #[derive(Clone, Debug)] pub enum LedgerCommand { OpenApp(Network), @@ -159,7 +165,7 @@ where match command { LedgerCommand::GetMasterFingerprint => { if res.data.len() < 4 { - return Err(LedgerError::UnexpectedResult(res.data).into()); + return Err(LedgerError::unexpected_result(res.data, "master fingerprint response").into()); } else { let mut fg = [0x00; 4]; fg.copy_from_slice(&res.data[0..4]); @@ -170,7 +176,7 @@ where } LedgerCommand::GetXpub { .. } => { let xpub = Xpub::from_str(&String::from_utf8_lossy(&res.data)) - .map_err(|_| LedgerError::UnexpectedResult(res.data))?; + .map_err(|_| LedgerError::unexpected_result(res.data, "xpub string"))?; self.state = State::Finished(LedgerResponse::Xpub(xpub)); } LedgerCommand::OpenApp(..) => { @@ -182,7 +188,7 @@ where ) { self.state = State::Finished(LedgerResponse::TaskDone); } else { - return Err(LedgerError::UnexpectedResult(res.data).into()); + return Err(LedgerError::unexpected_result(res.data, "open app status").into()); } } LedgerCommand::SignMessage { .. } => match res.status_word { @@ -193,10 +199,10 @@ where StatusWord::OK => { let header = res.data[0]; let sig = Signature::from_compact(&res.data[1..]) - .map_err(|_| LedgerError::UnexpectedResult(res.data))?; + .map_err(|_| LedgerError::unexpected_result(res.data, "signature compact data"))?; self.state = State::Finished(LedgerResponse::Signature(header, sig)); } - _ => return Err(LedgerError::UnexpectedResult(res.data).into()), + _ => return Err(LedgerError::unexpected_result(res.data, "sign message status").into()), }, } } @@ -245,7 +251,7 @@ impl From for Error { LedgerError::Apdu(e) => Error::Serialization(format!("{:?}", e)), LedgerError::Store(_) => Error::Request("Store operation failed"), LedgerError::Interrupted => Error::Request("Operation interrupted"), - LedgerError::UnexpectedResult(data) => Error::UnexpectedResult(data), + LedgerError::UnexpectedResult(data, ctx) => Error::unexpected_result(data, ctx), LedgerError::FailedToOpenApp(_) => Error::AuthenticationRefused, } } From 8d9c400e5dd24960590561fa4f5ba9f7173e4729 Mon Sep 17 00:00:00 2001 From: Trevor Arjeski Date: Fri, 10 Apr 2026 07:47:47 +0000 Subject: [PATCH 3/7] bhwi: stub out GetVersion command --- bhwi-async/src/lib.rs | 18 ++++++++++++++++++ bhwi/src/coldcard/mod.rs | 1 + bhwi/src/common.rs | 18 +++++++++++++++--- bhwi/src/jade/mod.rs | 1 + bhwi/src/ledger/mod.rs | 1 + 5 files changed, 36 insertions(+), 3 deletions(-) diff --git a/bhwi-async/src/lib.rs b/bhwi-async/src/lib.rs index 96cc472..776a42e 100644 --- a/bhwi-async/src/lib.rs +++ b/bhwi-async/src/lib.rs @@ -6,6 +6,7 @@ pub mod transport; use std::{error::Error as StdError, fmt::Debug}; use async_trait::async_trait; +pub use bhwi::common::Version; use bhwi::{ Interpreter, bitcoin::{ @@ -14,6 +15,7 @@ use bhwi::{ secp256k1::ecdsa::Signature, }, common, + common::Version, }; pub use jade::Jade; pub use ledger::Ledger; @@ -34,6 +36,7 @@ pub trait HttpClient { pub trait HWI { type Error: Debug; async fn unlock(&mut self, network: Network) -> Result<(), Self::Error>; + async fn get_version(&mut self) -> Result; async fn get_master_fingerprint(&mut self) -> Result; async fn get_extended_pubkey( &mut self, @@ -53,6 +56,7 @@ pub trait HWI { #[async_trait(?Send)] pub trait HWIDevice { async fn unlock(&mut self, network: Network) -> Result<(), HWIDeviceError>; + async fn get_version(&mut self) -> Result; async fn get_master_fingerprint(&mut self) -> Result; async fn get_extended_pubkey( &mut self, @@ -109,6 +113,16 @@ where Ok(()) } + async fn get_version(&mut self) -> Result { + if let common::Response::Version(version) = + run_command(self, common::Command::GetVersion).await? + { + Ok(version) + } else { + Err(common::Error::NoErrorOrResult.into()) + } + } + async fn get_master_fingerprint(&mut self) -> Result { if let common::Response::MasterFingerprint(fg) = run_command(self, common::Command::GetMasterFingerprint).await? @@ -166,6 +180,10 @@ where .map_err(HWIDeviceError::new) } + async fn get_version(&mut self) -> Result { + HWI::get_version(self).await.map_err(HWIDeviceError::new) + } + async fn get_master_fingerprint(&mut self) -> Result { HWI::get_master_fingerprint(self) .await diff --git a/bhwi/src/coldcard/mod.rs b/bhwi/src/coldcard/mod.rs index 58127ff..649935b 100644 --- a/bhwi/src/coldcard/mod.rs +++ b/bhwi/src/coldcard/mod.rs @@ -195,6 +195,7 @@ impl TryFrom for ColdcardCommand { Command::GetMasterFingerprint => Ok(Self::GetMasterFingerprint), Command::GetXpub { path, .. } => Ok(Self::GetXpub(path)), Command::SignMessage { message, path } => Ok(Self::SignMessage { message, path }), + Command::GetVersion => todo!(), } } } diff --git a/bhwi/src/common.rs b/bhwi/src/common.rs index 0fbef77..e3e0270 100644 --- a/bhwi/src/common.rs +++ b/bhwi/src/common.rs @@ -10,10 +10,8 @@ pub struct UnlockOptions { } pub enum Command { - Unlock { - options: UnlockOptions, - }, GetMasterFingerprint, + GetVersion, GetXpub { path: DerivationPath, display: bool, @@ -22,17 +20,31 @@ pub enum Command { message: Vec, path: DerivationPath, }, + Unlock { + options: UnlockOptions, + }, } pub enum Response { TaskDone, TaskBusy, + Version(Version), MasterFingerprint(Fingerprint), Xpub(Xpub), EncryptionKey([u8; 64]), Signature(u8, Signature), } +/// Version information returned from a device. +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct Version { + pub version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub network: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub firmware: Option, +} + pub enum Recipient { Device, PinServer { url: String }, diff --git a/bhwi/src/jade/mod.rs b/bhwi/src/jade/mod.rs index 9914a46..8d3fa5f 100644 --- a/bhwi/src/jade/mod.rs +++ b/bhwi/src/jade/mod.rs @@ -266,6 +266,7 @@ impl From for JadeCommand { Command::GetMasterFingerprint => Self::GetMasterFingerprint, Command::GetXpub { path, .. } => Self::GetXpub(path), Command::SignMessage { message, path } => Self::SignMessage { message, path }, + Command::GetVersion => todo!(), } } } diff --git a/bhwi/src/ledger/mod.rs b/bhwi/src/ledger/mod.rs index d0203a1..2990bb8 100644 --- a/bhwi/src/ledger/mod.rs +++ b/bhwi/src/ledger/mod.rs @@ -228,6 +228,7 @@ impl TryFrom for LedgerCommand { Command::GetMasterFingerprint => Ok(Self::GetMasterFingerprint), Command::GetXpub { path, display } => Ok(Self::GetXpub { path, display }), Command::SignMessage { message, path } => Ok(Self::SignMessage { message, path }), + Command::GetVersion => todo!(), } } } From c6e9afd49e8ac51f76cddc8709fa4789885ea2dc Mon Sep 17 00:00:00 2001 From: Trevor Arjeski Date: Fri, 10 Apr 2026 09:07:33 +0000 Subject: [PATCH 4/7] bhwi-cli: add get version call to device listing --- bhwi-async/src/lib.rs | 1 - bhwi-cli/src/bin/bhwi.rs | 20 +++++++++++++++----- bhwi-cli/src/lib.rs | 14 ++++++++++++++ 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/bhwi-async/src/lib.rs b/bhwi-async/src/lib.rs index 776a42e..646983e 100644 --- a/bhwi-async/src/lib.rs +++ b/bhwi-async/src/lib.rs @@ -15,7 +15,6 @@ use bhwi::{ secp256k1::ecdsa::Signature, }, common, - common::Version, }; pub use jade::Jade; pub use ledger::Ledger; diff --git a/bhwi-cli/src/bin/bhwi.rs b/bhwi-cli/src/bin/bhwi.rs index 217ef85..71fba84 100644 --- a/bhwi-cli/src/bin/bhwi.rs +++ b/bhwi-cli/src/bin/bhwi.rs @@ -89,17 +89,27 @@ async fn main() -> Result<()> { Commands::Device(DeviceCommands::List) => { let mut devices = dev_man.enumerate().await?; for (i, device) in devices.iter_mut().enumerate() { + // XXX: Coldcard always needs unlocking + device.device().unlock(dev_man.config.network).await?; let fingerprint = device.fingerprint().await?; - let name = device.name(); + let name = device.name().to_string(); let is_emulated = device.is_emulated(); + let version = device.version().await?; match format { Some(OutputFormat::Pretty) => { if i == 0 { - println!("{:<18} | {:<8} | {:<15}", "Name", "Emulated", "Fingerprint"); + println!( + "{:<18} | {:<8} | {:<15} | {:<12} | {:<8}", + "Name", "Emulated", "Fingerprint", "Network", "Version" + ); } - println!("{}", "-".repeat(55)); - println!("{name:<18} | {is_emulated:<8} | {fingerprint:<15}"); - println!("{}", "-".repeat(55)); + println!("{}", "-".repeat(80)); + let network = version.network.unwrap_or_default(); + println!( + "{name:<18} | {is_emulated:<8} | {fingerprint:<15} | {network:<12} | {:<8}", + version.version + ); + println!("{}", "-".repeat(80)); } Some(OutputFormat::Json) => {} None => println!("{fingerprint}"), diff --git a/bhwi-cli/src/lib.rs b/bhwi-cli/src/lib.rs index 1529943..c2d62a3 100644 --- a/bhwi-cli/src/lib.rs +++ b/bhwi-cli/src/lib.rs @@ -1,6 +1,7 @@ use anyhow::Result; use async_trait::async_trait; use bhwi_async::HWIDevice; +use bhwi_async::Version; use bitcoin::bip32::Fingerprint; use clap::ValueEnum; use futures::future::join_all; @@ -24,6 +25,8 @@ pub struct Device { is_emulated: bool, #[serde(default, serialize_with = "option_fingerprint")] fingerprint: Option, + #[serde(flatten, skip_serializing_if = "Option::is_none")] + version: Option, } impl Device { @@ -33,6 +36,7 @@ impl Device { device, is_emulated, fingerprint: None, + version: None, }) } @@ -57,6 +61,16 @@ impl Device { Ok(fingerprint) } } + + pub async fn version(&mut self) -> Result { + if let Some(ref version) = self.version { + Ok(version.clone()) + } else { + let version = self.device.get_version().await?; + self.version = Some(version.clone()); + Ok(version) + } + } } #[derive(Debug, EnumIter, strum::Display)] From f7611f2659dbf899d6fd771077d09e9199b65e33 Mon Sep 17 00:00:00 2001 From: Trevor Arjeski Date: Fri, 10 Apr 2026 09:13:17 +0000 Subject: [PATCH 5/7] bhwi(ledger): implement GetVersion Lots of work inspired by async-hwi and LedgerHQ/app-bitcoin-new --- bhwi/src/ledger/mod.rs | 111 +++++++++++++++++++++++++++++++++++------ e2e/ledger/src/lib.rs | 9 ++++ 2 files changed, 104 insertions(+), 16 deletions(-) diff --git a/bhwi/src/ledger/mod.rs b/bhwi/src/ledger/mod.rs index 2990bb8..e7548d7 100644 --- a/bhwi/src/ledger/mod.rs +++ b/bhwi/src/ledger/mod.rs @@ -12,12 +12,13 @@ use std::str::FromStr; use apdu::{ApduCommand, ApduError, ApduResponse, StatusWord}; use bitcoin::Network; use bitcoin::bip32::{DerivationPath, Fingerprint, Xpub}; +use bitcoin::consensus::encode::deserialize_partial; use bitcoin::secp256k1::ecdsa::Signature; use store::{DelegatedStore, StoreError}; pub use wallet::{WalletPolicy, WalletPubKey}; use crate::Interpreter; -use crate::common::{Command, Error, Response}; +use crate::common::{Command, Error, Response, Version}; use crate::device::DeviceId; pub const LEDGER_DEVICE_ID: DeviceId = DeviceId::new(0x2c97) @@ -57,6 +58,7 @@ impl LedgerError { #[derive(Clone, Debug)] pub enum LedgerCommand { OpenApp(Network), + GetAppInfo, GetMasterFingerprint, GetXpub { path: DerivationPath, @@ -68,11 +70,60 @@ pub enum LedgerCommand { }, } +/// Parsed response from the `GetAppInfo` APDU command. +/// +/// The raw response format from the device is: +/// - 1 byte: version tag (0x01) +/// - length-prefixed string: app name +/// - length-prefixed string: app version +/// - length-prefixed bytes: state flags +#[derive(Debug, Clone)] +pub struct GetAppInfoResponse { + pub app_name: String, + pub version: String, + pub flags: Vec, +} + +impl GetAppInfoResponse { + pub fn network(&self) -> Network { + if self.app_name == "Bitcoin" { + Network::Bitcoin + } else { + Network::Testnet + } + } +} + +impl TryFrom> for GetAppInfoResponse { + type Error = String; + + fn try_from(data: Vec) -> Result { + if data.is_empty() || data[0] != 0x01 { + return Err(format!( + "invalid version response header: expected 0x01, got {:02x}", + data.first().map_or(0, |b| *b) + )); + } + let (app_name, i): (String, usize) = deserialize_partial(&data[1..]) + .map_err(|e| format!("failed to parse app name: {e}"))?; + let (version, j): (String, usize) = deserialize_partial(&data[1 + i..]) + .map_err(|e| format!("failed to parse version: {e}"))?; + let (flags, _): (Vec, usize) = deserialize_partial(&data[1 + i + j..]) + .map_err(|e| format!("failed to parse flags: {e}"))?; + Ok(GetAppInfoResponse { + app_name, + version, + flags, + }) + } +} + pub enum LedgerResponse { - TaskDone, + AppInfo(GetAppInfoResponse), MasterFingerprint(Fingerprint), - Xpub(Xpub), Signature(u8, Signature), + TaskDone, + Xpub(Xpub), } #[derive(Default)] @@ -115,6 +166,7 @@ where fn start(&mut self, command: Self::Command) -> Result { let command: LedgerCommand = command.try_into()?; let (transmit, store) = match command { + LedgerCommand::GetAppInfo => (Self::Transmit::from(command::get_version()), None), LedgerCommand::GetMasterFingerprint => ( Self::Transmit::from(command::get_master_fingerprint()), None, @@ -160,12 +212,27 @@ where return Err(LedgerError::Interrupted.into()); } } - // FIXME: cleaner handling of res.status_word before processingn - // command results + // FIXME: cleaner handling of res.status_word before processing command results match command { + LedgerCommand::GetAppInfo => { + if res.status_word != StatusWord::OK { + return Err(LedgerError::unexpected_result( + res.data, + "get_version response", + ) + .into()); + } + let response = GetAppInfoResponse::try_from(res.data.clone()) + .map_err(|e| LedgerError::unexpected_result(res.data, e))?; + self.state = State::Finished(LedgerResponse::AppInfo(response)); + } LedgerCommand::GetMasterFingerprint => { if res.data.len() < 4 { - return Err(LedgerError::unexpected_result(res.data, "master fingerprint response").into()); + return Err(LedgerError::unexpected_result( + res.data, + "master fingerprint response", + ) + .into()); } else { let mut fg = [0x00; 4]; fg.copy_from_slice(&res.data[0..4]); @@ -182,13 +249,13 @@ where LedgerCommand::OpenApp(..) => { if matches!( res.status_word, - StatusWord::OK | - // An app is already open and the cla cannot be supported - StatusWord::ClaNotSupported + StatusWord::OK | StatusWord::ClaNotSupported ) { self.state = State::Finished(LedgerResponse::TaskDone); } else { - return Err(LedgerError::unexpected_result(res.data, "open app status").into()); + return Err( + LedgerError::unexpected_result(res.data, "open app response").into(), + ); } } LedgerCommand::SignMessage { .. } => match res.status_word { @@ -198,11 +265,18 @@ where } StatusWord::OK => { let header = res.data[0]; - let sig = Signature::from_compact(&res.data[1..]) - .map_err(|_| LedgerError::unexpected_result(res.data, "signature compact data"))?; + let sig = Signature::from_compact(&res.data[1..]).map_err(|_| { + LedgerError::unexpected_result(res.data, "signature compact data") + })?; self.state = State::Finished(LedgerResponse::Signature(header, sig)); } - _ => return Err(LedgerError::unexpected_result(res.data, "sign message status").into()), + _ => { + return Err(LedgerError::unexpected_result( + res.data, + "sign message status", + ) + .into()); + } }, } } @@ -228,7 +302,7 @@ impl TryFrom for LedgerCommand { Command::GetMasterFingerprint => Ok(Self::GetMasterFingerprint), Command::GetXpub { path, display } => Ok(Self::GetXpub { path, display }), Command::SignMessage { message, path } => Ok(Self::SignMessage { message, path }), - Command::GetVersion => todo!(), + Command::GetVersion => Ok(Self::GetAppInfo), } } } @@ -236,10 +310,15 @@ impl TryFrom for LedgerCommand { impl From for Response { fn from(res: LedgerResponse) -> Response { match res { - LedgerResponse::MasterFingerprint(fg) => Response::MasterFingerprint(fg), + LedgerResponse::AppInfo(res) => Response::Version(Version { + version: res.version.to_string(), + network: Some(res.network().to_string()), + firmware: None, + }), + LedgerResponse::Signature(header, signature) => Response::Signature(header, signature), LedgerResponse::TaskDone => Response::TaskDone, LedgerResponse::Xpub(xpub) => Response::Xpub(xpub), - LedgerResponse::Signature(header, signature) => Response::Signature(header, signature), + LedgerResponse::MasterFingerprint(fg) => Response::MasterFingerprint(fg), } } } diff --git a/e2e/ledger/src/lib.rs b/e2e/ledger/src/lib.rs index ee71cef..0505688 100644 --- a/e2e/ledger/src/lib.rs +++ b/e2e/ledger/src/lib.rs @@ -156,4 +156,13 @@ mod tests { .await; assert!(res.is_err()); } + + #[tokio::test] + async fn can_get_version() { + let (mut dev, _) = init().await; + let version = dev.get_version().await.unwrap(); + assert_eq!(version.version.to_string(), "2.4.5"); + assert_eq!(version.firmware, Some("Bitcoin Test".to_string())); + assert_eq!(version.network.unwrap().to_string(), "testnet"); + } } From 5893cfa3513a8cf4242aee400f9e7ac3dd9718c6 Mon Sep 17 00:00:00 2001 From: Trevor Arjeski Date: Fri, 10 Apr 2026 09:52:05 +0000 Subject: [PATCH 6/7] bhwi(coldcard): implement GetVersion Coldcold returns a giant newline delimited string, so we will parse that out and get the invalid semver (can contain letters) and the device model. --- bhwi/src/coldcard/api.rs | 21 ++++++++++++++++++--- bhwi/src/coldcard/mod.rs | 23 +++++++++++++++++++++-- e2e/coldcard/src/lib.rs | 8 ++++++++ 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/bhwi/src/coldcard/api.rs b/bhwi/src/coldcard/api.rs index 383ac64..395563a 100644 --- a/bhwi/src/coldcard/api.rs +++ b/bhwi/src/coldcard/api.rs @@ -1,3 +1,4 @@ +// See https://github.com/Coldcard/ckcc-protocol for implementation details. pub mod request { use bitcoin::bip32::DerivationPath; @@ -16,7 +17,6 @@ pub mod request { } } - // https://github.com/Coldcard/ckcc-protocol/blob/0bd92d4d6d01872e41ffc1e7d9a1e2f153130061/ckcc/protocol.py#L125 pub fn sign_message(message: &[u8], path: &DerivationPath) -> Vec { let mut data = b"smsg".to_vec(); // coldcard can support a few different address types: @@ -24,17 +24,19 @@ pub mod request { data.extend((0x01u32 | 0x02 | 0x04).to_le_bytes()); // hardcoding to P2WPKH let path_string = path.to_string(); data.extend((path_string.len() as u32).to_le_bytes()); - // https://github.com/Coldcard/ckcc-protocol/blob/0bd92d4d6d01872e41ffc1e7d9a1e2f153130061/ckcc/constants.py#L27-L31 data.extend((message.len() as u32).to_le_bytes()); data.extend(path_string.as_bytes()); data.extend_from_slice(message); data } - // https://github.com/Coldcard/ckcc-protocol/blob/0bd92d4d6d01872e41ffc1e7d9a1e2f153130061/ckcc/protocol.py#L131 pub fn get_signed_message() -> Vec { b"smok".to_vec() } + + pub fn get_version() -> Vec { + b"vers".to_vec() + } } #[cfg(test)] @@ -188,6 +190,19 @@ pub mod response { Ok(ColdcardResponse::Xpub(xpub(res)?)) } + pub fn version(res: &[u8]) -> Result { + let data = ResponseHandler::expect_response(res, ResponseMessage::Asci)?; + let version_string = + std::str::from_utf8(data).map_err(|e| ColdcardError::Serialization(e.to_string()))?; + let lines = version_string.lines().collect::>(); + let version = lines.get(1).unwrap_or(&version_string).to_string(); + let device_model = lines.last().cloned().unwrap_or_default().to_string(); + Ok(ColdcardResponse::Version { + version, + device_model, + }) + } + pub fn mypub(res: &[u8]) -> Result { let data = ResponseHandler::expect_response(res, ResponseMessage::MyPb)?; let (dev_pubkey, data) = split(data, 64)?; diff --git a/bhwi/src/coldcard/mod.rs b/bhwi/src/coldcard/mod.rs index 649935b..5a24391 100644 --- a/bhwi/src/coldcard/mod.rs +++ b/bhwi/src/coldcard/mod.rs @@ -8,7 +8,7 @@ use bitcoin::secp256k1::ecdsa::Signature; use crate::Interpreter; use crate::coldcard::api::response::ResponseMessage; -use crate::common::{Command, Error, Recipient, Response, Transmit}; +use crate::common::{Command, Error, Recipient, Response, Transmit, Version}; use crate::device::DeviceId; pub const DEFAULT_CKCC_SOCKET: &str = "/tmp/ckcc-simulator.sock"; @@ -54,6 +54,7 @@ impl ColdcardError { pub enum ColdcardCommand { StartEncryption, + GetVersion, GetMasterFingerprint, GetXpub(DerivationPath), SignMessage { @@ -65,6 +66,10 @@ pub enum ColdcardCommand { pub enum ColdcardResponse { Ok, Busy, + Version { + version: String, + device_model: String, + }, MasterFingerprint(Fingerprint), Xpub(Xpub), MyPub { @@ -131,6 +136,7 @@ where payload: api::request::start_encryption(None, &self.encryption.pub_key()?), encrypted: false, }, + ColdcardCommand::GetVersion => request(api::request::get_version(), self.encryption)?, ColdcardCommand::GetMasterFingerprint => request( api::request::get_xpub(&DerivationPath::master()), self.encryption, @@ -149,6 +155,11 @@ where fn exchange(&mut self, data: Vec) -> Result, Self::Error> { match &self.state { State::New => Ok(None), + State::Running(ColdcardCommand::GetVersion) => { + let data = self.encryption.decrypt(data)?; + self.state = State::Finished(api::response::version(&data)?); + Ok(None) + } State::Running(ColdcardCommand::GetMasterFingerprint) => { let data = self.encryption.decrypt(data)?; self.state = State::Finished(api::response::master_fingerprint(&data)?); @@ -195,7 +206,7 @@ impl TryFrom for ColdcardCommand { Command::GetMasterFingerprint => Ok(Self::GetMasterFingerprint), Command::GetXpub { path, .. } => Ok(Self::GetXpub(path)), Command::SignMessage { message, path } => Ok(Self::SignMessage { message, path }), - Command::GetVersion => todo!(), + Command::GetVersion => Ok(Self::GetVersion), } } } @@ -205,6 +216,14 @@ impl From for Response { match res { ColdcardResponse::MasterFingerprint(fg) => Response::MasterFingerprint(fg), ColdcardResponse::Xpub(xpub) => Response::Xpub(xpub), + ColdcardResponse::Version { + version, + device_model, + } => Response::Version(Version { + version: version.as_str().into(), + network: None, + firmware: Some(device_model), + }), ColdcardResponse::MyPub { encryption_key, .. } => { Response::EncryptionKey(encryption_key) } diff --git a/e2e/coldcard/src/lib.rs b/e2e/coldcard/src/lib.rs index 49a944d..2dd0a5a 100644 --- a/e2e/coldcard/src/lib.rs +++ b/e2e/coldcard/src/lib.rs @@ -85,4 +85,12 @@ mod tests { "KEMkoamxdI4o4yIKww0ZwbabbSWukI8WY1reuuPle/EJXzQ61fB/TFm+v/qmGCgTyEkhP3qCuAOOONBauJ/VtEA=" ); } + + #[tokio::test] + async fn can_get_version() { + let (mut dev, _) = device().await; + let version = dev.get_version().await.unwrap(); + assert_eq!(version.firmware, Some("mk4".to_string())); + assert_eq!(version.version.to_string(), "5.x.x"); + } } From 293a6e9fd222c7b2f85888fbba3b4d01de0fd702 Mon Sep 17 00:00:00 2001 From: Trevor Arjeski Date: Fri, 10 Apr 2026 11:13:54 +0000 Subject: [PATCH 7/7] bhwi(jade): implement GetVersion --- bhwi/src/jade/api.rs | 18 ++++++++++++++++-- bhwi/src/jade/mod.rs | 27 ++++++++++++++++++++++----- e2e/jade/src/lib.rs | 9 +++++++++ 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/bhwi/src/jade/api.rs b/bhwi/src/jade/api.rs index c3d887b..379a425 100644 --- a/bhwi/src/jade/api.rs +++ b/bhwi/src/jade/api.rs @@ -1,6 +1,6 @@ -/// See https://github.com/Blockstream/Jade/blob/master/docs/index.rst +// See https://github.com/Blockstream/Jade/blob/master/docs/index.rst use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; +use std::{collections::BTreeMap, fmt::Display}; use super::JadeError; @@ -133,6 +133,20 @@ pub enum JadeNetworks { All, } +impl Display for JadeNetworks { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + JadeNetworks::Main => "main", + JadeNetworks::Test => "test", + JadeNetworks::All => "all", + } + ) + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct DescriptorInfoResponse { pub descriptor_len: u32, diff --git a/bhwi/src/jade/mod.rs b/bhwi/src/jade/mod.rs index 8d3fa5f..6a8c869 100644 --- a/bhwi/src/jade/mod.rs +++ b/bhwi/src/jade/mod.rs @@ -11,8 +11,9 @@ use serde::Serialize; use serde::de::DeserializeOwned; use crate::Interpreter; -use crate::common::{Command, Error, Recipient, Response, Transmit}; +use crate::common::{Command, Error, Recipient, Response, Transmit, Version}; use crate::device::DeviceId; +use crate::jade::api::GetInfoResponse; pub const JADE_NETWORK_MAINNET: &str = "mainnet"; pub const JADE_NETWORK_TESTNET: &str = "testnet"; @@ -39,6 +40,7 @@ pub enum JadeError { pub enum JadeCommand { Auth, GetMasterFingerprint, + GetInfo, GetXpub(DerivationPath), SignMessage { message: Vec, @@ -47,10 +49,11 @@ pub enum JadeCommand { } pub enum JadeResponse { - TaskDone, + GetInfo(GetInfoResponse), MasterFingerprint(Fingerprint), - Xpub(Xpub), Signature(u8, Signature), + TaskDone, + Xpub(Xpub), } pub enum JadeRecipient { @@ -174,6 +177,7 @@ where .map_err(|e| JadeError::Serialization(e.to_string()))?, }), ), + JadeCommand::GetInfo => request("get_version_info", None::), }; self.state = State::Running(command); @@ -250,6 +254,11 @@ where self.response = Some(JadeResponse::Signature(sig_bytes[0], sig)); Ok(None) } + State::Running(JadeCommand::GetInfo) => { + let info: GetInfoResponse = from_response(&data)?.into_result()?; + self.response = Some(JadeResponse::GetInfo(info)); + Ok(None) + } } } fn end(self) -> Result { @@ -266,7 +275,7 @@ impl From for JadeCommand { Command::GetMasterFingerprint => Self::GetMasterFingerprint, Command::GetXpub { path, .. } => Self::GetXpub(path), Command::SignMessage { message, path } => Self::SignMessage { message, path }, - Command::GetVersion => todo!(), + Command::GetVersion => Self::GetInfo, } } } @@ -278,6 +287,11 @@ impl From for Response { JadeResponse::MasterFingerprint(fg) => Response::MasterFingerprint(fg), JadeResponse::Xpub(xpub) => Response::Xpub(xpub), JadeResponse::Signature(header, signature) => Response::Signature(header, signature), + JadeResponse::GetInfo(info) => Response::Version(Version { + version: info.jade_version.as_str().into(), + network: Some(info.jade_networks.to_string()), + firmware: None, + }), } } } @@ -308,7 +322,10 @@ impl From for Error { JadeError::NoErrorOrResult => Error::NoErrorOrResult, JadeError::Rpc(api_error) => Error::Rpc(api_error.code, api_error.message), JadeError::Serialization(s) => Error::Serialization(s), - JadeError::UnexpectedResult(msg) => Error::unexpected_result(msg.clone().into_bytes(), format!("jade unexpected result: {msg}")), + JadeError::UnexpectedResult(msg) => Error::unexpected_result( + msg.clone().into_bytes(), + format!("jade unexpected result: {msg}"), + ), JadeError::HandshakeRefused => Error::AuthenticationRefused, } } diff --git a/e2e/jade/src/lib.rs b/e2e/jade/src/lib.rs index f08eb2e..eccd6bf 100644 --- a/e2e/jade/src/lib.rs +++ b/e2e/jade/src/lib.rs @@ -53,4 +53,13 @@ mod tests { "H+SvKg15TSz+2C5ra6Q8/e8BaImOZVEeS0rOL6GCEt4vO+4xRRt+YYKavSqgAJBYZaGEiTqr7f9imyyElMNhYXU=" ); } + + #[tokio::test] + async fn can_get_version() { + let mut dev = device().await; + let version = dev.get_version().await.unwrap(); + // 1.0.39-beta2-11-g1ca0a0a4-dirty + assert!(version.version.to_string().contains("1.0.39")); + assert_eq!(version.network.unwrap(), "all"); + } }