diff --git a/CHANGELOG.md b/CHANGELOG.md index c36ea7b..d6e39ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog ## [Unreleased] +- Reset unfinished sessions when connecting to firmware v9.28.0 or newer, allowing host + reconnects while the device remains powered on. - Remove the `wasm` feature, the public `wasm` module, and the TypeScript/WASM package. The TypeScript library is now maintained in [BitBoxSwiss/bitbox-api-ts](https://github.com/BitBoxSwiss/bitbox-api-ts/). diff --git a/src/communication.rs b/src/communication.rs index 3d3610a..8a29f30 100644 --- a/src/communication.rs +++ b/src/communication.rs @@ -88,6 +88,8 @@ const HWW_REQ_NEW: u8 = 0x00; const HWW_REQ_RETRY: u8 = 0x01; // Cancel any outstanding request. // const HWW_REQ_CANCEL: u8 = 0x02; +// Reset the previous session before starting a new one (since v9.28.0). +const HWW_REQ_RESET: u8 = 0x03; // INFO api call (used to be OP_INFO api call), graduated to the toplevel framing so it works // the same way for all firmware versions. const HWW_INFO: u8 = b'i'; @@ -176,6 +178,23 @@ async fn get_info(communication: &dyn ReadWrite) -> Result { }) } +async fn reset_session( + communication: &dyn ReadWrite, + version: &semver::Version, +) -> Result<(), Error> { + if *version < semver::Version::new(9, 28, 0) { + return Ok(()); + } + // Send at the framing layer so an unfinished workflow cannot consume the request. + loop { + match communication.query(&[HWW_REQ_RESET]).await?.as_slice() { + [HWW_RSP_ACK] => return Ok(()), + [HWW_RSP_BUSY] => R::sleep(std::time::Duration::from_secs(1)).await, + _ => return Err(Error::Info), + } + } +} + impl HwwCommunication { pub async fn from(communication: Box) -> Result { let info = get_info(communication.as_ref()).await?; @@ -187,6 +206,8 @@ impl HwwCommunication { return Err(Error::Version(">=7.0.0")); } + reset_session::(communication.as_ref(), &info.version).await?; + Ok(HwwCommunication { communication, info, diff --git a/tests/test_device.rs b/tests/test_device.rs index 7d75e5f..a37854b 100644 --- a/tests/test_device.rs +++ b/tests/test_device.rs @@ -11,6 +11,120 @@ mod util; use util::{test_initialized_simulators, test_simulators_after_pairing}; +#[tokio::test] +async fn test_reconnect() { + use bitbox_api::{btc, pb, runtime::TokioRuntime, BitBox, NoiseConfigNoCache, PairedBitBox}; + use bitbox_api::{CommunicationError, ReadWrite}; + use std::sync::{Arc, Mutex}; + + // Record real simulator responses without replacing the transport or firmware behavior. + struct RecordingTransport { + transport: Box, + responses: Arc>>, + } + + impl bitbox_api::Threading for RecordingTransport {} + + #[cfg_attr(feature = "multithreaded", async_trait::async_trait)] + #[cfg_attr(not(feature = "multithreaded"), async_trait::async_trait(?Send))] + impl ReadWrite for RecordingTransport { + fn write(&self, msg: &[u8]) -> Result { + self.transport.write(msg) + } + + async fn read(&self) -> Result, CommunicationError> { + let response = self.transport.read().await?; + self.responses.lock().unwrap().extend_from_slice(&response); + Ok(response) + } + } + + async fn connect() -> PairedBitBox { + BitBox::from_simulator(None, Box::new(NoiseConfigNoCache {})) + .await + .unwrap() + .unlock_and_pair() + .await + .unwrap() + .wait_confirm() + .await + .unwrap() + } + + util::test_simulators(async |_stdout| { + let bitbox = connect().await; + 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. + let transaction = btc::Transaction { + script_configs: vec![pb::BtcScriptConfigWithKeypath { + script_config: Some(btc::make_script_config_simple( + pb::btc_script_config::SimpleType::P2wpkh, + )), + keypath: bitbox_api::Keypath::try_from("m/84'/0'/0'") + .unwrap() + .to_vec(), + }], + version: 2, + inputs: vec![btc::TxInput { + prev_out_hash: vec![1; 32], + prev_out_index: 0, + prev_out_value: 100_000, + sequence: 0xffffffff, + keypath: "m/84'/0'/0'/0/0".try_into().unwrap(), + script_config_index: 0, + prev_tx: None, + }], + outputs: vec![btc::TxOutput::Internal(btc::TxInternalOutput { + keypath: "m/84'/0'/0'/1/0".try_into().unwrap(), + value: 99_000, + script_config_index: 0, + })], + locktime: 0, + }; + let error = bitbox + .btc_sign( + pb::BtcCoin::Btc, + &transaction, + pb::btc_sign_init_request::FormatUnit::Default, + ) + .await + .unwrap_err(); + assert!(matches!(error, bitbox_api::error::Error::BtcSign(message) + if message == "input's previous transaction required but missing")); + } + drop(bitbox); + + // Reconnect to the same running simulator, preserving the firmware session state. + let responses = Arc::new(Mutex::new(Vec::new())); + let bitbox = BitBox::::from_transport( + Box::new(RecordingTransport { + transport: bitbox_api::simulator::try_connect::(None) + .await + .unwrap(), + responses: responses.clone(), + }), + Box::new(NoiseConfigNoCache {}), + ) + .await + .unwrap(); + responses.lock().unwrap().clear(); + let bitbox = bitbox + .unlock_and_pair() + .await + .unwrap() + .wait_confirm() + .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. + assert_eq!(&responses.lock().unwrap()[5..9], &[0, 2, 0, 0]); + assert_eq!(bitbox.root_fingerprint().await.unwrap(), "4c00739d"); + }) + .await +} + #[tokio::test] async fn test_device_info() { test_simulators_after_pairing(async |paired_bitbox| { diff --git a/tests/util/mod.rs b/tests/util/mod.rs index 0d6142e..0fb2549 100644 --- a/tests/util/mod.rs +++ b/tests/util/mod.rs @@ -548,10 +548,8 @@ async fn download_simulators() -> Result, ()> { Ok(filenames) } -/// 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 fresh simulator, allowing the test to connect and disconnect clients. +pub async fn test_simulators(run: impl AsyncFn(&SimulatorStdout)) { let simulator_filenames = if let Some(simulator_filename) = option_env!("SIMULATOR") { vec![simulator_filename.into()] } else { @@ -562,6 +560,15 @@ pub async fn test_simulators_after_pairing_with_stdout( println!("\tSimulator tests using {simulator_filename}"); let server = Server::launch(&simulator_filename); let stdout = server.stdout(); + run(&stdout).await; + } +} + +/// 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), +) { + test_simulators(async |stdout| { let noise_config = Box::new(bitbox_api::NoiseConfigNoCache {}); let bitbox = bitbox_api::BitBox::::from_simulator( None, @@ -571,8 +578,9 @@ pub async fn test_simulators_after_pairing_with_stdout( .unwrap(); let pairing_bitbox = bitbox.unlock_and_pair().await.unwrap(); let paired_bitbox = pairing_bitbox.wait_confirm().await.unwrap(); - run(&paired_bitbox, &stdout).await; - } + run(&paired_bitbox, stdout).await; + }) + .await } /// Tests on an initialized device, which is not yet seeded.