Skip to content

Commit 6514724

Browse files
committed
simulator: implement a simulator for bitbox02 device
HWI is thinking of updating its support policy such that supported wallets must implement a simulator/emulator. See bitcoin-core/HWI#685. That's why a simulator is implemented for bitbox02, supporting functionalities of its API. This first version of the simulator is capable of nearly every functionality of a normal Bitbox02 device, without promising any security or production use. Its main aim is to be able to run unit tests for features and test the API. In addition, it will be configured to run automated tests in CI, which helps both us and HWI integration. Right now, the simulator has 3 different ways to communicate with a client: giving inputs/getting output from CLI, using pipes or opening sockets. Socket is the most convenient and reliable choice in this version. It expects the clients to open a socket on port 15432, which is selected intentionally to avoid possible conflicts. The simulator resides with C unit-tests since it uses same mocks, therefore it can be built by `make unit-test`. Lastly, Python client implemented in `py/send_message.py` is updated to support communicating with simulator with the socket configuration mentioned above. Client can be started up with `./py/send_message.py --simulator` command. To run the simulator, `build-build/bin/test_simulator` command is sufficient. Signed-off-by: asi345 <[email protected]>
1 parent 3c0e9ac commit 6514724

File tree

15 files changed

+476
-17
lines changed

15 files changed

+476
-17
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
.ccls-cache
1010
compile_commands.json
1111
compile_flags.txt
12+
.vscode
13+
.DS_Store
1214

1315
# gnu global
1416
/src/GPATH

py/send_message.py

+69
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
# pylint: disable=too-many-lines
1919

2020
import argparse
21+
import socket
2122
import pprint
2223
import sys
2324
from typing import List, Any, Optional, Callable, Union, Tuple, Sequence
@@ -41,6 +42,7 @@
4142
FirmwareVersionOutdatedException,
4243
u2fhid,
4344
bitbox_api_protocol,
45+
PhysicalLayer,
4446
)
4547

4648
import u2f
@@ -1556,6 +1558,65 @@ def run(self) -> int:
15561558
return 0
15571559

15581560

