Skip to content

Commit

Permalink
simulator: implement a simulator for bitbox02 device
Browse files Browse the repository at this point in the history
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]>
  • Loading branch information
asi345 committed Jan 31, 2024
1 parent 3c0e9ac commit d50a7ee
Show file tree
Hide file tree
Showing 15 changed files with 476 additions and 17 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
.ccls-cache
compile_commands.json
compile_flags.txt
.vscode
.DS_Store

# gnu global
/src/GPATH
Expand Down
69 changes: 69 additions & 0 deletions py/send_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
# pylint: disable=too-many-lines

import argparse
import socket
import pprint
import sys
from typing import List, Any, Optional, Callable, Union, Tuple, Sequence
Expand All @@ -41,6 +42,7 @@
FirmwareVersionOutdatedException,
u2fhid,
bitbox_api_protocol,
PhysicalLayer,
)

import u2f
Expand Down Expand Up @@ -1556,6 +1558,65 @@ def run(self) -> int:
return 0


def connect_to_simulator_bitbox(debug: bool) -> int:
"""
Connects and runs the main menu on host computer,
simulating a BitBox02 connected over USB.
"""

class Simulator(PhysicalLayer):
"""
Simulator class handles the communication
with the firmware simulator
"""

def __init__(self) -> None:
self.client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
port = 15423
self.client_socket.bind(("", port))
self.client_socket.listen(50)
print(f"Waiting for connection on port {port}")
self.connection, addr = self.client_socket.accept()
print(f"Connected to {addr}")

def write(self, data: bytes) -> None:
self.connection.send(data[1:])
if debug:
print(f"Written to the simulator:\n{data.hex()[2:]}")

def read(self, size: int, timeout_ms: int) -> bytes:
res = self.connection.recv(64)
if debug:
print(f"Read from the simulator:\n{res.hex()}")
return res

def __del__(self) -> None:
print("Simulator quit")
if self.connection:
self.connection.shutdown(socket.SHUT_RDWR)
self.connection.close()

simulator = Simulator()

device_info: devices.DeviceInfo = {
"serial_number": "v9.16.0",
"path": b"",
"product_string": "BitBox02BTC",
}
noise_config = bitbox_api_protocol.BitBoxNoiseConfig()
bitbox_connection = bitbox02.BitBox02(
transport=u2fhid.U2FHid(simulator),
device_info=device_info,
noise_config=noise_config,
)
try:
bitbox_connection.check_min_version()
except FirmwareVersionOutdatedException as exc:
print("WARNING: ", exc)

return SendMessage(bitbox_connection, debug).run()


