From b9ec5ae892545289ce39b006a488a5da2e9df03b Mon Sep 17 00:00:00 2001 From: benma's agent Date: Thu, 17 Sep 2026 15:40:29 +0200 Subject: [PATCH] communication: reset sessions on connect Disconnecting after an intermediate signing response leaves the firmware waiting for a continuation while the device stays powered. On reconnect, the old workflow consumes the first unlock request and returns an encrypted error. Pairing can still succeed because the unlock response is ignored, masking the stale session. Send HWW_REQ_RESET (0x03) after version discovery and before unlock and Noise pairing on firmware v9.28.0 or newer. Keep the helper private, retry BUSY responses once per second, and require an ACK without a payload. Older firmware keeps its existing connection flow. Add a simulator regression that stops signing with previous transaction data missing, disconnects, and reconnects to the same running process. Record the real TCP responses to check that unlock succeeds, then pair and read the root fingerprint. Older simulator versions exercise ordinary reconnects. The regression uses the locally built v9.28.0 C simulator and fails if the reset call is disabled. Firmware counterpart: https://github.com/BitBoxSwiss/bitbox02-firmware/pull/2111 --- CHANGELOG.md | 2 + src/communication.rs | 21 ++++++++ tests/test_device.rs | 114 +++++++++++++++++++++++++++++++++++++++++++ tests/util/mod.rs | 20 +++++--- 4 files changed, 151 insertions(+), 6 deletions(-) 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.