From 90741c7b022e424c81ca73acea1d638bbc1406ce Mon Sep 17 00:00:00 2001 From: benma's agent Date: Tue, 13 Oct 2026 11:00:53 +0200 Subject: [PATCH 1/5] u2f: accept the last valid continuation frame The sequence counter is incremented after writing each continuation frame. After frame 127 it becomes 128, causing the encoder to reject a valid completed message. This affects payloads of 7,551 to 7,609 bytes. Only report too many frames if there is still payload left to encode. --- src/u2fframing.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/u2fframing.rs b/src/u2fframing.rs index a28aa2e..51b6c83 100644 --- a/src/u2fframing.rs +++ b/src/u2fframing.rs @@ -121,7 +121,7 @@ impl U2fHid { message = &message[len..]; seq += 1; - if seq > 127 { + if seq > 127 && !message.is_empty() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, "More frames than allowed", @@ -199,6 +199,19 @@ mod tests { assert_eq!(&data[..len], &expect[..]); } + #[test] + fn test_u2fhid_encode_last_continuation_frame() { + let codec = U2fHid::with_cid(0xEEEEEEEE, 0x55); + // These payloads require a partial and a full frame with sequence 127. + for len in [7551, 7609] { + let payload = vec![0x42; len]; + let mut buf = [0; MAX_LEN]; + assert_eq!(codec.encode(&payload, &mut buf).unwrap(), MAX_LEN); + assert_eq!(buf[128 * 64 + 4], 127); + assert_eq!(codec.decode(&buf).unwrap().unwrap(), payload); + } + } + #[test] fn test_u2fhid_decode_single() { let codec = U2fHid::with_cid(0xEEEEEEEE, 0x55); From f263e61536dd6b59c14373c24bb5496ecd2c25a5 Mon Sep 17 00:00:00 2001 From: benma's agent Date: Tue, 13 Oct 2026 11:00:56 +0200 Subject: [PATCH 2/5] u2f: reject oversized payloads The encoder casts message lengths to u16 before checking the required buffer size. Lengths above u16::MAX wrap, making that check unreliable; other oversized messages are only rejected later by buffer or frame limits. Reject payloads above the 7,609-byte U2F HID limit before narrowing the length or writing any frames. --- src/u2fframing.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/u2fframing.rs b/src/u2fframing.rs index 51b6c83..cb82154 100644 --- a/src/u2fframing.rs +++ b/src/u2fframing.rs @@ -14,7 +14,7 @@ const HEADER_CONT_LEN: usize = 5; // number 0, which then increments by one to a maximum of 127. // With a packet size of 64 bytes (max for full-speed devices), this means that the maximum message // payload length is 64 - 7 + 128 * (64 - 5) = 7609 bytes. -//pub const MAX_PAYLOAD_LEN: usize = 64 - HEADER_INIT_LEN + 128 * (64 - HEADER_CONT_LEN); +const MAX_PAYLOAD_LEN: usize = 64 - HEADER_INIT_LEN + 128 * (64 - HEADER_CONT_LEN); // This is the buffer size needed to fit the largest possible u2f package with headers pub const MAX_LEN: usize = 129 * 64; @@ -92,6 +92,12 @@ impl U2fHid { /// Encode function. pub fn encode(&self, mut message: &[u8], mut buf: &mut [u8]) -> io::Result { + if message.len() > MAX_PAYLOAD_LEN { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Message too long", + )); + } let enc_len = Self::get_encoded_len(message.len() as u16); if buf.len() < enc_len { return Err(std::io::Error::new( @@ -173,6 +179,21 @@ impl U2fHid { #[cfg(test)] mod tests { use super::*; + #[test] + fn test_u2fhid_payload_length_limit() { + let codec = U2fHid::with_cid(0xEEEEEEEE, 0x55); + let payload = vec![0x42; MAX_PAYLOAD_LEN]; + let mut buf = [0; MAX_LEN]; + assert_eq!(codec.encode(&payload, &mut buf).unwrap(), MAX_LEN); + assert_eq!(codec.decode(&buf).unwrap().unwrap(), payload); + for len in [MAX_PAYLOAD_LEN + 1, 65536] { + assert_eq!( + codec.encode(&vec![0; len], &mut buf).unwrap_err().kind(), + io::ErrorKind::InvalidInput, + ); + } + } + #[test] fn test_u2fhid_encode_single() { let codec = U2fHid::with_cid(0xEEEEEEEE, 0x55); From 4e499384cd71f1367baf6077219f3ebd92791e4c Mon Sep 17 00:00:00 2001 From: benma's agent Date: Tue, 13 Oct 2026 11:00:58 +0200 Subject: [PATCH 3/5] communication: propagate U2F encoding errors U2fHidCommunication::write unconditionally unwraps the framing result. If a message is too large for U2F HID, encoding returns an error and the library panics instead of returning a transport error. Add U2fEncode and propagate encoding failures through ReadWrite::write. --- src/communication.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/communication.rs b/src/communication.rs index 8a29f30..b670aef 100644 --- a/src/communication.rs +++ b/src/communication.rs @@ -18,6 +18,8 @@ pub enum Error { Read, #[error("u2f framing decoding error")] U2fDecode, + #[error("u2f framing encoding error")] + U2fEncode, #[error("error querying device info")] Info, #[error("firmware version {0} required")] @@ -57,7 +59,10 @@ impl U2fHidCommunication { impl ReadWrite for U2fHidCommunication { fn write(&self, msg: &[u8]) -> Result { let mut buf = [0u8; u2fframing::MAX_LEN]; - let size = self.u2fhid.encode(msg, &mut buf).unwrap(); + let size = self + .u2fhid + .encode(msg, &mut buf) + .or(Err(Error::U2fEncode))?; for chunk in buf[..size].chunks(64) { self.read_write.write(chunk)?; } From d36093812c9c46f6971e3bb0c13301abda2737bc Mon Sep 17 00:00:00 2001 From: benma's agent Date: Tue, 13 Oct 2026 12:26:31 +0200 Subject: [PATCH 4/5] introduce typed paired device states Introduce a PairedBitBox enum with locked, unlocked and uninitialized variants backed by a private session. Expose wallet operations only on UnlockedBitBox, and consume UninitializedBitBox when restoring a wallet. Keep device entry and unlock before pairing on every firmware version. Validate the legacy unlock response to distinguish an unlocked wallet from an uninitialized device. Reserve LockedBitBox::unlock for a later transition; the connection flow cannot return that state yet. Adapt existing examples and simulator helpers to the typed states. --- CHANGELOG.md | 1 + Cargo.toml | 2 +- examples/btc_miniscript.rs | 8 +- examples/btc_sign_msg.rs | 6 +- examples/btc_sign_op_return.rs | 10 +- examples/btc_sign_psbt.rs | 6 +- examples/btc_signtx.rs | 6 +- examples/cardano.rs | 6 +- examples/eth.rs | 6 +- examples/multithreaded.rs | 6 +- examples/simulator.rs | 9 +- examples/singlethreaded.rs | 6 +- src/btc.rs | 37 ++--- src/cardano.rs | 15 +- src/communication.rs | 1 - src/eth.rs | 33 ++--- src/lib.rs | 247 +++++++++++++++++++++++++-------- tests/test_btc_psbt.rs | 4 +- tests/test_device.rs | 15 +- tests/util/mod.rs | 35 +++-- 20 files changed, 328 insertions(+), 131 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8df68d6..8bbf26e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- Return typed device states after pairing, with explicit unlock and setup transitions. - Preserve the sign of positive EIP-712 signed integers supplied as JSON numbers. - Reset unfinished sessions when connecting to firmware v9.28.0 or newer, allowing host reconnects while the device remains powered on. diff --git a/Cargo.toml b/Cargo.toml index 5d6ba52..5aa2786 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,7 +92,7 @@ name = "simulator" required-features = ["simulator", "tokio/rt", "tokio/macros", "tokio/rt-multi-thread", "multithreaded"] [features] -# Implement Sync+Send for PairedBitBox and PairingBitBox. +# Implement Sync+Send for the BitBox state types. # This may or may not cause trouble on macOS, see: https://github.com/libusb/hidapi/issues/503 multithreaded = [] usb = ["dep:hidapi"] diff --git a/examples/btc_miniscript.rs b/examples/btc_miniscript.rs index e67b753..f465c41 100644 --- a/examples/btc_miniscript.rs +++ b/examples/btc_miniscript.rs @@ -4,7 +4,7 @@ use std::str::FromStr; use bitbox_api::pb; -async fn get_bitbox02() -> bitbox_api::PairedBitBox { +async fn get_bitbox02() -> bitbox_api::UnlockedBitBox { let noise_config = Box::new(bitbox_api::NoiseConfigNoCache {}); let bitbox = bitbox_api::BitBox::::from_hid_device( bitbox_api::usb::get_any_bitbox02().unwrap(), @@ -16,7 +16,11 @@ async fn get_bitbox02() -> bitbox_api::PairedBitBox device.unlock().await.unwrap(), + bitbox_api::PairedBitBox::Unlocked(device) => device, + bitbox_api::PairedBitBox::Uninitialized(_) => panic!("Initialize the BitBox first"), + } } #[tokio::main(flavor = "current_thread")] diff --git a/examples/btc_sign_msg.rs b/examples/btc_sign_msg.rs index b9dcc01..c9d5788 100644 --- a/examples/btc_sign_msg.rs +++ b/examples/btc_sign_msg.rs @@ -14,7 +14,11 @@ async fn signmsg() { if let Some(pairing_code) = pairing_bitbox.get_pairing_code().as_ref() { println!("Pairing code\n{pairing_code}"); } - let paired_bitbox = pairing_bitbox.wait_confirm().await.unwrap(); + let paired_bitbox = match pairing_bitbox.wait_confirm().await.unwrap() { + bitbox_api::PairedBitBox::Locked(device) => device.unlock().await.unwrap(), + bitbox_api::PairedBitBox::Unlocked(device) => device, + bitbox_api::PairedBitBox::Uninitialized(_) => panic!("Initialize the BitBox first"), + }; let keypath = bitbox_api::Keypath::try_from("m/49'/0'/0'/0/0").unwrap(); diff --git a/examples/btc_sign_op_return.rs b/examples/btc_sign_op_return.rs index b375b4d..6a52ee9 100644 --- a/examples/btc_sign_op_return.rs +++ b/examples/btc_sign_op_return.rs @@ -14,7 +14,7 @@ use bitcoin::{ }; use semver::VersionReq; -async fn connect_bitbox() -> bitbox_api::PairedBitBox { +async fn connect_bitbox() -> bitbox_api::UnlockedBitBox { let noise_config = Box::new(bitbox_api::NoiseConfigNoCache {}); let device = bitbox_api::BitBox::::from_hid_device( bitbox_api::usb::get_any_bitbox02().unwrap(), @@ -26,11 +26,15 @@ async fn connect_bitbox() -> bitbox_api::PairedBitBox device.unlock().await.unwrap(), + bitbox_api::PairedBitBox::Unlocked(device) => device, + bitbox_api::PairedBitBox::Uninitialized(_) => panic!("Initialize the BitBox first"), + } } async fn build_op_return_psbt( - paired: &bitbox_api::PairedBitBox, + paired: &bitbox_api::UnlockedBitBox, ) -> Psbt { let coin = pb::BtcCoin::Tbtc; let secp = secp256k1::Secp256k1::new(); diff --git a/examples/btc_sign_psbt.rs b/examples/btc_sign_psbt.rs index 1dbdae6..9583d6f 100644 --- a/examples/btc_sign_psbt.rs +++ b/examples/btc_sign_psbt.rs @@ -15,7 +15,11 @@ async fn sign_psbt(psbt: &mut bitcoin::psbt::Ps if let Some(pairing_code) = pairing_device.get_pairing_code().as_ref() { println!("Pairing code\n{pairing_code}"); } - let paired = pairing_device.wait_confirm().await.unwrap(); + let paired = match pairing_device.wait_confirm().await.unwrap() { + bitbox_api::PairedBitBox::Locked(device) => device.unlock().await.unwrap(), + bitbox_api::PairedBitBox::Unlocked(device) => device, + bitbox_api::PairedBitBox::Uninitialized(_) => panic!("Initialize the BitBox first"), + }; paired .btc_sign_psbt( pb::BtcCoin::Tbtc, diff --git a/examples/btc_signtx.rs b/examples/btc_signtx.rs index fa47749..f11105c 100644 --- a/examples/btc_signtx.rs +++ b/examples/btc_signtx.rs @@ -14,7 +14,11 @@ async fn signtx() { if let Some(pairing_code) = pairing_bitbox.get_pairing_code().as_ref() { println!("Pairing code\n{pairing_code}"); } - let paired_bitbox = pairing_bitbox.wait_confirm().await.unwrap(); + let paired_bitbox = match pairing_bitbox.wait_confirm().await.unwrap() { + bitbox_api::PairedBitBox::Locked(device) => device.unlock().await.unwrap(), + bitbox_api::PairedBitBox::Unlocked(device) => device, + bitbox_api::PairedBitBox::Uninitialized(_) => panic!("Initialize the BitBox first"), + }; let prevtx = bitbox_api::btc::PrevTx { version: 1, diff --git a/examples/cardano.rs b/examples/cardano.rs index 5f6336b..5270f2f 100644 --- a/examples/cardano.rs +++ b/examples/cardano.rs @@ -14,7 +14,11 @@ async fn demo() { if let Some(pairing_code) = pairing_bitbox.get_pairing_code().as_ref() { println!("Pairing code\n{pairing_code}"); } - let paired_bitbox = pairing_bitbox.wait_confirm().await.unwrap(); + let paired_bitbox = match pairing_bitbox.wait_confirm().await.unwrap() { + bitbox_api::PairedBitBox::Locked(device) => device.unlock().await.unwrap(), + bitbox_api::PairedBitBox::Unlocked(device) => device, + bitbox_api::PairedBitBox::Uninitialized(_) => panic!("Initialize the BitBox first"), + }; println!("Getting xpubs..."); let xpubs = paired_bitbox diff --git a/examples/eth.rs b/examples/eth.rs index 0c76c59..3f54d2b 100644 --- a/examples/eth.rs +++ b/examples/eth.rs @@ -60,7 +60,11 @@ async fn eth_demo() { if let Some(pairing_code) = pairing_bitbox.get_pairing_code().as_ref() { println!("Pairing code\n{pairing_code}"); } - let paired_bitbox = pairing_bitbox.wait_confirm().await.unwrap(); + let paired_bitbox = match pairing_bitbox.wait_confirm().await.unwrap() { + bitbox_api::PairedBitBox::Locked(device) => device.unlock().await.unwrap(), + bitbox_api::PairedBitBox::Unlocked(device) => device, + bitbox_api::PairedBitBox::Uninitialized(_) => panic!("Initialize the BitBox first"), + }; println!("Getting xpub..."); let xpub = paired_bitbox diff --git a/examples/multithreaded.rs b/examples/multithreaded.rs index 5f758f5..b0a43f1 100644 --- a/examples/multithreaded.rs +++ b/examples/multithreaded.rs @@ -15,7 +15,11 @@ async fn demo() { println!("Pairing code\n{pairing_code}"); } multithreading_type_check(&pairing_bitbox); - let paired_bitbox = pairing_bitbox.wait_confirm().await.unwrap(); + let paired_bitbox = match pairing_bitbox.wait_confirm().await.unwrap() { + bitbox_api::PairedBitBox::Locked(device) => device.unlock().await.unwrap(), + bitbox_api::PairedBitBox::Unlocked(device) => device, + bitbox_api::PairedBitBox::Uninitialized(_) => panic!("Initialize the BitBox first"), + }; println!( "root fingerprint: {}", paired_bitbox.root_fingerprint().await.unwrap() diff --git a/examples/simulator.rs b/examples/simulator.rs index 0c6baa5..83555ec 100644 --- a/examples/simulator.rs +++ b/examples/simulator.rs @@ -6,7 +6,14 @@ async fn demo() { .await .unwrap(); let pairing_bitbox = bitbox.unlock_and_pair().await.unwrap(); - let paired_bitbox = pairing_bitbox.wait_confirm().await.unwrap(); + let paired_bitbox = match pairing_bitbox.wait_confirm().await.unwrap() { + bitbox_api::PairedBitBox::Locked(device) => device.unlock().await.unwrap(), + bitbox_api::PairedBitBox::Unlocked(device) => device, + bitbox_api::PairedBitBox::Uninitialized(device) => { + println!("device info: {:?}", device.device_info().await.unwrap()); + return; + } + }; println!( "device info: {:?}", paired_bitbox.device_info().await.unwrap() diff --git a/examples/singlethreaded.rs b/examples/singlethreaded.rs index fde9a50..f54e0ae 100644 --- a/examples/singlethreaded.rs +++ b/examples/singlethreaded.rs @@ -12,7 +12,11 @@ async fn demo() { if let Some(pairing_code) = pairing_bitbox.get_pairing_code().as_ref() { println!("Pairing code\n{pairing_code}"); } - let paired_bitbox = pairing_bitbox.wait_confirm().await.unwrap(); + let paired_bitbox = match pairing_bitbox.wait_confirm().await.unwrap() { + bitbox_api::PairedBitBox::Locked(device) => device.unlock().await.unwrap(), + bitbox_api::PairedBitBox::Unlocked(device) => device, + bitbox_api::PairedBitBox::Uninitialized(_) => panic!("Initialize the BitBox first"), + }; println!( "root fingerprint: {}", paired_bitbox.root_fingerprint().await.unwrap() diff --git a/src/btc.rs b/src/btc.rs index 8280d43..49f7487 100644 --- a/src/btc.rs +++ b/src/btc.rs @@ -7,7 +7,7 @@ use crate::runtime::Runtime; use crate::error::Error; use crate::pb::{self, request::Request, response::Response}; use crate::Keypath; -use crate::PairedBitBox; +use crate::UnlockedBitBox; pub use bitcoin::{ bip32::{Fingerprint, Xpub}, @@ -678,7 +678,7 @@ fn find_or_add_script_config( } } -impl PairedBitBox { +impl UnlockedBitBox { /// Retrieves an xpub. For non-standard keypaths, a warning is displayed on the BitBox even if /// `display` is false. pub async fn btc_xpub( @@ -688,7 +688,7 @@ impl PairedBitBox { xpub_type: pb::btc_pub_request::XPubType, display: bool, ) -> Result { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; self.btc_xpub_inner(coin, keypath, xpub_type, display).await } @@ -700,6 +700,7 @@ impl PairedBitBox { display: bool, ) -> Result { match self + .session .query_proto(Request::BtcPub(pb::BtcPubRequest { coin: coin as _, keypath: keypath.to_vec(), @@ -721,8 +722,8 @@ impl PairedBitBox { keypaths: &[Keypath], xpub_type: pb::btc_xpubs_request::XPubType, ) -> Result, Error> { - let _api_call = self.begin_api_call().await; - if self.validate_version(">=9.24.0").is_err() { + let _api_call = self.session.begin_api_call().await; + if self.session.validate_version(">=9.24.0").is_err() { // Fallback to fetching them one-by-one on older firmware. let mut xpubs = Vec::::with_capacity(keypaths.len()); for keypath in keypaths { @@ -762,8 +763,9 @@ impl PairedBitBox { script_config: &pb::BtcScriptConfig, display: bool, ) -> Result { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; match self + .session .query_proto(Request::BtcPub(pb::BtcPubRequest { coin: coin as _, keypath: keypath.to_vec(), @@ -784,6 +786,7 @@ impl PairedBitBox { request: pb::btc_request::Request, ) -> Result { match self + .session .query_proto(Request::Btc(pb::BtcRequest { request: Some(request), })) @@ -797,7 +800,7 @@ impl PairedBitBox { } async fn get_next_response(&self, request: Request) -> Result { - match self.query_proto(request).await? { + match self.session.query_proto(request).await? { Response::BtcSignNext(next_response) => Ok(next_response), _ => Err(Error::UnexpectedResponse), } @@ -821,7 +824,7 @@ impl PairedBitBox { transaction: &Transaction, format_unit: pb::btc_sign_init_request::FormatUnit, ) -> Result>, Error> { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; self.btc_sign_inner(coin, transaction, &[], &[], format_unit) .await } @@ -841,14 +844,14 @@ impl PairedBitBox { "output script config indices must match transaction outputs".into(), )); } - self.validate_version(">=9.4.0")?; // anti-klepto since 9.4.0 + self.session.validate_version(">=9.4.0")?; // anti-klepto since 9.4.0 if transaction .script_configs .iter() .chain(output_script_configs) .any(is_taproot_simple) { - self.validate_version(">=9.10.0")?; // taproot since 9.10.0 + self.session.validate_version(">=9.10.0")?; // taproot since 9.10.0 } if transaction.outputs.iter().any(|output| { matches!( @@ -857,7 +860,7 @@ impl PairedBitBox { if tx_output.payload.output_type == pb::BtcOutputType::OpReturn ) }) { - self.validate_version(">=9.24.0")?; + self.session.validate_version(">=9.24.0")?; } let mut sigs: Vec> = Vec::new(); @@ -1050,11 +1053,11 @@ impl PairedBitBox { force_script_config: Option, format_unit: pb::btc_sign_init_request::FormatUnit, ) -> Result<(), Error> { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; // since v9.15.0, the BitBox02 accepts "internal" outputs (ones sent to the BitBox02 with // the keypath) even if the keypath is not a change keypath. PSBTs often contain the key // origin info in outputs even in regular send-to-self outputs. - self.validate_version(">=9.15.0")?; + self.session.validate_version(">=9.15.0")?; let our_root_fingerprint = self.root_fingerprint_inner().await?; let psbt_transaction = Transaction::from_psbt( @@ -1113,8 +1116,8 @@ impl PairedBitBox { script_config: pb::BtcScriptConfigWithKeypath, msg: &[u8], ) -> Result { - let _api_call = self.begin_api_call().await; - self.validate_version(">=9.5.0")?; + let _api_call = self.session.begin_api_call().await; + self.session.validate_version(">=9.5.0")?; let host_nonce = crate::antiklepto::gen_host_nonce()?; let request = pb::BtcSignMessageRequest { @@ -1176,7 +1179,7 @@ impl PairedBitBox { script_config: &pb::BtcScriptConfig, keypath_account: Option<&Keypath>, ) -> Result { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; match self .query_proto_btc(pb::btc_request::Request::IsScriptConfigRegistered( pb::BtcIsScriptConfigRegisteredRequest { @@ -1214,7 +1217,7 @@ impl PairedBitBox { xpub_type: pb::btc_register_script_config_request::XPubType, name: Option<&str>, ) -> Result<(), Error> { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; match self .query_proto_btc(pb::btc_request::Request::RegisterScriptConfig( pb::BtcRegisterScriptConfigRequest { diff --git a/src/cardano.rs b/src/cardano.rs index 0608b20..ce3ecb7 100644 --- a/src/cardano.rs +++ b/src/cardano.rs @@ -5,7 +5,7 @@ use crate::runtime::Runtime; use crate::error::Error; use crate::pb::{self, request::Request, response::Response}; use crate::Keypath; -use crate::PairedBitBox; +use crate::UnlockedBitBox; /// Create a Shelley PaymentKeyHash/StakeKeyHash config. /// @@ -23,14 +23,15 @@ pub fn make_script_config_pkh_skh( } } -impl PairedBitBox { +impl UnlockedBitBox { async fn query_proto_cardano( &self, request: pb::cardano_request::Request, ) -> Result { - self.validate_version(">=9.8.0")?; // Cardano since 9.8.0 + self.session.validate_version(">=9.8.0")?; // Cardano since 9.8.0 match self + .session .query_proto(Request::Cardano(pb::CardanoRequest { request: Some(request), })) @@ -51,7 +52,7 @@ impl PairedBitBox { /// Query the device for xpubs. The result contains one xpub per requested keypath. Each xpub is /// 64 bytes: 32 byte chain code + 32 byte pubkey. pub async fn cardano_xpubs(&self, keypaths: &[Keypath]) -> Result>, Error> { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; match self .query_proto_cardano(pb::cardano_request::Request::Xpubs( pb::CardanoXpubsRequest { @@ -72,7 +73,7 @@ impl PairedBitBox { script_config: &pb::CardanoScriptConfig, display: bool, ) -> Result { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; match self .query_proto_cardano(pb::cardano_request::Request::Address( pb::CardanoAddressRequest { @@ -93,9 +94,9 @@ impl PairedBitBox { &self, transaction: pb::CardanoSignTransactionRequest, ) -> Result { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; if transaction.tag_cbor_sets { - self.validate_version(">=9.22.0")?; + self.session.validate_version(">=9.22.0")?; } match self .query_proto_cardano(pb::cardano_request::Request::SignTransaction(transaction)) diff --git a/src/communication.rs b/src/communication.rs index b670aef..7411fac 100644 --- a/src/communication.rs +++ b/src/communication.rs @@ -126,7 +126,6 @@ pub struct Info { #[allow(dead_code)] pub unlocked: bool, // Is None before firmware version 9.20.0. - #[allow(dead_code)] pub initialized: Option, } diff --git a/src/eth.rs b/src/eth.rs index a17c4e2..8cdc51f 100644 --- a/src/eth.rs +++ b/src/eth.rs @@ -11,7 +11,7 @@ use crate::pb::{ response::Response, }; use crate::Keypath; -use crate::PairedBitBox; +use crate::UnlockedBitBox; use std::collections::HashMap; use std::str::FromStr; @@ -24,12 +24,13 @@ use serde_json::Value; /// Transactions with data larger than this use streaming mode. const STREAMING_THRESHOLD: usize = 6144; -impl PairedBitBox { +impl UnlockedBitBox { async fn query_proto_eth( &self, request: pb::eth_request::Request, ) -> Result { match self + .session .query_proto(Request::Eth(pb::EthRequest { request: Some(request), })) @@ -49,7 +50,7 @@ impl PairedBitBox { /// Query the device for an xpub. pub async fn eth_xpub(&self, keypath: &Keypath) -> Result { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; match self .query_proto_eth(pb::eth_request::Request::Pub(pb::EthPubRequest { keypath: keypath.to_vec(), @@ -73,7 +74,7 @@ impl PairedBitBox { keypath: &Keypath, display: bool, ) -> Result { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; match self .query_proto_eth(pb::eth_request::Request::Pub(pb::EthPubRequest { keypath: keypath.to_vec(), @@ -442,7 +443,7 @@ fn get_value( } } -impl PairedBitBox { +impl UnlockedBitBox { async fn handle_antiklepto( &self, response: &pb::eth_response::Response, @@ -510,13 +511,13 @@ impl PairedBitBox { tx: &Transaction, address_case: Option, ) -> Result<[u8; 65], Error> { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; // passing chainID instead of coin only since v9.10.0 - self.validate_version(">=9.10.0")?; + self.session.validate_version(">=9.10.0")?; let use_streaming = tx.data.len() > STREAMING_THRESHOLD; if use_streaming { - self.validate_version(">=9.26.0")?; + self.session.validate_version(">=9.26.0")?; } let host_nonce = crate::antiklepto::gen_host_nonce()?; @@ -561,13 +562,13 @@ impl PairedBitBox { tx: &EIP1559Transaction, address_case: Option, ) -> Result<[u8; 65], Error> { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; // EIP1559 is suported from v9.16.0 - self.validate_version(">=9.16.0")?; + self.session.validate_version(">=9.16.0")?; let use_streaming = tx.data.len() > STREAMING_THRESHOLD; if use_streaming { - self.validate_version(">=9.26.0")?; + self.session.validate_version(">=9.26.0")?; } let host_nonce = crate::antiklepto::gen_host_nonce()?; @@ -615,9 +616,9 @@ impl PairedBitBox { keypath: &Keypath, msg: &[u8], ) -> Result<[u8; 65], Error> { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; // passing chainID instead of coin only since v9.10.0 - self.validate_version(">=9.10.0")?; + self.session.validate_version(">=9.10.0")?; let host_nonce = crate::antiklepto::gen_host_nonce()?; let request = pb::eth_request::Request::SignMsg(pb::EthSignMessageRequest { @@ -646,10 +647,10 @@ impl PairedBitBox { json_msg: &str, use_antiklepto: bool, ) -> Result<[u8; 65], Error> { - let _api_call = self.begin_api_call().await; - self.validate_version(">=9.12.0")?; + let _api_call = self.session.begin_api_call().await; + self.session.validate_version(">=9.12.0")?; if !use_antiklepto { - self.validate_version(">=9.26.0")?; + self.session.validate_version(">=9.26.0")?; } let msg: Eip712Message = serde_json::from_str(json_msg) diff --git a/src/lib.rs b/src/lib.rs index dbd7855..4b430f6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -56,6 +56,8 @@ const _OP_ATTESTATION: u8 = b'a'; const OP_UNLOCK: u8 = b'u'; const RESPONSE_SUCCESS: u8 = 0x00; +const RESPONSE_FAILURE: u8 = 0x01; +const RESPONSE_FAILURE_UNINITIALIZED: u8 = 0x02; type Cipher = noise_rust_crypto::ChaCha20Poly1305; type HandshakeState = @@ -120,11 +122,17 @@ impl BitBox { } /// Invokes the device unlock and pairing. - pub async fn unlock_and_pair(self) -> Result, Error> { - self.communication - .query(&[OP_UNLOCK]) - .await - .or(Err(Error::Unknown))?; + pub async fn unlock_and_pair(mut self) -> Result, Error> { + // The legacy response also reports initialization on firmware older than + // v9.20.0, whose transport info does not include an initialized flag. + self.communication.info.initialized = Some( + match self.communication.query(&[OP_UNLOCK]).await?.as_slice() { + [RESPONSE_SUCCESS] => true, + [RESPONSE_FAILURE_UNINITIALIZED] => false, + [RESPONSE_FAILURE] => return Err(BitBoxError::Generic.into()), + _ => return Err(Error::UnexpectedResponse), + }, + ); self.pair().await } @@ -249,7 +257,13 @@ impl PairingBitBox { self.pairing_code.clone() } - /// Proceed to the paired state. + /// Waits for pairing confirmation and returns the paired device's state. + /// + /// Initialized devices were unlocked by [`BitBox::unlock_and_pair`] and return + /// [`PairedBitBox::Unlocked`]. Uninitialized devices return + /// [`PairedBitBox::Uninitialized`]. The locked state is not returned yet. + /// + /// Once this returns, the application can dismiss its pairing UI before proceeding. pub async fn wait_confirm(self) -> Result, Error> { if self.pairing_code.is_some() { let response = self @@ -265,23 +279,153 @@ impl PairingBitBox { config_data.add_device_static_pubkey(&remote_static_pubkey); self.noise_config.store_config(&config_data)?; } - Ok(PairedBitBox::from(self.communication, self.host)) + let initialized = self + .communication + .info + .initialized + .ok_or(Error::UnexpectedResponse)?; + let session = Session::from(self.communication, self.host); + Ok(if !initialized { + PairedBitBox::Uninitialized(UninitializedBitBox { session }) + } else { + PairedBitBox::Unlocked(UnlockedBitBox { session }) + }) + } +} + +/// A confirmed pairing, with the operations available in the device's current state. +/// +/// Match this value to unlock, use the wallet, or set up an uninitialized device. +pub enum PairedBitBox { + Locked(LockedBitBox), + Unlocked(UnlockedBitBox), + Uninitialized(UninitializedBitBox), +} + +impl PairedBitBox { + /// Returns which product we are connected to. + pub fn product(&self) -> Product { + match self { + Self::Locked(device) => device.product(), + Self::Unlocked(device) => device.product(), + Self::Uninitialized(device) => device.product(), + } + } + + /// Returns the firmware version. + pub fn version(&self) -> &semver::Version { + match self { + Self::Locked(device) => device.version(), + Self::Unlocked(device) => device.version(), + Self::Uninitialized(device) => device.version(), + } + } +} + +/// A paired device whose unlock step has not yet completed through this client. +/// +/// Reserved for unlocking after pairing. The current connection flow unlocks before +/// pairing, so it never returns this state. Wallet methods are unavailable in this state. +/// +/// ```compile_fail +/// async fn fingerprint(device: bitbox_api::LockedBitBox) { +/// device.root_fingerprint().await; +/// } +/// ``` +pub struct LockedBitBox { + session: Session, +} + +impl LockedBitBox { + /// Reserved for unlocking after pairing; the current connection flow never returns this state. + pub async fn unlock(self) -> Result, Error> { + unreachable!("devices are unlocked before pairing") + } + + /// Returns which product we are connected to. + pub fn product(&self) -> Product { + self.session.communication.info.product + } + + /// Returns the firmware version. + pub fn version(&self) -> &semver::Version { + &self.session.communication.info.version + } +} + +/// A paired, initialized and unlocked device, ready for wallet operations. +pub struct UnlockedBitBox { + session: Session, +} + +/// A paired device that needs setup before wallet operations are available. +/// +/// This includes devices whose setup has started but is not yet complete. +/// [`Self::restore_from_mnemonic`] consumes this value and returns an [`UnlockedBitBox`]. +/// +/// ```compile_fail +/// async fn fingerprint(device: bitbox_api::UninitializedBitBox) { +/// device.root_fingerprint().await; +/// } +/// ``` +pub struct UninitializedBitBox { + session: Session, +} + +impl UninitializedBitBox { + pub async fn device_info(&self) -> Result { + self.session.device_info().await + } + + /// Returns which product we are connected to. + pub fn product(&self) -> Product { + self.session.communication.info.product + } + + /// Returns the firmware version. + pub fn version(&self) -> &semver::Version { + &self.session.communication.info.version + } + + /// Restores recovery words on the BitBox and returns the initialized, unlocked device. + pub async fn restore_from_mnemonic(self) -> Result, Error> { + { + let _api_call = self.session.begin_api_call().await; + let now = std::time::SystemTime::now(); + let duration_since_epoch = now.duration_since(std::time::UNIX_EPOCH).unwrap(); + match self + .session + .query_proto(Request::RestoreFromMnemonic( + pb::RestoreFromMnemonicRequest { + timestamp: duration_since_epoch.as_secs() as u32, + timezone_offset: chrono::Local::now().offset().local_minus_utc(), + }, + )) + .await? + { + Response::Success(_) => (), + _ => return Err(Error::UnexpectedResponse), + } + } + Ok(UnlockedBitBox { + session: self.session, + }) } } -/// Paired BitBox. This is where you can invoke most API functions like getting xpubs, displaying -/// receive addresses, etc. -pub struct PairedBitBox { +// The encrypted channel is shared by the public state types. Keeping it private prevents +// callers from bypassing the transitions that enable setup or wallet operations. +struct Session { communication: communication::HwwCommunication, api_mutex: AsyncMutex<()>, noise_send: Mutex, noise_recv: Mutex, } -impl PairedBitBox { +impl Session { fn from(communication: communication::HwwCommunication, host: HandshakeState) -> Self { let (send, recv) = host.get_ciphers(); - PairedBitBox { + Session { communication, api_mutex: AsyncMutex::new(()), noise_send: Mutex::new(send), @@ -295,7 +439,7 @@ impl PairedBitBox { /// multiplexed transport. Some public calls span multiple encrypted /// requests, so this mutex guard is held for the whole public method /// instead of only one query_proto() round trip. - pub(crate) async fn begin_api_call(&self) -> ApiMutexGuard<'_, ()> { + async fn begin_api_call(&self) -> ApiMutexGuard<'_, ()> { self.api_mutex.lock().await } @@ -350,7 +494,7 @@ impl PairedBitBox { } } - pub async fn device_info(&self) -> Result { + async fn device_info(&self) -> Result { let _api_call = self.begin_api_call().await; match self .query_proto(Request::DeviceInfo(pb::DeviceInfoRequest {})) @@ -360,10 +504,16 @@ impl PairedBitBox { _ => Err(Error::UnexpectedResponse), } } +} + +impl UnlockedBitBox { + pub async fn device_info(&self) -> Result { + self.session.device_info().await + } /// Returns which product we are connected to. pub fn product(&self) -> Product { - self.communication.info.product + self.session.communication.info.product } fn is_multi_edition(&self) -> bool { @@ -375,17 +525,18 @@ impl PairedBitBox { /// Returns the firmware version. pub fn version(&self) -> &semver::Version { - &self.communication.info.version + &self.session.communication.info.version } /// Returns the hex-encoded 4-byte root fingerprint. pub async fn root_fingerprint(&self) -> Result { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; self.root_fingerprint_inner().await.map(hex::encode) } pub(crate) async fn root_fingerprint_inner(&self) -> Result, Error> { match self + .session .query_proto(Request::Fingerprint(pb::RootFingerprintRequest {})) .await? { @@ -396,8 +547,9 @@ impl PairedBitBox { /// Show recovery words on the Bitbox. pub async fn show_mnemonic(&self) -> Result<(), Error> { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; match self + .session .query_proto(Request::ShowMnemonic(pb::ShowMnemonicRequest {})) .await? { @@ -406,31 +558,13 @@ impl PairedBitBox { } } - /// Restore from recovery words on the Bitbox. - pub async fn restore_from_mnemonic(&self) -> Result<(), Error> { - let _api_call = self.begin_api_call().await; - let now = std::time::SystemTime::now(); - let duration_since_epoch = now.duration_since(std::time::UNIX_EPOCH).unwrap(); - match self - .query_proto(Request::RestoreFromMnemonic( - pb::RestoreFromMnemonicRequest { - timestamp: duration_since_epoch.as_secs() as u32, - timezone_offset: chrono::Local::now().offset().local_minus_utc(), - }, - )) - .await? - { - Response::Success(_) => Ok(()), - _ => Err(Error::UnexpectedResponse), - } - } - /// Invokes the password change workflow on the device. /// Requires firmware version >=9.25.0. pub async fn change_password(&self) -> Result<(), Error> { - let _api_call = self.begin_api_call().await; - self.validate_version(">=9.25.0")?; + let _api_call = self.session.begin_api_call().await; + self.session.validate_version(">=9.25.0")?; match self + .session .query_proto(Request::ChangePassword(pb::ChangePasswordRequest {})) .await? { @@ -442,9 +576,10 @@ impl PairedBitBox { /// Invokes the BIP85-BIP39 workflow on the device, letting the user select the number of words /// (12, 28, 24) and an index and display a derived BIP-39 mnemonic. pub async fn bip85_app_bip39(&self) -> Result<(), Error> { - let _api_call = self.begin_api_call().await; - self.validate_version(">=9.17.0")?; + let _api_call = self.session.begin_api_call().await; + self.session.validate_version(">=9.17.0")?; match self + .session .query_proto(Request::Bip85(pb::Bip85Request { app: Some(pb::bip85_request::App::Bip39(())), })) @@ -535,21 +670,23 @@ mod tests { } } - fn paired_for_test(state: Rc>) -> PairedBitBox { + fn paired_for_test(state: Rc>) -> UnlockedBitBox { let key = [7u8; 32]; - PairedBitBox { - communication: communication::HwwCommunication::from_test_parts( - Box::new(BlockingReadWrite { state }), - communication::Info { - version: semver::Version::parse("9.26.0").unwrap(), - product: Product::BitBox02Multi, - unlocked: true, - initialized: Some(true), - }, - ), - api_mutex: AsyncMutex::new(()), - noise_send: Mutex::new(CipherState::new(&key, 0)), - noise_recv: Mutex::new(CipherState::new(&key, 0)), + UnlockedBitBox { + session: Session { + communication: communication::HwwCommunication::from_test_parts( + Box::new(BlockingReadWrite { state }), + communication::Info { + version: semver::Version::parse("9.26.0").unwrap(), + product: Product::BitBox02Multi, + unlocked: true, + initialized: Some(true), + }, + ), + api_mutex: AsyncMutex::new(()), + noise_send: Mutex::new(CipherState::new(&key, 0)), + noise_recv: Mutex::new(CipherState::new(&key, 0)), + }, } } diff --git a/tests/test_btc_psbt.rs b/tests/test_btc_psbt.rs index b121c95..ecf1a7c 100644 --- a/tests/test_btc_psbt.rs +++ b/tests/test_btc_psbt.rs @@ -521,7 +521,7 @@ fn assert_invalid_input_error(vector: &TestVector, error: &bitbox_api::error::Er } async fn register_script_configs( - bitbox: &bitbox_api::PairedBitBox, + bitbox: &bitbox_api::UnlockedBitBox, coin: pb::BtcCoin, vector: &TestVector, ) -> Result<(), bitbox_api::error::Error> { @@ -562,7 +562,7 @@ fn convert_format_unit(format_unit: FormatUnit) -> pb::btc_sign_init_request::Fo } async fn run_vector( - bitbox: &bitbox_api::PairedBitBox, + bitbox: &bitbox_api::UnlockedBitBox, stdout: &SimulatorStdout, vector: &TestVector, ) { diff --git a/tests/test_device.rs b/tests/test_device.rs index a37854b..239b719 100644 --- a/tests/test_device.rs +++ b/tests/test_device.rs @@ -9,7 +9,7 @@ compile_error!("Enable the tokio feature to run simulator tests"); mod util; -use util::{test_initialized_simulators, test_simulators_after_pairing}; +use util::{test_initialized_simulators, test_uninitialized_simulators}; #[tokio::test] async fn test_reconnect() { @@ -52,8 +52,10 @@ async fn test_reconnect() { } util::test_simulators(async |_stdout| { - let bitbox = connect().await; - bitbox.restore_from_mnemonic().await.unwrap(); + let PairedBitBox::Uninitialized(bitbox) = connect().await else { + panic!("expected an uninitialized simulator"); + }; + let bitbox = bitbox.restore_from_mnemonic().await.unwrap(); if *bitbox.version() >= semver::Version::new(9, 28, 0) { // Missing previous transaction data stops the host while the firmware is still // waiting for the next signing request. @@ -118,8 +120,11 @@ async fn test_reconnect() { .await .unwrap(); // The first reply must be a two-byte HWW ACK + unlock SUCCESS. An unfinished - // workflow would return its encrypted error, which unlock_and_pair currently ignores. + // workflow would instead return its encrypted error. assert_eq!(&responses.lock().unwrap()[5..9], &[0, 2, 0, 0]); + let PairedBitBox::Unlocked(bitbox) = bitbox else { + panic!("initialized devices must be unlocked before pairing"); + }; assert_eq!(bitbox.root_fingerprint().await.unwrap(), "4c00739d"); }) .await @@ -127,7 +132,7 @@ async fn test_reconnect() { #[tokio::test] async fn test_device_info() { - test_simulators_after_pairing(async |paired_bitbox| { + test_uninitialized_simulators(async |paired_bitbox| { let device_info = paired_bitbox.device_info().await.unwrap(); // Since v9.24.0, the simulator simulates a Nova device. diff --git a/tests/util/mod.rs b/tests/util/mod.rs index 0fb2549..1d2cf5f 100644 --- a/tests/util/mod.rs +++ b/tests/util/mod.rs @@ -564,9 +564,12 @@ pub async fn test_simulators(run: impl AsyncFn(&SimulatorStdout)) { } } -/// Tests on an initialized device, which is not yet seeded. -pub async fn test_simulators_after_pairing_with_stdout( - run: impl AsyncFn(&bitbox_api::PairedBitBox, &SimulatorStdout), +/// Tests on a paired, uninitialized device, which is not yet seeded. +pub async fn test_uninitialized_simulators_with_stdout( + run: impl AsyncFn( + bitbox_api::UninitializedBitBox, + &SimulatorStdout, + ), ) { test_simulators(async |stdout| { let noise_config = Box::new(bitbox_api::NoiseConfigNoCache {}); @@ -577,17 +580,21 @@ pub async fn test_simulators_after_pairing_with_stdout( .await .unwrap(); let pairing_bitbox = bitbox.unlock_and_pair().await.unwrap(); - let paired_bitbox = pairing_bitbox.wait_confirm().await.unwrap(); - run(&paired_bitbox, stdout).await; + let bitbox_api::PairedBitBox::Uninitialized(device) = + pairing_bitbox.wait_confirm().await.unwrap() + else { + panic!("expected an uninitialized simulator"); + }; + run(device, stdout).await; }) .await } -/// Tests on an initialized device, which is not yet seeded. -pub async fn test_simulators_after_pairing( - run: impl AsyncFn(&bitbox_api::PairedBitBox), +/// Tests on a paired, uninitialized device, which is not yet seeded. +pub async fn test_uninitialized_simulators( + run: impl AsyncFn(bitbox_api::UninitializedBitBox), ) { - test_simulators_after_pairing_with_stdout(async |paired_bitbox, _stdout| { + test_uninitialized_simulators_with_stdout(async |paired_bitbox, _stdout| { run(paired_bitbox).await; }) .await @@ -597,18 +604,18 @@ pub async fn test_simulators_after_pairing( /// The simulator is initialized with the following mnemonic: /// boring mistake dish oyster truth pigeon viable emerge sort crash wire portion cannon couple enact box walk height pull today solid off enable tide pub async fn test_initialized_simulators_with_stdout( - run: impl AsyncFn(&bitbox_api::PairedBitBox, &SimulatorStdout), + run: impl AsyncFn(&bitbox_api::UnlockedBitBox, &SimulatorStdout), ) { - test_simulators_after_pairing_with_stdout(async |paired_bitbox, stdout| { - assert!(paired_bitbox.restore_from_mnemonic().await.is_ok()); - run(paired_bitbox, stdout).await; + test_uninitialized_simulators_with_stdout(async |paired_bitbox, stdout| { + let unlocked = paired_bitbox.restore_from_mnemonic().await.unwrap(); + run(&unlocked, stdout).await; }) .await } /// Tests on an initialized/seeded device. pub async fn test_initialized_simulators( - run: impl AsyncFn(&bitbox_api::PairedBitBox), + run: impl AsyncFn(&bitbox_api::UnlockedBitBox), ) { test_initialized_simulators_with_stdout(async |paired_bitbox, _stdout| { run(paired_bitbox).await; From 7f503d8962e360faadb7b4a57cf5a40ff79942de Mon Sep 17 00:00:00 2001 From: benma's agent Date: Tue, 13 Oct 2026 12:26:31 +0200 Subject: [PATCH 5/5] add configurable host passphrase entry Use paired protobuf unlock on firmware v9.28.0 and later, returning LockedBitBox after pairing and completing the transition through LockedBitBox::unlock. Keep legacy device entry on older firmware. Add PassphraseConfig with asynchronous input and optional availability callbacks. Integrations can offer a host-entry button while device entry is available, or request host entry automatically. Invoke the input callback only after approval on the device. Expire stale request handles and reset the session when unlocking fails. Copy the firmware protobuf definitions and regenerate the Rust bindings. Add a simple automatic CLI example and a Cursive example with a host button. --- CHANGELOG.md | 2 + Cargo.lock | 659 ++++++++++++++++++++++++- Cargo.toml | 10 + Makefile | 4 + ci.sh | 4 + examples/btc_miniscript.rs | 5 +- examples/btc_sign_msg.rs | 5 +- examples/btc_sign_op_return.rs | 5 +- examples/btc_sign_psbt.rs | 5 +- examples/btc_signtx.rs | 5 +- examples/cardano.rs | 5 +- examples/eth.rs | 5 +- examples/host_passphrase.rs | 135 +++++ examples/host_passphrase_auto.rs | 51 ++ examples/multithreaded.rs | 5 +- examples/simulator.rs | 5 +- examples/singlethreaded.rs | 5 +- messages/bitbox02_system.proto | 2 + messages/bitboxsync.proto | 72 +++ messages/btc.proto | 26 +- messages/eth.proto | 9 + messages/hww.proto | 7 + messages/keystore.proto | 32 ++ src/communication.rs | 7 + src/error.rs | 2 + src/eth.rs | 1 + src/lib.rs | 59 ++- src/shiftcrypto.bitbox02.rs | 271 ++++++++++- src/unlock.rs | 265 ++++++++++ src/unlock/tests.rs | 813 +++++++++++++++++++++++++++++++ tests/test_device.rs | 23 +- 31 files changed, 2451 insertions(+), 53 deletions(-) create mode 100644 examples/host_passphrase.rs create mode 100644 examples/host_passphrase_auto.rs create mode 100644 messages/bitboxsync.proto create mode 100644 src/unlock.rs create mode 100644 src/unlock/tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bbf26e..e4716ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,9 @@ # Changelog ## [Unreleased] +- Support host passphrase entry during unlock on firmware v9.28.0+. - Return typed device states after pairing, with explicit unlock and setup transitions. +- Expose the optional `bootloader_version` field in `device_info()` (firmware v9.27.1+). - Preserve the sign of positive EIP-712 signed integers supplied as JSON numbers. - Reset unfinished sessions when connecting to firmware v9.28.0 or newer, allowing host reconnects while the device remains powered on. diff --git a/Cargo.lock b/Cargo.lock index 70d7acf..4300ba0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,19 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -125,11 +138,13 @@ dependencies = [ "bitcoinconsensus", "byteorder", "chrono", + "cursive", "futures-channel", "futures-util", "getrandom 0.2.17", "hex", "hidapi", + "inquire", "miniscript", "noise-protocol", "noise-rust-crypto", @@ -205,6 +220,12 @@ dependencies = [ "cc", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" @@ -247,6 +268,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.62" @@ -309,6 +339,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -344,6 +397,64 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.11.1", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.11.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -369,6 +480,57 @@ dependencies = [ "cipher", ] +[[package]] +name = "cursive" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "386d5a36020bb856e9a34ecb8a4e6c9bd6b0262d1857bae4db7bc7e2fdaa532e" +dependencies = [ + "ahash", + "cfg-if", + "crossbeam-channel", + "crossterm 0.28.1", + "cursive_core", + "lazy_static", + "libc", + "log", + "signal-hook", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "cursive-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac7ac0eb0cede3dfdfebf4d5f22354e05a730b79c25fd03481fc69fcfba0a73e" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "cursive_core" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d40c54bcff5c30d198d0c089b3f1674e2b3b7b438147b798fe247c955e171c4f" +dependencies = [ + "ahash", + "compact_str", + "crossbeam-channel", + "cursive-macros", + "enum-map", + "enumset", + "jiff", + "lazy_static", + "log", + "num", + "parking_lot", + "serde_json", + "unicode-segmentation", + "unicode-width 0.2.2", + "xi-unicode", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -395,6 +557,93 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + [[package]] name = "digest" version = "0.10.7" @@ -417,6 +666,21 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.15.0" @@ -432,6 +696,47 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum-map" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +dependencies = [ + "enum-map-derive", +] + +[[package]] +name = "enum-map-derive" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "enumset" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc5801fd11762e24d1e420d01d2ac518f2a2ca4329d4fbb6639f2412b6204e0" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -562,6 +867,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -570,7 +887,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -891,6 +1208,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -933,6 +1256,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inquire" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" +dependencies = [ + "bitflags 2.11.1", + "crossterm 0.29.0", + "dyn-clone", + "unicode-segmentation", + "unicode-width 0.2.2", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -954,6 +1290,60 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "js-sys" version = "0.3.69" @@ -963,6 +1353,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -975,6 +1371,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -987,6 +1389,21 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -1023,6 +1440,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1068,6 +1486,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1078,6 +1509,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -1087,6 +1527,26 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1114,7 +1574,7 @@ version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cfg-if", "foreign-types", "libc", @@ -1151,6 +1611,29 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1192,6 +1675,21 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1252,6 +1750,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1267,6 +1771,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "reqwest" version = "0.12.12" @@ -1350,16 +1863,29 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -1405,6 +1931,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "ryu" version = "1.0.23" @@ -1420,6 +1952,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "secp256k1" version = "0.29.1" @@ -1445,7 +1983,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -1540,6 +2078,37 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "slab" version = "0.4.12" @@ -1568,6 +2137,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "subtle" version = "2.6.1" @@ -1622,7 +2197,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -1646,7 +2221,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -1812,6 +2387,24 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -1991,7 +2584,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -2007,6 +2600,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -2242,7 +2857,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "indexmap", "log", "serde", @@ -2289,6 +2904,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "xi-unicode" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a67300977d3dc3f8034dae89778f502b6ba20b269527b3223ba59c0cf393bb8a" + [[package]] name = "yoke" version = "0.8.2" @@ -2312,6 +2933,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index 5aa2786..6172646 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,8 @@ serde = { version = "1.0", features = ["derive"] } tokio = { version = "1", optional = true, features = ["time"] } [dev-dependencies] +cursive = { version = "0.21.1", default-features = false, features = ["crossterm-backend"] } +inquire = { version = "0.9.4", default-features = false, features = ["crossterm"] } # For the base64 PSBT input/output in examples/btc_sign_psbt.rs. bitcoin = { version = "0.32", features = ["base64"] } futures-channel = "0.3" @@ -51,6 +53,14 @@ rlp = "0.6" bitcoinconsensus = { version = "0.106.0", default-features = false } miniscript = "13.0.0" +[[example]] +name = "host_passphrase" +required-features = ["usb", "tokio/rt", "tokio/macros"] + +[[example]] +name = "host_passphrase_auto" +required-features = ["usb", "tokio/rt", "tokio/macros"] + [[example]] name = "singlethreaded" required-features = ["usb", "tokio/rt", "tokio/macros"] diff --git a/Makefile b/Makefile index 030b0f6..1afbf05 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,10 @@ build-protos: rust-script scripts/build-protos.rs +example-host-passphrase: + cargo run --example host_passphrase --features=usb,tokio/rt,tokio/macros +example-host-passphrase-auto: + cargo run --example host_passphrase_auto --features=usb,tokio/rt,tokio/macros example-singlethreaded: cargo run --example singlethreaded --features=usb,tokio/rt,tokio/macros example-multithreaded: diff --git a/ci.sh b/ci.sh index a964906..704f4b1 100755 --- a/ci.sh +++ b/ci.sh @@ -10,6 +10,10 @@ features=( ) examples=( + "--example host_passphrase --features=usb,tokio/rt,tokio/macros" + "--example host_passphrase --features=usb,tokio/rt,tokio/macros,multithreaded" + "--example host_passphrase_auto --features=usb,tokio/rt,tokio/macros" + "--example host_passphrase_auto --features=usb,tokio/rt,tokio/macros,multithreaded" "--example singlethreaded --features=usb,tokio/rt,tokio/macros" "--example multithreaded --features=usb,tokio/rt,tokio/macros,tokio/rt-multi-thread,multithreaded" "--example btc_signtx --features=usb,tokio/rt,tokio/macros" diff --git a/examples/btc_miniscript.rs b/examples/btc_miniscript.rs index f465c41..a7aca5e 100644 --- a/examples/btc_miniscript.rs +++ b/examples/btc_miniscript.rs @@ -17,7 +17,10 @@ async fn get_bitbox02() -> bitbox_api::UnlockedBitBox device.unlock().await.unwrap(), + bitbox_api::PairedBitBox::Locked(device) => device + .unlock(bitbox_api::PassphraseConfig::default()) + .await + .unwrap(), bitbox_api::PairedBitBox::Unlocked(device) => device, bitbox_api::PairedBitBox::Uninitialized(_) => panic!("Initialize the BitBox first"), } diff --git a/examples/btc_sign_msg.rs b/examples/btc_sign_msg.rs index c9d5788..a3a2a97 100644 --- a/examples/btc_sign_msg.rs +++ b/examples/btc_sign_msg.rs @@ -15,7 +15,10 @@ async fn signmsg() { println!("Pairing code\n{pairing_code}"); } let paired_bitbox = match pairing_bitbox.wait_confirm().await.unwrap() { - bitbox_api::PairedBitBox::Locked(device) => device.unlock().await.unwrap(), + bitbox_api::PairedBitBox::Locked(device) => device + .unlock(bitbox_api::PassphraseConfig::default()) + .await + .unwrap(), bitbox_api::PairedBitBox::Unlocked(device) => device, bitbox_api::PairedBitBox::Uninitialized(_) => panic!("Initialize the BitBox first"), }; diff --git a/examples/btc_sign_op_return.rs b/examples/btc_sign_op_return.rs index 6a52ee9..5224eed 100644 --- a/examples/btc_sign_op_return.rs +++ b/examples/btc_sign_op_return.rs @@ -27,7 +27,10 @@ async fn connect_bitbox() -> bitbox_api::UnlockedBitBox device.unlock().await.unwrap(), + bitbox_api::PairedBitBox::Locked(device) => device + .unlock(bitbox_api::PassphraseConfig::default()) + .await + .unwrap(), bitbox_api::PairedBitBox::Unlocked(device) => device, bitbox_api::PairedBitBox::Uninitialized(_) => panic!("Initialize the BitBox first"), } diff --git a/examples/btc_sign_psbt.rs b/examples/btc_sign_psbt.rs index 9583d6f..0a3dd64 100644 --- a/examples/btc_sign_psbt.rs +++ b/examples/btc_sign_psbt.rs @@ -16,7 +16,10 @@ async fn sign_psbt(psbt: &mut bitcoin::psbt::Ps println!("Pairing code\n{pairing_code}"); } let paired = match pairing_device.wait_confirm().await.unwrap() { - bitbox_api::PairedBitBox::Locked(device) => device.unlock().await.unwrap(), + bitbox_api::PairedBitBox::Locked(device) => device + .unlock(bitbox_api::PassphraseConfig::default()) + .await + .unwrap(), bitbox_api::PairedBitBox::Unlocked(device) => device, bitbox_api::PairedBitBox::Uninitialized(_) => panic!("Initialize the BitBox first"), }; diff --git a/examples/btc_signtx.rs b/examples/btc_signtx.rs index f11105c..24c13b5 100644 --- a/examples/btc_signtx.rs +++ b/examples/btc_signtx.rs @@ -15,7 +15,10 @@ async fn signtx() { println!("Pairing code\n{pairing_code}"); } let paired_bitbox = match pairing_bitbox.wait_confirm().await.unwrap() { - bitbox_api::PairedBitBox::Locked(device) => device.unlock().await.unwrap(), + bitbox_api::PairedBitBox::Locked(device) => device + .unlock(bitbox_api::PassphraseConfig::default()) + .await + .unwrap(), bitbox_api::PairedBitBox::Unlocked(device) => device, bitbox_api::PairedBitBox::Uninitialized(_) => panic!("Initialize the BitBox first"), }; diff --git a/examples/cardano.rs b/examples/cardano.rs index 5270f2f..fac07b3 100644 --- a/examples/cardano.rs +++ b/examples/cardano.rs @@ -15,7 +15,10 @@ async fn demo() { println!("Pairing code\n{pairing_code}"); } let paired_bitbox = match pairing_bitbox.wait_confirm().await.unwrap() { - bitbox_api::PairedBitBox::Locked(device) => device.unlock().await.unwrap(), + bitbox_api::PairedBitBox::Locked(device) => device + .unlock(bitbox_api::PassphraseConfig::default()) + .await + .unwrap(), bitbox_api::PairedBitBox::Unlocked(device) => device, bitbox_api::PairedBitBox::Uninitialized(_) => panic!("Initialize the BitBox first"), }; diff --git a/examples/eth.rs b/examples/eth.rs index 3f54d2b..6274b53 100644 --- a/examples/eth.rs +++ b/examples/eth.rs @@ -61,7 +61,10 @@ async fn eth_demo() { println!("Pairing code\n{pairing_code}"); } let paired_bitbox = match pairing_bitbox.wait_confirm().await.unwrap() { - bitbox_api::PairedBitBox::Locked(device) => device.unlock().await.unwrap(), + bitbox_api::PairedBitBox::Locked(device) => device + .unlock(bitbox_api::PassphraseConfig::default()) + .await + .unwrap(), bitbox_api::PairedBitBox::Unlocked(device) => device, bitbox_api::PairedBitBox::Uninitialized(_) => panic!("Initialize the BitBox first"), }; diff --git a/examples/host_passphrase.rs b/examples/host_passphrase.rs new file mode 100644 index 0000000..dd427f9 --- /dev/null +++ b/examples/host_passphrase.rs @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! A host-entry button and password dialog driven by the passphrase callbacks. +//! Requires firmware v9.28.0+ and the optional passphrase setting enabled on the device. + +use bitbox_api::{error::Error, runtime::TokioRuntime, PassphraseConfig}; +use cursive::traits::{Nameable, Resizable}; +use cursive::views::{Dialog, DialogFocus, EditView, TextView}; +use cursive::{CbSink, Cursive}; +use futures_channel::oneshot; +use zeroize::Zeroize; + +type Result = std::result::Result>; + +fn finish_input(ui: &mut Cursive, passphrase: Option) { + let reply = ui + .take_user_data::>>() + .expect("pending passphrase input"); + ui.pop_layer(); + if let Err(mut unsent) = reply.send(passphrase) { + unsent.zeroize(); + } +} + +// The callbacks only send UI messages; they never wait synchronously for user input. +fn config(ui: CbSink) -> PassphraseConfig { + let input_ui = ui.clone(); + PassphraseConfig::new(move || { + let ui = input_ui.clone(); + async move { + let (reply, input) = oneshot::channel(); + ui.send(Box::new(move |ui| { + ui.set_user_data(reply); + ui.add_layer( + Dialog::around( + EditView::new() + .secret() + .on_submit(|ui, text| { + // Never trim or normalize the user's input. The device validates it. + finish_input(ui, Some(text.to_owned())); + }) + .fixed_width(40), + ) + .title("Host passphrase (Enter to submit)") + .button("Cancel", |ui| finish_input(ui, None)), + ); + })) + .map_err(|_| Error::PassphraseInput("UI closed".into()))?; + input + .await + .map_err(|_| Error::PassphraseInput("UI closed".into())) + } + }) + .with_on_host_passphrase_available(move |request| { + let _ = ui.send(Box::new(move |ui| { + ui.call_on_name("device", |dialog: &mut Dialog| { + dialog.set_content(TextView::new("Follow the instructions on the device.")); + dialog.clear_buttons(); + if let Some(request) = request { + dialog.add_button("Enter on host", move |_| request.request()); + } + dialog.add_button("Quit", Cursive::quit); + dialog.set_focus(DialogFocus::Button(0)); + }); + })); + }) +} + +fn status(ui: &CbSink, text: String) { + let _ = ui.send(Box::new(move |ui| { + ui.call_on_name("device", |dialog: &mut Dialog| { + dialog.set_content(TextView::new(text)); + }); + })); +} + +async fn connect(ui: &CbSink) -> Result { + let bitbox = bitbox_api::BitBox::::from_hid_device( + bitbox_api::usb::get_any_bitbox02()?, + Box::new(bitbox_api::NoiseConfigNoCache), + ) + .await?; + let pairing = bitbox.unlock_and_pair().await?; + if let Some(code) = pairing.get_pairing_code() { + status( + ui, + format!("Confirm the pairing code on the device:\n{code}"), + ); + } + let paired = pairing.wait_confirm().await?; + status(ui, "Follow the instructions on the device.".into()); + let unlocked = match paired { + bitbox_api::PairedBitBox::Locked(device) => device.unlock(config(ui.clone())).await?, + bitbox_api::PairedBitBox::Unlocked(device) => device, + bitbox_api::PairedBitBox::Uninitialized(_) => { + return Err("Initialize the BitBox first".into()) + } + }; + Ok(unlocked.root_fingerprint().await?) +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<()> { + // Cursive owns terminal input on its thread; device communication stays asynchronous. + let (ready, started) = oneshot::channel(); + let mut terminal = tokio::task::spawn_blocking(move || { + let mut ui = cursive::default(); + ui.add_layer( + Dialog::text("Follow the instructions on the device.") + .title("BitBox02 host passphrase") + .button("Quit", Cursive::quit) + .with_name("device"), + ); + let _ = ready.send(ui.cb_sink().clone()); + ui.try_run().map_err(|error| error.to_string()) + }); + let ui = started.await?; + let result = tokio::select! { + result = connect(&ui) => result, + result = &mut terminal => { + // Closing the UI also drops the connection future and any pending input. + result??; + return Ok(()); + } + }; + status( + &ui, + match &result { + Ok(fingerprint) => format!("Connection ready. Root fingerprint: {fingerprint}"), + Err(error) => format!("Connection failed: {error}"), + }, + ); + terminal.await??; + Ok(()) +} diff --git a/examples/host_passphrase_auto.rs b/examples/host_passphrase_auto.rs new file mode 100644 index 0000000..9865242 --- /dev/null +++ b/examples/host_passphrase_auto.rs @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Request host passphrase entry automatically, then prompt after approval on the device. +//! Requires firmware v9.28.0+ and the optional passphrase setting enabled on the device. + +use bitbox_api::{error::Error, runtime::TokioRuntime, PassphraseConfig}; +use inquire::{InquireError, Password}; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + // Without an availability callback, host entry is requested once automatically. + let config = PassphraseConfig::new(|| async { + let input = tokio::task::spawn_blocking(|| { + Password::new("Host passphrase:") + .without_confirmation() + .with_help_message("Enter submits (empty allowed); Escape or Ctrl-C cancels.") + .prompt() + }) + .await + .map_err(|error| Error::PassphraseInput(error.to_string()))?; + // Never trim or normalize the user's input. The device validates it. + match input { + Ok(passphrase) => Ok(Some(passphrase)), + Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Ok(None), + Err(error) => Err(Error::PassphraseInput(error.to_string())), + } + }); + + println!("Follow the instructions on the device; approve host entry when prompted."); + let bitbox = bitbox_api::BitBox::::from_hid_device( + bitbox_api::usb::get_any_bitbox02()?, + Box::new(bitbox_api::NoiseConfigNoCache), + ) + .await?; + let pairing = bitbox.unlock_and_pair().await?; + if let Some(code) = pairing.get_pairing_code() { + println!("Pairing code:\n{code}"); + } + let unlocked = match pairing.wait_confirm().await? { + bitbox_api::PairedBitBox::Locked(device) => device.unlock(config).await?, + bitbox_api::PairedBitBox::Unlocked(device) => device, + bitbox_api::PairedBitBox::Uninitialized(_) => { + return Err("Initialize the BitBox first".into()) + } + }; + println!( + "Connection ready. Root fingerprint: {}", + unlocked.root_fingerprint().await? + ); + Ok(()) +} diff --git a/examples/multithreaded.rs b/examples/multithreaded.rs index b0a43f1..57dd997 100644 --- a/examples/multithreaded.rs +++ b/examples/multithreaded.rs @@ -16,7 +16,10 @@ async fn demo() { } multithreading_type_check(&pairing_bitbox); let paired_bitbox = match pairing_bitbox.wait_confirm().await.unwrap() { - bitbox_api::PairedBitBox::Locked(device) => device.unlock().await.unwrap(), + bitbox_api::PairedBitBox::Locked(device) => device + .unlock(bitbox_api::PassphraseConfig::default()) + .await + .unwrap(), bitbox_api::PairedBitBox::Unlocked(device) => device, bitbox_api::PairedBitBox::Uninitialized(_) => panic!("Initialize the BitBox first"), }; diff --git a/examples/simulator.rs b/examples/simulator.rs index 83555ec..cff73f2 100644 --- a/examples/simulator.rs +++ b/examples/simulator.rs @@ -7,7 +7,10 @@ async fn demo() { .unwrap(); let pairing_bitbox = bitbox.unlock_and_pair().await.unwrap(); let paired_bitbox = match pairing_bitbox.wait_confirm().await.unwrap() { - bitbox_api::PairedBitBox::Locked(device) => device.unlock().await.unwrap(), + bitbox_api::PairedBitBox::Locked(device) => device + .unlock(bitbox_api::PassphraseConfig::default()) + .await + .unwrap(), bitbox_api::PairedBitBox::Unlocked(device) => device, bitbox_api::PairedBitBox::Uninitialized(device) => { println!("device info: {:?}", device.device_info().await.unwrap()); diff --git a/examples/singlethreaded.rs b/examples/singlethreaded.rs index f54e0ae..f094419 100644 --- a/examples/singlethreaded.rs +++ b/examples/singlethreaded.rs @@ -13,7 +13,10 @@ async fn demo() { println!("Pairing code\n{pairing_code}"); } let paired_bitbox = match pairing_bitbox.wait_confirm().await.unwrap() { - bitbox_api::PairedBitBox::Locked(device) => device.unlock().await.unwrap(), + bitbox_api::PairedBitBox::Locked(device) => device + .unlock(bitbox_api::PassphraseConfig::default()) + .await + .unwrap(), bitbox_api::PairedBitBox::Unlocked(device) => device, bitbox_api::PairedBitBox::Uninitialized(_) => panic!("Initialize the BitBox first"), }; diff --git a/messages/bitbox02_system.proto b/messages/bitbox02_system.proto index 463e689..b234b5b 100644 --- a/messages/bitbox02_system.proto +++ b/messages/bitbox02_system.proto @@ -34,6 +34,8 @@ message DeviceInfoResponse { // From v9.25.0. This together with `securechip_model` determines the password stretching // algorithm. string password_stretching_algo = 8; + // Marketing version of the installed stage1 bootloader. Not present on legacy bootloaders. + optional string bootloader_version = 9; } message InsertRemoveSDCardRequest { diff --git a/messages/bitboxsync.proto b/messages/bitboxsync.proto new file mode 100644 index 0000000..6c0e8f1 --- /dev/null +++ b/messages/bitboxsync.proto @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; +package shiftcrypto.bitbox02; + +message BitBoxSyncIdentityRequest {} + +message BitBoxSyncIdentityResponse { + bytes auth_public_key = 1; + bytes wrap_public_key = 2; +} + +message BitBoxSyncSignLoginIntentRequest { + bytes challenge = 1; +} + +message BitBoxSyncSignRefreshIntentRequest { + bytes challenge = 1; +} + +message BitBoxSyncSignRevokeAllTokensIntentRequest { + bytes challenge = 1; +} + +message BitBoxSyncSignCreateNamespaceInviteIntentRequest { + bytes challenge = 1; + bytes namespace_id = 2; + bytes invite_id = 3; + bytes invite_server_secret_hash = 4; + uint64 expires_at = 5; + uint32 max_accepted = 6; +} + +message BitBoxSyncSignJoinRequestIntentRequest { + bytes namespace_id = 1; + bytes invite_id = 2; + string server_origin = 3; + uint64 expires_at = 4; +} + +message BitBoxSyncUnwrapNamespaceDEKRequest { + bytes namespace_id = 1; + bytes wrapped_dek = 2; +} + +message BitBoxSyncSignatureResponse { + bytes signature = 1; +} + +message BitBoxSyncUnwrapNamespaceDEKResponse { + bytes namespace_dek = 1; +} + +message BitBoxSyncRequest { + oneof request { + BitBoxSyncIdentityRequest identity = 1; + BitBoxSyncSignLoginIntentRequest sign_login_intent = 2; + BitBoxSyncSignRefreshIntentRequest sign_refresh_intent = 3; + BitBoxSyncSignRevokeAllTokensIntentRequest sign_revoke_all_tokens_intent = 4; + BitBoxSyncSignCreateNamespaceInviteIntentRequest sign_create_namespace_invite_intent = 5; + BitBoxSyncSignJoinRequestIntentRequest sign_join_request_intent = 6; + BitBoxSyncUnwrapNamespaceDEKRequest unwrap_namespace_dek = 7; + } +} + +message BitBoxSyncResponse { + oneof response { + BitBoxSyncIdentityResponse identity = 1; + BitBoxSyncSignatureResponse signature = 2; + BitBoxSyncUnwrapNamespaceDEKResponse unwrap_namespace_dek = 3; + } +} diff --git a/messages/btc.proto b/messages/btc.proto index bee46ad..08e802b 100644 --- a/messages/btc.proto +++ b/messages/btc.proto @@ -103,7 +103,8 @@ message BTCSignInitRequest { uint32 version = 4; // must be 1 or 2 uint32 num_inputs = 5; uint32 num_outputs = 6; - uint32 locktime = 7; // must be <500000000 + // Consensus nLockTime: values below 500000000 are block heights, otherwise Unix timestamps. + uint32 locktime = 7; enum FormatUnit { // According to `coin` (BTC, LTC, etc.). @@ -152,6 +153,8 @@ message BTCSignInputRequest { repeated uint32 keypath = 6; // all inputs must be ours. // References a script config from BTCSignInitRequest uint32 script_config_index = 7; + // If omitted, the signature uses the historical deterministic zero-contribution S2C fallback. + // This differs from plain RFC6979 and does not provide anti-klepto protection. AntiKleptoHostNonceCommitment host_nonce_commitment = 8; } @@ -246,8 +249,27 @@ message BTCPaymentRequestRequest { message TextMemo { string note = 1; } + message CoinPurchaseMemo { + uint32 coin_type = 1; // SLIP-44 coin type + string amount = 2; // Human-readable amount (e.g. "0.25 ETH") + string address = 3; // Address to send the purchased coins to + // Derivation info for verifying address ownership. + // NOT part of the SLIP-24 sighash. + message EthAddressDerivation { + repeated uint32 keypath = 1; // Keypath to the address + } + message BtcAddressDerivation { + // Script config + keypath are needed to derive BTC/LTC-family addresses. + BTCScriptConfigWithKeypath script_config = 1; + } + oneof address_derivation { + EthAddressDerivation eth = 4; + BtcAddressDerivation btc = 5; + } + } oneof memo { TextMemo text_memo = 1; + CoinPurchaseMemo coin_purchase_memo = 2; } } @@ -262,6 +284,8 @@ message BTCSignMessageRequest { BTCCoin coin = 1; BTCScriptConfigWithKeypath script_config = 2; bytes msg = 3; + // If omitted, the signature uses the historical deterministic zero-contribution S2C fallback. + // This differs from plain RFC6979 and does not provide anti-klepto protection. AntiKleptoHostNonceCommitment host_nonce_commitment = 4; } diff --git a/messages/eth.proto b/messages/eth.proto index 02c5c00..a2f3012 100644 --- a/messages/eth.proto +++ b/messages/eth.proto @@ -5,6 +5,7 @@ package shiftcrypto.bitbox02; import "common.proto"; import "antiklepto.proto"; +import "btc.proto"; // Kept for backwards compatibility. Use chain_id instead, introduced in v9.10.0. enum ETHCoin { @@ -47,6 +48,8 @@ message ETHSignRequest { bytes recipient = 6; // 20 byte recipient bytes value = 7; // smallest big endian serialization, max. 32 bytes bytes data = 8; + // If omitted, the signature uses the historical deterministic zero-contribution S2C fallback. + // This differs from plain RFC6979 and does not provide anti-klepto protection. AntiKleptoHostNonceCommitment host_nonce_commitment = 9; // If non-zero, `coin` is ignored and `chain_id` is used to identify the network. uint64 chain_id = 10; @@ -66,10 +69,13 @@ message ETHSignEIP1559Request { bytes recipient = 7; // 20 byte recipient bytes value = 8; // smallest big endian serialization, max. 32 bytes bytes data = 9; + // If omitted, the signature uses the historical deterministic zero-contribution S2C fallback. + // This differs from plain RFC6979 and does not provide anti-klepto protection. AntiKleptoHostNonceCommitment host_nonce_commitment = 10; ETHAddressCase address_case = 11; // For streaming: if non-zero, data field should be empty and data will be requested in chunks uint32 data_length = 12; + BTCPaymentRequestRequest payment_request = 13; } message ETHSignDataRequestChunkResponse { @@ -86,6 +92,8 @@ message ETHSignMessageRequest { ETHCoin coin = 1; repeated uint32 keypath = 2; bytes msg = 3; + // If omitted, the signature uses the historical deterministic zero-contribution S2C fallback. + // This differs from plain RFC6979 and does not provide anti-klepto protection. AntiKleptoHostNonceCommitment host_nonce_commitment = 4; // If non-zero, `coin` is ignored and `chain_id` is used to identify the network. uint64 chain_id = 5; @@ -129,6 +137,7 @@ message ETHSignTypedMessageRequest { repeated uint32 keypath = 2; repeated StructType types = 3; string primary_type = 4; + // If omitted, plain RFC6979 signing is used without anti-klepto protection. AntiKleptoHostNonceCommitment host_nonce_commitment = 5; } diff --git a/messages/hww.proto b/messages/hww.proto index 07cb660..bf6d032 100644 --- a/messages/hww.proto +++ b/messages/hww.proto @@ -7,6 +7,7 @@ import "common.proto"; import "backup_commands.proto"; import "bitbox02_system.proto"; +import "bitboxsync.proto"; import "bluetooth.proto"; import "btc.proto"; import "cardano.proto"; @@ -58,6 +59,10 @@ message Request { BIP85Request bip85 = 28; BluetoothRequest bluetooth = 29; ChangePasswordRequest change_password = 30; + BitBoxSyncRequest bitbox_sync = 31; + UnlockRequest unlock = 32; + UnlockContinueRequest unlock_continue = 33; + UnlockHostInfoRequest unlock_host_info = 34; } } @@ -81,5 +86,7 @@ message Response { CardanoResponse cardano = 15; BIP85Response bip85 = 16; BluetoothResponse bluetooth = 17; + BitBoxSyncResponse bitbox_sync = 18; + UnlockResponse unlock = 19; } } diff --git a/messages/keystore.proto b/messages/keystore.proto index 4f63cd3..b793fdf 100644 --- a/messages/keystore.proto +++ b/messages/keystore.proto @@ -8,6 +8,38 @@ package shiftcrypto.bitbox02; import "google/protobuf/empty.proto"; +// Unlock inside the paired Noise channel (since v9.28.0). Uninitialized and already unlocked +// devices return DONE immediately and are left unchanged. Continuations are only valid within +// this workflow. +// If the passphrase feature is enabled, device entry returns PASSPHRASE_PENDING. The host +// polls with UnlockContinueRequest until entry completes or it requests host entry. +message UnlockRequest {} + +message UnlockContinueRequest { + // False polls device entry; true interrupts it to ask for host-entry consent. + // After PASSPHRASE_ENTERED, continue to await confirmation and unlock; + // host entry is ignored. + bool request_host_entry = 1; +} + +message UnlockHostInfoRequest { + // Only sent after HOST_ENTRY_READY. Absent cancels host input and restarts device entry; + // the empty string submits the empty passphrase. The device confirms the actual value. + optional string passphrase = 1; +} + +message UnlockResponse { + enum State { + PASSPHRASE_PENDING = 0; + HOST_ENTRY_READY = 1; + // Passphrase entry on the device finished; confirmation may still be pending. + // Withdraw host entry and send UnlockContinue to await completion. + PASSPHRASE_ENTERED = 2; + DONE = 3; + } + State state = 1; +} + message ElectrumEncryptionKeyRequest { repeated uint32 keypath = 1; } diff --git a/src/communication.rs b/src/communication.rs index 7411fac..b4b2d8c 100644 --- a/src/communication.rs +++ b/src/communication.rs @@ -228,6 +228,13 @@ impl HwwCommunication { } } + pub(crate) async fn reset_session_once(&self) -> Result<(), Error> { + match self.communication.query(&[HWW_REQ_RESET]).await?.as_slice() { + [HWW_RSP_ACK] => Ok(()), + _ => Err(Error::Info), + } + } + pub async fn query(&self, msg: &[u8]) -> Result, Error> { let mut framed_msg = Vec::from([HWW_REQ_NEW]); framed_msg.extend_from_slice(msg); diff --git a/src/error.rs b/src/error.rs index 574dbd2..4815795 100644 --- a/src/error.rs +++ b/src/error.rs @@ -48,6 +48,8 @@ pub enum Error { NoiseConfig(#[from] crate::noise::ConfigError), #[error("pairing code rejected by user")] NoisePairingRejected, + #[error("host passphrase input failed: {0}")] + PassphraseInput(String), #[error("BitBox returned an unexpected response")] UnexpectedResponse, #[error("protobuf message could not be decoded")] diff --git a/src/eth.rs b/src/eth.rs index 8cdc51f..ed166c0 100644 --- a/src/eth.rs +++ b/src/eth.rs @@ -574,6 +574,7 @@ impl UnlockedBitBox { let host_nonce = crate::antiklepto::gen_host_nonce()?; let request = pb::eth_request::Request::SignEip1559(pb::EthSignEip1559Request { chain_id: tx.chain_id, + payment_request: None, keypath: keypath.to_vec(), nonce: crate::util::remove_leading_zeroes(&tx.nonce), max_priority_fee_per_gas: crate::util::remove_leading_zeroes( diff --git a/src/lib.rs b/src/lib.rs index 4b430f6..9dc9bcd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,7 @@ mod communication; mod keypath; mod secp256k1; mod u2fframing; +mod unlock; mod util; /// BitBox protobuf messages. @@ -37,10 +38,12 @@ use prost::Message; use futures_util::lock::{Mutex as AsyncMutex, MutexGuard as ApiMutexGuard}; use std::sync::Mutex; +use zeroize::{Zeroize, Zeroizing}; pub use keypath::Keypath; pub use noise::PersistedNoiseConfig; pub use noise::{ConfigError, NoiseConfig, NoiseConfigData, NoiseConfigNoCache}; +pub use unlock::{HostPassphraseRequest, PassphraseConfig}; pub use util::Threading; use communication::HwwCommunication; @@ -121,18 +124,22 @@ impl BitBox { .await } - /// Invokes the device unlock and pairing. + /// Starts pairing, unlocking first only on firmware older than v9.28.0. + /// + /// On firmware v9.28.0 and later, call [`LockedBitBox::unlock`] after confirming pairing. pub async fn unlock_and_pair(mut self) -> Result, Error> { - // The legacy response also reports initialization on firmware older than - // v9.20.0, whose transport info does not include an initialized flag. - self.communication.info.initialized = Some( - match self.communication.query(&[OP_UNLOCK]).await?.as_slice() { - [RESPONSE_SUCCESS] => true, - [RESPONSE_FAILURE_UNINITIALIZED] => false, - [RESPONSE_FAILURE] => return Err(BitBoxError::Generic.into()), - _ => return Err(Error::UnexpectedResponse), - }, - ); + if !unlock::supports_paired_unlock(&self.communication.info.version) { + // The legacy response also reports initialization on firmware older than + // v9.20.0, whose transport info does not include an initialized flag. + self.communication.info.initialized = Some( + match self.communication.query(&[OP_UNLOCK]).await?.as_slice() { + [RESPONSE_SUCCESS] => true, + [RESPONSE_FAILURE_UNINITIALIZED] => false, + [RESPONSE_FAILURE] => return Err(BitBoxError::Generic.into()), + _ => return Err(Error::UnexpectedResponse), + }, + ); + } self.pair().await } @@ -259,9 +266,11 @@ impl PairingBitBox { /// Waits for pairing confirmation and returns the paired device's state. /// - /// Initialized devices were unlocked by [`BitBox::unlock_and_pair`] and return - /// [`PairedBitBox::Unlocked`]. Uninitialized devices return - /// [`PairedBitBox::Uninitialized`]. The locked state is not returned yet. + /// This does not start unlocking. Initialized devices on firmware v9.28.0 and later + /// return [`PairedBitBox::Locked`], even if already unlocked on the device. Older + /// firmware was unlocked by [`BitBox::unlock_and_pair`] and returns + /// [`PairedBitBox::Unlocked`]. Uninitialized devices always return + /// [`PairedBitBox::Uninitialized`]. /// /// Once this returns, the application can dismiss its pairing UI before proceeding. pub async fn wait_confirm(self) -> Result, Error> { @@ -284,9 +293,12 @@ impl PairingBitBox { .info .initialized .ok_or(Error::UnexpectedResponse)?; + let needs_unlock = unlock::supports_paired_unlock(&self.communication.info.version); let session = Session::from(self.communication, self.host); Ok(if !initialized { PairedBitBox::Uninitialized(UninitializedBitBox { session }) + } else if needs_unlock { + PairedBitBox::Locked(LockedBitBox { session }) } else { PairedBitBox::Unlocked(UnlockedBitBox { session }) }) @@ -324,8 +336,9 @@ impl PairedBitBox { /// A paired device whose unlock step has not yet completed through this client. /// -/// Reserved for unlocking after pairing. The current connection flow unlocks before -/// pairing, so it never returns this state. Wallet methods are unavailable in this state. +/// Call [`Self::unlock`] to obtain an [`UnlockedBitBox`]. If the device is already +/// unlocked, that call completes without prompting for a password or passphrase. +/// Wallet methods are unavailable before this transition. /// /// ```compile_fail /// async fn fingerprint(device: bitbox_api::LockedBitBox) { @@ -337,11 +350,6 @@ pub struct LockedBitBox { } impl LockedBitBox { - /// Reserved for unlocking after pairing; the current connection flow never returns this state. - pub async fn unlock(self) -> Result, Error> { - unreachable!("devices are unlocked before pairing") - } - /// Returns which product we are connected to. pub fn product(&self) -> Product { self.session.communication.info.product @@ -458,10 +466,14 @@ impl Session { let mut encrypted = vec![OP_NOISE_MSG]; encrypted.extend_from_slice({ let mut send = self.noise_send.lock().unwrap(); - let proto_msg = pb::Request { + let mut proto_msg = pb::Request { request: Some(request), }; - &send.encrypt_vec(&proto_msg.encode_to_vec()) + let plaintext = Zeroizing::new(proto_msg.encode_to_vec()); + if let Some(Request::UnlockHostInfo(info)) = &mut proto_msg.request { + info.passphrase.zeroize(); + } + &send.encrypt_vec(&plaintext) }); let response = self.communication.query(&encrypted).await?; @@ -656,6 +668,7 @@ mod tests { securechip_model: "ATECC608B".into(), bluetooth: None, password_stretching_algo: "pwhash".into(), + bootloader_version: None, })), } .encode_to_vec(); diff --git a/src/shiftcrypto.bitbox02.rs b/src/shiftcrypto.bitbox02.rs index 0e339da..34ecad6 100644 --- a/src/shiftcrypto.bitbox02.rs +++ b/src/shiftcrypto.bitbox02.rs @@ -121,6 +121,9 @@ pub struct DeviceInfoResponse { /// algorithm. #[prost(string, tag = "8")] pub password_stretching_algo: ::prost::alloc::string::String, + /// Marketing version of the installed stage1 bootloader. Not present on legacy bootloaders. + #[prost(string, optional, tag = "9")] + pub bootloader_version: ::core::option::Option<::prost::alloc::string::String>, } /// Nested message and enum types in `DeviceInfoResponse`. pub mod device_info_response { @@ -201,6 +204,117 @@ pub struct SetPasswordRequest { #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct ChangePasswordRequest {} #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct BitBoxSyncIdentityRequest {} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct BitBoxSyncIdentityResponse { + #[prost(bytes = "vec", tag = "1")] + pub auth_public_key: ::prost::alloc::vec::Vec, + #[prost(bytes = "vec", tag = "2")] + pub wrap_public_key: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct BitBoxSyncSignLoginIntentRequest { + #[prost(bytes = "vec", tag = "1")] + pub challenge: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct BitBoxSyncSignRefreshIntentRequest { + #[prost(bytes = "vec", tag = "1")] + pub challenge: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct BitBoxSyncSignRevokeAllTokensIntentRequest { + #[prost(bytes = "vec", tag = "1")] + pub challenge: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct BitBoxSyncSignCreateNamespaceInviteIntentRequest { + #[prost(bytes = "vec", tag = "1")] + pub challenge: ::prost::alloc::vec::Vec, + #[prost(bytes = "vec", tag = "2")] + pub namespace_id: ::prost::alloc::vec::Vec, + #[prost(bytes = "vec", tag = "3")] + pub invite_id: ::prost::alloc::vec::Vec, + #[prost(bytes = "vec", tag = "4")] + pub invite_server_secret_hash: ::prost::alloc::vec::Vec, + #[prost(uint64, tag = "5")] + pub expires_at: u64, + #[prost(uint32, tag = "6")] + pub max_accepted: u32, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct BitBoxSyncSignJoinRequestIntentRequest { + #[prost(bytes = "vec", tag = "1")] + pub namespace_id: ::prost::alloc::vec::Vec, + #[prost(bytes = "vec", tag = "2")] + pub invite_id: ::prost::alloc::vec::Vec, + #[prost(string, tag = "3")] + pub server_origin: ::prost::alloc::string::String, + #[prost(uint64, tag = "4")] + pub expires_at: u64, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct BitBoxSyncUnwrapNamespaceDekRequest { + #[prost(bytes = "vec", tag = "1")] + pub namespace_id: ::prost::alloc::vec::Vec, + #[prost(bytes = "vec", tag = "2")] + pub wrapped_dek: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct BitBoxSyncSignatureResponse { + #[prost(bytes = "vec", tag = "1")] + pub signature: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct BitBoxSyncUnwrapNamespaceDekResponse { + #[prost(bytes = "vec", tag = "1")] + pub namespace_dek: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct BitBoxSyncRequest { + #[prost(oneof = "bit_box_sync_request::Request", tags = "1, 2, 3, 4, 5, 6, 7")] + pub request: ::core::option::Option, +} +/// Nested message and enum types in `BitBoxSyncRequest`. +pub mod bit_box_sync_request { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Request { + #[prost(message, tag = "1")] + Identity(super::BitBoxSyncIdentityRequest), + #[prost(message, tag = "2")] + SignLoginIntent(super::BitBoxSyncSignLoginIntentRequest), + #[prost(message, tag = "3")] + SignRefreshIntent(super::BitBoxSyncSignRefreshIntentRequest), + #[prost(message, tag = "4")] + SignRevokeAllTokensIntent(super::BitBoxSyncSignRevokeAllTokensIntentRequest), + #[prost(message, tag = "5")] + SignCreateNamespaceInviteIntent( + super::BitBoxSyncSignCreateNamespaceInviteIntentRequest, + ), + #[prost(message, tag = "6")] + SignJoinRequestIntent(super::BitBoxSyncSignJoinRequestIntentRequest), + #[prost(message, tag = "7")] + UnwrapNamespaceDek(super::BitBoxSyncUnwrapNamespaceDekRequest), + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct BitBoxSyncResponse { + #[prost(oneof = "bit_box_sync_response::Response", tags = "1, 2, 3")] + pub response: ::core::option::Option, +} +/// Nested message and enum types in `BitBoxSyncResponse`. +pub mod bit_box_sync_response { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Response { + #[prost(message, tag = "1")] + Identity(super::BitBoxSyncIdentityResponse), + #[prost(message, tag = "2")] + Signature(super::BitBoxSyncSignatureResponse), + #[prost(message, tag = "3")] + UnwrapNamespaceDek(super::BitBoxSyncUnwrapNamespaceDekResponse), + } +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct BluetoothToggleEnabledRequest {} #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct BluetoothUpgradeInitRequest { @@ -549,7 +663,7 @@ pub struct BtcSignInitRequest { pub num_inputs: u32, #[prost(uint32, tag = "6")] pub num_outputs: u32, - /// must be <500000000 + /// Consensus nLockTime: values below 500000000 are block heights, otherwise Unix timestamps. #[prost(uint32, tag = "7")] pub locktime: u32, #[prost(enumeration = "btc_sign_init_request::FormatUnit", tag = "8")] @@ -703,6 +817,8 @@ pub struct BtcSignInputRequest { /// References a script config from BTCSignInitRequest #[prost(uint32, tag = "7")] pub script_config_index: u32, + /// If omitted, the signature uses the historical deterministic zero-contribution S2C fallback. + /// This differs from plain RFC6979 and does not provide anti-klepto protection. #[prost(message, optional, tag = "8")] pub host_nonce_commitment: ::core::option::Option, } @@ -866,9 +982,9 @@ pub struct BtcPaymentRequestRequest { } /// Nested message and enum types in `BTCPaymentRequestRequest`. pub mod btc_payment_request_request { - #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] + #[derive(Clone, PartialEq, ::prost::Message)] pub struct Memo { - #[prost(oneof = "memo::Memo", tags = "1")] + #[prost(oneof = "memo::Memo", tags = "1, 2")] pub memo: ::core::option::Option, } /// Nested message and enum types in `Memo`. @@ -878,10 +994,54 @@ pub mod btc_payment_request_request { #[prost(string, tag = "1")] pub note: ::prost::alloc::string::String, } - #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + #[derive(Clone, PartialEq, ::prost::Message)] + pub struct CoinPurchaseMemo { + /// SLIP-44 coin type + #[prost(uint32, tag = "1")] + pub coin_type: u32, + /// Human-readable amount (e.g. "0.25 ETH") + #[prost(string, tag = "2")] + pub amount: ::prost::alloc::string::String, + /// Address to send the purchased coins to + #[prost(string, tag = "3")] + pub address: ::prost::alloc::string::String, + #[prost(oneof = "coin_purchase_memo::AddressDerivation", tags = "4, 5")] + pub address_derivation: ::core::option::Option< + coin_purchase_memo::AddressDerivation, + >, + } + /// Nested message and enum types in `CoinPurchaseMemo`. + pub mod coin_purchase_memo { + /// Derivation info for verifying address ownership. + /// NOT part of the SLIP-24 sighash. + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] + pub struct EthAddressDerivation { + /// Keypath to the address + #[prost(uint32, repeated, tag = "1")] + pub keypath: ::prost::alloc::vec::Vec, + } + #[derive(Clone, PartialEq, ::prost::Message)] + pub struct BtcAddressDerivation { + /// Script config + keypath are needed to derive BTC/LTC-family addresses. + #[prost(message, optional, tag = "1")] + pub script_config: ::core::option::Option< + super::super::super::BtcScriptConfigWithKeypath, + >, + } + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum AddressDerivation { + #[prost(message, tag = "4")] + Eth(EthAddressDerivation), + #[prost(message, tag = "5")] + Btc(BtcAddressDerivation), + } + } + #[derive(Clone, PartialEq, ::prost::Oneof)] pub enum Memo { #[prost(message, tag = "1")] TextMemo(TextMemo), + #[prost(message, tag = "2")] + CoinPurchaseMemo(CoinPurchaseMemo), } } } @@ -893,6 +1053,8 @@ pub struct BtcSignMessageRequest { pub script_config: ::core::option::Option, #[prost(bytes = "vec", tag = "3")] pub msg: ::prost::alloc::vec::Vec, + /// If omitted, the signature uses the historical deterministic zero-contribution S2C fallback. + /// This differs from plain RFC6979 and does not provide anti-klepto protection. #[prost(message, optional, tag = "4")] pub host_nonce_commitment: ::core::option::Option, } @@ -1404,6 +1566,8 @@ pub struct EthSignRequest { pub value: ::prost::alloc::vec::Vec, #[prost(bytes = "vec", tag = "8")] pub data: ::prost::alloc::vec::Vec, + /// If omitted, the signature uses the historical deterministic zero-contribution S2C fallback. + /// This differs from plain RFC6979 and does not provide anti-klepto protection. #[prost(message, optional, tag = "9")] pub host_nonce_commitment: ::core::option::Option, /// If non-zero, `coin` is ignored and `chain_id` is used to identify the network. @@ -1416,7 +1580,7 @@ pub struct EthSignRequest { pub data_length: u32, } /// TX payload for an EIP-1559 (type 2) transaction: -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct EthSignEip1559Request { #[prost(uint64, tag = "1")] pub chain_id: u64, @@ -1442,6 +1606,8 @@ pub struct EthSignEip1559Request { pub value: ::prost::alloc::vec::Vec, #[prost(bytes = "vec", tag = "9")] pub data: ::prost::alloc::vec::Vec, + /// If omitted, the signature uses the historical deterministic zero-contribution S2C fallback. + /// This differs from plain RFC6979 and does not provide anti-klepto protection. #[prost(message, optional, tag = "10")] pub host_nonce_commitment: ::core::option::Option, #[prost(enumeration = "EthAddressCase", tag = "11")] @@ -1449,6 +1615,8 @@ pub struct EthSignEip1559Request { /// For streaming: if non-zero, data field should be empty and data will be requested in chunks #[prost(uint32, tag = "12")] pub data_length: u32, + #[prost(message, optional, tag = "13")] + pub payment_request: ::core::option::Option, } #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct EthSignDataRequestChunkResponse { @@ -1471,6 +1639,8 @@ pub struct EthSignMessageRequest { pub keypath: ::prost::alloc::vec::Vec, #[prost(bytes = "vec", tag = "3")] pub msg: ::prost::alloc::vec::Vec, + /// If omitted, the signature uses the historical deterministic zero-contribution S2C fallback. + /// This differs from plain RFC6979 and does not provide anti-klepto protection. #[prost(message, optional, tag = "4")] pub host_nonce_commitment: ::core::option::Option, /// If non-zero, `coin` is ignored and `chain_id` is used to identify the network. @@ -1493,6 +1663,7 @@ pub struct EthSignTypedMessageRequest { pub types: ::prost::alloc::vec::Vec, #[prost(string, tag = "4")] pub primary_type: ::prost::alloc::string::String, + /// If omitted, plain RFC6979 signing is used without anti-klepto protection. #[prost(message, optional, tag = "5")] pub host_nonce_commitment: ::core::option::Option, } @@ -1750,6 +1921,80 @@ impl EthAddressCase { } } } +/// Unlock inside the paired Noise channel (since v9.28.0). Uninitialized and already unlocked +/// devices return DONE immediately and are left unchanged. Continuations are only valid within +/// this workflow. +/// If the passphrase feature is enabled, device entry returns PASSPHRASE_PENDING. The host +/// polls with UnlockContinueRequest until entry completes or it requests host entry. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct UnlockRequest {} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct UnlockContinueRequest { + /// False polls device entry; true interrupts it to ask for host-entry consent. + /// After PASSPHRASE_ENTERED, continue to await confirmation and unlock; + /// host entry is ignored. + #[prost(bool, tag = "1")] + pub request_host_entry: bool, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct UnlockHostInfoRequest { + /// Only sent after HOST_ENTRY_READY. Absent cancels host input and restarts device entry; + /// the empty string submits the empty passphrase. The device confirms the actual value. + #[prost(string, optional, tag = "1")] + pub passphrase: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct UnlockResponse { + #[prost(enumeration = "unlock_response::State", tag = "1")] + pub state: i32, +} +/// Nested message and enum types in `UnlockResponse`. +pub mod unlock_response { + #[derive( + Clone, + Copy, + Debug, + PartialEq, + Eq, + Hash, + PartialOrd, + Ord, + ::prost::Enumeration + )] + #[repr(i32)] + pub enum State { + PassphrasePending = 0, + HostEntryReady = 1, + /// Passphrase entry on the device finished; confirmation may still be pending. + /// Withdraw host entry and send UnlockContinue to await completion. + PassphraseEntered = 2, + Done = 3, + } + impl State { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::PassphrasePending => "PASSPHRASE_PENDING", + Self::HostEntryReady => "HOST_ENTRY_READY", + Self::PassphraseEntered => "PASSPHRASE_ENTERED", + Self::Done => "DONE", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "PASSPHRASE_PENDING" => Some(Self::PassphrasePending), + "HOST_ENTRY_READY" => Some(Self::HostEntryReady), + "PASSPHRASE_ENTERED" => Some(Self::PassphraseEntered), + "DONE" => Some(Self::Done), + _ => None, + } + } + } +} #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct ElectrumEncryptionKeyRequest { #[prost(uint32, repeated, tag = "1")] @@ -1887,7 +2132,7 @@ pub struct Success {} pub struct Request { #[prost( oneof = "request::Request", - tags = "2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 24, 25, 26, 27, 28, 29, 30" + tags = "2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34" )] pub request: ::core::option::Option, } @@ -1953,13 +2198,21 @@ pub mod request { Bluetooth(super::BluetoothRequest), #[prost(message, tag = "30")] ChangePassword(super::ChangePasswordRequest), + #[prost(message, tag = "31")] + BitboxSync(super::BitBoxSyncRequest), + #[prost(message, tag = "32")] + Unlock(super::UnlockRequest), + #[prost(message, tag = "33")] + UnlockContinue(super::UnlockContinueRequest), + #[prost(message, tag = "34")] + UnlockHostInfo(super::UnlockHostInfoRequest), } } #[derive(Clone, PartialEq, ::prost::Message)] pub struct Response { #[prost( oneof = "response::Response", - tags = "1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17" + tags = "1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19" )] pub response: ::core::option::Option, } @@ -2000,5 +2253,9 @@ pub mod response { Bip85(super::Bip85Response), #[prost(message, tag = "17")] Bluetooth(super::BluetoothResponse), + #[prost(message, tag = "18")] + BitboxSync(super::BitBoxSyncResponse), + #[prost(message, tag = "19")] + Unlock(super::UnlockResponse), } } diff --git a/src/unlock.rs b/src/unlock.rs new file mode 100644 index 0000000..43d04b5 --- /dev/null +++ b/src/unlock.rs @@ -0,0 +1,265 @@ +// SPDX-License-Identifier: Apache-2.0 + +use crate::{error::Error, pb, runtime::Runtime, LockedBitBox, Session, UnlockedBitBox}; +use futures_util::future::{poll_fn, select, Either}; +use futures_util::task::AtomicWaker; +use pb::{request::Request, response::Response, unlock_response::State}; +use std::future::Future; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Weak}; +use std::task::Poll; +use std::time::Duration; + +#[cfg(feature = "multithreaded")] +type InputFuture = futures_util::future::BoxFuture<'static, Result, Error>>; +#[cfg(not(feature = "multithreaded"))] +type InputFuture = futures_util::future::LocalBoxFuture<'static, Result, Error>>; + +#[cfg(feature = "multithreaded")] +type InputCallback = dyn Fn() -> InputFuture + Send + Sync; +#[cfg(not(feature = "multithreaded"))] +type InputCallback = dyn Fn() -> InputFuture; + +#[cfg(feature = "multithreaded")] +type AvailabilityCallback = dyn Fn(Option) + Send + Sync; +#[cfg(not(feature = "multithreaded"))] +type AvailabilityCallback = dyn Fn(Option); + +/// Optional host entry for the device's BIP39 passphrase setting (firmware v9.28.0+). +/// +/// With only an input callback, host entry is requested automatically once per unlock. +/// Rejection, cancellation or invalid input falls back to device entry. Add an availability +/// callback to let the application decide when to request host entry instead. +/// +/// Callbacks run as part of [`LockedBitBox::unlock`] and must not make device +/// queries. The input callback must yield while waiting for the user; GUI applications should +/// send a message to their UI and await its reply. Dropping `unlock` drops that future, +/// so the callback should arrange to dismiss its dialog on cancellation. +/// +/// The default configuration uses device entry only. Older firmware unlocks on the device before +/// pairing, without using this configuration. +#[derive(Default)] +pub struct PassphraseConfig { + enter: Option>, + available: Option>, +} + +impl PassphraseConfig { + /// Configure async host input, called only after approval on the device. + /// Return `Ok(None)` to cancel, or `Ok(Some(passphrase))` to submit, including an empty + /// string. Errors abort unlocking; [`Error::PassphraseInput`] can describe UI errors. + #[cfg(feature = "multithreaded")] + pub fn new(enter_mnemonic_passphrase: F) -> Self + where + F: Fn() -> Fut + Send + Sync + 'static, + Fut: Future, Error>> + Send + 'static, + { + Self { + enter: Some(Box::new(move || Box::pin(enter_mnemonic_passphrase()))), + available: None, + } + } + + /// Configure async host input, called only after approval on the device. + /// Return `Ok(None)` to cancel, or `Ok(Some(passphrase))` to submit, including an empty + /// string. Errors abort unlocking; [`Error::PassphraseInput`] can describe UI errors. + #[cfg(not(feature = "multithreaded"))] + pub fn new(enter_mnemonic_passphrase: F) -> Self + where + F: Fn() -> Fut + 'static, + Fut: Future, Error>> + 'static, + { + Self { + enter: Some(Box::new(move || Box::pin(enter_mnemonic_passphrase()))), + available: None, + } + } + + /// Let the application offer a host-entry control instead of requesting automatically. + /// `Some(handle)` enables the control; `None` withdraws it. Notify once per device-entry + /// phase, and again with a fresh handle if entry restarts. This callback must return promptly. + #[cfg(feature = "multithreaded")] + pub fn with_on_host_passphrase_available(mut self, callback: F) -> Self + where + F: Fn(Option) + Send + Sync + 'static, + { + self.available = Some(Box::new(callback)); + self + } + + /// Let the application offer a host-entry control instead of requesting automatically. + /// `Some(handle)` enables the control; `None` withdraws it. Notify once per device-entry + /// phase, and again with a fresh handle if entry restarts. This callback must return promptly. + #[cfg(not(feature = "multithreaded"))] + pub fn with_on_host_passphrase_available(mut self, callback: F) -> Self + where + F: Fn(Option) + 'static, + { + self.available = Some(Box::new(callback)); + self + } +} + +#[derive(Default)] +struct HostEntrySignal { + requested: AtomicBool, + waker: AtomicWaker, +} + +/// Requests device consent to enter the passphrase on the host. +/// +/// Safe to clone and call from a UI thread, even without the `multithreaded` feature. +/// Handles from withdrawn controls have no effect on subsequent entry phases or connections. +#[derive(Clone)] +pub struct HostPassphraseRequest { + signal: Weak, +} + +impl HostPassphraseRequest { + /// Queue one request and wake the unlock loop, without doing device I/O. + /// Repeated calls for the same entry phase coalesce into a single request. + pub fn request(&self) { + if let Some(signal) = self.signal.upgrade() { + signal.requested.store(true, Ordering::Release); + signal.waker.wake(); + } + } +} + +struct Availability<'a> { + notify: Option<&'a AvailabilityCallback>, + current: Option>, +} + +impl Availability<'_> { + fn offer(&mut self) { + if self.current.is_none() { + if let Some(notify) = self.notify { + let signal = Arc::new(HostEntrySignal::default()); + let handle = HostPassphraseRequest { + signal: Arc::downgrade(&signal), + }; + self.current = Some(signal); + notify(Some(handle)); + } + } + } + + fn withdraw(&mut self) { + if self.current.take().is_some() { + if let Some(notify) = self.notify { + notify(None); + } + } + } + + async fn wait(&self) -> bool { + let delay = std::pin::pin!(R::sleep(Duration::from_millis(100))); + let Some(signal) = &self.current else { + delay.await; + return false; + }; + let requested = std::pin::pin!(poll_fn(|cx| { + signal.waker.register(cx.waker()); + if signal.requested.load(Ordering::Acquire) { + Poll::Ready(()) + } else { + Poll::Pending + } + })); + matches!(select(requested, delay).await, Either::Left(_)) + } +} + +impl Drop for Availability<'_> { + fn drop(&mut self) { + self.withdraw(); + } +} + +pub(crate) fn supports_paired_unlock(version: &semver::Version) -> bool { + *version >= semver::Version::new(9, 28, 0) +} + +impl LockedBitBox { + /// Unlocks the device and enables wallet operations. + /// + /// Pass [`PassphraseConfig::default`] for device entry, or configure host entry callbacks. + /// This consumes the locked device. On failure or cancellation, reconnect to try again. + pub async fn unlock(self, config: PassphraseConfig) -> Result, Error> { + self.session.unlock(&config).await?; + Ok(UnlockedBitBox { + session: self.session, + }) + } +} + +impl Session { + async fn unlock(&self, config: &PassphraseConfig) -> Result<(), Error> { + let _api_call = self.begin_api_call().await; + let result = self.unlock_inner(config).await; + if result.is_err() { + // Release an abandoned workflow after a known failure. A dropped future cannot do + // async cleanup; reconnect also resets the session before starting a new handshake. + let _ = self.communication.reset_session_once().await; + } + result + } + + async fn unlock_inner(&self, config: &PassphraseConfig) -> Result<(), Error> { + let mut availability = Availability { + notify: config.available.as_deref(), + current: None, + }; + let mut auto_request = config.enter.is_some() && config.available.is_none(); + let mut consent_requested = false; + let mut request = Request::Unlock(pb::UnlockRequest {}); + + loop { + let Response::Unlock(reply) = self.query_proto(request).await? else { + return Err(Error::UnexpectedResponse); + }; + match State::try_from(reply.state).map_err(|_| Error::UnexpectedResponse)? { + State::Done => return Ok(()), + State::PassphrasePending => { + consent_requested = if auto_request { + // Request only once so rejection/cancellation can fall back to device + // entry without immediately prompting for host entry again. + auto_request = false; + true + } else { + if config.enter.is_some() { + availability.offer(); + } + availability.wait::().await + }; + if consent_requested { + availability.withdraw(); + } + request = Request::UnlockContinue(pb::UnlockContinueRequest { + request_host_entry: consent_requested, + }); + } + State::PassphraseEntered => { + consent_requested = false; + availability.withdraw(); + // Confirmation may still be pending; the host control must stay hidden. + request = Request::UnlockContinue(pb::UnlockContinueRequest::default()); + } + State::HostEntryReady => { + if !consent_requested { + return Err(Error::UnexpectedResponse); + } + consent_requested = false; + let enter = config.enter.as_ref().ok_or(Error::UnexpectedResponse)?; + request = Request::UnlockHostInfo(pb::UnlockHostInfoRequest { + passphrase: enter().await?, + }); + } + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/src/unlock/tests.rs b/src/unlock/tests.rs new file mode 100644 index 0000000..32e5df6 --- /dev/null +++ b/src/unlock/tests.rs @@ -0,0 +1,813 @@ +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use crate::communication::{Error as CommunicationError, ReadWrite}; +use crate::{ + BitBox, CipherState, HandshakeState, NoiseConfig, NoiseConfigData, PairedBitBox, PairingBitBox, +}; +use async_trait::async_trait; +use futures_channel::oneshot; +use noise_protocol::DH; +use prost::Message; +use std::collections::VecDeque; +use std::sync::Mutex; + +async fn poll_once(mut future: std::pin::Pin<&mut F>) -> Poll { + poll_fn(|cx| Poll::Ready(future.as_mut().poll(cx))).await +} + +struct TestRuntime; + +#[cfg_attr(feature = "multithreaded", async_trait)] +#[cfg_attr(not(feature = "multithreaded"), async_trait(?Send))] +impl Runtime for TestRuntime { + async fn sleep(_: Duration) { + tokio::task::yield_now().await; + } +} + +enum Reply { + Proto(Response), + Malformed, + ReadError, + Pending, +} + +fn state(state: State) -> Reply { + Reply::Proto(Response::Unlock(pb::UnlockResponse { + state: state as i32, + })) +} + +struct Peer { + version: String, + initialized: Option, + unlocked: bool, + legacy_response: Vec, + codec: crate::u2fframing::U2fHid, + incoming: Vec, + outgoing: Vec, + handshake: HandshakeState, + ciphers: Option<(CipherState, CipherState)>, + script: VecDeque, + commands: Vec, + requests: Vec, + read_error: bool, + pending: bool, + pairing_response: u8, + require_pairing: bool, +} + +impl Peer { + fn respond(&mut self, request: &[u8]) -> Vec { + self.commands.push(if request[0] == 0 { + request[1] + } else { + request[0] + }); + match request { + [b'i'] => { + let version = format!("v{}", self.version); + let mut info = vec![version.len() as u8]; + info.extend_from_slice(version.as_bytes()); + info.extend_from_slice(&[0, 0, u8::from(self.unlocked)]); + if let Some(initialized) = self.initialized { + info.push(u8::from(initialized)); + } + info + } + [3] => vec![0], + [0, b'h'] => vec![0, 0], + [0, b'u'] => { + let mut response = vec![0]; + response.extend_from_slice(&self.legacy_response); + response + } + [0, b'v'] => vec![0, self.pairing_response], + [0, b'H', message @ ..] => { + self.handshake.read_message_vec(message).unwrap(); + let mut response = vec![0, 0]; + if self.handshake.completed() { + self.ciphers = Some(self.handshake.get_ciphers()); + response.push(u8::from(self.require_pairing)); + } else { + response.extend_from_slice(&self.handshake.write_message_vec(b"").unwrap()); + } + response + } + [0, b'n', message @ ..] => { + let (receive, send) = self.ciphers.as_mut().unwrap(); + let plaintext = receive.decrypt_vec(message).unwrap(); + let request = pb::Request::decode(plaintext.as_slice()) + .unwrap() + .request + .unwrap(); + self.requests.push(request); + let plaintext = match self.script.pop_front().unwrap() { + Reply::Proto(response) => pb::Response { + response: Some(response), + } + .encode_to_vec(), + Reply::Malformed => vec![0xff], + Reply::ReadError => { + self.read_error = true; + return vec![]; + } + Reply::Pending => { + self.pending = true; + return vec![]; + } + }; + let mut response = vec![0, 0]; + response.extend_from_slice(&send.encrypt_vec(&plaintext)); + response + } + _ => panic!("unexpected command: {request:?}"), + } + } +} + +struct Transport(Arc>); +impl crate::Threading for Transport {} + +#[cfg_attr(feature = "multithreaded", async_trait)] +#[cfg_attr(not(feature = "multithreaded"), async_trait(?Send))] +impl ReadWrite for Transport { + fn write(&self, msg: &[u8]) -> Result { + let mut peer = self.0.lock().unwrap(); + peer.incoming.extend_from_slice(msg); + if let Some(request) = peer.codec.decode(&peer.incoming).unwrap() { + peer.incoming.clear(); + let response = peer.respond(&request); + if !peer.read_error && !peer.pending { + let mut framed = vec![0; crate::u2fframing::MAX_LEN]; + let len = peer.codec.encode(&response, &mut framed).unwrap(); + framed.truncate(len); + peer.outgoing = framed; + } + } + Ok(msg.len()) + } + + async fn read(&self) -> Result, CommunicationError> { + let pending = { + let mut peer = self.0.lock().unwrap(); + if std::mem::take(&mut peer.read_error) { + return Err(CommunicationError::Read); + } + if !peer.pending { + return Ok(std::mem::take(&mut peer.outgoing)); + } + peer.pending + }; + assert!(pending); + futures_util::future::pending().await + } +} + +struct CachedNoise; +impl crate::Threading for CachedNoise {} +impl NoiseConfig for CachedNoise { + fn read_config(&self) -> Result { + let key = noise_rust_crypto::sensitive::Sensitive::from([7u8; 32].into()); + Ok(NoiseConfigData { + app_static_privkey: Some([8; 32]), + device_static_pubkeys: vec![noise_rust_crypto::X25519::pubkey(&key).to_vec()], + }) + } +} + +struct Fixture(Arc>); + +impl Fixture { + fn new(version: &str, script: Vec) -> Self { + Self(Arc::new(Mutex::new(Peer { + version: version.into(), + initialized: (semver::Version::parse(version).unwrap() + >= semver::Version::new(9, 20, 0)) + .then_some(true), + unlocked: false, + legacy_response: vec![0], + codec: crate::u2fframing::U2fHid::new(crate::communication::FIRMWARE_CMD), + incoming: vec![], + outgoing: vec![], + handshake: HandshakeState::new( + noise_protocol::patterns::noise_xx(), + false, + b"Noise_XX_25519_ChaChaPoly_SHA256", + Some(noise_rust_crypto::sensitive::Sensitive::from( + [7u8; 32].into(), + )), + None, + None, + None, + ), + ciphers: None, + script: script.into(), + commands: vec![], + requests: vec![], + read_error: false, + pending: false, + pairing_response: 0, + require_pairing: true, + }))) + } + + async fn pair(&self) -> PairingBitBox { + BitBox::from_transport(Box::new(Transport(self.0.clone())), Box::new(CachedNoise)) + .await + .unwrap() + .unlock_and_pair() + .await + .unwrap() + } + + async fn locked(&self) -> LockedBitBox { + let PairedBitBox::Locked(device) = self.pair().await.wait_confirm().await.unwrap() else { + panic!("expected a locked device"); + }; + device + } + + async fn connect( + &self, + config: PassphraseConfig, + ) -> Result, Error> { + self.locked().await.unlock(config).await + } + + fn requests(&self) -> Vec { + self.0.lock().unwrap().requests.clone() + } +} + +fn start() -> Request { + Request::Unlock(pb::UnlockRequest {}) +} +fn poll(host: bool) -> Request { + Request::UnlockContinue(pb::UnlockContinueRequest { + request_host_entry: host, + }) +} +fn input(value: Option<&str>) -> Request { + Request::UnlockHostInfo(pb::UnlockHostInfoRequest { + passphrase: value.map(str::to_owned), + }) +} + +#[derive(Debug, PartialEq)] +enum Event { + Available, + Withdrawn, + Input, +} + +#[derive(Default)] +struct Ui { + events: Vec, + handles: Vec, + input: VecDeque>, +} + +fn manual(ui: &Arc>, clicks: usize) -> PassphraseConfig { + let entry_ui = ui.clone(); + let available_ui = ui.clone(); + PassphraseConfig::new(move || { + let mut ui = entry_ui.lock().unwrap(); + assert_eq!(ui.events.last(), Some(&Event::Withdrawn)); + ui.events.push(Event::Input); + let input = ui.input.pop_front().unwrap(); + async move { Ok(input) } + }) + .with_on_host_passphrase_available(move |handle| { + let mut ui = available_ui.lock().unwrap(); + match handle { + Some(handle) => { + ui.events.push(Event::Available); + if ui.handles.len() < clicks { + handle.request(); + handle.request(); + } + // A stale click during a new attempt must never request host entry. + for old in &ui.handles { + old.request(); + } + ui.handles.push(handle); + } + None => ui.events.push(Event::Withdrawn), + } + }) +} + +#[tokio::test] +async fn pairing_returns_the_correct_state() { + for version in ["7.0.0", "9.19.0", "9.20.0", "9.27.1", "9.28.0"] { + for initialized in [false, true] { + for cached in [false, true] { + let modern = version == "9.28.0"; + let fixture = Fixture::new(version, vec![state(State::Done)]); + { + let mut peer = fixture.0.lock().unwrap(); + peer.require_pairing = !cached; + peer.initialized = peer.initialized.map(|_| initialized); + peer.legacy_response = vec![if initialized { 0 } else { 2 }]; + } + let pairing = fixture.pair().await; + assert_eq!(pairing.get_pairing_code().is_none(), cached); + let paired = pairing.wait_confirm().await.unwrap(); + { + let peer = fixture.0.lock().unwrap(); + let expected: &[u8] = match (modern, cached) { + (false, false) => b"iuhHHv", + (false, true) => b"iuhHH", + (true, false) => b"i\x03hHHv", + (true, true) => b"i\x03hHH", + }; + assert_eq!(peer.commands, expected); + assert!(peer.requests.is_empty()); + } + match paired { + PairedBitBox::Uninitialized(_) => assert!(!initialized), + PairedBitBox::Unlocked(_) => assert!(initialized && !modern), + PairedBitBox::Locked(device) => { + assert!(initialized && modern); + let config = PassphraseConfig::new(|| async { panic!("unexpected input") }) + .with_on_host_passphrase_available(|_| { + panic!("unexpected availability") + }); + device.unlock(config).await.unwrap(); + } + } + assert_eq!( + fixture.requests(), + if modern && initialized { + vec![start()] + } else { + vec![] + } + ); + } + } + } +} + +#[tokio::test] +async fn legacy_unlock_rejects_failure_and_malformed_responses() { + for response in [vec![1], vec![], vec![3], vec![0, 0]] { + let fixture = Fixture::new("9.19.0", vec![]); + fixture.0.lock().unwrap().legacy_response = response.clone(); + let bitbox = BitBox::::from_transport( + Box::new(Transport(fixture.0.clone())), + Box::new(CachedNoise), + ) + .await + .unwrap(); + let result = bitbox.unlock_and_pair().await; + if response == [1] { + assert!(matches!( + result, + Err(Error::BitBox(crate::error::BitBoxError::Generic)) + )); + } else { + assert!(matches!(result, Err(Error::UnexpectedResponse))); + } + assert_eq!(fixture.0.lock().unwrap().commands, b"iu"); + } +} + +#[tokio::test] +async fn modern_firmware_requires_initialization_info() { + let fixture = Fixture::new("9.28.0", vec![]); + fixture.0.lock().unwrap().initialized = None; + assert!(matches!( + fixture.pair().await.wait_confirm().await, + Err(Error::UnexpectedResponse) + )); + assert!(fixture.requests().is_empty()); +} + +#[tokio::test] +async fn pairing_completes_before_unlock_starts() { + for cached in [false, true] { + for unlocked in [false, true] { + let fixture = Fixture::new("9.28.0", vec![Reply::Pending]); + { + let mut peer = fixture.0.lock().unwrap(); + peer.require_pairing = !cached; + peer.unlocked = unlocked; + } + // Pairing returns even when the firmware would wait indefinitely for a password. + let device = fixture.locked().await; + assert!(fixture.requests().is_empty()); + let mut unlock = Box::pin(device.unlock(PassphraseConfig::default())); + assert!(poll_once(unlock.as_mut()).await.is_pending()); + assert_eq!(fixture.requests(), [start()]); + } + } +} + +#[tokio::test] +async fn rejected_pairing_never_starts_unlock() { + let fixture = Fixture::new("9.28.0", vec![]); + fixture.0.lock().unwrap().pairing_response = 1; + assert!(matches!( + fixture.pair().await.wait_confirm().await, + Err(Error::NoisePairingRejected) + )); + assert!(fixture.requests().is_empty()); +} + +#[tokio::test] +async fn device_entry_withdraws_before_confirmation() { + for clicks in [0, 1] { + let fixture = Fixture::new( + "9.28.0", + vec![ + state(State::PassphrasePending), + state(State::PassphraseEntered), + Reply::Pending, + ], + ); + let ui = Arc::new(Mutex::new(Ui::default())); + let locked = fixture.locked().await; + let mut wait = Box::pin(locked.unlock(manual(&ui, clicks))); + assert!(poll_once(wait.as_mut()).await.is_pending()); + while fixture.requests().len() < 3 { + assert!(poll_once(wait.as_mut()).await.is_pending()); + } + assert_eq!( + ui.lock().unwrap().events, + [Event::Available, Event::Withdrawn] + ); + assert_eq!(fixture.requests(), [start(), poll(clicks > 0), poll(false)]); + drop(wait); + assert!(ui.lock().unwrap().handles[0].signal.upgrade().is_none()); + } +} + +#[tokio::test] +async fn device_entry_polls_and_reoffers_after_rejection() { + let fixture = Fixture::new( + "9.28.0", + vec![ + state(State::PassphrasePending), + state(State::PassphrasePending), + state(State::PassphraseEntered), + state(State::PassphrasePending), + state(State::Done), + ], + ); + let ui = Arc::new(Mutex::new(Ui::default())); + fixture.connect(manual(&ui, 0)).await.unwrap(); + assert_eq!( + ui.lock().unwrap().events, + [ + Event::Available, + Event::Withdrawn, + Event::Available, + Event::Withdrawn + ] + ); + assert_eq!( + fixture.requests(), + [start(), poll(false), poll(false), poll(false), poll(false)] + ); +} + +#[tokio::test] +async fn host_input_requires_consent_and_preserves_exact_value() { + for value in [Some(" host passphrase "), Some(""), None] { + let fixture = Fixture::new( + "9.28.0", + vec![ + state(State::PassphrasePending), + state(State::HostEntryReady), + state(State::PassphrasePending), + state(State::Done), + ], + ); + let ui = Arc::new(Mutex::new(Ui { + input: vec![value.map(str::to_owned)].into(), + ..Ui::default() + })); + fixture.connect(manual(&ui, 1)).await.unwrap(); + assert_eq!( + fixture.requests(), + [start(), poll(true), input(value), poll(false)] + ); + assert_eq!( + ui.lock().unwrap().events, + [ + Event::Available, + Event::Withdrawn, + Event::Input, + Event::Available, + Event::Withdrawn + ] + ); + assert!(ui + .lock() + .unwrap() + .handles + .iter() + .all(|handle| handle.signal.upgrade().is_none())); + } +} + +#[tokio::test] +async fn consent_rejection_allows_a_fresh_host_request() { + let fixture = Fixture::new( + "9.28.0", + vec![ + state(State::PassphrasePending), + state(State::PassphrasePending), + state(State::HostEntryReady), + state(State::Done), + ], + ); + let ui = Arc::new(Mutex::new(Ui { + input: vec![Some("retry".into())].into(), + ..Ui::default() + })); + fixture.connect(manual(&ui, 2)).await.unwrap(); + assert_eq!( + fixture.requests(), + [start(), poll(true), poll(true), input(Some("retry"))] + ); + assert_eq!( + ui.lock().unwrap().events, + [ + Event::Available, + Event::Withdrawn, + Event::Available, + Event::Withdrawn, + Event::Input + ] + ); +} + +#[tokio::test] +async fn automatic_request_happens_only_once() { + for (next, value) in [ + (State::HostEntryReady, Some("")), + (State::HostEntryReady, None), + (State::HostEntryReady, Some("invalid\ninput")), + (State::PassphrasePending, None), + (State::PassphraseEntered, None), + ] { + let script = if next == State::HostEntryReady { + vec![ + state(State::PassphrasePending), + state(next), + state(State::PassphrasePending), + state(State::Done), + ] + } else { + vec![ + state(State::PassphrasePending), + state(next), + state(State::Done), + ] + }; + let fixture = Fixture::new("9.28.0", script); + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let input_calls = calls.clone(); + fixture + .connect(PassphraseConfig::new(move || { + input_calls.fetch_add(1, Ordering::Relaxed); + async move { Ok(value.map(str::to_owned)) } + })) + .await + .unwrap(); + let mut expected = vec![start(), poll(true)]; + if next == State::HostEntryReady { + expected.push(input(value)); + } + expected.push(poll(false)); + assert_eq!(fixture.requests(), expected); + assert_eq!( + calls.load(Ordering::Relaxed), + usize::from(next == State::HostEntryReady) + ); + } +} + +#[tokio::test] +async fn no_input_callback_means_device_entry() { + let fixture = Fixture::new( + "9.28.0", + vec![state(State::PassphrasePending), state(State::Done)], + ); + fixture + .connect( + PassphraseConfig::default() + .with_on_host_passphrase_available(|_| panic!("no host input configured")), + ) + .await + .unwrap(); + assert_eq!(fixture.requests(), [start(), poll(false)]); +} + +#[tokio::test] +async fn errors_withdraw_controls_and_reset_once() { + for reply in [ + state(State::HostEntryReady), + Reply::Proto(Response::Unlock(pb::UnlockResponse { state: 99 })), + Reply::Proto(Response::Success(pb::Success {})), + Reply::Proto(Response::Error(pb::Error { + code: 104, + message: String::new(), + })), + Reply::Malformed, + Reply::ReadError, + ] { + let fixture = Fixture::new("9.28.0", vec![state(State::PassphrasePending), reply]); + let ui = Arc::new(Mutex::new(Ui::default())); + assert!(fixture.connect(manual(&ui, 0)).await.is_err()); + assert_eq!( + ui.lock().unwrap().events, + [Event::Available, Event::Withdrawn] + ); + assert!(ui.lock().unwrap().handles[0].signal.upgrade().is_none()); + assert_eq!( + fixture + .0 + .lock() + .unwrap() + .commands + .iter() + .filter(|&&cmd| cmd == 3) + .count(), + 2 + ); + } +} + +#[tokio::test] +async fn input_errors_do_not_submit_a_passphrase() { + let fixture = Fixture::new( + "9.28.0", + vec![ + state(State::PassphrasePending), + state(State::HostEntryReady), + ], + ); + let result = fixture + .connect(PassphraseConfig::new(|| async { + Err(Error::PassphraseInput("dialog closed".into())) + })) + .await; + assert!(matches!(result, Err(Error::PassphraseInput(_)))); + assert_eq!(fixture.requests(), [start(), poll(true)]); + assert_eq!(fixture.0.lock().unwrap().commands.last(), Some(&3)); +} + +#[tokio::test] +async fn dropping_unlock_withdraws_availability() { + let fixture = Fixture::new("9.28.0", vec![state(State::PassphrasePending)]); + let ui = Arc::new(Mutex::new(Ui::default())); + let locked = fixture.locked().await; + let mut wait = Box::pin(locked.unlock(manual(&ui, 0))); + assert!(poll_once(wait.as_mut()).await.is_pending()); + assert_eq!(ui.lock().unwrap().events, [Event::Available]); + drop(wait); + assert_eq!( + ui.lock().unwrap().events, + [Event::Available, Event::Withdrawn] + ); + assert!(ui.lock().unwrap().handles[0].signal.upgrade().is_none()); +} + +#[tokio::test] +async fn host_input_is_async_and_canceled_with_unlock() { + let fixture = Fixture::new( + "9.28.0", + vec![ + state(State::PassphrasePending), + state(State::HostEntryReady), + ], + ); + let (tx, rx) = oneshot::channel::>(); + let rx = Mutex::new(Some(rx)); + let locked = fixture.locked().await; + let config = PassphraseConfig::new(move || { + let rx = rx.lock().unwrap().take().unwrap(); + async move { Ok(rx.await.unwrap()) } + }); + let mut wait = Box::pin(locked.unlock(config)); + assert!(poll_once(wait.as_mut()).await.is_pending()); + tokio::task::yield_now().await; + assert_eq!(fixture.requests(), [start(), poll(true)]); + assert!(!tx.is_canceled()); + drop(wait); + assert!(tx.is_canceled()); +} + +#[test] +fn a_request_from_another_thread_wakes_the_waiter() { + struct WaitingRuntime; + #[cfg_attr(feature = "multithreaded", async_trait)] + #[cfg_attr(not(feature = "multithreaded"), async_trait(?Send))] + impl Runtime for WaitingRuntime { + async fn sleep(_: Duration) { + futures_util::future::pending().await + } + } + let (tx, rx) = std::sync::mpsc::channel(); + let notify = move |handle| { + if let Some(handle) = handle { + tx.send(handle).unwrap(); + } + }; + let mut availability = Availability { + notify: Some(¬ify), + current: None, + }; + availability.offer(); + let handle: HostPassphraseRequest = rx.recv().unwrap(); + #[derive(Default)] + struct WakeCounter(std::sync::atomic::AtomicUsize); + impl std::task::Wake for WakeCounter { + fn wake(self: Arc) { + self.0.fetch_add(1, Ordering::Relaxed); + } + } + let counter = Arc::new(WakeCounter::default()); + let waker = std::task::Waker::from(counter.clone()); + let mut context = std::task::Context::from_waker(&waker); + let mut waiter = std::pin::pin!(availability.wait::()); + assert!(waiter.as_mut().poll(&mut context).is_pending()); + std::thread::spawn(move || handle.request()).join().unwrap(); + assert_eq!(counter.0.load(Ordering::Relaxed), 1); + assert_eq!(waiter.as_mut().poll(&mut context), Poll::Ready(true)); +} + +#[tokio::test] +async fn oversized_input_returns_an_error_without_panicking() { + for len in [8000, 65536] { + let fixture = Fixture::new( + "9.28.0", + vec![ + state(State::PassphrasePending), + state(State::HostEntryReady), + ], + ); + let result = fixture + .connect(PassphraseConfig::new(move || async move { + Ok(Some("x".repeat(len))) + })) + .await; + assert!(matches!( + result, + Err(Error::Communication(CommunicationError::U2fEncode)) + )); + assert_eq!(fixture.requests(), [start(), poll(true)]); + } +} + +#[cfg(not(feature = "multithreaded"))] +#[tokio::test] +async fn callbacks_can_capture_local_state() { + let called = std::rc::Rc::new(std::cell::Cell::new(false)); + let input_called = called.clone(); + let fixture = Fixture::new( + "9.28.0", + vec![ + state(State::PassphrasePending), + state(State::HostEntryReady), + state(State::Done), + ], + ); + fixture + .connect(PassphraseConfig::new(move || { + let called = input_called.clone(); + async move { + called.set(true); + Ok(Some(String::new())) + } + })) + .await + .unwrap(); + assert!(called.get()); +} + +#[cfg(feature = "multithreaded")] +#[tokio::test] +async fn configured_connection_future_is_send() { + fn assert_send(_: &T) {} + fn assert_send_sync(_: &T) {} + let config = PassphraseConfig::new(|| async { Ok(None) }); + assert_send_sync(&config); + let fixture = Fixture::new("9.28.0", vec![state(State::Done)]); + let pairing = fixture.pair().await; + assert_send_sync(&pairing); + let wait = pairing.wait_confirm(); + assert_send(&wait); + let PairedBitBox::Locked(device) = wait.await.unwrap() else { + panic!("expected a locked device"); + }; + assert_send_sync(&device); + let unlock = device.unlock(config); + assert_send(&unlock); + assert_send_sync(&unlock.await.unwrap()); +} diff --git a/tests/test_device.rs b/tests/test_device.rs index 239b719..ca91dc7 100644 --- a/tests/test_device.rs +++ b/tests/test_device.rs @@ -119,11 +119,28 @@ async fn test_reconnect() { .wait_confirm() .await .unwrap(); - // The first reply must be a two-byte HWW ACK + unlock SUCCESS. An unfinished + // The first reply must be a two-byte HWW ACK + SUCCESS: from the handshake on + // firmware v9.28.0+, or from legacy unlock on older firmware. An unfinished // workflow would instead return its encrypted error. assert_eq!(&responses.lock().unwrap()[5..9], &[0, 2, 0, 0]); - let PairedBitBox::Unlocked(bitbox) = bitbox else { - panic!("initialized devices must be unlocked before pairing"); + let modern = match &bitbox { + PairedBitBox::Locked(device) => device.version(), + PairedBitBox::Unlocked(device) => device.version(), + PairedBitBox::Uninitialized(_) => panic!("restored wallet must stay initialized"), + } >= &semver::Version::new(9, 28, 0); + let bitbox = if modern { + let PairedBitBox::Locked(bitbox) = bitbox else { + panic!("modern firmware must require the explicit unlock step"); + }; + bitbox + .unlock(bitbox_api::PassphraseConfig::default()) + .await + .unwrap() + } else { + let PairedBitBox::Unlocked(bitbox) = bitbox else { + panic!("legacy firmware must already be unlocked"); + }; + bitbox }; assert_eq!(bitbox.root_fingerprint().await.unwrap(), "4c00739d"); })