def connect_to_usb_bitbox(debug: bool, use_cache: bool) -> int:
"""
Connects and runs the main menu on a BitBox02 connected
Expand Down Expand Up @@ -1643,6 +1704,11 @@ def main() -> int:
parser = argparse.ArgumentParser(description="Tool for communicating with bitbox device")
parser.add_argument("--debug", action="store_true", help="Print messages sent and received")
parser.add_argument("--u2f", action="store_true", help="Use u2f menu instead")
parser.add_argument(
"--simulator",
action="store_true",
help="Connect to the BitBox02 simulator instead of a real BitBox02",
)
parser.add_argument(
"--no-cache", action="store_true", help="Don't use cached or store noise keys"
)
Expand All @@ -1663,6 +1729,9 @@ def main() -> int:
return u2fapp.run()
return 1

if args.simulator:
return connect_to_simulator_bitbox(args.debug)

return connect_to_usb_bitbox(args.debug, not args.no_cache)


Expand Down
1 change: 1 addition & 0 deletions src/rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/rust/bitbox02-rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ bitcoin = { version = "0.31.0", default-features = false, features = ["no-std"],
# small-hash feature to reduce the binary size, saving around 2784 bytes (as measured at time of
# writing, this might fluctuate over time).
bitcoin_hashes = { version = "0.13.0", default-features = false, features = ["small-hash"] }
cfg-if = "1.0"

[dependencies.prost]
# keep version in sync with tools/prost-build/Cargo.toml.
Expand Down
2 changes: 2 additions & 0 deletions src/rust/bitbox02-rust/src/workflow/confirm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,7 @@ pub async fn confirm(params: &Params<'_>) -> Result<(), UserAbort> {
};
});
component.screen_stack_push();
#[cfg(feature = "c-unit-testing")]
bitbox02::print_stdout(&format!("CONFIRM SCREEN START\nTITLE: {}\nBODY: {}\nCONFIRM SCREEN END\n", params.title, params.body));
option_no_screensaver(&result).await
}
67 changes: 57 additions & 10 deletions src/rust/bitbox02-rust/src/workflow/mnemonic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,35 @@
// limitations under the License.

pub use super::cancel::Error as CancelError;
use super::cancel::{cancel, set_result, with_cancel};
use super::confirm;
use super::menu;
use super::status::status;
use super::trinary_choice::{choose, TrinaryChoice};
use super::trinary_input_string;

use alloc::boxed::Box;
use alloc::string::String;
use alloc::vec::Vec;
use core::cell::RefCell;
use alloc::string::ToString;

use sha2::{Digest, Sha256};
cfg_if::cfg_if! {
if #[cfg(not(feature = "c-unit-testing"))] {
use super::cancel::{cancel, set_result, with_cancel};
use super::menu;
use super::status::status;
use super::trinary_choice::{choose, TrinaryChoice};
use super::trinary_input_string;

const NUM_RANDOM_WORDS: u8 = 5;
use alloc::boxed::Box;
use alloc::vec::Vec;
use core::cell::RefCell;

use sha2::{Digest, Sha256};

const NUM_RANDOM_WORDS: u8 = 5;
}
}

#[cfg(not(feature = "c-unit-testing"))]
fn as_str_vec(v: &[zeroize::Zeroizing<String>]) -> Vec<&str> {
v.iter().map(|s| s.as_str()).collect()
}

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

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

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

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

#[cfg(feature = "c-unit-testing")]
pub async fn show_and_confirm_mnemonic(words: &[&str]) -> Result<(), CancelError> {
let _ = confirm::confirm(&confirm::Params {
title: "",
body: "Please confirm\neach word",
accept_only: true,
accept_is_nextarrow: true,
..Default::default()
})
.await;

for word in words.iter() {
bitbox02::println_stdout(word);
}
bitbox02::println_stdout("Words confirmed");

Ok(())
}

#[cfg(not(feature = "c-unit-testing"))]
/// Given 11/17/23 initial words, this function returns a list of candidate words for the last word,
/// such that the resulting bip39 phrase has a valid checksum. There are always exactly 8 such words
/// for 24 word mnemonics, 32 words for 18 word mnemonics and 128 words for 12 word mnemonics.
Expand Down Expand Up @@ -197,13 +229,15 @@ fn lastword_choices(entered_words: &[&str]) -> Vec<u16> {
.collect()
}

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

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

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

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

#[cfg(feature = "c-unit-testing")]
pub async fn get() -> Result<zeroize::Zeroizing<String>, CancelError> {
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";
bitbox02::println_stdout("Restored from recovery words below:");
bitbox02::println_stdout(words);
return Ok(zeroize::Zeroizing::new(
words
.to_string()
));
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
2 changes: 2 additions & 0 deletions src/rust/bitbox02-rust/src/workflow/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,7 @@ pub async fn status(title: &str, status_success: bool) {
*result.borrow_mut() = Some(());
});
component.screen_stack_push();
#[cfg(feature = "c-unit-testing")]
bitbox02::print_stdout(&format!("STATUS SCREEN START\nTITLE: {}\nSTATUS SCREEN END\n", title));
option_no_screensaver(&result).await
}
2 changes: 2 additions & 0 deletions src/rust/bitbox02-rust/src/workflow/trinary_input_string.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ pub async fn enter(
bitbox02::ui::trinary_input_string_set_input(&mut component, preset);
}
component.screen_stack_push();
#[cfg(feature = "c-unit-testing")]
bitbox02::print_stdout(&format!("ENTER SCREEN START\nTITLE: {}\nENTER SCREEN END\n", params.title));
option(&result)
.await
.or(Err(super::cancel::Error::Cancelled))
Expand Down
8 changes: 8 additions & 0 deletions src/rust/bitbox02/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,14 @@ pub fn print_stdout(msg: &str) {
}
}

#[cfg(any(feature = "testing", feature = "c-unit-testing"))]
pub fn println_stdout(msg: &str) {
unsafe {
bitbox02_sys::printf(crate::util::str_to_cstr_vec(msg).unwrap().as_ptr());
bitbox02_sys::printf(crate::util::str_to_cstr_vec("\n").unwrap().as_ptr());
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
1 change: 1 addition & 0 deletions src/rust/bitbox02/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
mod types;

#[cfg_attr(feature = "testing", path = "ui/ui_stub.rs")]
#[cfg_attr(not(feature = "testing"), cfg_attr(feature = "c-unit-testing", path = "ui/ui_stub_c_unit_tests.rs"))]
// We don't actually use ui::ui anywhere, we re-export below.
#[allow(clippy::module_inception)]
mod ui;
Expand Down
8 changes: 4 additions & 4 deletions src/rust/bitbox02/src/ui/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ use util::Survive;
pub use bitbox02_sys::trinary_choice_t as TrinaryChoice;

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

#[derive(Default)]
Expand All @@ -33,7 +33,7 @@ pub enum Font {
}

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

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

impl<'a> TrinaryInputStringParams<'a> {
#[cfg_attr(feature = "testing", allow(dead_code))]
#[cfg_attr(any(feature = "testing", feature = "c-unit-testing"), allow(dead_code))]
pub(crate) fn to_c_params(
&self,
title_scratch: &'a mut Vec<u8>,
Expand Down
Loading

0 comments on commit d50a7ee

Please sign in to comment.