1561+
def connect_to_simulator_bitbox(debug: bool) -> int:
1562+
"""
1563+
Connects and runs the main menu on host computer,
1564+
simulating a BitBox02 connected over USB.
1565+
"""
1566+
1567+
class Simulator(PhysicalLayer):
1568+
"""
1569+
Simulator class handles the communication
1570+
with the firmware simulator
1571+
"""
1572+
1573+
def __init__(self) -> None:
1574+
self.client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
1575+
port = 15423
1576+
self.client_socket.bind(("", port))
1577+
self.client_socket.listen(50)
1578+
print(f"Waiting for connection on port {port}")
1579+
self.connection, addr = self.client_socket.accept()
1580+
print(f"Connected to {addr}")
1581+
1582+
def write(self, data: bytes) -> None:
1583+
self.connection.send(data[1:])
1584+
if debug:
1585+
print(f"Written to the simulator:\n{data.hex()[2:]}")
1586+
1587+
def read(self, size: int, timeout_ms: int) -> bytes:
1588+
res = self.connection.recv(64)
1589+
if debug:
1590+
print(f"Read from the simulator:\n{res.hex()}")
1591+
return res
1592+
1593+
def __del__(self) -> None:
1594+
print("Simulator quit")
1595+
if self.connection:
1596+
self.connection.shutdown(socket.SHUT_RDWR)
1597+
self.connection.close()
1598+
1599+
simulator = Simulator()
1600+
1601+
device_info: devices.DeviceInfo = {
1602+
"serial_number": "v9.16.0",
1603+
"path": b"",
1604+
"product_string": "BitBox02BTC",
1605+
}
1606+
noise_config = bitbox_api_protocol.BitBoxNoiseConfig()
1607+
bitbox_connection = bitbox02.BitBox02(
1608+
transport=u2fhid.U2FHid(simulator),
1609+
device_info=device_info,
1610+
noise_config=noise_config,
1611+
)
1612+
try:
1613+
bitbox_connection.check_min_version()
1614+
except FirmwareVersionOutdatedException as exc:
1615+
print("WARNING: ", exc)
1616+
1617+
return SendMessage(bitbox_connection, debug).run()
1618+
1619+
15591620
def connect_to_usb_bitbox(debug: bool, use_cache: bool) -> int:
15601621
"""
15611622
Connects and runs the main menu on a BitBox02 connected
@@ -1643,6 +1704,11 @@ def main() -> int:
16431704
parser = argparse.ArgumentParser(description="Tool for communicating with bitbox device")
16441705
parser.add_argument("--debug", action="store_true", help="Print messages sent and received")
16451706
parser.add_argument("--u2f", action="store_true", help="Use u2f menu instead")
1707+
parser.add_argument(
1708+
"--simulator",
1709+
action="store_true",
1710+
help="Connect to the BitBox02 simulator instead of a real BitBox02",
1711+
)
16461712
parser.add_argument(
16471713
"--no-cache", action="store_true", help="Don't use cached or store noise keys"
16481714
)
@@ -1663,6 +1729,9 @@ def main() -> int:
16631729
return u2fapp.run()
16641730
return 1
16651731

1732+
if args.simulator:
1733+
return connect_to_simulator_bitbox(args.debug)
1734+
16661735
return connect_to_usb_bitbox(args.debug, not args.no_cache)
16671736

16681737

src/rust/Cargo.lock

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/rust/bitbox02-rust/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ bitcoin = { version = "0.31.0", default-features = false, features = ["no-std"],
5353
# small-hash feature to reduce the binary size, saving around 2784 bytes (as measured at time of
5454
# writing, this might fluctuate over time).
5555
bitcoin_hashes = { version = "0.13.0", default-features = false, features = ["small-hash"] }
56+
cfg-if = "1.0"
5657

5758
[dependencies.prost]
5859
# keep version in sync with tools/prost-build/Cargo.toml.

src/rust/bitbox02-rust/src/workflow/confirm.rs

+2
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,7 @@ pub async fn confirm(params: &Params<'_>) -> Result<(), UserAbort> {
3131
};
3232
});
3333
component.screen_stack_push();
34+
#[cfg(feature = "c-unit-testing")]
35+
bitbox02::print_stdout(&format!("CONFIRM SCREEN START\nTITLE: {}\nBODY: {}\nCONFIRM SCREEN END\n", params.title, params.body));
3436
option_no_screensaver(&result).await
3537
}

src/rust/bitbox02-rust/src/workflow/mnemonic.rs

+57-10
Original file line numberDiff line numberDiff line change
@@ -13,26 +13,35 @@
1313
// limitations under the License.
1414

1515
pub use super::cancel::Error as CancelError;
16-
use super::cancel::{cancel, set_result, with_cancel};
1716
use super::confirm;
18-
use super::menu;
19-
use super::status::status;
20-
use super::trinary_choice::{choose, TrinaryChoice};
21-
use super::trinary_input_string;
2217

23-
use alloc::boxed::Box;
2418
use alloc::string::String;
25-
use alloc::vec::Vec;
26-
use core::cell::RefCell;
19+
use alloc::string::ToString;
2720

28-
use sha2::{Digest, Sha256};
21+
cfg_if::cfg_if! {
22+
if #[cfg(not(feature = "c-unit-testing"))] {
23+
use super::cancel::{cancel, set_result, with_cancel};
24+
use super::menu;
25+
use super::status::status;
26+
use super::trinary_choice::{choose, TrinaryChoice};
27+
use super::trinary_input_string;
2928

30-
const NUM_RANDOM_WORDS: u8 = 5;
29+
use alloc::boxed::Box;
30+
use alloc::vec::Vec;
31+
use core::cell::RefCell;
3132

33+
use sha2::{Digest, Sha256};
34+
35+
const NUM_RANDOM_WORDS: u8 = 5;
36+
}
37+
}
38+
39+
#[cfg(not(feature = "c-unit-testing"))]
3240
fn as_str_vec(v: &[zeroize::Zeroizing<String>]) -> Vec<&str> {
3341
v.iter().map(|s| s.as_str()).collect()
3442
}
3543

44+
#[cfg(not(feature = "c-unit-testing"))]
3645
/// Return 5 words from the BIP39 wordlist, 4 of which are random, and
3746
/// one of them is provided `word`. Returns the position of `word` in
3847
/// the list of words, and the lis of words. This is used to test if
@@ -73,6 +82,7 @@ fn create_random_unique_words(word: &str, length: u8) -> (u8, Vec<zeroize::Zeroi
7382
(index_word, result)
7483
}
7584

85+
#[cfg(not(feature = "c-unit-testing"))]
7686
/// Displays all mnemonic words in a scroll-through screen.
7787
async fn show_mnemonic(words: &[&str]) -> Result<(), CancelError> {
7888
let result = RefCell::new(None);
@@ -90,6 +100,7 @@ async fn show_mnemonic(words: &[&str]) -> Result<(), CancelError> {
90100
with_cancel("Recovery\nwords", &mut component, &result).await
91101
}
92102

103+
#[cfg(not(feature = "c-unit-testing"))]
93104
/// Displays the `choices` to the user, returning the index of the selected choice.
94105
async fn confirm_word(choices: &[&str], title: &str) -> Result<u8, CancelError> {
95106
let result = RefCell::new(None);
@@ -107,6 +118,7 @@ async fn confirm_word(choices: &[&str], title: &str) -> Result<u8, CancelError>
107118
with_cancel("Recovery\nwords", &mut component, &result).await
108119
}
109120

121+
#[cfg(not(feature = "c-unit-testing"))]
110122
pub async fn show_and_confirm_mnemonic(words: &[&str]) -> Result<(), CancelError> {
111123
// Part 1) Scroll through words
112124
show_mnemonic(words).await?;
@@ -140,6 +152,26 @@ pub async fn show_and_confirm_mnemonic(words: &[&str]) -> Result<(), CancelError
140152
Ok(())
141153
}
142154

155+
#[cfg(feature = "c-unit-testing")]
156+
pub async fn show_and_confirm_mnemonic(words: &[&str]) -> Result<(), CancelError> {
157+
let _ = confirm::confirm(&confirm::Params {
158+
title: "",
159+
body: "Please confirm\neach word",
160+
accept_only: true,
161+
accept_is_nextarrow: true,
162+
..Default::default()
163+
})
164+
.await;
165+
166+
for word in words.iter() {
167+
bitbox02::println_stdout(word);
168+
}
169+
bitbox02::println_stdout("Words confirmed");
170+
171+
Ok(())
172+
}
173+
174+
#[cfg(not(feature = "c-unit-testing"))]
143175
/// Given 11/17/23 initial words, this function returns a list of candidate words for the last word,
144176
/// such that the resulting bip39 phrase has a valid checksum. There are always exactly 8 such words
145177
/// for 24 word mnemonics, 32 words for 18 word mnemonics and 128 words for 12 word mnemonics.
@@ -197,13 +229,15 @@ fn lastword_choices(entered_words: &[&str]) -> Vec<u16> {
197229
.collect()
198230
}
199231

232+
#[cfg(not(feature = "c-unit-testing"))]
200233
fn lastword_choices_strings(entered_words: &[&str]) -> Vec<zeroize::Zeroizing<String>> {
201234
lastword_choices(entered_words)
202235
.into_iter()
203236
.map(|word_idx| bitbox02::keystore::get_bip39_word(word_idx).unwrap())
204237
.collect()
205238
}
206239

240+
#[cfg(not(feature = "c-unit-testing"))]
207241
/// Select the 24th word from a list of 8 valid candidate words presented as a menu.
208242
/// Returns `Ok(None)` if the user chooses "None of them".
209243
/// Returns `Ok(Some(word))` if the user chooses a word.
@@ -249,6 +283,7 @@ async fn get_24th_word(
249283
}
250284
}
251285

286+
#[cfg(not(feature = "c-unit-testing"))]
252287
/// Select the last word of a 12 or 18 word mnemonic from a list of valid candidate words. The input
253288
/// is the trinary input keyboard with the wordlist restricted to these candidates.
254289
///
@@ -289,6 +324,7 @@ async fn get_12th_18th_word(
289324
}
290325
}
291326

327+
#[cfg(not(feature = "c-unit-testing"))]
292328
/// Retrieve a BIP39 mnemonic sentence of 12, 18 or 24 words from the user.
293329
pub async fn get() -> Result<zeroize::Zeroizing<String>, CancelError> {
294330
let num_words: usize = match choose("How many words?", "12", "18", "24").await {
@@ -403,6 +439,17 @@ pub async fn get() -> Result<zeroize::Zeroizing<String>, CancelError> {
403439
))
404440
}
405441

442+
#[cfg(feature = "c-unit-testing")]
443+
pub async fn get() -> Result<zeroize::Zeroizing<String>, CancelError> {
444+
let words = "boring mistake dish oyster truth pigeon viable emerge sort crash wire portion cannon couple enact box walk height pull today solid off enable tide";
445+
bitbox02::println_stdout("Restored from recovery words below:");
446+
bitbox02::println_stdout(words);
447+
return Ok(zeroize::Zeroizing::new(
448+
words
449+
.to_string()
450+
));
451+
}
452+
406453
#[cfg(test)]
407454
mod tests {
408455
use super::*;

src/rust/bitbox02-rust/src/workflow/status.rs

+2
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,7 @@ pub async fn status(title: &str, status_success: bool) {
2121
*result.borrow_mut() = Some(());
2222
});
2323
component.screen_stack_push();
24+
#[cfg(feature = "c-unit-testing")]
25+
bitbox02::print_stdout(&format!("STATUS SCREEN START\nTITLE: {}\nSTATUS SCREEN END\n", title));
2426
option_no_screensaver(&result).await
2527
}

src/rust/bitbox02-rust/src/workflow/trinary_input_string.rs

+2
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ pub async fn enter(
4949
bitbox02::ui::trinary_input_string_set_input(&mut component, preset);
5050
}
5151
component.screen_stack_push();
52+
#[cfg(feature = "c-unit-testing")]
53+
bitbox02::print_stdout(&format!("ENTER SCREEN START\nTITLE: {}\nENTER SCREEN END\n", params.title));
5254
option(&result)
5355
.await
5456
.or(Err(super::cancel::Error::Cancelled))

src/rust/bitbox02/src/lib.rs

+8
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,14 @@ pub fn print_stdout(msg: &str) {
183183
}
184184
}
185185

186+
#[cfg(any(feature = "testing", feature = "c-unit-testing"))]
187+
pub fn println_stdout(msg: &str) {
188+
unsafe {
189+
bitbox02_sys::printf(crate::util::str_to_cstr_vec(msg).unwrap().as_ptr());
190+
bitbox02_sys::printf(crate::util::str_to_cstr_vec("\n").unwrap().as_ptr());
191+
}
192+
}
193+
186194
#[cfg(test)]
187195
mod tests {
188196
use super::*;

src/rust/bitbox02/src/ui.rs

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
mod types;
1717

1818
#[cfg_attr(feature = "testing", path = "ui/ui_stub.rs")]
19+
#[cfg_attr(feature = "c-unit-testing", path = "ui/ui_stub_c_unit_tests.rs")]
1920
// We don't actually use ui::ui anywhere, we re-export below.
2021
#[allow(clippy::module_inception)]
2122
mod ui;

src/rust/bitbox02/src/ui/types.rs

+4-4
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ use util::Survive;
2121
pub use bitbox02_sys::trinary_choice_t as TrinaryChoice;
2222

2323
// Taking the constant straight from C, as it's excluding the null terminator.
24-
#[cfg_attr(feature = "testing", allow(dead_code))]
24+
#[cfg_attr(any(feature = "testing", feature = "c-unit-testing"), allow(dead_code))]
2525
pub(crate) const MAX_LABEL_SIZE: usize = bitbox02_sys::MAX_LABEL_SIZE as _;
2626

2727
#[derive(Default)]
@@ -33,7 +33,7 @@ pub enum Font {
3333
}
3434

3535
impl Font {
36-
#[cfg_attr(feature = "testing", allow(dead_code))]
36+
#[cfg_attr(any(feature = "testing", feature = "c-unit-testing"), allow(dead_code))]
3737
pub(crate) fn as_ptr(&self) -> *const bitbox02_sys::UG_FONT {
3838
match self {
3939
Font::Default => core::ptr::null() as *const _,
@@ -65,7 +65,7 @@ pub struct ConfirmParams<'a> {
6565
}
6666

6767
impl<'a> ConfirmParams<'a> {
68-
#[cfg_attr(feature = "testing", allow(dead_code))]
68+
#[cfg_attr(any(feature = "testing", feature = "c-unit-testing"), allow(dead_code))]
6969
/// `title_scratch` and `body_scratch` exist to keep the data
7070
/// alive for as long as the C params live.
7171
pub(crate) fn to_c_params(
@@ -110,7 +110,7 @@ pub struct TrinaryInputStringParams<'a> {
110110
}
111111

112112
impl<'a> TrinaryInputStringParams<'a> {
113-
#[cfg_attr(feature = "testing", allow(dead_code))]
113+
#[cfg_attr(any(feature = "testing", feature = "c-unit-testing"), allow(dead_code))]
114114
pub(crate) fn to_c_params(
115115
&self,
116116
title_scratch: &'a mut Vec<u8>,

0 commit comments

Comments
 (0)