chore(core/eckhart): implement BLE device unpairing
What changed, and why it matters
This commit adds the ability for a Trezor hardware wallet to forget (unpair) a specific Bluetooth device by its MAC address, instead of only being able to unpair the currently connected device or all devices. It is a feature-completion change for the new Eckhart model's Bluetooth settings menu. There is no direct evidence in the commit that this fixes a security vulnerability; it appears to be a routine user-facing feature implementation.
No immediate security action required. As a defensive review note, verify that the 6-byte MAC conversion (`iter_into_array`) rejects inputs of other lengths safely and that the BLE stack's `ble_unpair` handles a null or unmatched pointer correctly. Review whether the new `addr` field can be sent from an untrusted host and whether that path requires additional authorization or UI confirmation.
Security signals we found
New wire message field `addr` added to `BleUnpair` (protobuf field 2, bytes)
Rust micropython binding now accepts a 6-byte bytes-like or None and converts via `iter_into_array`
UI flow now uses `show_warning`/`show_success` instead of `confirm_action`
No visible bounds/length check on `addr` other than the array conversion itself
Bond lookup is linear and returns on first MAC match; no match raises an exception
Evidence from the diff
The change extends the BleUnpair protobuf message with an optional addr bytes field (6-byte MAC), updates the Rust BLE HAL to accept an optional address, and wires the device-menu UI to call unpair with the selected bond’s address. It also replaces a generic confirm_action with show_warning/show_success layouts and adds corresponding translation strings. The Rust code scans bonded devices and calls the vendor BLE unpair function with the matching bond; if no match is found it raises ValueError. No input-length validation beyond the fixed 6-byte array conversion is visible in the diff.
Changed components
common/protob/messages-ble.protocore/embed/rust/src/trezorhal/ble/micropython.rscore/embed/rust/src/trezorhal/ble/mod.rscore/src/apps/homescreen/device_menu.pycore/src/apps/management/ble/unpair.pycore/src/trezor/messages.pypython/src/trezorlib/messages.pyrust/trezor-client/src/protos/generated/messages_ble.rsInspect captured patch +196 / −33
diff --git a/common/protob/messages-ble.proto b/common/protob/messages-ble.proto
index ea34aa1e..d4760e8a 100644
--- a/common/protob/messages-ble.proto
+++ b/common/protob/messages-ble.proto
@@ -11,12 +11,19 @@ option (include_in_bitcoin_only) = true;
/**
- * Request: erases bond for currently connected device
+ * Request: erases BLE bond(s).
+ *
+ * If `all` is set, all bonds are erased.
+ * If `addr` is provided, the bond for that specific device (6-byte MAC) is erased.
+ * Otherwise, the bond for the currently connected device is erased.
+ *
* @start
* @next Success
* @next Failure
*/
message BleUnpair {
- optional bool all = 1; // whether to erase bonds for all devices
+ optional bool all = 1; // whether to erase bonds for all devices
+ optional bytes addr = 2; // 6-byte MAC address to unpair
}
+
diff --git a/core/embed/rust/librust_qstr.h b/core/embed/rust/librust_qstr.h
index 82afd138..e388ecdc 100644
--- a/core/embed/rust/librust_qstr.h
+++ b/core/embed/rust/librust_qstr.h
@@ -177,6 +177,12 @@ static void _librust_qstrs(void) {
MP_QSTR_ble__disable;
MP_QSTR_ble__enable;
MP_QSTR_ble__forget_all;
+ MP_QSTR_ble__forget_all_description;
+ MP_QSTR_ble__forget_all_devices;
+ MP_QSTR_ble__forget_all_success;
+ MP_QSTR_ble__forget_this_description;
+ MP_QSTR_ble__forget_this_device;
+ MP_QSTR_ble__forget_this_success;
MP_QSTR_ble__limit_reached;
MP_QSTR_ble__manage_paired;
MP_QSTR_ble__pair_new;
diff --git a/core/embed/rust/src/translations/generated/translated_string.rs b/core/embed/rust/src/translations/generated/translated_string.rs
index 5bb44f1c..4e166ac8 100644
--- a/core/embed/rust/src/translations/generated/translated_string.rs
+++ b/core/embed/rust/src/translations/generated/translated_string.rs
@@ -1517,6 +1517,12 @@ pub enum TranslatedString {
words__forget = 1126, // "Forget"
words__power = 1127, // "Power"
ble__limit_reached = 1128, // "Limit of paired devices reached"
+ ble__forget_all_description = 1129, // "They'll be removed, and you'll need to pair them again before use."
+ ble__forget_all_devices = 1130, // "Forget all devices?"
+ ble__forget_all_success = 1131, // "All hosts removed."
+ ble__forget_this_description = 1132, // "It will be removed, and you'll need to pair it again before use."
+ ble__forget_this_device = 1133, // "Forget this device?"
+ ble__forget_this_success = 1134, // "Host removed."
}
impl TranslatedString {
@@ -3360,6 +3366,12 @@ impl TranslatedString {
(Self::words__forget, "Forget"),
(Self::words__power, "Power"),
(Self::ble__limit_reached, "Limit of paired devices reached"),
+ (Self::ble__forget_all_description, "They'll be removed, and you'll need to pair them again before use."),
+ (Self::ble__forget_all_devices, "Forget all devices?"),
+ (Self::ble__forget_all_success, "All hosts removed."),
+ (Self::ble__forget_this_description, "It will be removed, and you'll need to pair it again before use."),
+ (Self::ble__forget_this_device, "Forget this device?"),
+ (Self::ble__forget_this_success, "Host removed."),
];
#[cfg(feature = "micropython")]
@@ -3440,6 +3452,12 @@ impl TranslatedString {
(Qstr::MP_QSTR_ble__disable, Self::ble__disable),
(Qstr::MP_QSTR_ble__enable, Self::ble__enable),
(Qstr::MP_QSTR_ble__forget_all, Self::ble__forget_all),
+ (Qstr::MP_QSTR_ble__forget_all_description, Self::ble__forget_all_description),
+ (Qstr::MP_QSTR_ble__forget_all_devices, Self::ble__forget_all_devices),
+ (Qstr::MP_QSTR_ble__forget_all_success, Self::ble__forget_all_success),
+ (Qstr::MP_QSTR_ble__forget_this_description, Self::ble__forget_this_description),
+ (Qstr::MP_QSTR_ble__forget_this_device, Self::ble__forget_this_device),
+ (Qstr::MP_QSTR_ble__forget_this_success, Self::ble__forget_this_success),
(Qstr::MP_QSTR_ble__limit_reached, Self::ble__limit_reached),
(Qstr::MP_QSTR_ble__manage_paired, Self::ble__manage_paired),
(Qstr::MP_QSTR_ble__pair_new, Self::ble__pair_new),
diff --git a/core/embed/rust/src/trezorhal/ble/micropython.rs b/core/embed/rust/src/trezorhal/ble/micropython.rs
index 6e5c10dd..4e15f898 100644
--- a/core/embed/rust/src/trezorhal/ble/micropython.rs
+++ b/core/embed/rust/src/trezorhal/ble/micropython.rs
@@ -23,11 +23,34 @@ extern "C" fn py_erase_bonds() -> Obj {
unsafe { util::try_or_raise(block) }
}
-extern "C" fn py_unpair() -> Obj {
+extern "C" fn py_unpair(obj: Obj) -> Obj {
+ // Accepts: None OR a 6-byte MAC address (bytes-like)
+ let addr_bytes_opt = if obj == Obj::const_none() {
+ None
+ } else {
+ let bytes: [u8; 6] = unwrap!(util::iter_into_array(obj));
+ Some(bytes)
+ };
+
let block = || {
- unpair()?;
+ if let Some(bytes) = addr_bytes_opt {
+ // Scan bonds, unpair on match, and stop.
+ get_bonds(|bonds| -> Result<(), Error> {
+ for b in bonds {
+ if b.addr == bytes {
+ return unpair(Some(b));
+ }
+ }
+ Err(Error::ValueError(c"Address not found among bonds"))
+ })?;
+ } else {
+ // Unpair current connection
+ unpair(None)?;
+ }
+
Ok(Obj::const_none())
};
+
unsafe { util::try_or_raise(block) }
}
@@ -270,12 +293,12 @@ pub static mp_module_trezorble: Module = obj_module! {
/// """
Qstr::MP_QSTR_erase_bonds => obj_fn_0!(py_erase_bonds).as_obj(),
- /// def unpair():
+ /// def unpair(addr: bytes | None = None):
/// """
- /// Erases bond for current connection, if any.
+ /// Erases the bond for the given address or for current connection if addr is None.
/// Raises exception if BLE driver reports an error.
/// """
- Qstr::MP_QSTR_unpair => obj_fn_0!(py_unpair).as_obj(),
+ Qstr::MP_QSTR_unpair => obj_fn_1!(py_unpair).as_obj(),
/// def start_comm():
/// """
diff --git a/core/embed/rust/src/trezorhal/ble/mod.rs b/core/embed/rust/src/trezorhal/ble/mod.rs
index 6d289779..6fd39c47 100644
--- a/core/embed/rust/src/trezorhal/ble/mod.rs
+++ b/core/embed/rust/src/trezorhal/ble/mod.rs
@@ -54,6 +54,10 @@ impl bt_le_addr_t {
addr: [0; 6],
}
}
+
+ fn new(type_: u8, addr: [u8; 6]) -> bt_le_addr_t {
+ bt_le_addr_t { type_, addr }
+ }
}
fn state() -> ffi::ble_state_t {
@@ -145,13 +149,16 @@ pub fn erase_bonds() -> Result<(), Error> {
issue_command(ffi::ble_command_type_t_BLE_ERASE_BONDS, data_none())
}
-pub fn unpair() -> Result<(), Error> {
- unsafe {
- if !ffi::ble_unpair(ptr::null_mut()) {
- return Err(COMMAND_FAILED);
- }
- Ok(())
+pub fn unpair(addr: Option<&bt_le_addr_t>) -> Result<(), Error> {
+ let ptr: *const bt_le_addr_t = match addr {
+ Some(a) => a as *const bt_le_addr_t,
+ None => ptr::null(),
+ };
+
+ if !unsafe { ffi::ble_unpair(ptr) } {
+ return Err(COMMAND_FAILED);
}
+ Ok(())
}
pub fn disconnect() -> Result<(), Error> {
diff --git a/core/mocks/generated/trezorble.pyi b/core/mocks/generated/trezorble.pyi
index 74044cf3..48f96bb6 100644
--- a/core/mocks/generated/trezorble.pyi
+++ b/core/mocks/generated/trezorble.pyi
@@ -38,9 +38,9 @@ def erase_bonds():
# rust/src/trezorhal/ble/micropython.rs
-def unpair():
+def unpair(addr: bytes | None = None):
"""
- Erases bond for current connection, if any.
+ Erases the bond for the given address or for current connection if addr is None.
Raises exception if BLE driver reports an error.
"""
diff --git a/core/mocks/trezortranslate_keys.pyi b/core/mocks/trezortranslate_keys.pyi
index c83c0296..5cf5df20 100644
--- a/core/mocks/trezortranslate_keys.pyi
+++ b/core/mocks/trezortranslate_keys.pyi
@@ -78,6 +78,12 @@ class TR:
ble__disable: str = "Turn Bluetooth off?"
ble__enable: str = "Turn Bluetooth on?"
ble__forget_all: str = "Forget all"
+ ble__forget_all_description: str = "They'll be removed, and you'll need to pair them again before use."
+ ble__forget_all_devices: str = "Forget all devices?"
+ ble__forget_all_success: str = "All hosts removed."
+ ble__forget_this_description: str = "It will be removed, and you'll need to pair it again before use."
+ ble__forget_this_device: str = "Forget this device?"
+ ble__forget_this_success: str = "Host removed."
ble__limit_reached: str = "Limit of paired devices reached"
ble__manage_paired: str = "Manage paired devices"
ble__pair_new: str = "Pair new device"
diff --git a/core/src/apps/homescreen/device_menu.py b/core/src/apps/homescreen/device_menu.py
index 30176015..9428d5bd 100644
--- a/core/src/apps/homescreen/device_menu.py
+++ b/core/src/apps/homescreen/device_menu.py
@@ -128,18 +128,17 @@ async def handle_device_menu() -> None:
from apps.management.ble.unpair import unpair
await unpair(BleUnpair(all=True))
+
elif isinstance(menu_result, tuple):
- from trezor.ui.layouts import confirm_action
+ from trezor.messages import BleUnpair
+
+ from apps.management.ble.unpair import unpair
# It's a tuple with (result_type, index)
result_type, index = menu_result
- if result_type is DeviceMenuResult.DeviceUnpair:
- await confirm_action(
- "device_unpair",
- "device_unpair",
- f"unpair {index} device?",
- )
- # TODO implement device unpair handling
+ if result_type is DeviceMenuResult.DeviceUnpair and index < len(bonds):
+
+ await unpair(BleUnpair(addr=bonds[index]))
else:
raise RuntimeError(f"Unknown menu {result_type}, {index}")
# Bluetooth
diff --git a/core/src/apps/management/ble/unpair.py b/core/src/apps/management/ble/unpair.py
index 0fe9873f..3860f464 100644
--- a/core/src/apps/management/ble/unpair.py
+++ b/core/src/apps/management/ble/unpair.py
@@ -9,13 +9,23 @@ if TYPE_CHECKING:
async def unpair(msg: BleUnpair) -> None:
from trezor.messages import Success
- from trezor.ui.layouts import confirm_action
+ from trezor.ui.layouts import show_success, show_warning
from trezor.wire.context import NoWireContext, get_context
if msg.all:
- await confirm_action("erase bonds", TR.ble__unpair_title, TR.ble__unpair_all)
+ await show_warning(
+ "prompt_all_devices_unpair",
+ TR.ble__forget_all_devices,
+ TR.ble__forget_all_description,
+ TR.buttons__confirm,
+ )
else:
- await confirm_action("unpair", TR.ble__unpair_title, TR.ble__unpair_current)
+ await show_warning(
+ "prompt_device_unpair",
+ TR.ble__forget_this_device,
+ TR.ble__forget_this_description,
+ TR.buttons__confirm,
+ )
# NOTE: refactor into ctx.maybe_write if we end up doing this in multiple places
try:
@@ -27,5 +37,20 @@ async def unpair(msg: BleUnpair) -> None:
if msg.all:
ble.erase_bonds()
+ elif msg.addr is not None:
+ ble.unpair(msg.addr)
else:
ble.unpair()
+
+ if msg.all:
+ await show_success(
+ br_name="device_unpair_all_success",
+ content=TR.ble__forget_all_success,
+ button=TR.buttons__close,
+ )
+ else:
+ await show_success(
+ br_name="device_unpair_success",
+ content=TR.ble__forget_this_device,
+ button=TR.buttons__close,
+ )
diff --git a/core/src/trezor/messages.py b/core/src/trezor/messages.py
index efda21c4..7fcbb5f5 100644
--- a/core/src/trezor/messages.py
+++ b/core/src/trezor/messages.py
@@ -1072,11 +1072,13 @@ if TYPE_CHECKING:
class BleUnpair(protobuf.MessageType):
all: "bool | None"
+ addr: "bytes | None"
def __init__(
self,
*,
all: "bool | None" = None,
+ addr: "bytes | None" = None,
) -> None:
pass
diff --git a/core/translations/en.json b/core/translations/en.json
index 340dd6f1..fdb8492a 100644
--- a/core/translations/en.json
+++ b/core/translations/en.json
@@ -110,6 +110,12 @@
"ble__disable": "Turn Bluetooth off?",
"ble__enable": "Turn Bluetooth on?",
"ble__forget_all": "Forget all",
+ "ble__forget_all_description": "They'll be removed, and you'll need to pair them again before use.",
+ "ble__forget_all_devices": "Forget all devices?",
+ "ble__forget_all_success": "All hosts removed.",
+ "ble__forget_this_description": "It will be removed, and you'll need to pair it again before use.",
+ "ble__forget_this_device": "Forget this device?",
+ "ble__forget_this_success": "Host removed.",
"ble__limit_reached": "Limit of paired devices reached",
"ble__manage_paired": "Manage paired devices",
"ble__pair_new": "Pair new device",
diff --git a/core/translations/order.json b/core/translations/order.json
index 6dd23cb9..c0392571 100644
--- a/core/translations/order.json
+++ b/core/translations/order.json
@@ -1127,5 +1127,11 @@
"1125": "words__connect",
"1126": "words__forget",
"1127": "words__power",
- "1128": "ble__limit_reached"
+ "1128": "ble__limit_reached",
+ "1129": "ble__forget_all_description",
+ "1130": "ble__forget_all_devices",
+ "1131": "ble__forget_all_success",
+ "1132": "ble__forget_this_description",
+ "1133": "ble__forget_this_device",
+ "1134": "ble__forget_this_success"
}
diff --git a/core/translations/signatures.json b/core/translations/signatures.json
index 9e0e2f14..0867bf08 100644
--- a/core/translations/signatures.json
+++ b/core/translations/signatures.json
@@ -1,8 +1,8 @@
{
"current": {
- "merkle_root": "06c9698d72cb5ebd00fdfcd6526ebc952ad6066b04cd809856802180bf4fa2c1",
- "datetime": "2025-09-02T12:43:55.064463+00:00",
- "commit": "2b89842ade26577663651df3573467ca2ccfd202"
+ "merkle_root": "554ac946b02ef03dbabd00d8ac3d5ccf8560a64e7780fc3cca358b689aff1064",
+ "datetime": "2025-09-02T12:44:24.024539+00:00",
+ "commit": "9b054c1d05cc5f6854f4c0b437cab90705034ba7"
},
"history": [
{
diff --git a/python/src/trezorlib/messages.py b/python/src/trezorlib/messages.py
index a5953d0b..68741ebf 100644
--- a/python/src/trezorlib/messages.py
+++ b/python/src/trezorlib/messages.py
@@ -2035,14 +2035,17 @@ class BleUnpair(protobuf.MessageType):
MESSAGE_WIRE_TYPE = 8001
FIELDS = {
1: protobuf.Field("all", "bool", repeated=False, required=False, default=None),
+ 2: protobuf.Field("addr", "bytes", repeated=False, required=False, default=None),
}
def __init__(
self,
*,
all: Optional["bool"] = None,
+ addr: Optional["bytes"] = None,
) -> None:
self.all = all
+ self.addr = addr
class FirmwareErase(protobuf.MessageType):
diff --git a/rust/trezor-client/src/protos/generated/messages_ble.rs b/rust/trezor-client/src/protos/generated/messages_ble.rs
index be03ae4a..adb3be78 100644
--- a/rust/trezor-client/src/protos/generated/messages_ble.rs
+++ b/rust/trezor-client/src/protos/generated/messages_ble.rs
@@ -30,6 +30,8 @@ pub struct BleUnpair {
// message fields
// @@protoc_insertion_point(field:hw.trezor.messages.ble.BleUnpair.all)
pub all: ::std::option::Option<bool>,
+ // @@protoc_insertion_point(field:hw.trezor.messages.ble.BleUnpair.addr)
+ pub addr: ::std::option::Option<::std::vec::Vec<u8>>,
// special fields
// @@protoc_insertion_point(special_field:hw.trezor.messages.ble.BleUnpair.special_fields)
pub special_fields: ::protobuf::SpecialFields,
@@ -65,14 +67,55 @@ impl BleUnpair {
self.all = ::std::option::Option::Some(v);
}
+ // optional bytes addr = 2;
+
+ pub fn addr(&self) -> &[u8] {
+ match self.addr.as_ref() {
+ Some(v) => v,
+ None => &[],
+ }
+ }
+
+ pub fn clear_addr(&mut self) {
+ self.addr = ::std::option::Option::None;
+ }
+
+ pub fn has_addr(&self) -> bool {
+ self.addr.is_some()
+ }
+
+ // Param is passed by value, moved
+ pub fn set_addr(&mut self, v: ::std::vec::Vec<u8>) {
+ self.addr = ::std::option::Option::Some(v);
+ }
+
+ // Mutable pointer to the field.
+ // If field is not initialized, it is initialized with default value first.
+ pub fn mut_addr(&mut self) -> &mut ::std::vec::Vec<u8> {
+ if self.addr.is_none() {
+ self.addr = ::std::option::Option::Some(::std::vec::Vec::new());
+ }
+ self.addr.as_mut().unwrap()
+ }
+
+ // Take field
+ pub fn take_addr(&mut self) -> ::std::vec::Vec<u8> {
+ self.addr.take().unwrap_or_else(|| ::std::vec::Vec::new())
+ }
+
fn generated_message_descriptor_data() -> ::protobuf::reflect::GeneratedMessageDescriptorData {
- let mut fields = ::std::vec::Vec::with_capacity(1);
+ let mut fields = ::std::vec::Vec::with_capacity(2);
let mut oneofs = ::std::vec::Vec::with_capacity(0);
fields.push(::protobuf::reflect::rt::v2::make_option_accessor::<_, _>(
"all",
|m: &BleUnpair| { &m.all },
|m: &mut BleUnpair| { &mut m.all },
));
+ fields.push(::protobuf::reflect::rt::v2::make_option_accessor::<_, _>(
+ "addr",
+ |m: &BleUnpair| { &m.addr },
+ |m: &mut BleUnpair| { &mut m.addr },
+ ));
::protobuf::reflect::GeneratedMessageDescriptorData::new_2::<BleUnpair>(
"BleUnpair",
fields,
@@ -94,6 +137,9 @@ impl ::protobuf::Message for BleUnpair {
8 => {
self.all = ::std::option::Option::Some(is.read_bool()?);
},
+ 18 => {
+ self.addr = ::std::option::Option::Some(is.read_bytes()?);
+ },
tag => {
::protobuf::rt::read_unknown_or_skip_group(tag, is, self.special_fields.mut_unknown_fields())?;
},
@@ -109,6 +155,9 @@ impl ::protobuf::Message for BleUnpair {
if let Some(v) = self.all {
my_size += 1 + 1;
}
+ if let Some(v) = self.addr.as_ref() {
+ my_size += ::protobuf::rt::bytes_size(2, &v);
+ }
my_size += ::protobuf::rt::unknown_fields_size(self.special_fields.unknown_fields());
self.special_fields.cached_size().set(my_size as u32);
my_size
@@ -118,6 +167,9 @@ impl ::protobuf::Message for BleUnpair {
if let Some(v) = self.all {
os.write_bool(1, v)?;
}
+ if let Some(v) = self.addr.as_ref() {
+ os.write_bytes(2, v)?;
+ }
os.write_unknown_fields(self.special_fields.unknown_fields())?;
::std::result::Result::Ok(())
}
@@ -136,12 +188,14 @@ impl ::protobuf::Message for BleUnpair {
fn clear(&mut self) {
self.all = ::std::option::Option::None;
+ self.addr = ::std::option::Option::None;
self.special_fields.clear();
}
fn default_instance() -> &'static BleUnpair {
static instance: BleUnpair = BleUnpair {
all: ::std::option::Option::None,
+ addr: ::std::option::Option::None,
special_fields: ::protobuf::SpecialFields::new(),
};
&instance
@@ -167,8 +221,9 @@ impl ::protobuf::reflect::ProtobufValue for BleUnpair {
static file_descriptor_proto_data: &'static [u8] = b"\
\n\x12messages-ble.proto\x12\x16hw.trezor.messages.ble\x1a\roptions.prot\
- o\"\x1d\n\tBleUnpair\x12\x10\n\x03all\x18\x01\x20\x01(\x08R\x03allB;\n#c\
- om.satoshilabs.trezor.lib.protobufB\x10TrezorMessageBle\x80\xa6\x1d\x01\
+ o\"1\n\tBleUnpair\x12\x10\n\x03all\x18\x01\x20\x01(\x08R\x03all\x12\x12\
+ \n\x04addr\x18\x02\x20\x01(\x0cR\x04addrB;\n#com.satoshilabs.trezor.lib.\
+ protobufB\x10TrezorMessageBle\x80\xa6\x1d\x01\
";
/// `FileDescriptorProto` object which was a source for this generated file
Why this scored 21/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.