feat(core): add Rust-based THP implementation
What changed, and why it matters
This commit adds a new Rust implementation of the Trezor Host Protocol (THP), which is the secure communication layer between a Trezor hardware wallet and a host computer/phone. It introduces encryption, channel management, pairing, and credential verification. Because this is a large new cryptographic and network-facing subsystem, any bugs here could affect device security, but the commit itself is a feature addition rather than a documented fix for a known vulnerability.
Treat this as a security-relevant feature addition requiring review. Before enabling the `trezorthp` module (removing the `#if 0` guard), conduct a thorough security audit of the THP protocol implementation, focusing on buffer handling, credential callback safety, state machine correctness, timing side-channels, and the interaction between Rust and MicroPython. Run the included unit tests and add fuzzing/negative tests for malformed packets and edge cases.
Security signals we found
New cryptographic transport protocol implementation (THP) added
Global mutable state protected by spin::Mutex in single-threaded environment
Credential verification callback invoked from Rust into MicroPython
Buffer slicing and length checks in message_out and get_slice
Interface and channel isolation enforced in packet routing logic
Channel replacement based on host static public key
Retransmission and timeout handling for unacknowledged messages
Module registration guarded by #if 0, not currently enabled
Evidence from the diff
The commit adds a Rust-based THP (Trezor Host Protocol) stack to the Trezor firmware core. It introduces a new trezorthp MicroPython module backed by Rust code, with features for channel allocation, Noise-style handshake, pairing/credential verification, encrypted transport, retransmission, timeouts, and channel replacement. The code uses spin::Mutex for global state (THP_CONTEXT, THP_AUX) and exposes functions for packet processing, message encryption/decryption, and channel lifecycle management. The module is registered behind a #if 0 guard in rustmods.c, so it is not yet active in production builds. The implementation includes unit tests covering basic handshake, interface isolation, and channel replacement.
Changed components
core/embed/rust/src/thp/micropython.rscore/embed/rust/src/thp/mod.rscore/embed/rust/src/thp/tests.rscore/embed/rust/src/thp/time.rscore/embed/rust/src/error.rscore/embed/rust/src/lib.rscore/embed/rust/src/micropython/obj.rscore/embed/rust/src/micropython/util.rscore/embed/rust/src/time.rscore/embed/Cargo.tomlcore/embed/projects/firmware/Cargo.tomlcore/embed/projects/unix/Cargo.tomlcore/embed/rust-staticlib/Cargo.tomlcore/embed/rust/Cargo.tomlcore/embed/rust/build.rscore/embed/rust/librust.hcore/embed/rust/librust_qstr.hcore/embed/upymod/rustmods.ccore/mocks/generated/trezorthp.pyiInspect captured patch +2571 / −3
diff --git a/core/embed/Cargo.toml b/core/embed/Cargo.toml
index 9f41c69e..140c6154 100644
--- a/core/embed/Cargo.toml
+++ b/core/embed/Cargo.toml
@@ -82,8 +82,9 @@ num-derive = "0.4.2"
num-traits = { version = "0.2.19", default-features = false, features = ["libm"] }
pareen = { version = "0.3.3", path = "../../rust/pareen", default-features = false, features = ["libm", "easer"] }
qrcodegen = { version = "1.8.0", path = "../vendor/QR-Code-generator/rust-no-heap" }
-spin = { version = "0.9.8", features = ["rwlock"], default-features = false }
+spin = { version = "0.9.8", features = ["rwlock", "spin_mutex", "lazy"], default-features = false }
static-alloc = "0.2.6"
+trezor-thp = { path = "../../rust/trezor-thp" }
trezor-tjpgdec = { version = "0.1.0", path = "../../rust/trezor-tjpgdec" }
ufmt = "0.2.0"
unsize = "1.1.0"
diff --git a/core/embed/projects/firmware/Cargo.toml b/core/embed/projects/firmware/Cargo.toml
index da756187..7e900584 100644
--- a/core/embed/projects/firmware/Cargo.toml
+++ b/core/embed/projects/firmware/Cargo.toml
@@ -109,7 +109,7 @@ secret = ["sec/secret"]
secure_aes = ["sec/secure_aes"]
serial_number = ["upymod/serial_number", "trezor_lib/serial_number"]
suspend = ["io/suspend"]
-thp = ["upymod/thp", "rtl/aes_gcm"]
+thp = ["upymod/thp", "rtl/aes_gcm", "trezor_lib/thp"]
tamper = ["sec/tamper"]
telemetry = ["sec/telemetry", "upymod/telemetry", "trezor_lib/telemetry"]
touch = ["io/touch", "upymod/touch", "trezor_lib/touch"]
diff --git a/core/embed/projects/unix/Cargo.toml b/core/embed/projects/unix/Cargo.toml
index 3740a564..02c96934 100644
--- a/core/embed/projects/unix/Cargo.toml
+++ b/core/embed/projects/unix/Cargo.toml
@@ -108,7 +108,7 @@ serial_number = ["upymod/serial_number", "trezor_lib/serial_number"]
suspend = ["io/suspend"]
tamper = ["sec/tamper"]
telemetry = ["sec/telemetry", "upymod/telemetry", "trezor_lib/telemetry"]
-thp = ["upymod/thp", "rtl/aes_gcm"]
+thp = ["upymod/thp", "rtl/aes_gcm", "trezor_lib/thp"]
touch = ["io/touch", "upymod/touch", "trezor_lib/touch"]
touch_wakeup = ["io/touch_wakeup", "upymod/touch_wakeup", "trezor_lib/touch_wakeup"]
tropic = ["sec/tropic", "upymod/tropic", "trezor_lib/tropic"]
diff --git a/core/embed/rust-staticlib/Cargo.toml b/core/embed/rust-staticlib/Cargo.toml
index c45363f0..bc37cae3 100644
--- a/core/embed/rust-staticlib/Cargo.toml
+++ b/core/embed/rust-staticlib/Cargo.toml
@@ -65,6 +65,7 @@ secmon_layout = ["trezor_lib/secmon_layout"]
dbg_console = ["trezor_lib/dbg_console"]
app_loading = ["trezor_lib/app_loading"]
universal_fw = ["trezor_lib/universal_fw"]
+thp = ["trezor_lib/thp"]
[dependencies]
trezor_lib = { workspace = true, default-features = false }
diff --git a/core/embed/rust/Cargo.toml b/core/embed/rust/Cargo.toml
index d8498f22..a4011e0e 100644
--- a/core/embed/rust/Cargo.toml
+++ b/core/embed/rust/Cargo.toml
@@ -63,6 +63,7 @@ translations = ["crypto"]
secmon_layout = []
dbg_console = []
app_loading = []
+thp = ["crypto", "dep:trezor-thp"]
test = [
"backlight",
"button",
@@ -78,8 +79,10 @@ test = [
"protobuf",
"smp",
"storage",
+ "thp",
"touch",
"translations",
+ "trezor-thp?/use_std",
"ui",
"ui_jpeg",
"ui_font_kerning",
@@ -116,6 +119,7 @@ unsize.workspace = true
without-alloc.workspace = true
io = { workspace = true, optional = true }
+trezor-thp = { workspace = true, optional = true }
upymod = { workspace = true, optional = true }
zeroize = { workspace = true, optional = true }
diff --git a/core/embed/rust/build.rs b/core/embed/rust/build.rs
index e5e7c20f..42d35433 100644
--- a/core/embed/rust/build.rs
+++ b/core/embed/rust/build.rs
@@ -331,6 +331,8 @@ fn generate_micropython_bindings() {
.allowlist_function("trezor_obj_get_ll_checked")
.allowlist_function("trezor_obj_str_from_rom_text")
// buffer
+ .allowlist_function("mp_obj_new_slice")
+ .allowlist_function("mp_obj_subscr")
.allowlist_function("mp_get_buffer")
.allowlist_var("MP_BUFFER_READ")
.allowlist_var("MP_BUFFER_WRITE")
diff --git a/core/embed/rust/librust.h b/core/embed/rust/librust.h
index 30bf0742..fb46c75a 100644
--- a/core/embed/rust/librust.h
+++ b/core/embed/rust/librust.h
@@ -11,6 +11,7 @@ extern mp_obj_module_t mp_module_trezorproto;
extern mp_obj_module_t mp_module_trezorui_api;
extern mp_obj_module_t mp_module_trezortranslate;
extern mp_obj_module_t mp_module_trezorble;
+extern mp_obj_module_t mp_module_trezorthp;
#ifdef USE_DBG_CONSOLE
extern mp_obj_module_t mp_module_trezorlog;
diff --git a/core/embed/rust/librust_qstr.h b/core/embed/rust/librust_qstr.h
index 2f880924..fcaae7b3 100644
--- a/core/embed/rust/librust_qstr.h
+++ b/core/embed/rust/librust_qstr.h
@@ -16,7 +16,9 @@ static void _librust_qstrs(void) {
MP_QSTR_8;
MP_QSTR_9;
MP_QSTR_;
+ MP_QSTR_ACK;
MP_QSTR_ALERT;
+ MP_QSTR_APP_HEADER_LEN;
MP_QSTR_ATTACHED;
MP_QSTR_AttachType;
MP_QSTR_BACK;
@@ -30,14 +32,21 @@ static void _librust_qstrs(void) {
MP_QSTR_DONE;
MP_QSTR_DeviceMenuResult;
MP_QSTR_DisconnectDevice;
+ MP_QSTR_FAILED;
MP_QSTR_INFO;
MP_QSTR_INITIAL;
+ MP_QSTR_KEY_REQUIRED;
+ MP_QSTR_KEY_REQUIRED_UNLOCK;
MP_QSTR_LOW;
MP_QSTR_LayoutObj;
MP_QSTR_LayoutState;
MP_QSTR_MAX;
MP_QSTR_MAX_BONDS;
+ MP_QSTR_MAX_CREDENTIAL_LEN;
+ MP_QSTR_MAX_DEVICE_PROPERTIES_LEN;
MP_QSTR_MESSAGE_NAME;
+ MP_QSTR_MESSAGE_READY;
+ MP_QSTR_MESSAGE_READY_ACK;
MP_QSTR_MESSAGE_WIRE_TYPE;
MP_QSTR_MessageType;
MP_QSTR_Msg;
@@ -54,6 +63,7 @@ static void _librust_qstrs(void) {
MP_QSTR_RemovePin;
MP_QSTR_RemoveWipeCode;
MP_QSTR_ReviewFailedBackup;
+ MP_QSTR_SEND_BUFFER_OVERHEAD;
MP_QSTR_SUCCESS;
MP_QSTR_SWIPE_DOWN;
MP_QSTR_SWIPE_LEFT;
@@ -68,6 +78,7 @@ static void _librust_qstrs(void) {
MP_QSTR_TR;
MP_QSTR_TRANSITIONING;
MP_QSTR_TX_PACKET_LEN;
+ MP_QSTR_ThpError;
MP_QSTR_ToggleBluetooth;
MP_QSTR_ToggleHaptics;
MP_QSTR_ToggleLed;
@@ -274,6 +285,13 @@ static void _librust_qstrs(void) {
MP_QSTR_can_go_back;
MP_QSTR_cancel;
MP_QSTR_case_sensitive;
+ MP_QSTR_channel_close;
+ MP_QSTR_channel_close_all;
+ MP_QSTR_channel_info;
+ MP_QSTR_channel_is_open;
+ MP_QSTR_channel_paired;
+ MP_QSTR_channel_update_last_usage;
+ MP_QSTR_channel_was_closed;
MP_QSTR_check_homescreen_format;
MP_QSTR_chunkify;
MP_QSTR_code;
@@ -313,6 +331,7 @@ static void _librust_qstrs(void) {
MP_QSTR_continue_recovery_homepage;
MP_QSTR_count;
MP_QSTR_coveragedata;
+ MP_QSTR_credential;
MP_QSTR_current;
MP_QSTR_danger;
MP_QSTR_data_hash;
@@ -336,6 +355,7 @@ static void _librust_qstrs(void) {
MP_QSTR_erase;
MP_QSTR_erase_bonds;
MP_QSTR_error;
+ MP_QSTR_exclude_channel_id;
MP_QSTR_experimental_mode__enable;
MP_QSTR_experimental_mode__only_for_dev;
MP_QSTR_experimental_mode__title;
@@ -359,6 +379,8 @@ static void _librust_qstrs(void) {
MP_QSTR_get_enabled;
MP_QSTR_get_language;
MP_QSTR_get_transition_out;
+ MP_QSTR_handshake_hash;
+ MP_QSTR_handshake_key;
MP_QSTR_haptic_feedback__disable;
MP_QSTR_haptic_feedback__enable;
MP_QSTR_haptic_feedback__subtitle;
@@ -384,6 +406,7 @@ static void _librust_qstrs(void) {
MP_QSTR_homescreen__title_seedless;
MP_QSTR_homescreen__title_set;
MP_QSTR_horizontal;
+ MP_QSTR_host_static_public_key;
MP_QSTR_icon_name;
MP_QSTR_iface;
MP_QSTR_iface_num;
@@ -442,6 +465,7 @@ static void _librust_qstrs(void) {
MP_QSTR_language__progress;
MP_QSTR_language__title;
MP_QSTR_last_attempt;
+ MP_QSTR_last_write;
MP_QSTR_led__disable;
MP_QSTR_led__enable;
MP_QSTR_led__title;
@@ -460,6 +484,9 @@ static void _librust_qstrs(void) {
MP_QSTR_max_ms;
MP_QSTR_max_rounds;
MP_QSTR_menu_title;
+ MP_QSTR_message_in;
+ MP_QSTR_message_out;
+ MP_QSTR_message_retransmit;
MP_QSTR_min_count;
MP_QSTR_min_ms;
MP_QSTR_misc__decrypt_value;
@@ -490,12 +517,18 @@ static void _librust_qstrs(void) {
MP_QSTR_n4w1__hold_next;
MP_QSTR_n4w1__reading;
MP_QSTR_n4w1__writing;
+ MP_QSTR_next_timeout;
MP_QSTR_notification;
+ MP_QSTR_packet_in;
+ MP_QSTR_packet_in_channel;
+ MP_QSTR_packet_out;
+ MP_QSTR_packet_out_channel;
MP_QSTR_page_count;
MP_QSTR_page_counter;
MP_QSTR_pages;
MP_QSTR_paint;
MP_QSTR_paired_devices;
+ MP_QSTR_pairing_state;
MP_QSTR_passphrase__access_hidden_wallet;
MP_QSTR_passphrase__access_wallet;
MP_QSTR_passphrase__always_on_device;
@@ -803,6 +836,7 @@ static void _librust_qstrs(void) {
MP_QSTR_send__transaction_id;
MP_QSTR_send__transaction_signed;
MP_QSTR_send__you_are_contributing;
+ MP_QSTR_send_transport_busy;
MP_QSTR_set_brightness;
MP_QSTR_set_enabled;
MP_QSTR_set_high_speed;
@@ -889,6 +923,7 @@ static void _librust_qstrs(void) {
MP_QSTR_trezorble;
MP_QSTR_trezorlog;
MP_QSTR_trezorproto;
+ MP_QSTR_trezorthp;
MP_QSTR_trezorui_api;
MP_QSTR_tutorial;
MP_QSTR_tutorial__continue;
diff --git a/core/embed/rust/src/error.rs b/core/embed/rust/src/error.rs
index 05762095..77379943 100644
--- a/core/embed/rust/src/error.rs
+++ b/core/embed/rust/src/error.rs
@@ -10,6 +10,9 @@ use {
core::convert::TryInto,
};
+#[cfg(feature = "thp")]
+use crate::thp::micropython::ThpError;
+
#[allow(clippy::enum_variant_names)] // We mimic the Python exception classnames here.
#[derive(Clone, Copy, Debug)]
pub enum Error {
@@ -30,6 +33,8 @@ pub enum Error {
ValueErrorParam(&'static CStr, Obj),
RuntimeError(&'static CStr),
NotImplementedError,
+ #[cfg(feature = "thp")]
+ ThpError(&'static CStr),
}
#[allow(unused_macros)]
@@ -71,6 +76,8 @@ impl Error {
Error::EOFError => new_exception(exception::EOFError),
Error::RuntimeError(msg) => new_exception_arg_from(exception::RuntimeError, msg),
Error::NotImplementedError => new_exception(exception::NotImplementedError),
+ #[cfg(feature = "thp")]
+ Error::ThpError(msg) => new_exception_arg_from(&ThpError, msg),
}
}
}
@@ -90,3 +97,17 @@ impl From<TryFromIntError> for Error {
Self::OutOfRange
}
}
+
+#[cfg(feature = "thp")]
+impl From<trezor_thp::Error> for Error {
+ fn from(error: trezor_thp::Error) -> Self {
+ match error {
+ trezor_thp::Error::UnexpectedInput => Error::ThpError(c"Unexpected input"),
+ trezor_thp::Error::NotReady => Error::ThpError(c"Not ready"),
+ trezor_thp::Error::MalformedData => Error::ThpError(c"Malformed data"),
+ trezor_thp::Error::InvalidChecksum => Error::ThpError(c"Invalid checksum"),
+ trezor_thp::Error::InsufficientBuffer => Error::ThpError(c"Insufficient buffer"),
+ trezor_thp::Error::CryptoError => Error::ThpError(c"Crypto error"),
+ }
+ }
+}
diff --git a/core/embed/rust/src/lib.rs b/core/embed/rust/src/lib.rs
index 6586b436..ecfe935b 100644
--- a/core/embed/rust/src/lib.rs
+++ b/core/embed/rust/src/lib.rs
@@ -36,6 +36,8 @@ mod protobuf;
#[cfg(feature = "storage")]
mod storage;
mod strutil;
+#[cfg(feature = "thp")]
+mod thp;
mod time;
#[cfg(feature = "ui_debug")]
mod trace;
diff --git a/core/embed/rust/src/micropython/obj.rs b/core/embed/rust/src/micropython/obj.rs
index b6e9280c..23d771ef 100644
--- a/core/embed/rust/src/micropython/obj.rs
+++ b/core/embed/rust/src/micropython/obj.rs
@@ -76,6 +76,12 @@ impl Obj {
unsafe { Self::from_bits(0) }
}
+ pub const fn const_sentinel() -> Self {
+ // micropython/py/obj.h
+ // #define MP_OBJ_SENTINEL (MP_OBJ_FROM_PTR((void *)4))
+ unsafe { Self::from_bits(4) }
+ }
+
pub const fn const_none() -> Self {
// micropython/py/obj.h
// #define mp_const_none MP_OBJ_NEW_IMMEDIATE_OBJ(0)
@@ -451,6 +457,16 @@ impl Obj {
Err(e) => Err(e.into()),
}
}
+
+ pub fn from_option<T>(val: Option<T>) -> Result<Self, Error>
+ where
+ T: TryInto<Obj, Error = Error>,
+ {
+ match val {
+ Some(v) => v.try_into(),
+ None => Ok(Self::const_none()),
+ }
+ }
}
impl Obj {
diff --git a/core/embed/rust/src/micropython/util.rs b/core/embed/rust/src/micropython/util.rs
index 5e144b6f..fdd5f239 100644
--- a/core/embed/rust/src/micropython/util.rs
+++ b/core/embed/rust/src/micropython/util.rs
@@ -154,3 +154,14 @@ pub fn modulo_format(format: Obj, args: &[Obj]) -> Result<Obj, Error> {
ffi::str_modulo_format(format, args.len(), args.as_ptr(), Obj::const_none())
})
}
+
+/// Return `obj[offset : offset + len]`.
+pub fn get_slice(obj: Obj, offset: u16, len: u16) -> Result<Obj, Error> {
+ let start = Obj::small_int(offset);
+ let stop = Obj::small_int(offset.checked_add(len).ok_or(Error::OutOfRange)?);
+ let step = Obj::small_int(1);
+ catch_exception(|| unsafe {
+ let slice_obj = ffi::mp_obj_new_slice(start, stop, step);
+ ffi::mp_obj_subscr(obj, slice_obj, Obj::const_sentinel())
+ })
+}
diff --git a/core/embed/rust/src/thp/micropython.rs b/core/embed/rust/src/thp/micropython.rs
new file mode 100644
index 00000000..1636a46f
--- /dev/null
+++ b/core/embed/rust/src/thp/micropython.rs
@@ -0,0 +1,572 @@
+use trezor_thp::channel::{
+ Phase, APP_HEADER_LEN, MAX_CREDENTIAL_LEN, MAX_DEVICE_PROPERTIES_LEN, SEND_BUFFER_OVERHEAD,
+};
+
+use crate::{
+ error::Error,
+ micropython::{
+ buffer::{get_buffer, get_buffer_mut},
+ exception,
+ macros::{
+ attr_tuple, obj_fn_0, obj_fn_1, obj_fn_2, obj_fn_3, obj_fn_kw, obj_module, obj_type,
+ },
+ map::Map,
+ module::Module,
+ obj::Obj,
+ qstr::Qstr,
+ simple_type::SimpleTypeObj,
+ typ::Type,
+ util,
+ },
+};
+
+use super::{TrezorInResult, CANNOT_UNLOCK, THP_AUX, THP_CONTEXT};
+
+extern "C" fn thp_init(iface_num: Obj, device_properties: Obj) -> Obj {
+ let block = || {
+ let iface_num: u8 = iface_num.try_into()?;
+ // SAFETY: reference is discarded at the end of this block.
+ let device_properties = unsafe { get_buffer(device_properties)? };
+
+ let mut thp = THP_CONTEXT.try_lock().ok_or(CANNOT_UNLOCK)?;
+ thp.add_interface(iface_num, device_properties)?;
+
+ #[cfg(feature = "debug")]
+ if thp.message_out_ready(iface_num).is_some() {
+ log::error!("Message ready from previous event loop session but buffer is lost, waiting for retransmission.");
+ }
+
+ Ok(Obj::const_none())
+ };
+
+ unsafe { util::try_or_raise(block) }
+}
+
+extern "C" fn thp_packet_in(iface_num: Obj, buffer_view: Obj, credential_fn: Obj) -> Obj {
+ let block = || {
+ let iface_num: u8 = iface_num.try_into()?;
+ // SAFETY: reference is discarded at the end of this block.
+ let buffer = unsafe { get_buffer(buffer_view)? };
+
+ let mut thp = THP_CONTEXT.try_lock().ok_or(CANNOT_UNLOCK)?;
+ let res = thp.packet_in(iface_num, buffer, credential_fn)?;
+ res.try_into()
+ };
+
+ unsafe { util::try_or_raise(block) }
+}
+
+extern "C" fn thp_packet_in_channel(
+ channel_id: Obj,
+ packet_buffer: Obj,
+ receive_buffer: Obj,
+) -> Obj {
+ let block = || {
+ let channel_id: u16 = channel_id.try_into()?;
+ // SAFETY: reference is discarded at the end of this block.
+ let packet_buffer = unsafe { get_buffer(packet_buffer)? };
+ // SAFETY: reference is discarded at the end of this block.
+ let receive_buffer = unsafe { get_buffer_mut(receive_buffer)? };
+
+ let mut thp = THP_CONTEXT.try_lock().ok_or(CANNOT_UNLOCK)?;
+ let res = thp.packet_in_channel(channel_id, packet_buffer, receive_buffer)?;
+ res.try_into()
+ };
+
+ unsafe { util::try_or_raise(block) }
+}
+
+extern "C" fn thp_packet_out(iface_num: Obj, packet_buffer: Obj) -> Obj {
+ let block = || {
+ let iface_num: u8 = iface_num.try_into()?;
+ // SAFETY: reference is discarded at the end of this block.
+ let packet_buffer = unsafe { get_buffer_mut(packet_buffer)? };
+
+ let mut thp = THP_CONTEXT.try_lock().ok_or(CANNOT_UNLOCK)?;
+ let written = thp.packet_out(iface_num, packet_buffer)?;
+ Ok(written.into())
+ };
+
+ unsafe { util::try_or_raise(block) }
+}
+
+extern "C" fn thp_packet_out_channel(channel_id: Obj, send_buffer: Obj, packet_buffer: Obj) -> Obj {
+ let block = || {
+ let channel_id: u16 = channel_id.try_into()?;
+ // SAFETY: reference is discarded at the end of this block.
+ let send_buffer = unsafe { get_buffer(send_buffer)? };
+ // SAFETY: reference is discarded at the end of this block.
+ let packet_buffer = unsafe { get_buffer_mut(packet_buffer)? };
+
+ let mut thp = THP_CONTEXT.try_lock().ok_or(CANNOT_UNLOCK)?;
+ let written = thp.packet_out_channel(channel_id, send_buffer, packet_buffer)?;
+ Ok(written.into())
+ };
+
+ unsafe { util::try_or_raise(block) }
+}
+
+extern "C" fn thp_message_out(channel_id: Obj, receive_buffer_obj: Obj) -> Obj {
+ let block = || {
+ let channel_id: u16 = channel_id.try_into()?;
+
+ let (sid, message_type, message_len) = {
+ // SAFETY: reference is discarded at the end of this block.
+ let receive_buffer = unsafe { get_buffer_mut(receive_buffer_obj)? };
+ let mut thp = THP_CONTEXT.try_lock().ok_or(CANNOT_UNLOCK)?;
+ thp.message_out(channel_id, receive_buffer)?
+ };
+ // Something is very wrong if message is longer than 64k, OK to panic.
+ let message_len = unwrap!(u16::try_from(message_len));
+ (
+ sid.into(),
+ message_type.into(),
+ util::get_slice(receive_buffer_obj, APP_HEADER_LEN as u16, message_len)?,
+ )
+ .try_into()
+ };
+
+ unsafe { util::try_or_raise(block) }
+}
+
+extern "C" fn thp_message_in(channel_id: Obj, plaintext_len: Obj, send_buffer: Obj) -> Obj {
+ let block = || {
+ let channel_id: u16 = channel_id.try_into()?;
+ let plaintext_len: usize = plaintext_len.try_into()?;
+ // SAFETY: reference is discarded at the end of this block.
+ let send_buffer = unsafe { get_buffer_mut(send_buffer)? };
+
+ let mut thp = THP_CONTEXT.try_lock().ok_or(CANNOT_UNLOCK)?;
+ thp.message_in(channel_id, plaintext_len, send_buffer)?;
+ Ok(Obj::const_none())
+ };
+
+ unsafe { util::try_or_raise(block) }
+}
+
+extern "C" fn thp_message_retransmit(channel_id: Obj) -> Obj {
+ let block = || {
+ let channel_id: u16 = channel_id.try_into()?;
+
+ let mut thp = THP_CONTEXT.try_lock().ok_or(CANNOT_UNLOCK)?;
+ let channel_ok = thp.message_retransmit(channel_id)?;
+ Ok(channel_ok.into())
+ };
+
+ unsafe { util::try_or_raise(block) }
+}
+
+extern "C" fn thp_send_transport_busy(channel_id: Obj) -> Obj {
+ let block = || {
+ let channel_id: u16 = channel_id.try_into()?;
+
+ let mut thp = THP_CONTEXT.try_lock().ok_or(CANNOT_UNLOCK)?;
+ thp.send_transport_busy(channel_id)?;
+ Ok(Obj::const_none())
+ };
+
+ unsafe { util::try_or_raise(block) }
+}
+
+extern "C" fn thp_channel_info(channel_id: Obj) -> Obj {
+ let block = || {
+ let channel_id: u16 = channel_id.try_into()?;
+
+ let thp = THP_CONTEXT.try_lock().ok_or(CANNOT_UNLOCK)?;
+
+ let (last_write_age_ms, phase) = thp.channel_info(channel_id)?;
+ let hash = thp.handshake_hash(channel_id)?;
+ let remote_static_pubkey = thp.remote_static_pubkey(channel_id)?;
+ let pairing_state: Option<u8> = match phase {
+ Phase::PairingCredential {
+ handshake_pairing_state,
+ } => Some(handshake_pairing_state.into()),
+ Phase::EncryptedTransport => None,
+ };
+
+ let credential = {
+ let aux = THP_AUX.try_lock().ok_or(CANNOT_UNLOCK)?;
+ Obj::from_option(aux.get_credential(channel_id))?
+ };
+
+ attr_tuple! {
+ Qstr::MP_QSTR_last_write => Obj::from_option(last_write_age_ms)?,
+ Qstr::MP_QSTR_pairing_state => pairing_state.into(),
+ Qstr::MP_QSTR_handshake_hash => hash.try_into()?,
+ Qstr::MP_QSTR_host_static_public_key => remote_static_pubkey.try_into()?,
+ Qstr::MP_QSTR_credential => credential,
+ }
+ };
+
+ unsafe { util::try_or_raise(block) }
+}
+
+extern "C" fn thp_channel_paired(channel_id: Obj) -> Obj {
+ let block = || {
+ let channel_id: u16 = channel_id.try_into()?;
+
+ let mut thp = THP_CONTEXT.try_lock().ok_or(CANNOT_UNLOCK)?;
+ let replaced_channel_id = thp.channel_paired(channel_id)?;
+ Ok(replaced_channel_id.into())
+ };
+
+ unsafe { util::try_or_raise(block) }
+}
+
+extern "C" fn thp_channel_close(channel_id: Obj) -> Obj {
+ let block = || {
+ let channel_id: u16 = channel_id.try_into()?;
+
+ let mut thp = THP_CONTEXT.try_lock().ok_or(CANNOT_UNLOCK)?;
+ thp.channel_close(channel_id);
+ Ok(Obj::const_none())
+ };
+
+ unsafe { util::try_or_raise(block) }
+}
+
+extern "C" fn thp_channel_close_all(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj {
+ let block = |_args: &[Obj], kwargs: &Map| {
+ let exclude_channel_id: Option<u16> = kwargs
+ .get(Qstr::MP_QSTR_exclude_channel_id)
+ .unwrap_or_else(|_| Obj::const_none())
+ .try_into_option()?;
+
+ let mut thp = THP_CONTEXT.try_lock().ok_or(CANNOT_UNLOCK)?;
+ thp.channel_close_all(exclude_channel_id);
+ Ok(Obj::const_none())
+ };
+
+ unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) }
+}
+
+extern "C" fn thp_channel_update_last_usage(channel_id: Obj) -> Obj {
+ let block = || {
+ let channel_id: u16 = channel_id.try_into()?;
+
+ let mut thp = THP_CONTEXT.try_lock().ok_or(CANNOT_UNLOCK)?;
+ thp.channel_update_last_usage(channel_id);
+ Ok(Obj::const_none())
+ };
+
+ unsafe { util::try_or_raise(block) }
+}
+
+extern "C" fn thp_channel_was_closed() -> Obj {
+ let block = || {
+ let mut thp = THP_CONTEXT.try_lock().ok_or(CANNOT_UNLOCK)?;
+ let closed = thp.channel_was_closed();
+ Ok(closed.into())
+ };
+
+ unsafe { util::try_or_raise(block) }
+}
+
+extern "C" fn thp_channel_is_open(channel_id: Obj) -> Obj {
+ let block = || {
+ let channel_id: u16 = channel_id.try_into()?;
+
+ let thp = THP_CONTEXT.try_lock().ok_or(CANNOT_UNLOCK)?;
+ let ok = thp.channel_is_open(channel_id);
+ Ok(ok.into())
+ };
+
+ unsafe { util::try_or_raise(block) }
+}
+
+extern "C" fn thp_next_timeout(iface_num: Obj) -> Obj {
+ let block = || {
+ let iface_num: u8 = iface_num.try_into()?;
+
+ let thp = THP_CONTEXT.try_lock().ok_or(CANNOT_UNLOCK)?;
+ match thp.next_timeout(iface_num)? {
+ None => Ok(Obj::const_none()),
+ Some((channel_id, timeout_ms)) => {
+ (channel_id.into(), timeout_ms.try_into()?).try_into()
+ }
+ }
+ };
+
+ unsafe { util::try_or_raise(block) }
+}
+
+extern "C" fn thp_handshake_key(iface_num: Obj, local_static_privkey: Obj) -> Obj {
+ let block = || {
+ let iface_num: u8 = iface_num.try_into()?;
+
+ let mut thp = THP_CONTEXT.try_lock().ok_or(CANNOT_UNLOCK)?;
+ if local_static_privkey == Obj::const_none() {
+ thp.send_device_locked(iface_num)?;
+ } else {
+ // SAFETY: reference is discarded at the end of this block.
+ let key = unsafe { get_buffer(local_static_privkey)? };
+ thp.handshake_static_key(iface_num, key)?;
+ }
+ Ok(Obj::const_none())
+ };
+
+ unsafe { util::try_or_raise(block) }
+}
+
+#[allow(non_upper_case_globals)]
+pub static ThpError: Type =
+ exception::define_exception(Qstr::MP_QSTR_ThpError, exception::Exception);
+
+static FAILED_TYPE: Type = obj_type! { name: Qstr::MP_QSTR_FAILED, };
+static KEY_REQUIRED_TYPE: Type = obj_type! { name: Qstr::MP_QSTR_KEY_REQUIRED, };
+static KEY_REQUIRED_UNLOCK_TYPE: Type = obj_type! { name: Qstr::MP_QSTR_KEY_REQUIRED_UNLOCK, };
+static MESSAGE_READY_TYPE: Type = obj_type! { name: Qstr::MP_QSTR_MESSAGE_READY, };
+static ACK_TYPE: Type = obj_type! { name: Qstr::MP_QSTR_ACK, };
+static MESSAGE_READY_ACK_TYPE: Type = obj_type! { name: Qstr::MP_QSTR_MESSAGE_READY_ACK, };
+
+pub static FAILED_OBJ: SimpleTypeObj = SimpleTypeObj::new(&FAILED_TYPE);
+pub static KEY_REQUIRED_OBJ: SimpleTypeObj = SimpleTypeObj::new(&KEY_REQUIRED_TYPE);
+pub static KEY_REQUIRED_UNLOCK_OBJ: SimpleTypeObj = SimpleTypeObj::new(&KEY_REQUIRED_UNLOCK_TYPE);
+pub static MESSAGE_READY_OBJ: SimpleTypeObj = SimpleTypeObj::new(&MESSAGE_READY_TYPE);
+pub static ACK_OBJ: SimpleTypeObj = SimpleTypeObj::new(&ACK_TYPE);
+pub static MESSAGE_READY_ACK_OBJ: SimpleTypeObj = SimpleTypeObj::new(&MESSAGE_READY_ACK_TYPE);
+
+impl TryFrom<TrezorInResult> for Obj {
+ type Error = Error;
+
+ fn try_from(val: TrezorInResult) -> Result<Obj, Error> {
+ Ok(match val {
+ TrezorInResult::None => Obj::const_none(),
+ TrezorInResult::Route {
+ channel_id,
+ buffer_size: None,
+ } => Obj::small_int(channel_id),
+ TrezorInResult::Route {
+ channel_id,
+ buffer_size: Some(s),
+ } => {
+ // Encode channel and size as 000SSSSSSSSSSSSSCCCCCCCCCCCCCCCC which
+ // should fit a micropython smallint. Size is in 8-byte blocks.
+ let val: u32 = s.get().into();
+ let val = val.next_multiple_of(8) >> 3;
+ let val = val << 16 | u32::from(channel_id);
+ val.try_into()?
+ }
+ TrezorInResult::Failed => FAILED_OBJ.as_obj(),
+ TrezorInResult::KeyRequired {
+ try_to_unlock: true,
+ } => KEY_REQUIRED_UNLOCK_OBJ.as_obj(),
+ TrezorInResult::KeyRequired { .. } => KEY_REQUIRED_OBJ.as_obj(),
+ TrezorInResult::MessageReady => MESSAGE_READY_OBJ.as_obj(),
+ TrezorInResult::MessageReadyAck => MESSAGE_READY_ACK_OBJ.as_obj(),
+ TrezorInResult::Ack => ACK_OBJ.as_obj(),
+ })
+ }
+}
+
+#[no_mangle]
+#[rustfmt::skip]
+pub static mp_module_trezorthp: Module = obj_module! {
+ Qstr::MP_QSTR___name__ => Qstr::MP_QSTR_trezorthp.to_obj(),
+
+ /// ThpError: type[Exception]
+ Qstr::MP_QSTR_ThpError => ThpError.as_obj(),
+
+ /// MESSAGE_READY: object
+ Qstr::MP_QSTR_MESSAGE_READY => MESSAGE_READY_OBJ.as_obj(),
+
+ /// MESSAGE_READY_ACK: object
+ Qstr::MP_QSTR_MESSAGE_READY_ACK => MESSAGE_READY_ACK_OBJ.as_obj(),
+
+ /// ACK: object
+ Qstr::MP_QSTR_ACK => ACK_OBJ.as_obj(),
+
+ /// KEY_REQUIRED: object
+ Qstr::MP_QSTR_KEY_REQUIRED => KEY_REQUIRED_OBJ.as_obj(),
+
+ /// KEY_REQUIRED_UNLOCK: object
+ Qstr::MP_QSTR_KEY_REQUIRED_UNLOCK => KEY_REQUIRED_UNLOCK_OBJ.as_obj(),
+
+ /// FAILED: object
+ Qstr::MP_QSTR_FAILED => FAILED_OBJ.as_obj(),
+
+ /// MAX_CREDENTIAL_LEN: int
+ Qstr::MP_QSTR_MAX_CREDENTIAL_LEN => Obj::small_int(MAX_CREDENTIAL_LEN as u16),
+
+ /// MAX_DEVICE_PROPERTIES_LEN: int
+ Qstr::MP_QSTR_MAX_DEVICE_PROPERTIES_LEN => Obj::small_int(MAX_DEVICE_PROPERTIES_LEN as u16),
+
+ /// APP_HEADER_LEN: int
+ Qstr::MP_QSTR_APP_HEADER_LEN => Obj::small_int(APP_HEADER_LEN as u16),
+
+ /// SEND_BUFFER_OVERHEAD: int
+ Qstr::MP_QSTR_SEND_BUFFER_OVERHEAD => Obj::small_int(SEND_BUFFER_OVERHEAD as u16),
+
+ /// def init(iface_num: int, device_properties: AnyBytes) -> None:
+ /// """
+ /// Initialize Trezor Host Protocol communication stack on a single interface.
+ /// - `iface_num` is an arbitrary numeric identifier between 0 and 255.
+ /// - `device_properties` is a serialized `ThpDeviceProperties` protobuf message.
+ /// It is safe to call this function multiple times on the same interface.
+ /// """
+ Qstr::MP_QSTR_init => obj_fn_2!(thp_init).as_obj(),
+
+ /// def packet_in(iface_num: int, packet_buffer: AnyBytes, credential_verify_fn: Callable[[bytes, bytes], int]) -> object | int | None:
+ /// """
+ /// Handle received packet.
+ /// - `credential_verify_fn` is a function that will be called to verify host credentials.
+ /// Returns:
+ /// - `None`: If no action is required from caller.
+ /// - `KEY_REQUIRED`, `KEY_REQUIRED_UNLOCK`: If a channel handshake requires device static key.
+ /// The event loop should call the `handshake_key()` function for this interface.
+ /// - An integer: Lower 16 bits contain channel id, upper 16 bits contain buffer size hint in 8-byte blocks.
+ /// The event loop should call the `packet_in_channel()` function for this interface and if
+ /// the size hint is non-zero, then the receive buffer needs to be at least as large.
+ /// If such buffer cannot be obtained, `channel_close()` should be called.
+ /// If buffer is in use by another channel, `send_transport_busy()` should be called.
+ /// """
+ Qstr::MP_QSTR_packet_in => obj_fn_3!(thp_packet_in).as_obj(),
+
+ /// def packet_in_channel(channel_id: int, packet_buffer: AnyBytes, receive_buffer: AnyBuffer) -> object | None:
+ /// """
+ /// Handle received packet that `packet_in` routed to given `channel_id`.
+ /// Returns:
+ /// - `None`: If no action is required from caller, e.g. continuation packet was received.
+ /// - `MESSAGE_READY`, `MESSAGE_READY_ACK`: If a message with valid checksum was received.
+ /// The event loop should call `message_out()` to obtain the message.
+ /// - `ACK`, `MESSAGE_READY_ACK`: If the last sent message was acknowledged received by peer,
+ /// it is now possible to send another using `message_in()`.
+ /// """
+ Qstr::MP_QSTR_packet_in_channel => obj_fn_3!(thp_packet_in_channel).as_obj(),
+
+ /// def message_out(channel_id: int, receive_buffer: memoryview) -> tuple[int, int, memoryview]:
+ /// """
+ /// Decrypt an incoming message if one is ready for the given `channel_id`. Returns the triple
+ /// `(session_id, message_type, plaintext)` - message is decrypted in-place in receive buffer
+ /// and plaintext is a memoryview backed by that buffer.
+ ///
+ /// After successfully calling this function an ACK will be sent by the next `packet_out` on
+ /// this channel.
+ ///
+ /// Raises an exception if decryption failed - next call to `packet_out` will send an error
+ /// to the peer and close the channel.
+ /// """
+ Qstr::MP_QSTR_message_out => obj_fn_2!(thp_message_out).as_obj(),
+
+ /// def packet_out(iface_num: int, packet_buffer: AnyBuffer) -> bool:
+ /// """
+ /// Writes outgoing packet to `packet_buffer`. This function is used for the broadcast
+ /// channel or channels in opening/handshake phase that are associated with `iface_num`.
+ /// Returns false if there's no packet ready to be sent.
+ /// """
+ Qstr::MP_QSTR_packet_out => obj_fn_2!(thp_packet_out).as_obj(),
+
+ /// def packet_out_channel(channel_id: int, send_buffer: AnyBytes, packet_buffer: AnyBuffer) -> bool:
+ /// """
+ /// Writes outgoing packet to `packet_buffer` from channel in pairing or application data phase
+ /// identified by `channel_id`. Returns false if there's no packet ready to be sent.
+ /// """
+ Qstr::MP_QSTR_packet_out_channel => obj_fn_3!(thp_packet_out_channel).as_obj(),
+
+ /// def message_in(channel_id: int, plaintext_len: int, send_buffer: AnyBuffer) -> None:
+ /// """
+ /// Encrypts and starts transmission of given message on a channel. Send buffer must contain
+ /// serialized message:
+ /// * session id: 1 byte
+ /// * message type: 2 bytes
+ /// * message: (plaintext_len - 3) bytes
+ ///
+ /// Send buffer must be at least `plaintext_len + 16` long in order to accommodate AEAD tag.
+ /// """
+ Qstr::MP_QSTR_message_in => obj_fn_3!(thp_message_in).as_obj(),
+
+ /// def message_retransmit(channel_id: int) -> bool:
+ /// """
+ /// Starts message retransmission.
+ /// Returns False if this was the last attempt and the channel has been closed.
+ /// """
+ Qstr::MP_QSTR_message_retransmit => obj_fn_1!(thp_message_retransmit).as_obj(),
+
+ /// def send_transport_busy(channel_id: int) -> None:
+ /// """
+ /// Sends `TRANSPORT_BUSY` transport error on a given channel.
+ /// """
+ Qstr::MP_QSTR_send_transport_busy => obj_fn_1!(thp_send_transport_busy).as_obj(),
+
+ /// class ThpChannelInfo:
+ /// """THP channel metadata."""
+ /// last_write: int | None
+ /// pairing_state: int | None
+ /// handshake_hash: bytes | None
+ /// host_static_public_key: bytes
+ /// credential: bytes | None
+ ///
+ /// mock:global
+
+ /// def channel_info(channel_id: int) -> ThpChannelInfo:
+ /// """
+ /// Returns information for given channel:
+ /// * last write timestamp
+ /// * pairing state for channels in the pairing phase, or None if already in encrypted transport phase
+ /// * handshake hash
+ /// * host static public key
+ /// * encoded credential provided during handshake - it is discarded at the end of pairing/credential phase
+ /// """
+ Qstr::MP_QSTR_channel_info => obj_fn_1!(thp_channel_info).as_obj(),
+
+ /// def channel_paired(channel_id: int) -> int | None:
+ /// """
+ /// Mark channel as paired, i.e. transitioned to encrypted transport of application data.
+ /// If established channel with the same host public key exists on the same interface,
+ /// it is closed and its channel id is returned.
+ /// """
+ Qstr::MP_QSTR_channel_paired => obj_fn_1!(thp_channel_paired).as_obj(),
+
+ /// def channel_close(channel_id: int) -> None:
+ /// """
+ /// Closes a channel identified by its `channel_id`. It is safe to close
+ /// an already closed channel - the function won't raise an exception.
+ /// """
+ Qstr::MP_QSTR_channel_close => obj_fn_1!(thp_channel_close).as_obj(),
+
+ /// def channel_close_all(*, exclude_channel_id: int | None = None) -> None:
+ /// """
+ /// Closes all channels on all interfaces. If `exclude_channel_id` is not None, it
+ /// will be left as the only channel.
+ /// Please note the closed channels are not returned by `channel_was_closed()`.
+ /// Caller is responsible for deleting all relevant sessions manually.
+ /// """
+ Qstr::MP_QSTR_channel_close_all => obj_fn_kw!(0, thp_channel_close_all).as_obj(),
+
+ /// def channel_update_last_usage(channel_id: int):
+ /// """
+ /// Update last usage timestamp of a channel. These are used when channel limit is reached
+ /// and the oldest one has to be closed.
+ /// TODO do not expose to python and do the update in message_out instead
+ /// """
+ Qstr::MP_QSTR_channel_update_last_usage => obj_fn_1!(thp_channel_update_last_usage).as_obj(),
+
+ /// def channel_was_closed() -> bool:
+ /// """
+ /// Returns true if any channel in encrypted transport state was closed since calling
+ /// this function last time. Sessions belonging to these channels should be discarded.
+ /// """
+ Qstr::MP_QSTR_channel_was_closed => obj_fn_0!(thp_channel_was_closed).as_obj(),
+
+ /// def channel_is_open(channel_id: int) -> bool:
+ /// """
+ /// Returns true if a channel with the given id exists in the encrypted transport state.
+ /// """
+ Qstr::MP_QSTR_channel_is_open => obj_fn_1!(thp_channel_is_open).as_obj(),
+
+ /// def next_timeout(iface_num: int) -> tuple[int, int] | None:
+ /// """
+ /// Returns `(channel_id, timeout_ms)` of the earliest channel to time out waiting for ACK.
+ /// Event loop needs to call `message_retransmit(channel_id)` after `timeout_ms`.
+ /// Returns None if there is no channel that's waiting for an ACK.
+ /// """
+ Qstr::MP_QSTR_next_timeout => obj_fn_1!(thp_next_timeout).as_obj(),
+
+ /// def handshake_key(iface_num: int, trezor_static_private_key: AnyBytes | None) -> None:
+ /// """
+ /// Provide device static key in order to progress a handshake after `packet_in`
+ /// returned `KEY_REQUIRED`. If the second argument is None, handshake is aborted
+ /// and `DEVICE_LOCKED` sent to the peer.
+ /// """
+ Qstr::MP_QSTR_handshake_key => obj_fn_2!(thp_handshake_key).as_obj(),
+};
diff --git a/core/embed/rust/src/thp/mod.rs b/core/embed/rust/src/thp/mod.rs
new file mode 100644
index 00000000..9ff895ad
--- /dev/null
+++ b/core/embed/rust/src/thp/mod.rs
@@ -0,0 +1,946 @@
+mod crypto;
+pub mod micropython;
+#[cfg(test)]
+mod tests;
+mod time;
+
+use crate::{error::Error, micropython::obj::Obj, time::Instant};
+
+use core::{mem::replace, num::NonZeroU16};
+
+use heapless::{
+ deque::{Deque, DequeView},
+ linear_map::{Entry, LinearMap, LinearMapView},
+ Vec,
+};
+use spin::{Lazy, Mutex};
+
+use trezor_thp::{
+ channel::{
+ device::{Channel, ChannelIdAllocator, ChannelOpen, Mux},
+ PacketInResult, PairingState, Phase, MAX_CREDENTIAL_LEN, MAX_RETRANSMISSION_COUNT,
+ PUBKEY_LEN,
+ },
+ control_byte::ControlByte,
+ credential::CredentialVerifier,
+ error::TransportError,
+ ChannelIO, Error as ThpError,
+};
+
+use crypto::TrezorCrypto;
+use time::{least_recently_used, ChannelTiming};
+
+type TrezorMux = Mux<TrezorCrypto>;
+type TrezorChannelOpen = ChannelOpen<TrezorCredentialVerifier, TrezorCrypto>;
+type TrezorChannel = Channel<TrezorCrypto>;
+
+type PubKey = [u8; PUBKEY_LEN];
+
+#[cfg(not(any(test, feature = "ble")))]
+const MAX_INTERFACES: usize = 1;
+#[cfg(any(test, feature = "ble"))]
+const MAX_INTERFACES: usize = 2;
+
+// Channel limits, shared across interfaces.
+const MAX_CHANNELS_OPENING: usize = 4;
+const MAX_CHANNELS_APPDATA: usize = 10;
+
+const CANNOT_UNLOCK: Error = Error::ThpError(c"THP state locked");
+const CHANNEL_NOT_FOUND: Error = Error::ThpError(c"Channel not found");
+const INTERFACE_NOT_FOUND: Error = Error::ThpError(c"Invalid interface");
+
+/// Global THP state.
+/// Needs to be wrapped in a mutex because even without threads the compiler
+/// cannot guarantee that borrowing rules are obeyed.
+static THP_CONTEXT: Mutex<ThpContext> = Mutex::new(ThpContext::new());
+
+/// Auxiliary THP state. Contains data that need to be accessed by
+/// TrezorCredentialVerifier::verify while THP_CONTEXT is already locked.
+static THP_AUX: Mutex<ThpAuxiliaryInfo> = Mutex::new(ThpAuxiliaryInfo::new());
+
+/// Next channel ID to be allocated. These are unique across interfaces.
+static CHANNEL_ID_COUNTER: Lazy<ChannelIdAllocator> =
+ Lazy::new(ChannelIdAllocator::new_random::<TrezorCrypto>);
+
+/// A THP channel with additional data.
+struct ChannelEntry<T> {
+ /// The channel.
+ channel: T,
+ /// Interface the channel is bound to.
+ iface_num: u8,
+ /// Timing information.
+ timing: ChannelTiming,
+}
+
+/// State of channels and interfaces.
+// Currently around 7KiB with 4/10 opening/appdata.
+struct ThpContext {
+ /// Muxes handles broadcast messages, channel allocation, CodecV1 responses.
+ ifaces: LinearMap<u8, TrezorMux, MAX_INTERFACES>,
+ /// Channels in the opening/handshake phase, with associated data, indexed
+ /// by `channel_id`. Unlike application data channels, their messages
+ /// are handled internally and not passed to python.
+ /// Size note: these are larger than appdata channels due to the Noise
+ /// handshake state and an internal buffer that needs to fit
+ /// MAX_CREDENTIAL_LEN + 48 bytes.
+ channel_opening: LinearMap<u16, ChannelEntry<TrezorChannelOpen>, MAX_CHANNELS_OPENING>,
+ /// Channels in the pairing, credential, or encrypted transport phase.
+ /// Maps `channel_id` to a channel with its associated data.
+ channel_appdata: LinearMap<u16, ChannelEntry<TrezorChannel>, MAX_CHANNELS_APPDATA>,
+ /// Flag indicating that a channel was closed due to an error and
+ /// micropython needs to delete sessions without open channel. Only channels
+ /// in encrypted transport state are considered, others can't have sessions.
+ channel_closed: bool,
+ /// Sort of logical clock that is increased every time
+ /// [`ChannelTiming::last_usage`] is increased.
+ last_usage_counter: u32,
+ /// Allows simulating timeouts in unit tests without actually waiting.
+ now_instant: Option<Instant>,
+}
+
+impl ThpContext {
+ pub const fn new() -> Self {
+ Self {
+ ifaces: LinearMap::new(),
+ channel_opening: LinearMap::new(),
+ channel_appdata: LinearMap::new(),
+ channel_closed: false,
+ last_usage_counter: 0,
+ now_instant: None,
+ }
+ }
+
+ /// Create new initial interface context. Returns error when
+ /// `device_properties` is longer than `MAX_DEVICE_PROPERTIES_LEN`.
+ pub fn add_interface(&mut self, iface_num: u8, device_properties: &[u8]) -> Result<(), Error> {
+ match self.ifaces.entry(iface_num) {
+ Entry::Occupied(_) => {
+ // already exists
+ }
+ Entry::Vacant(v) => {
+ v.insert(TrezorMux::new(device_properties)?)
+ .map_err(|_| Error::ThpError(c"Too many interfaces"))?;
+ }
+ }
+ Ok(())
+ }
+
+ /// Process a packet received by an interface. Returns [`TrezorInResult`]
+ /// which indicates if anything else needs to be done with the packet.
+ pub fn packet_in(
+ &mut self,
+ iface_num: u8,
+ packet_buffer: &[u8],
+ credential_fn: Obj,
+ ) -> Result<TrezorInResult, Error> {
+ let mux = self.ifaces.get_mut(&iface_num).ok_or(INTERFACE_NOT_FOUND)?;
+ let pir = mux.packet_in(packet_buffer, &mut []);
+ let res = match pir {
+ PacketInResult::Accepted { .. } => TrezorInResult::None,
+ PacketInResult::Ignored { .. } => TrezorInResult::None,
+ PacketInResult::Route {
+ channel_id,
+ buffer_size,
+ } => {
+ if let Some(che) = self.channel_opening.get(&channel_id) {
+ if che.iface_num == iface_num {
+ return self.packet_in_handshake(channel_id, packet_buffer, credential_fn);
+ }
+ } else if let Some(che) = self.channel_appdata.get(&channel_id) {
+ if che.iface_num == iface_num {
+ return Ok(TrezorInResult::Route {
+ channel_id,
+ buffer_size,
+ });
+ }
+ }
+ log::debug!(
+ "[{:04x}] Received packet for unallocated channel.",
+ channel_id
+ );
+ // Only reply to initiation packets.
+ if packet_buffer
+ .first()
+ .and_then(|b| ControlByte::try_from(*b).ok())
+ .is_some_and(|cb| !cb.is_continuation())
+ {
+ mux.send_unallocated_channel(channel_id)?;
+ }
+ TrezorInResult::None
+ }
+ PacketInResult::ChannelAllocation => {
+ self.packet_in_alloc(iface_num)?;
+ TrezorInResult::None
+ }
+ _ => {
+ return Err(Error::ThpError(c"Unexpected PacketInResult"));
+ }
+ };
+ Ok(res)
+ }
+
+ /// Allocates a channel and starts the handshake process. Closes the oldest
+ /// channel in handshake phase if needed.
+ fn packet_in_alloc(&mut self, iface_num: u8) -> Result<(), Error> {
+ let channel_id = self.get_channel_id();
+ let mux = self.ifaces.get_mut(&iface_num).ok_or(INTERFACE_NOT_FOUND)?;
+ let channel = mux.channel_alloc(
+ channel_id,
+ TrezorCredentialVerifier::new(iface_num, channel_id),
+ )?;
+
+ if let Some(cid) = Self::lru_needs_closing(&self.channel_opening) {
+ self.channel_close(cid);
+ }
+ let mut timing = ChannelTiming::new(self.now());
+ timing.update_last_usage(self.last_usage_next());
+ Self::insert_channel(
+ self.channel_opening.as_mut_view(),
+ iface_num,
+ channel_id,
+ channel,
+ timing,
+ );
+ Ok(())
+ }
+
+ /// Called by `packet_in` to process a packet for channel in the handshake
+ /// phase. Might invoke the python credential verification callback.
+ fn packet_in_handshake(
+ &mut self,
+ channel_id: u16,
+ packet_buffer: &[u8],
+ credential_fn: Obj,
+ ) -> Result<TrezorInResult, Error> {
+ let now = self.now();
+ let ChannelEntry {
+ channel,
+ iface_num,
+ timing,
+ } = self
+ .channel_opening
+ .get_mut(&channel_id)
+ .ok_or(CHANNEL_NOT_FOUND)?;
+ // Set all host keys aside so that TrezorCredentialVerifier can look up
+ // peer key for channel replacement purposes. Does not work across interfaces.
+ // As a possible optimization we can check packet's control byte and only do it
+ // if it's HandshakeCompletionRequest.
+ // Alternatively we can copy these to micropython before credential_fn is
+ // called.
+ {
+ let mut aux = THP_AUX.try_lock().ok_or(CANNOT_UNLOCK)?;
+ aux.host_keys_copy_from(*iface_num, self.channel_appdata.as_view());
+ };
+ // Set credential verification callback here - we don't want to keep a
+ // longer-lived reference as it could either keep the function alive
+ // across session restart (if GC is aware of it), or we could end up
+ // holding a reference to non-existent object (if GC is not aware of it).
+ channel.credential_verifier().verify_fn = credential_fn;
+ let pir = channel.packet_in(packet_buffer, &mut []);
+ channel.credential_verifier().verify_fn = Obj::const_none();
+ if pir.got_ack() {
+ timing.read_ack(now);
+ }
+ if pir.got_message() && channel.sending_retry() == Some(0) {
+ // Internal state machine accepted incoming message, and prepared outgoing one.
+ // Update last_write as if message_in was called.
+ timing.update_last_write(now);
+ }
+ let res = match pir {
+ PacketInResult::Accepted { .. } => TrezorInResult::None,
+ PacketInResult::Ignored { .. } => TrezorInResult::None,
+ PacketInResult::Failed { .. } => {
+ log::error!("[{:04x}] Handshake failed.", channel_id);
+ self.channel_close(channel_id);
+ // micropython doesn't know about the channel, no point returning Failure
+ return Ok(TrezorInResult::None);
+ }
+ PacketInResult::HandshakeKeyRequired { try_to_unlock } => {
+ TrezorInResult::KeyRequired { try_to_unlock }
+ }
+ _ => {
+ return Err(Error::ThpError(c"Unexpected PacketInResult"));
+ }
+ };
+ if channel.handshake_done() {
+ let ChannelEntry {
+ channel,
+ mut timing,
+ iface_num,
+ } = unwrap!(self.channel_opening.remove(&channel_id));
+ let channel = channel.complete()?;
+ timing.update_last_usage(self.last_usage_next());
+
+ if let Some(cid) = Self::lru_needs_closing(&self.channel_appdata) {
+ self.channel_close(cid);
+ }
+ Self::insert_channel(
+ self.channel_appdata.as_mut_view(),
+ iface_num,
+ channel_id,
+ channel,
+ timing,
+ );
+ }
+ Ok(res)
+ }
+
+ /// Process a packet for channel in pairing+credential, or encrypted
+ /// transport (appdata) phase - when `packet_in` returns
+ /// `TrezorInResult::Route`. A receive buffer needs to be supplied by
+ /// micropython caller.
+ pub fn packet_in_channel(
+ &mut self,
+ channel_id: u16,
+ packet_buffer: &[u8],
+ receive_buffer: &mut [u8],
+ ) -> Result<TrezorInResult, Error> {
+ let now = self.now();
+ let ChannelEntry {
+ channel, timing, ..
+ } = self.lookup_channel_mut(channel_id)?;
+ let pir = channel.packet_in(packet_buffer, receive_buffer);
+ let res = match pir {
+ PacketInResult::Accepted {
+ ack_received,
+ message_ready,
+ ..
+ } => {
+ if ack_received {
+ timing.read_ack(now);
+ }
+ match (message_ready, ack_received) {
+ (true, true) => TrezorInResult::MessageReadyAck,
+ (true, false) => TrezorInResult::MessageReady,
+ (false, true) => TrezorInResult::Ack,
+ _ => TrezorInResult::None,
+ }
+ }
+ PacketInResult::Ignored { .. } => TrezorInResult::None,
+ PacketInResult::Failed { .. } | PacketInResult::TransportError { .. } => {
+ self.channel_close(channel_id);
+ TrezorInResult::Failed
+ }
+ _ => {
+ return Err(Error::ThpError(c"Unexpected PacketInResult"));
+ }
+ };
+ Ok(res)
+ }
+
+ /// Write outgoing packet for the broadcast channel or any channel in the
+ /// handshake phase. Returns false if no such packet is ready to be
+ /// sent.
+ pub fn packet_out(
+ &mut self,
+ out_iface_num: u8,
+ packet_buffer: &mut [u8],
+ ) -> Result<bool, Error> {
+ let mux = self
+ .ifaces
+ .get_mut(&out_iface_num)
+ .ok_or(INTERFACE_NOT_FOUND)?;
+ if mux.packet_out_ready() {
+ mux.packet_out(packet_buffer, &[])?;
+ return Ok(true);
+ }
+ let mut written = false;
+ let mut failed = None;
+ for ChannelEntry {
+ channel, iface_num, ..
+ } in self.channel_opening.values_mut()
+ {
+ if *iface_num != out_iface_num {
+ continue;
+ }
+ if channel.packet_out_ready() {
+ channel.packet_out(packet_buffer, &[])?;
+ written = true;
+ failed = channel.handshake_failed().then_some(channel.channel_id());
+ break;
+ }
+ }
+ if let Some(cid) = failed {
+ self.channel_close(cid);
+ }
+ Ok(written)
+ }
+
+ /// Write outgoing packet for a channel with the given ID - it must be
+ /// either in the pairing+credential or encrypted transport (appdata) phase.
+ /// Returns `false` if channel is not ready to send a packet.
+ pub fn packet_out_channel(
+ &mut self,
+ channel_id: u16,
+ send_buffer: &[u8],
+ packet_buffer: &mut [u8],
+ ) -> Result<bool, Error> {
+ let ChannelEntry { channel, .. } = self.lookup_channel_mut(channel_id)?;
+ let res = channel.packet_out(packet_buffer, send_buffer);
+ if channel.is_failed() {
+ self.channel_close(channel_id);
+ }
+ match res {
+ Ok(()) => Ok(true),
+ Err(ThpError::NotReady) => Ok(false),
+ Err(e) => Err(e.into()),
+ }
+ }
+
+ /// Decrypt and return a message after `packet_in_channel` returned
+ /// `TrezorInResult::MessageReady` or `TrezorInResult::MessageReadyAck`.
+ /// Message is considered delivered and sending an ACK to the host is
+ /// scheduled.
+ pub fn message_out(
+ &mut self,
+ channel_id: u16,
+ receive_buffer: &mut [u8],
+ ) -> Result<(u8, u16, usize), Error> {
+ let ChannelEntry { channel, .. } = self.lookup_channel_mut(channel_id)?;
+ let (sid, message_type, message) = channel.message_out(receive_buffer)?;
+ Ok((sid, message_type, message.len()))
+ }
+
+ /// Encrypt and start sending application message to the peer. Send buffer
+ /// must contain serialized message including the application header
+ /// (session id and message type) that is `plaintext_len` long, and there
+ /// must be space for at least 16 more bytes in the send buffer.
+ pub fn message_in(
+ &mut self,
+ channel_id: u16,
+ plaintext_len: usize,
+ send_buffer: &mut [u8],
+ ) -> Result<(), Error> {
+ let now = self.now();
+ let ChannelEntry {
+ channel, timing, ..
+ } = self.lookup_channel_mut(channel_id)?;
+ channel.message_in(plaintext_len, send_buffer)?;
+ timing.update_last_write(now);
+ Ok(())
+ }
+
+ /// Resend a message on a channel if it has not been acknowledged by host in
+ /// the time limit. Returns false if the maximum retransmission attempts
+ /// have been exceeded, true otherwise.
+ pub fn message_retransmit(&mut self, channel_id: u16) -> Result<bool, Error> {
+ let retry = if let Some(che) = self.channel_appdata.get_mut(&channel_id) {
+ che.channel.message_retransmit()?;
+ che.channel.sending_retry()
+ } else if let Some(che) = self.channel_opening.get_mut(&channel_id) {
+ che.channel.message_retransmit()?;
+ che.channel.sending_retry()
+ } else {
+ return Err(CHANNEL_NOT_FOUND);
+ };
+ match retry {
+ None => {
+ log::error!(
+ "[{:04x}] Requested to retransmit but not currently sending.",
+ channel_id
+ );
+ Ok(true)
+ }
+ Some(r) if r > MAX_RETRANSMISSION_COUNT => {
+ log::warn!(
+ "[{:04x}] Closing channel after too many retransmissions.",
+ channel_id
+ );
+ self.channel_close(channel_id);
+ Ok(false)
+ }
+ _ => Ok(true),
+ }
+ }
+
+ /// Returns channel ID of a channel with application message ready to be
+ /// decrypted.
+ #[cfg(feature = "debug")]
+ pub fn message_out_ready(&self, iface_num: u8) -> Option<u16> {
+ self.channel_appdata
+ .iter()
+ .filter(|&(_cid, che)| che.iface_num == iface_num)
+ .find_map(|(cid, che)| che.channel.message_out_ready().then_some(*cid))
+ }
+
+ /// Returns handshake hash of a channel.
+ pub fn handshake_hash(&self, channel_id: u16) -> Result<&[u8], Error> {
+ let ChannelEntry { channel, .. } = self.lookup_channel(channel_id)?;
+ Ok(channel.handshake_hash())
+ }
+
+ /// Returns host's static public key.
+ pub fn remote_static_pubkey(&self, channel_id: u16) -> Result<&[u8], Error> {
+ let ChannelEntry { channel, .. } = self.lookup_channel(channel_id)?;
+ Ok(channel.remote_static_pubkey())
+ }
+
+ /// Returns information for a channel:
+ /// - duration between now and the last time a (non-ACK) packet has been
+ /// sent
+ /// - phase of the channel, possibly with initial pairing state
+ pub fn channel_info(&self, channel_id: u16) -> Result<(Option<u32>, Phase), Error> {
+ let ChannelEntry {
+ channel, timing, ..
+ } = self.lookup_channel(channel_id)?;
+ let last_write_age_ms = timing
+ .last_write_age(self.now())
+ .map(|duration| duration.to_millis());
+ Ok((last_write_age_ms, channel.phase()))
+ }
+
+ /// Indicate that a pairing+credential phase was successfully finished,
+ /// transition channel to encrypted transport (appdata) phase.
+ /// The "channel replacement" mechanism happens here - if an open channel
+ /// with the same host static public key exists, it is closed and its ID
+ /// is returned so that micropython app can migrate the channel's sessions.
+ pub fn channel_paired(&mut self, channel_id: u16) -> Result<Option<u16>, Error> {
+ log::debug!("[{:04x}] Pairing/credential phase complete.", channel_id);
+ let ChannelEntry {
+ channel, iface_num, ..
+ } = self.lookup_channel_mut(channel_id)?;
+ if channel.is_encrypted_transport() {
+ log::error!(
+ "[{:04x}] Channel is already in encrypted transport state!",
+ channel_id
+ );
+ return Ok(None);
+ }
+ // Transition to encrypted transport.
+ channel.end_pairing();
+
+ // Replace ENCRYPTED channel with the same host pubkey if there is one.
+ let iface_num = *iface_num;
+ let host_key = *channel.remote_static_pubkey();
+ let old_channel_id = self
+ .channel_appdata
+ .values()
+ .find(|che| {
+ che.iface_num == iface_num
+ && che.channel.channel_id() != channel_id
+ && che.channel.is_encrypted_transport()
+ && che.channel.remote_static_pubkey() == &host_key
+ })
+ .map(|che| che.channel.channel_id());
+ if let Some(cid) = old_channel_id {
+ self.channel_close(cid);
+ }
+
+ // Delete saved credential as it's no longer needed.
+ {
+ let mut aux = THP_AUX.try_lock().ok_or(CANNOT_UNLOCK)?;
+ aux.delete_credential(channel_id);
+ }
+
+ Ok(old_channel_id)
+ }
+
+ /// Remove a channel and any associated state.
+ pub fn channel_close(&mut self, channel_id: u16) {
+ log::debug!("[{:04x}] Closing channel.", channel_id);
+ if let Some(ChannelEntry { channel, .. }) = self.channel_appdata.remove(&channel_id) {
+ // Pairing/credential channels don't need notification
+ // because they don't have sessions.
+ if channel.is_encrypted_transport() {
+ self.channel_closed = true;
+ }
+ } else {
+ self.channel_opening.remove(&channel_id);
+ }
+ // Delete credential from the credential queue.
+ if let Some(mut aux) = THP_AUX.try_lock() {
+ aux.delete_credential(channel_id);
+ }
+ }
+
+ /// Close all channels on all interfaces, possibly keeping the one in
+ /// `exclude` argument. Please note that the `channel_closed` flag is
+ /// cleared, micropython is responsible for removing sessions of all
+ /// affected channels.
+ pub fn channel_close_all(&mut self, exclude: Option<u16>) {
+ log::warn!("Close all");
+ for mux in self.ifaces.values_mut() {
+ mux.reset();
+ }
+ self.channel_appdata
+ .retain(|&cid, _ch| Some(cid) == exclude);
+ self.channel_opening.clear();
+ self.channel_closed = false;
+ if let Some(mut aux) = THP_AUX.try_lock() {
+ aux.delete_credential_all();
+ }
+ }
+
+ /// Returns `true` if any channel in encrypted transport phase has been
+ /// closed since this function was last called. Sessions associated with
+ /// closed channels should be removed.
+ pub fn channel_was_closed(&mut self) -> bool {
+ replace(&mut self.channel_closed, false)
+ }
+
+ /// Returns true if a channel with the given id exists in the encrypted
+ /// transport state.
+ pub fn channel_is_open(&self, channel_id: u16) -> bool {
+ self.channel_appdata
+ .get(&channel_id)
+ .is_some_and(|che| che.channel.is_encrypted_transport())
+ }
+
+ /// Update the `last_usage` logical timestamp of a channel.
+ pub fn channel_update_last_usage(&mut self, channel_id: u16) {
+ let new = self.last_usage_next();
+ if let Ok(ChannelEntry { timing, .. }) = self.lookup_channel_mut(channel_id) {
+ timing.update_last_usage(new);
+ }
+ }
+
+ /// Returns the ID and relative time in milliseconds of when a channel
+ /// should start retransmitting its message. When there are multiple
+ /// such channels, the one with the earliest retransmission is returned.
+ /// Returns None if no channel is currently transmitting.
+ /// The ID can belong to a channel in handshake state, which is not
+ /// otherwise exposed to micropython.
+ pub fn next_timeout(&self, iface_num: u8) -> Result<Option<(u16, u32)>, Error> {
+ let now = self.now();
+ let mut earliest: Option<(u16, u32)> = None;
+ // Get iterator of tuples (channel_id, retry, timing) for channels that are
+ // currently sending.
+ let sending = self
+ .channel_appdata
+ .values()
+ .filter(|che| che.iface_num == iface_num)
+ .filter_map(|che| {
+ che.channel
+ .sending_retry()
+ .map(|retry| (che.channel.channel_id(), retry, &che.timing))
+ })
+ .chain(
+ self.channel_opening
+ .values()
+ .filter(|che| che.iface_num == iface_num)
+ .filter_map(|che| {
+ che.channel
+ .sending_retry()
+ .map(|retry| (che.channel.channel_id(), retry, &che.timing))
+ }),
+ );
+ for (channel_id, retry, timing) in sending {
+ let timeout_ms = timing.timeout_from_now(now, retry).to_millis();
+ earliest = match (earliest, timeout_ms) {
+ // No result yet - update.
+ (None, t) => Some((channel_id, t)),
+ // Earlier timeout - update.
+ (Some((_, t_best)), t_cur) if t_cur < t_best => Some((channel_id, t_cur)),
+ // Otherwise keep the current best.
+ _ => earliest,
+ }
+ }
+ Ok(earliest)
+ }
+
+ /// Ask the host to try again later because the receive buffer is used by
+ /// another channel.
+ pub fn send_transport_busy(&mut self, channel_id: u16) -> Result<(), Error> {
+ let ChannelEntry { channel, .. } = self.lookup_channel_mut(channel_id)?;
+ channel.send_error(TransportError::TransportBusy);
+ Ok(())
+ }
+
+ /// Abort ongoing handshakes because Trezor's static key is not available.
+ pub fn send_device_locked(&mut self, iface_num: u8) -> Result<(), Error> {
+ let mut first_err = None;
+ for che in self.channel_opening.values_mut() {
+ if che.iface_num != iface_num {
+ continue;
+ }
+ if che.channel.static_key_required() {
+ if let Err(e) = che.channel.send_device_locked() {
+ // Channel will be closed after the error is sent out.
+ first_err.get_or_insert(e);
+ }
+ }
+ }
+ first_err.map_or(Ok(()), |e| Err(e.into()))
+ }
+
+ /// Provide Trezor's static key for the ongoing handshake(s). The key is not
+ /// copied and the slice can be overwritten after the function returns.
+ pub fn handshake_static_key(
+ &mut self,
+ iface_num: u8,
+ local_static_privkey: &[u8],
+ ) -> Result<(), Error> {
+ let now = self.now();
+ let key = local_static_privkey
+ .try_into()
+ .map_err(|_| Error::ThpError(c"Invalid key length"))?;
+ let mut first_err = None;
+ for che in self.channel_opening.values_mut() {
+ if che.iface_num != iface_num {
+ continue;
+ }
+ if che.channel.static_key_required() {
+ if let Err(e) = che.channel.set_static_key(key) {
+ first_err.get_or_insert(e);
+ }
+ // Outgoing message is now ready, update last_write.
+ if let Some(0) = che.channel.sending_retry() {
+ che.timing.update_last_write(now);
+ }
+ }
+ }
+ first_err.map_or(Ok(()), |e| Err(e.into()))
+ }
+
+ /// Look up channel in pairing/credential/encrypted-transport phase by its
+ /// id.
+ fn lookup_channel_mut(
+ &mut self,
+ channel_id: u16,
+ ) -> Result<&mut ChannelEntry<TrezorChannel>, Error> {
+ self.channel_appdata
+ .get_mut(&channel_id)
+ .ok_or(CHANNEL_NOT_FOUND)
+ }
+
+ /// Look up channel in pairing/credential/encrypted-transport phase by its
+ /// id.
+ fn lookup_channel(&self, channel_id: u16) -> Result<&ChannelEntry<TrezorChannel>, Error> {
+ self.channel_appdata
+ .get(&channel_id)
+ .ok_or(CHANNEL_NOT_FOUND)
+ }
+
+ /// Returns None if `channels` is not full, or ID of its least recently used
+ /// channel otherwise.
+ fn lru_needs_closing<T, const N: usize>(
+ channels: &LinearMap<u16, ChannelEntry<T>, N>,
+ ) -> Option<u16> {
+ if !channels.is_full() {
+ return None;
+ }
+ let cid = least_recently_used(&mut channels.iter().map(|(cid, che)| (*cid, &che.timing)));
+ assert!(cid.is_some());
+ cid
+ }
+
+ /// Insert a channel into `LinearMap`. Panic if it is full or the ID is not
+ /// unique.
+ fn insert_channel<T>(
+ channels: &mut LinearMapView<u16, ChannelEntry<T>>,
+ iface_num: u8,
+ channel_id: u16,
+ channel: T,
+ timing: ChannelTiming,
+ ) {
+ let res = channels.insert(
+ channel_id,
+ ChannelEntry {
+ channel,
+ iface_num,
+ timing,
+ },
+ );
+ // should not panic since a slot was freed up before calling this function
+ let res = unwrap!(res);
+ // should not panic as we don't expect duplicate ids
+ assert!(res.is_none());
+ }
+
+ /// Get unique channel ID for newly allocated channel.
+ fn get_channel_id(&self) -> u16 {
+ let is_unique = |cid: &u16| {
+ !self.channel_appdata.contains_key(cid) && !self.channel_opening.contains_key(cid)
+ };
+ let mut result = CHANNEL_ID_COUNTER.get();
+ while !is_unique(&result) {
+ result = CHANNEL_ID_COUNTER.get();
+ }
+ result
+ }
+
+ fn now(&self) -> Instant {
+ self.now_instant.unwrap_or_else(Instant::now)
+ }
+
+ fn last_usage_next(&mut self) -> u32 {
+ self.last_usage_counter = self.last_usage_counter.wrapping_add(1);
+ self.last_usage_counter
+ }
+}
+
+/// Append element into a queue. Drop first item if full.
+fn insert_replace_queue<T>(queue: &mut DequeView<T>, elem: T) {
+ if queue.is_full() {
+ queue.pop_front();
+ log::error!("THP queue full.")
+ }
+ unwrap!(queue.push_back(elem));
+}
+
+/// Context for credential verification callback.
+#[derive(Clone)]
+pub struct TrezorCredentialVerifier {
+ /// Interface ID.
+ iface_num: u8,
+ /// Channel ID.
+ channel_id: u16,
+ /// Micropython credential verification function. It is set just before it's
+ /// needed to avoid holding long-lived reference to micropython memory
+ /// in a global variable.
+ verify_fn: Obj,
+}
+
+// Required by spin::Mutex to be able to lock verify_fn.
+// SAFETY: We are in a single-threaded environment.
+unsafe impl Send for TrezorCredentialVerifier {}
+
+impl TrezorCredentialVerifier {
+ fn new(iface_num: u8, channel_id: u16) -> Self {
+ Self {
+ iface_num,
+ channel_id,
+ verify_fn: Obj::const_none(),
+ }
+ }
+}
+
+impl CredentialVerifier for TrezorCredentialVerifier {
+ fn verify(&self, remote_static_pubkey: &[u8], credential: &[u8]) -> PairingState {
+ log::debug!("[{:04x}] TrezorCredentialVerifier::verify", self.channel_id);
+ let func = || -> Result<PairingState, Error> {
+ if self.verify_fn == Obj::const_none()
+ || credential.is_empty()
+ || remote_static_pubkey.is_empty()
+ {
+ log::info!("No credential, skipping verification.");
+ return Ok(PairingState::Unpaired);
+ }
+ let res = self
+ .verify_fn
+ .call_with_n_args(&[remote_static_pubkey.try_into()?, credential.try_into()?])?;
+ let ps = PairingState::try_from(u8::try_from(res)?)?;
+ // Channel replacement - check if we already trust this key.
+ if ps == PairingState::Paired {
+ let mut aux = THP_AUX.try_lock().ok_or(CANNOT_UNLOCK)?;
+ aux.add_credential(self.channel_id, credential);
+ if aux.host_keys_contain(remote_static_pubkey) {
+ return Ok(PairingState::PairedAutoconnect);
+ }
+ }
+ Ok(ps)
+ };
+ let res = func();
+ match res {
+ Ok(ps) => log::debug!("[{:04x}] Result: {}", self.channel_id, ps as u8),
+ Err(e) => log::error!(
+ "[{:04x}] Credential verification error: {:?}",
+ self.channel_id,
+ e
+ ),
+ }
+ res.unwrap_or(PairingState::Unpaired)
+ }
+}
+
+/// Result of `InterfaceContext::packet_in` and
+/// `InterfaceContext::packet_in_channel`.
+#[cfg_attr(test, derive(Debug))]
+enum TrezorInResult {
+ /// Either a valid packet was consumed, or malformed one was ignored. No
+ /// further action required.
+ None,
+ /// Packet should be processed by a channel in pairing+credential or
+ /// encrypted transport phase by calling `packet_in_channel`.
+ /// If `buffer_size` is `Some` then the receive buffer needs to be at least
+ /// as large.
+ Route {
+ channel_id: u16,
+ buffer_size: Option<NonZeroU16>,
+ },
+ /// Packet caused an unrecoverable error and the associated channel was
+ /// closed.
+ Failed,
+ /// Handshake packet requires Trezor's static key. Micropython needs to call
+ /// either `InterfaceContext::send_device_locked()` or
+ /// `InterfaceContext::handshake_static_key()`.
+ KeyRequired { try_to_unlock: bool },
+ /// Incoming message is ready on a channel, `InterfaceContext::message_out`
+ /// should be called. Does not contain ACK bit.
+ MessageReady,
+ /// Incoming message is ready on a channel, `InterfaceContext::message_out`
+ /// should be called. Message contains a valid ACK bit indicating that
+ /// outgoing message was received and Trezor can send another one.
+ MessageReadyAck,
+ /// Valid ACK message was received, indicating that outgoing message was
+ /// received and Trezor can send another one.
+ Ack,
+}
+
+/// Helper data structure, needs to be accessible by credential verification
+/// callback when THP_INTERFACES is already locked.
+struct ThpAuxiliaryInfo {
+ /// Host static public keys for open channels in appdata phase. Used for
+ /// "channel replacement".
+ host_keys: Vec<PubKey, MAX_CHANNELS_APPDATA>,
+ /// Credential is copied here during handshake, then picked up by
+ /// micropython during pairing+credential phase.
+ credentials: Deque<(u16, Vec<u8, MAX_CREDENTIAL_LEN>), MAX_CHANNELS_APPDATA>,
+}
+
+impl ThpAuxiliaryInfo {
+ pub const fn new() -> Self {
+ Self {
+ host_keys: Vec::new(),
+ credentials: Deque::new(),
+ }
+ }
+
+ pub fn add_credential(&mut self, channel_id: u16, credential: &[u8]) {
+ let Ok(credential) = Vec::from_slice(credential) else {
+ log::error!(
+ "[{:04x}] Credential too long: {}",
+ channel_id,
+ credential.len()
+ );
+ return;
+ };
+ insert_replace_queue(self.credentials.as_mut_view(), (channel_id, credential));
+ }
+
+ pub fn get_credential(&self, channel_id: u16) -> Option<&[u8]> {
+ self.credentials
+ .iter()
+ .find(|&(cid, _)| cid == &channel_id)
+ .map(|(_, cred)| cred.as_slice())
+ }
+
+ pub fn delete_credential(&mut self, channel_id: u16) {
+ self.credentials.retain(|(cid, _)| *cid != channel_id);
+ }
+
+ pub fn delete_credential_all(&mut self) {
+ self.credentials.clear();
+ }
+
+ pub fn host_keys_copy_from(
+ &mut self,
+ iface_num: u8,
+ channels: &LinearMapView<u16, ChannelEntry<TrezorChannel>>,
+ ) {
+ self.host_keys.clear();
+ self.host_keys.extend(
+ channels
+ .values()
+ .filter(|che| che.iface_num == iface_num && che.channel.is_encrypted_transport())
+ .map(|che| *che.channel.remote_static_pubkey()),
+ );
+ }
+
+ pub fn host_keys_contain(&self, host_key: &[u8]) -> bool {
+ match host_key.try_into() {
+ Ok(hk) => self.host_keys.contains(hk),
+ _ => false,
+ }
+ }
+}
diff --git a/core/embed/rust/src/thp/tests.rs b/core/embed/rust/src/thp/tests.rs
new file mode 100644
index 00000000..cca18a2d
--- /dev/null
+++ b/core/embed/rust/src/thp/tests.rs
@@ -0,0 +1,559 @@
+use crate::micropython::{func::Func, macros::obj_fn_2, obj::Obj, testutil::mpy_init};
+
+use std::{
+ assert_matches,
+ collections::{HashMap, VecDeque},
+ iter::repeat_n,
+};
+
+use spin::MutexGuard;
+
+use trezor_thp::{
+ channel::{
+ buffered::{Buffered, ChannelExt},
+ host, ChannelIO, PacketInResult, PairingState, Phase, APP_HEADER_LEN, PRIVKEY_LEN, TAG_LEN,
+ },
+ credential::{CredentialStore, FoundCredential, NullCredentialStore},
+ error::TransportError,
+};
+
+use super::{
+ Error, ThpAuxiliaryInfo, ThpContext, TrezorCrypto, TrezorInResult, THP_AUX, THP_CONTEXT,
+};
+
+type HostChannel = host::Channel<TrezorCrypto>;
+type HostChannelOpen<C> = host::ChannelOpen<C, TrezorCrypto>;
+
+const DUMMY_PRIVKEY: [u8; PRIVKEY_LEN] = [0; _];
+const IFACE_USB: u8 = 0x58;
+const IFACE_BLE: u8 = 0x13;
+
+/// Model of the environment that the module interacts with.
+struct TestContext {
+ /// Unique THP_CONTEXT reference.
+ thp: MutexGuard<'static, ThpContext>,
+
+ /// Queue of packets in transit between Trezor and Host. Two directions per
+ /// interface.
+ wire_h2t: HashMap<u8, VecDeque<Vec<u8>>>,
+ wire_t2h: HashMap<u8, VecDeque<Vec<u8>>>,
+
+ /// Micropython code maintains send and receive buffers bound to
+ /// channel+interface.
+ send_buffer: HashMap<(u8, u16), Vec<u8>>,
+ receive_buffer: HashMap<(u8, u16), Vec<u8>>,
+
+ /// Micropython credential verification callback.
+ credential_fn: Obj,
+}
+
+impl TestContext {
+ fn new(thp: MutexGuard<'static, ThpContext>) -> Self {
+ TestContext {
+ thp,
+ wire_h2t: HashMap::new(),
+ wire_t2h: HashMap::new(),
+ send_buffer: HashMap::new(),
+ receive_buffer: HashMap::new(),
+ credential_fn: Obj::const_none(),
+ }
+ }
+
+ fn wire_h2t(&mut self, iface_num: u8) -> &mut VecDeque<Vec<u8>> {
+ self.wire_h2t
+ .entry(iface_num)
+ .or_insert_with(|| VecDeque::new())
+ }
+
+ fn wire_t2h(&mut self, iface_num: u8) -> &mut VecDeque<Vec<u8>> {
+ self.wire_t2h
+ .entry(iface_num)
+ .or_insert_with(|| VecDeque::new())
+ }
+}
+
+impl Drop for TestContext {
+ fn drop(&mut self) {
+ for (iface_num, wire) in self.wire_h2t.iter() {
+ if !wire.is_empty() {
+ log::error!(
+ "wire_h2t[{:02x}] must end up empty, has {} packets:",
+ iface_num,
+ wire.len()
+ );
+ }
+ for packet in wire {
+ log::error!("{}", hex::encode(&packet));
+ }
+ }
+ for (iface_num, wire) in self.wire_t2h.iter() {
+ if !wire.is_empty() {
+ log::error!(
+ "wire_t2h[{:02x}] must end up empty, has {} packets:",
+ iface_num,
+ wire.len()
+ );
+ }
+ for packet in wire {
+ log::error!("{}", hex::encode(&packet));
+ }
+ }
+ }
+}
+
+#[derive(Debug)]
+enum ExchangeResult {
+ Trezor(TrezorInResult),
+ Host(PacketInResult),
+}
+use ExchangeResult::*;
+
+impl ExchangeResult {
+ fn assert_host_error(&self, te: TransportError) {
+ match self {
+ Host(PacketInResult::TransportError { error }) if *error == te => {}
+ r => panic!("Unexpected ExchangeResult: {:?}", r),
+ }
+ }
+}
+
+impl TestContext {
+ /// Send packets back and forth between Host and Trezor until there's
+ /// nothing left to send, or something interesting happens.
+ fn exchange_packets<C: ChannelIO>(
+ &mut self,
+ iface_num: u8,
+ host: &mut Buffered<C>,
+ ) -> Result<ExchangeResult, Error> {
+ let mut idle = false;
+
+ while !idle {
+ idle = true;
+ // host to trezor
+ while host.packet_out_ready() {
+ self.wire_h2t(iface_num).push_back(host.packet_out()?);
+ idle = false;
+ }
+ while !self.wire_h2t(iface_num).is_empty() {
+ let packet = self.wire_h2t(iface_num).pop_front().unwrap();
+ log::debug!("[{:02x}] > {}", iface_num, hex::encode(&packet));
+ let mut res = self.thp.packet_in(iface_num, &packet, self.credential_fn)?;
+ if let TrezorInResult::Route {
+ channel_id,
+ buffer_size,
+ } = res
+ {
+ if let Some(buffer_size) = buffer_size {
+ self.receive_buffer.insert(
+ (iface_num, channel_id),
+ Vec::from_iter(repeat_n(0, buffer_size.get().into())),
+ );
+ }
+ let mut receive_buffer = self
+ .receive_buffer
+ .get_mut(&(iface_num, channel_id))
+ .unwrap();
+ res = self
+ .thp
+ .packet_in_channel(channel_id, &packet, &mut receive_buffer)?;
+ }
+ if !matches!(res, TrezorInResult::None) {
+ log::debug!("trezor[{:02x}]: {:?}", iface_num, res);
+ return Ok(ExchangeResult::Trezor(res));
+ }
+ }
+ // trezor to host
+ let mut packet_buffer = Vec::from_iter(repeat_n(0, host.packet_len()));
+ while self.thp.packet_out(iface_num, &mut packet_buffer)? {
+ self.wire_t2h(iface_num).push_back(packet_buffer.clone());
+ idle = false;
+ }
+ // FIXME: maybe just send host.channel_id() if iface_num matches?
+ let iface_channels = self
+ .send_buffer
+ .keys()
+ .filter_map(|&(ifn, cid)| (ifn == iface_num).then_some(cid))
+ .collect::<Vec<u16>>();
+ for channel_id in iface_channels {
+ while self.thp.packet_out_channel(
+ channel_id,
+ self.send_buffer.get_mut(&(iface_num, channel_id)).unwrap(),
+ &mut packet_buffer,
+ )? {
+ self.wire_t2h(iface_num).push_back(packet_buffer.clone());
+ idle = false;
+ }
+ }
+ while !self.wire_t2h(iface_num).is_empty() {
+ let packet = self.wire_t2h(iface_num).pop_front().unwrap();
+ log::debug!("[{:02x}] < {}", iface_num, hex::encode(&packet));
+ let res = host.packet_in(&packet).check_failed()?;
+ if !matches!(
+ res,
+ PacketInResult::Ignored { .. } | PacketInResult::Accepted { .. }
+ ) {
+ log::debug!("host[{:02x}]: {:?}", iface_num, res);
+ return Ok(ExchangeResult::Host(res));
+ }
+ }
+ }
+
+ Ok(ExchangeResult::Trezor(TrezorInResult::None))
+ }
+
+ /// Allocate new channel on given interface.
+ fn allocate_channel<C: CredentialStore>(
+ &mut self,
+ iface_num: u8,
+ cred_store: C,
+ ) -> Result<Buffered<HostChannelOpen<C>>, Error> {
+ let mut host = host::Mux::<TrezorCrypto>::new().into_buffered();
+ host.request_channel(false);
+ self.exchange_packets(iface_num, &mut host)?;
+ assert!(host.channel_alloc_ready());
+ let mut host = host.channel_alloc(cred_store)?.into_buffered();
+ // enable ACK piggybacking
+ host.set_device_protocol_version(2, 1);
+ Ok(host)
+ }
+
+ /// Perform handshake on allocated channel.
+ fn perform_handshake<C: CredentialStore>(
+ &mut self,
+ iface_num: u8,
+ mut host: Buffered<HostChannelOpen<C>>,
+ ) -> Result<Buffered<HostChannel>, Error> {
+ let res = self.exchange_packets(iface_num, &mut host)?;
+ assert_matches!(res, Trezor(TrezorInResult::KeyRequired { .. }));
+ self.thp.handshake_static_key(iface_num, &DUMMY_PRIVKEY)?;
+ self.exchange_packets(iface_num, &mut host)?;
+ assert!(host.handshake_done());
+ let host = host.map(|h| h.complete())?;
+ assert!(!host.is_encrypted_transport());
+ assert_matches!(
+ self.trezor_phase(host.channel_id()),
+ Phase::PairingCredential { .. }
+ );
+ Ok(host)
+ }
+
+ /// Transition from pairing/credential phase to encrypted transport.
+ fn end_pairing(&mut self, host: &mut Buffered<HostChannel>) -> Result<Option<u16>, Error> {
+ let channel_id = host.channel_id();
+ assert!(!host.is_encrypted_transport());
+ assert_matches!(
+ self.trezor_phase(channel_id),
+ Phase::PairingCredential { .. }
+ );
+ host.end_pairing();
+ let replaced_channel = self.thp.channel_paired(channel_id)?;
+ assert!(host.is_encrypted_transport());
+ assert_matches!(self.trezor_phase(channel_id), Phase::EncryptedTransport);
+ Ok(replaced_channel)
+ }
+
+ /// Send application message from Host to Trezor. Does not send ACK.
+ fn send_h2t(
+ &mut self,
+ iface_num: u8,
+ host: &mut Buffered<HostChannel>,
+ message: &[u8],
+ ) -> Result<(), Error> {
+ let channel_id = host.channel_id();
+ host.message_in(0, 1234, message)?;
+ let res = self.exchange_packets(iface_num, host)?;
+ assert_matches!(
+ res,
+ Trezor(TrezorInResult::MessageReady | TrezorInResult::MessageReadyAck)
+ );
+ let mut receive_buffer = self
+ .receive_buffer
+ .get_mut(&(iface_num, channel_id))
+ .unwrap();
+ let (sid, mty, len) = self.thp.message_out(channel_id, &mut receive_buffer)?;
+ let msg = &receive_buffer[APP_HEADER_LEN..][..len];
+ assert_eq!(sid, 0);
+ assert_eq!(mty, 1234);
+ assert_eq!(msg, message);
+ Ok(())
+ }
+
+ /// Send application message from Trezor to Host. Does not send ACK.
+ fn send_t2h(
+ &mut self,
+ iface_num: u8,
+ host: &mut Buffered<HostChannel>,
+ message: &[u8],
+ ) -> Result<(), Error> {
+ let channel_id = host.channel_id();
+ let mut msg = Vec::new();
+ msg.push(0u8);
+ msg.extend_from_slice(&[0xc0, 0xfe]);
+ msg.extend_from_slice(message);
+ let plaintext_len = msg.len();
+ msg.extend_from_slice(&[0; TAG_LEN]);
+ self.send_buffer.insert((iface_num, channel_id), msg);
+ let mut send_buffer = self.send_buffer.get_mut(&(iface_num, channel_id)).unwrap();
+ self.thp
+ .message_in(channel_id, plaintext_len, &mut send_buffer)?;
+ self.exchange_packets(iface_num, host)?;
+ assert!(host.message_out_ready());
+ let (sid, mty, msg_host) = host.message_out()?;
+ assert_eq!(sid, 0);
+ assert_eq!(mty, 0xc0fe);
+ assert_eq!(msg_host, message);
+ Ok(())
+ }
+
+ /// Send a sequence of requests-responses from Host to Trezor and back.
+ /// After last response, a standalone ACK packet is sent to Trezor.
+ fn call(
+ &mut self,
+ iface_num: u8,
+ host: &mut Buffered<HostChannel>,
+ request_response: &[(&[u8], &[u8])],
+ ) -> Result<(), Error> {
+ for (request, response) in request_response {
+ self.send_h2t(iface_num, host, request)?;
+ self.send_t2h(iface_num, host, response)?;
+ }
+ // end of piggybacking, host needs to send standalone ACK
+ let res = self.exchange_packets(iface_num, host)?;
+ assert_matches!(res, Trezor(TrezorInResult::Ack));
+ Ok(())
+ }
+
+ /// Assert there are no packets pending.
+ fn assert_silence<C: ChannelIO>(&mut self, host: &Buffered<C>) {
+ assert!(!host.packet_out_ready());
+ let mut packet_buffer = Vec::from_iter(repeat_n(0, host.packet_len()));
+ for iface_num in self.thp.ifaces.keys().map(|x| *x).collect::<Vec<u8>>() {
+ assert!(!self.thp.packet_out(iface_num, &mut packet_buffer).unwrap());
+ }
+ for &(iface_num, channel_id) in self.send_buffer.keys() {
+ assert!(!self
+ .thp
+ .packet_out_channel(
+ channel_id,
+ self.send_buffer.get(&(iface_num, channel_id)).unwrap(),
+ &mut packet_buffer
+ )
+ .unwrap());
+ }
+ }
+
+ fn trezor_phase(&self, channel_id: u16) -> Phase {
+ self.thp.channel_info(channel_id).unwrap().1
+ }
+
+ /// Assert particular pairing state on both sides of an interface.
+ fn assert_pairing_state(&mut self, host: &Buffered<HostChannel>, ps: PairingState) {
+ match self.trezor_phase(host.channel_id()) {
+ Phase::PairingCredential {
+ handshake_pairing_state,
+ } if handshake_pairing_state == ps => {}
+ tp => panic!("Unexpected trezor pairing state: {:?}", tp),
+ }
+ match host.phase() {
+ Phase::PairingCredential {
+ handshake_pairing_state,
+ } if handshake_pairing_state == ps => {}
+ hp => panic!("Unexpected host pairing state: {:?}", hp),
+ }
+ }
+}
+
+/// Every test must start by calling this function and not discarding the
+/// MutexGuard until the end.
+/// - By default `cargo test` uses multiple threads but we only have one
+/// THP_CONTEXT.
+/// - If we somehow get rid of THP_AUX we can stop with this circus and just
+/// make a new ThpContext instance.
+fn setup(ifaces: &[u8]) -> TestContext {
+ unsafe { mpy_init() };
+
+ let mut thp = THP_CONTEXT.lock();
+ *thp = ThpContext::new();
+ let mut aux = THP_AUX.lock();
+ *aux = ThpAuxiliaryInfo::new();
+ // should be fine to leave CHANNEL_ID_COUNTER as is
+ for &iface_num in ifaces {
+ thp.add_interface(iface_num, b"FakeDeviceProperties")
+ .unwrap();
+ }
+ TestContext::new(thp)
+}
+
+#[test]
+fn test_open() -> Result<(), Error> {
+ let mut test = setup(&[IFACE_USB, IFACE_BLE]);
+
+ // channel allocation
+ let host = test.allocate_channel(IFACE_USB, NullCredentialStore)?;
+ assert_eq!(host.device_properties(), b"FakeDeviceProperties");
+
+ // handshake
+ let mut host = test.perform_handshake(IFACE_USB, host)?;
+ test.assert_pairing_state(&host, PairingState::Unpaired);
+
+ // pairing
+ test.call(
+ IFACE_USB,
+ &mut host,
+ &[(b"DummySkipPairing", b"DummyThpEndResponse")],
+ )?;
+
+ // pairing done
+ test.end_pairing(&mut host)?;
+
+ // application messages
+ test.call(
+ IFACE_USB,
+ &mut host,
+ &[(b"Ping", b"Pong"), (&[123; 130], &[45; 140])],
+ )?;
+
+ Ok(())
+}
+
+#[test]
+fn test_isolation_handshake_1() -> Result<(), Error> {
+ let mut test = setup(&[IFACE_USB, IFACE_BLE]);
+ // allocate channel on USB interface
+ let mut host = test.allocate_channel(IFACE_USB, NullCredentialStore)?;
+ // host initiates handshake on BLE, is ignored
+ test.exchange_packets(IFACE_BLE, &mut host)?
+ .assert_host_error(TransportError::UnallocatedChannel);
+ assert!(host.handshake_failed());
+ test.assert_silence(&host);
+ Ok(())
+}
+
+#[test]
+fn test_isolation_handshake_2() -> Result<(), Error> {
+ let mut test = setup(&[IFACE_BLE, IFACE_USB]);
+ // allocate channel on USB interface
+ let mut host = test.allocate_channel(IFACE_USB, NullCredentialStore)?;
+ // start handshake on USB
+ let res = test.exchange_packets(IFACE_USB, &mut host)?;
+ assert_matches!(res, Trezor(TrezorInResult::KeyRequired { .. }));
+ test.thp.handshake_static_key(IFACE_USB, &DUMMY_PRIVKEY)?;
+ let mut packet_buffer = Vec::from_iter(repeat_n(0, host.packet_len()));
+ while test.thp.packet_out(IFACE_USB, &mut packet_buffer)? {
+ log::debug!("[{:02x}] < {}", IFACE_USB, hex::encode(&packet_buffer));
+ host.packet_in(&packet_buffer).check_failed()?;
+ }
+ // host sends completion request over BLE, is ignored
+ test.exchange_packets(IFACE_BLE, &mut host)?
+ .assert_host_error(TransportError::UnallocatedChannel);
+ assert!(host.handshake_failed());
+ test.assert_silence(&host);
+ Ok(())
+}
+
+#[test]
+fn test_isolation_appdata_1() -> Result<(), Error> {
+ let mut test = setup(&[IFACE_USB, IFACE_BLE]);
+ // allocate channel on BLE interface
+ let host = test.allocate_channel(IFACE_BLE, NullCredentialStore)?;
+ let mut host = test.perform_handshake(IFACE_BLE, host)?;
+ // message on USB interface should be ignored
+ host.message_in(0, 1234, b"DummySkipPairing")?;
+ test.exchange_packets(IFACE_USB, &mut host)?
+ .assert_host_error(TransportError::UnallocatedChannel);
+ assert!(host.is_failed());
+ test.assert_silence(&host);
+ Ok(())
+}
+
+#[test]
+fn test_isolation_appdata_2() -> Result<(), Error> {
+ let mut test = setup(&[IFACE_USB, IFACE_BLE]);
+ // allocate channel on BLE interface
+ let host = test.allocate_channel(IFACE_BLE, NullCredentialStore)?;
+ let mut host = test.perform_handshake(IFACE_BLE, host)?;
+ // send first message over BLE
+ test.call(
+ IFACE_BLE,
+ &mut host,
+ &[(b"DummySkipPairing", b"DummyThpEndResponse")],
+ )?;
+ // pairing done
+ test.end_pairing(&mut host)?;
+ // sending second message over USB should fail
+ host.message_in(0, 6667, b"Ping")?;
+ test.exchange_packets(IFACE_USB, &mut host)?
+ .assert_host_error(TransportError::UnallocatedChannel);
+ assert!(host.is_failed());
+ test.assert_silence(&host);
+ Ok(())
+}
+
+/// Dummy store that returns the same credential and privkey made of repeating
+/// byte regardless of the inputs.
+struct SingleCredentialStore(u8);
+
+impl CredentialStore for SingleCredentialStore {
+ fn lookup<'a>(
+ &self,
+ _ephemeral: &[u8],
+ _masked_static: &[u8],
+ dest: &'a mut [u8],
+ ) -> Option<FoundCredential<'a>> {
+ const CRED: &[u8] = b"hello";
+ let (local_static_privkey, auth_credential) =
+ dest.split_first_chunk_mut::<PRIVKEY_LEN>().unwrap();
+ local_static_privkey.copy_from_slice(&[self.0; PRIVKEY_LEN]);
+ let auth_credential = &mut auth_credential[..CRED.len()];
+ auth_credential.copy_from_slice(CRED);
+ Some(FoundCredential {
+ local_static_privkey,
+ auth_credential,
+ })
+ }
+}
+
+// Had weird issues trying to convert Obj arguments to slices - just accept
+// everything. Possibly related to HEAP and https://doc.rust-lang.org/edition-guide/rust-2024/static-mut-references.html
+extern "C" fn py_accept_everything(_host_static_pubkey: Obj, _credential: Obj) -> Obj {
+ Obj::small_int(u16::from(u8::from(PairingState::Paired)))
+}
+static ACCEPT_EVERYTHING_FN: Func = obj_fn_2!(py_accept_everything);
+
+#[test]
+fn test_channel_replacement() -> Result<(), Error> {
+ let mut test = setup(&[IFACE_USB, IFACE_BLE]);
+ test.credential_fn = ACCEPT_EVERYTHING_FN.as_obj();
+
+ // open channel with privkey AA...
+ let host = test.allocate_channel(IFACE_USB, SingleCredentialStore(0xaa))?;
+ let mut host = test.perform_handshake(IFACE_USB, host)?;
+ test.assert_pairing_state(&host, PairingState::Paired);
+ let replaced = test.end_pairing(&mut host)?;
+ assert_eq!(replaced, None);
+ let channel_id_1 = host.channel_id();
+
+ // open channel with privkey BB... - no replacement
+ let host = test.allocate_channel(IFACE_USB, SingleCredentialStore(0xbb))?;
+ let mut host = test.perform_handshake(IFACE_USB, host)?;
+ test.assert_pairing_state(&host, PairingState::Paired);
+ let replaced = test.end_pairing(&mut host)?;
+ assert_eq!(replaced, None);
+
+ // open channel with privkey AA... - replaces first channel
+ let host = test.allocate_channel(IFACE_USB, SingleCredentialStore(0xaa))?;
+ let mut host = test.perform_handshake(IFACE_USB, host)?;
+ test.assert_pairing_state(&host, PairingState::PairedAutoconnect);
+ let replaced = test.end_pairing(&mut host)?;
+ assert_eq!(replaced, Some(channel_id_1));
+ assert_eq!(test.thp.channel_was_closed(), true);
+
+ // open channel with privkey BB... on the other interface - no replacement
+ let host = test.allocate_channel(IFACE_BLE, SingleCredentialStore(0xbb))?;
+ let mut host = test.perform_handshake(IFACE_BLE, host)?;
+ test.assert_pairing_state(&host, PairingState::Paired);
+ let replaced = test.end_pairing(&mut host)?;
+ assert_eq!(replaced, None);
+
+ Ok(())
+}
diff --git a/core/embed/rust/src/thp/time.rs b/core/embed/rust/src/thp/time.rs
new file mode 100644
index 00000000..0074a954
--- /dev/null
+++ b/core/embed/rust/src/thp/time.rs
@@ -0,0 +1,188 @@
+use crate::time::{Duration, Instant};
+
+use trezor_thp::channel::retransmit_after_ms;
+
+const MAX_LATENCY_MS: Duration = Duration::from_millis(800);
+
+/// Timing data for THP channel.
+#[cfg_attr(test, derive(Debug))]
+pub struct ChannelTiming {
+ /// Timestamp of last sent message (only first attempt), used for:
+ /// - computing when to retransmit,
+ /// - updating `ack_latency`,
+ /// - deciding whether channel is stale and should be preempted.
+ last_write: Instant,
+ /// Duration between last message sent and ACK received.
+ ack_latency: Duration,
+ /// Logical monotonic timestamp, greater means more recent. Used to
+ /// determine which channel to evict when new one is opened and the array is
+ /// full.
+ last_usage: u32,
+}
+
+impl ChannelTiming {
+ pub fn new(now: Instant) -> Self {
+ Self {
+ last_write: now,
+ ack_latency: Duration::ZERO,
+ last_usage: 0,
+ }
+ }
+
+ /// Update last write timestamp. Called before first attempt of each
+ /// outgoing message.
+ pub fn update_last_write(&mut self, now: Instant) {
+ self.last_write = now;
+ }
+
+ /// Update `ack_latency` when valid ACK is received.
+ pub fn read_ack(&mut self, now: Instant) {
+ let new_ack_latency = now.saturating_duration_since(self.last_write);
+ self.ack_latency = new_ack_latency.min(MAX_LATENCY_MS);
+ }
+
+ /// How long to wait for n-th retry after outgoing message is submitted.
+ fn timeout_ms(&self, attempt: u8) -> Duration {
+ // Total duration since writing a message is the sum of the durations between
+ // retries. Each duration between retries is variable delay plus ACK latency.
+ (0..=attempt)
+ .map(retransmit_after_ms)
+ .map(Duration::from_millis)
+ .fold(Duration::ZERO, |acc, variable_delay| {
+ acc.saturating_add(variable_delay)
+ .saturating_add(self.ack_latency)
+ })
+ }
+
+ /// Returns time remaining before n-th retransmission should be requested.
+ pub fn timeout_from_now(&self, now: Instant, attempt: u8) -> Duration {
+ let Some(since_last_write) = self.last_write_age(now) else {
+ return Duration::ZERO; // timeout now if wrapped around
+ };
+ match self.timeout_ms(attempt).checked_sub(since_last_write) {
+ None => Duration::ZERO, // we're past the timeout
+ Some(duration) => duration,
+ }
+ }
+
+ /// Returns time since last write, or None on overflow.
+ pub fn last_write_age(&self, now: Instant) -> Option<Duration> {
+ now.checked_duration_since(self.last_write)
+ }
+
+ /// Returns last usage value.
+ pub fn last_usage(&self) -> u32 {
+ self.last_usage
+ }
+
+ /// Update last usage timestamp to current value of a monotonic counter.
+ pub fn update_last_usage(&mut self, counter: u32) {
+ self.last_usage = counter;
+ }
+}
+
+/// Returns ID of the least recently used channel in the `channels` iterator.
+pub fn least_recently_used(
+ channels: &mut dyn Iterator<Item = (u16, &ChannelTiming)>,
+) -> Option<u16> {
+ let mut oldest = None;
+ for (idx, timing) in channels {
+ let last_used = timing.last_usage();
+ match oldest {
+ None => {
+ oldest = Some((idx, last_used));
+ }
+ Some((_oldest_idx, oldest_val)) if last_used < oldest_val => {
+ oldest = Some((idx, last_used));
+ }
+ _ => {}
+ }
+ }
+ oldest.map(|(idx, _)| idx)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use heapless::Vec;
+ use trezor_thp::channel::MAX_RETRANSMISSION_COUNT;
+
+ fn make_map(channels: &[(u16, u32)]) -> Vec<(u16, ChannelTiming), 16> {
+ let mut chans = Vec::new();
+ for (ch, last_used) in channels {
+ let mut t = ChannelTiming::new(Instant::now());
+ t.update_last_usage(*last_used);
+ chans.push((*ch, t)).unwrap();
+ }
+ chans
+ }
+
+ fn it(chans: &[(u16, ChannelTiming)]) -> impl Iterator<Item = (u16, &ChannelTiming)> {
+ chans.iter().map(|(cid, timing)| (*cid, timing))
+ }
+
+ #[test]
+ fn test_lru_no_channels() {
+ assert_eq!(least_recently_used(&mut it(&[])), None);
+ }
+
+ #[test]
+ fn test_lru_simple() {
+ let chans = make_map(&[(7, 7)]);
+ assert_eq!(least_recently_used(&mut it(&chans)), Some(7));
+
+ let chans = make_map(&[(1, 2), (3, 4), (5, 1)]);
+ assert_eq!(least_recently_used(&mut it(&chans)), Some(5));
+
+ let chans = make_map(&[(5, 100), (3, 400), (1, 200), (6, 50), (8, 600)]);
+ assert_eq!(least_recently_used(&mut it(&chans)), Some(6));
+ }
+
+ #[test]
+ fn test_timeout_ms_monotonic() {
+ let now = Instant::now();
+ let high_latency = MAX_LATENCY_MS.checked_add(MAX_LATENCY_MS).unwrap();
+ let mut timing = ChannelTiming::new(now);
+ assert_eq!(timing.ack_latency, Duration::ZERO);
+
+ for i in 0..(2 * MAX_RETRANSMISSION_COUNT) {
+ assert!(timing.timeout_ms(i) < timing.timeout_ms(i + 1));
+ }
+
+ timing.update_last_write(now);
+ timing.read_ack(now.checked_add(high_latency).unwrap());
+ assert_eq!(timing.ack_latency, MAX_LATENCY_MS);
+
+ for i in 0..(2 * MAX_RETRANSMISSION_COUNT) {
+ assert!(timing.timeout_ms(i) < timing.timeout_ms(i + 1));
+ }
+ }
+
+ #[test]
+ fn test_timeout_ms_limits() {
+ let now = Instant::now();
+ let high_latency = MAX_LATENCY_MS.checked_add(MAX_LATENCY_MS).unwrap();
+ let mut timing = ChannelTiming::new(now);
+ assert_eq!(timing.ack_latency, Duration::ZERO);
+
+ // at least 200ms before first retry
+ assert_eq!(timing.timeout_ms(0).to_millis(), 200);
+ assert_eq!(timing.timeout_ms(1).to_millis(), 500);
+ assert_eq!(
+ timing.timeout_ms(MAX_RETRANSMISSION_COUNT - 1).to_secs(),
+ 103
+ );
+
+ timing.update_last_write(now);
+ timing.read_ack(now.checked_add(high_latency).unwrap());
+ assert_eq!(timing.ack_latency, MAX_LATENCY_MS);
+
+ assert_eq!(timing.timeout_ms(0).to_millis(), 1000);
+ assert_eq!(timing.timeout_ms(1).to_millis(), 2100);
+ assert_eq!(
+ timing.timeout_ms(MAX_RETRANSMISSION_COUNT - 1).to_secs(),
+ 143
+ );
+ // at most 2m23s until reaching the limit
+ }
+}
diff --git a/core/embed/rust/src/time.rs b/core/embed/rust/src/time.rs
index 27b24075..2a4a173d 100644
--- a/core/embed/rust/src/time.rs
+++ b/core/embed/rust/src/time.rs
@@ -83,6 +83,10 @@ impl Duration {
self.millis.checked_sub(rhs.millis).map(Self::from_millis)
}
+ pub fn saturating_add(self, rhs: Self) -> Self {
+ Self::from_millis(self.millis.saturating_add(rhs.millis))
+ }
+
/// Returns a new Duration containing only the largest complete time unit
/// (days, hours, minutes, or seconds)
///
diff --git a/core/embed/upymod/rustmods.c b/core/embed/upymod/rustmods.c
index 20bb769e..c5511039 100644
--- a/core/embed/upymod/rustmods.c
+++ b/core/embed/upymod/rustmods.c
@@ -36,6 +36,10 @@ MP_REGISTER_MODULE(MP_QSTR_trezortranslate, mp_module_trezortranslate);
MP_REGISTER_MODULE(MP_QSTR_trezorble, mp_module_trezorble);
#endif
+#if 0
+MP_REGISTER_MODULE(MP_QSTR_trezorthp, mp_module_trezorthp);
+#endif
+
#if defined(TREZOR_EMULATOR) && PYOPT == 0
MP_REGISTER_MODULE(MP_QSTR_coveragedata, mp_module_coveragedata);
#endif
diff --git a/core/mocks/generated/trezorthp.pyi b/core/mocks/generated/trezorthp.pyi
new file mode 100644
index 00000000..4fd4cde9
--- /dev/null
+++ b/core/mocks/generated/trezorthp.pyi
@@ -0,0 +1,201 @@
+from typing import *
+from buffer_types import *
+ThpError: type[Exception]
+MESSAGE_READY: object
+MESSAGE_READY_ACK: object
+ACK: object
+KEY_REQUIRED: object
+KEY_REQUIRED_UNLOCK: object
+FAILED: object
+MAX_CREDENTIAL_LEN: int
+MAX_DEVICE_PROPERTIES_LEN: int
+APP_HEADER_LEN: int
+SEND_BUFFER_OVERHEAD: int
+
+
+# rust/src/thp/micropython.rs
+def init(iface_num: int, device_properties: AnyBytes) -> None:
+ """
+ Initialize Trezor Host Protocol communication stack on a single interface.
+ - `iface_num` is an arbitrary numeric identifier between 0 and 255.
+ - `device_properties` is a serialized `ThpDeviceProperties` protobuf message.
+ It is safe to call this function multiple times on the same interface.
+ """
+
+
+# rust/src/thp/micropython.rs
+def packet_in(iface_num: int, packet_buffer: AnyBytes, credential_verify_fn: Callable[[bytes, bytes], int]) -> object | int | None:
+ """
+ Handle received packet.
+ - `credential_verify_fn` is a function that will be called to verify host credentials.
+ Returns:
+ - `None`: If no action is required from caller.
+ - `KEY_REQUIRED`, `KEY_REQUIRED_UNLOCK`: If a channel handshake requires device static key.
+ The event loop should call the `handshake_key()` function for this interface.
+ - An integer: Lower 16 bits contain channel id, upper 16 bits contain buffer size hint in 8-byte blocks.
+ The event loop should call the `packet_in_channel()` function for this interface and if
+ the size hint is non-zero, then the receive buffer needs to be at least as large.
+ If such buffer cannot be obtained, `channel_close()` should be called.
+ If buffer is in use by another channel, `send_transport_busy()` should be called.
+ """
+
+
+# rust/src/thp/micropython.rs
+def packet_in_channel(channel_id: int, packet_buffer: AnyBytes, receive_buffer: AnyBuffer) -> object | None:
+ """
+ Handle received packet that `packet_in` routed to given `channel_id`.
+ Returns:
+ - `None`: If no action is required from caller, e.g. continuation packet was received.
+ - `MESSAGE_READY`, `MESSAGE_READY_ACK`: If a message with valid checksum was received.
+ The event loop should call `message_out()` to obtain the message.
+ - `ACK`, `MESSAGE_READY_ACK`: If the last sent message was acknowledged received by peer,
+ it is now possible to send another using `message_in()`.
+ """
+
+
+# rust/src/thp/micropython.rs
+def message_out(channel_id: int, receive_buffer: memoryview) -> tuple[int, int, memoryview]:
+ """
+ Decrypt an incoming message if one is ready for the given `channel_id`. Returns the triple
+ `(session_id, message_type, plaintext)` - message is decrypted in-place in receive buffer
+ and plaintext is a memoryview backed by that buffer.
+ After successfully calling this function an ACK will be sent by the next `packet_out` on
+ this channel.
+ Raises an exception if decryption failed - next call to `packet_out` will send an error
+ to the peer and close the channel.
+ """
+
+
+# rust/src/thp/micropython.rs
+def packet_out(iface_num: int, packet_buffer: AnyBuffer) -> bool:
+ """
+ Writes outgoing packet to `packet_buffer`. This function is used for the broadcast
+ channel or channels in opening/handshake phase that are associated with `iface_num`.
+ Returns false if there's no packet ready to be sent.
+ """
+
+
+# rust/src/thp/micropython.rs
+def packet_out_channel(channel_id: int, send_buffer: AnyBytes, packet_buffer: AnyBuffer) -> bool:
+ """
+ Writes outgoing packet to `packet_buffer` from channel in pairing or application data phase
+ identified by `channel_id`. Returns false if there's no packet ready to be sent.
+ """
+
+
+# rust/src/thp/micropython.rs
+def message_in(channel_id: int, plaintext_len: int, send_buffer: AnyBuffer) -> None:
+ """
+ Encrypts and starts transmission of given message on a channel. Send buffer must contain
+ serialized message:
+ * session id: 1 byte
+ * message type: 2 bytes
+ * message: (plaintext_len - 3) bytes
+ Send buffer must be at least `plaintext_len + 16` long in order to accommodate AEAD tag.
+ """
+
+
+# rust/src/thp/micropython.rs
+def message_retransmit(channel_id: int) -> bool:
+ """
+ Starts message retransmission.
+ Returns False if this was the last attempt and the channel has been closed.
+ """
+
+
+# rust/src/thp/micropython.rs
+def send_transport_busy(channel_id: int) -> None:
+ """
+ Sends `TRANSPORT_BUSY` transport error on a given channel.
+ """
+
+
+# rust/src/thp/micropython.rs
+class ThpChannelInfo:
+ """THP channel metadata."""
+ last_write: int | None
+ pairing_state: int | None
+ handshake_hash: bytes | None
+ host_static_public_key: bytes
+ credential: bytes | None
+
+
+# rust/src/thp/micropython.rs
+def channel_info(channel_id: int) -> ThpChannelInfo:
+ """
+ Returns information for given channel:
+ * last write timestamp
+ * pairing state for channels in the pairing phase, or None if already in encrypted transport phase
+ * handshake hash
+ * host static public key
+ * encoded credential provided during handshake - it is discarded at the end of pairing/credential phase
+ """
+
+
+# rust/src/thp/micropython.rs
+def channel_paired(channel_id: int) -> int | None:
+ """
+ Mark channel as paired, i.e. transitioned to encrypted transport of application data.
+ If established channel with the same host public key exists on the same interface,
+ it is closed and its channel id is returned.
+ """
+
+
+# rust/src/thp/micropython.rs
+def channel_close(channel_id: int) -> None:
+ """
+ Closes a channel identified by its `channel_id`. It is safe to close
+ an already closed channel - the function won't raise an exception.
+ """
+
+
+# rust/src/thp/micropython.rs
+def channel_close_all(*, exclude_channel_id: int | None = None) -> None:
+ """
+ Closes all channels on all interfaces. If `exclude_channel_id` is not None, it
+ will be left as the only channel.
+ Please note the closed channels are not returned by `channel_was_closed()`.
+ Caller is responsible for deleting all relevant sessions manually.
+ """
+
+
+# rust/src/thp/micropython.rs
+def channel_update_last_usage(channel_id: int):
+ """
+ Update last usage timestamp of a channel. These are used when channel limit is reached
+ and the oldest one has to be closed.
+ TODO do not expose to python and do the update in message_out instead
+ """
+
+
+# rust/src/thp/micropython.rs
+def channel_was_closed() -> bool:
+ """
+ Returns true if any channel in encrypted transport state was closed since calling
+ this function last time. Sessions belonging to these channels should be discarded.
+ """
+
+
+# rust/src/thp/micropython.rs
+def channel_is_open(channel_id: int) -> bool:
+ """
+ Returns true if a channel with the given id exists in the encrypted transport state.
+ """
+
+
+# rust/src/thp/micropython.rs
+def next_timeout(iface_num: int) -> tuple[int, int] | None:
+ """
+ Returns `(channel_id, timeout_ms)` of the earliest channel to time out waiting for ACK.
+ Event loop needs to call `message_retransmit(channel_id)` after `timeout_ms`.
+ Returns None if there is no channel that's waiting for an ACK.
+ """
+
+
+# rust/src/thp/micropython.rs
+def handshake_key(iface_num: int, trezor_static_private_key: AnyBytes | None) -> None:
+ """
+ Provide device static key in order to progress a handshake after `packet_in`
+ returned `KEY_REQUIRED`. If the second argument is None, handshake is aborted
+ and `DEVICE_LOCKED` sent to the peer.
+ """
Why this scored 39/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.