feat(core/ble): enable unpairing specific device identified by MAC address
What changed, and why it matters
This commit adds the ability to unpair a specific Bluetooth device by its MAC address, instead of only being able to unpair whichever device is currently connected. It also exposes this function through the system call interface and a production-test command. There is no direct evidence in the commit that this fixes a security vulnerability; it appears to be a feature improvement for managing paired devices.
No immediate security action required. Treat as a normal feature/refactor commit. If auditing, verify that the new syscall verifier correctly handles the NULL-addr case (currently it calls probe_read_access on addr before the NULL check, which could be a minor robustness issue but is gated by apptask_access_violation).
Security signals we found
New syscall added with memory-access verifier (ble_unpair__verified)
Production-test CLI command added for unpairing by bond index
Nordic bond erasure now accepts an address from host MCU
Address type is ignored and both PUBLIC/RANDOM types are attempted during unpair
Evidence from the diff
The patch refactors BLE unpairing: removes the BLE_UNPAIR command enum value, adds a new ble_unpair(const bt_le_addr_t *addr) API that sends INTERNAL_CMD_UNPAIR with an optional address payload, and updates the Nordic firmware to erase bonds for a specific address when provided. It adds syscall plumbing (SYSCALL_BLE_UNPAIR, verified wrapper, stub) and a prodtest CLI command ‘ble-unpair
Changed components
core/embed/io/ble/stm32/ble.ccore/embed/io/ble/unix/ble.ccore/embed/io/ble/inc/io/ble.hcore/embed/sys/syscall/stm32/syscall_dispatch.ccore/embed/sys/syscall/stm32/syscall_stubs.ccore/embed/sys/syscall/stm32/syscall_verifiers.ccore/embed/sys/syscall/stm32/syscall_verifiers.hcore/embed/sys/syscall/inc/sys/syscall_numbers.hcore/embed/rust/src/trezorhal/ble/mod.rscore/embed/rust/build.rscore/embed/projects/prodtest/cmd/prodtest_ble.cnordic/trezor/trezor-ble/src/ble/ble_management.cnordic/trezor/trezor-ble/src/ble/bonds.cInspect captured patch +195 / −17
diff --git a/core/embed/io/ble/inc/io/ble.h b/core/embed/io/ble/inc/io/ble.h
index e096aea73..2a51d3cf5 100644
--- a/core/embed/io/ble/inc/io/ble.h
+++ b/core/embed/io/ble/inc/io/ble.h
@@ -41,9 +41,8 @@ typedef enum {
BLE_ERASE_BONDS = 4, // Erase all bonding information
BLE_ALLOW_PAIRING = 5, // Accept pairing request
BLE_REJECT_PAIRING = 6, // Reject pairing request
- BLE_UNPAIR = 7, // Erase bond for currently connected device
BLE_KEEP_CONNECTION =
- 8, // Keep connection to the connected device, but do not advertise
+ 7, // Keep connection to the connected device, but do not advertise
} ble_command_type_t;
typedef enum {
@@ -181,6 +180,9 @@ void ble_get_advertising_name(char *name, size_t max_len);
// Check if write is possible
bool ble_can_write(void);
+// Unpair device. Unpairs currently connected device if addr is NULL.
+bool ble_unpair(const bt_le_addr_t *addr);
+
// Writes data to a connected BLE device
//
// Sends data over an established BLE connection.
diff --git a/core/embed/io/ble/stm32/ble.c b/core/embed/io/ble/stm32/ble.c
index 4a5b3d6da..163620975 100644
--- a/core/embed/io/ble/stm32/ble.c
+++ b/core/embed/io/ble/stm32/ble.c
@@ -82,6 +82,9 @@ typedef struct {
bt_le_addr_t bonds[BLE_MAX_BONDS];
bool bonds_ready;
+ bool result;
+ bool result_ready;
+
systimer_t *timer;
uint16_t ping_cntr;
} ble_driver_t;
@@ -138,13 +141,6 @@ static bool ble_send_erase_bonds(ble_driver_t *drv) {
0;
}
-static bool ble_send_unpair(ble_driver_t *drv) {
- (void)drv;
- uint8_t cmd = INTERNAL_CMD_UNPAIR;
- return nrf_send_msg(NRF_SERVICE_BLE_MANAGER, &cmd, sizeof(cmd), NULL, NULL) >=
- 0;
-}
-
static bool ble_send_disconnect(ble_driver_t *drv) {
(void)drv;
uint8_t cmd = INTERNAL_CMD_DISCONNECT;
@@ -405,6 +401,8 @@ static void ble_process_rx_msg_bond_list(const uint8_t *data, uint32_t len) {
}
static void ble_process_rx_msg(const uint8_t *data, uint32_t len) {
+ ble_driver_t *drv = &g_ble_driver;
+
if (len < 1) {
return;
}
@@ -428,6 +426,14 @@ static void ble_process_rx_msg(const uint8_t *data, uint32_t len) {
case INTERNAL_EVENT_BOND_LIST:
ble_process_rx_msg_bond_list(data, len);
break;
+ case INTERNAL_EVENT_SUCCESS:
+ drv->result_ready = true;
+ drv->result = true;
+ break;
+ case INTERNAL_EVENT_FAILURE:
+ drv->result_ready = true;
+ drv->result = false;
+ break;
default:
break;
}
@@ -867,9 +873,6 @@ bool ble_issue_command(ble_command_t *command) {
case BLE_REJECT_PAIRING:
result = ble_send_pairing_reject(drv);
break;
- case BLE_UNPAIR:
- result = ble_send_unpair(drv);
- break;
case BLE_KEEP_CONNECTION:
drv->restart_adv_on_disconnect = false;
if (drv->connected) {
@@ -1076,6 +1079,50 @@ void ble_get_advertising_name(char *name, size_t max_len) {
memcpy(name, drv->adv_cmd.name, sizeof(drv->adv_cmd.name));
}
+bool ble_unpair(const bt_le_addr_t *addr) {
+ ble_driver_t *drv = &g_ble_driver;
+
+ if (!drv->initialized) {
+ return false;
+ }
+
+ irq_key_t key = irq_lock();
+ drv->result_ready = false;
+ irq_unlock(key);
+
+ bool result;
+ if (addr == NULL) {
+ uint8_t cmd = INTERNAL_CMD_UNPAIR;
+ result = nrf_send_msg(NRF_SERVICE_BLE_MANAGER, &cmd, sizeof(cmd), NULL,
+ NULL) >= 0;
+ } else {
+ uint8_t data[1 + sizeof(bt_le_addr_t)] = {0};
+ data[0] = INTERNAL_CMD_UNPAIR;
+ memcpy(&data[1], addr, sizeof(bt_le_addr_t));
+ result = nrf_send_msg(NRF_SERVICE_BLE_MANAGER, data, sizeof(data), NULL,
+ NULL) >= 0;
+ }
+
+ if (!result) {
+ return false;
+ }
+
+ result = false;
+
+ uint32_t timeout = ticks_timeout(100);
+ while (!ticks_expired(timeout)) {
+ key = irq_lock();
+ if (drv->result_ready) {
+ result = drv->result;
+ irq_unlock(key);
+ return result;
+ }
+ irq_unlock(key);
+ }
+
+ return result;
+}
+
static void on_ble_iface_event_poll(void *context, bool read_awaited,
bool write_awaited) {
UNUSED(context);
diff --git a/core/embed/io/ble/unix/ble.c b/core/embed/io/ble/unix/ble.c
index b865ebb73..298fbb53e 100644
--- a/core/embed/io/ble/unix/ble.c
+++ b/core/embed/io/ble/unix/ble.c
@@ -35,4 +35,6 @@ void ble_get_advertising_name(char *name, size_t max_len) {
memset(name, 0, max_len);
}
+bool ble_unpair(const bt_le_addr_t *addr) { return false; }
+
uint8_t ble_get_bond_list(bt_le_addr_t *bonds, size_t count) { return 0; }
diff --git a/core/embed/projects/prodtest/README.md b/core/embed/projects/prodtest/README.md
index 665abcc31..6ae0acc20 100644
--- a/core/embed/projects/prodtest/README.md
+++ b/core/embed/projects/prodtest/README.md
@@ -212,6 +212,19 @@ ble-get-bonds
OK
```
+### ble-unpair
+Unpairs a BLE device. It accepts one parameter, which is index returned by the `ble-get-bonds` command.
+
+`ble-unpair <index>`
+
+Example:
+```
+ble-unpair 1
+# Initializing the BLE...
+# Unpaired.
+OK
+```
+
### ble-radio-test
Runs radio test proxy-client. It requires special nRF radio test firmware, see https://docs.nordicsemi.com/bundle/sdk_nrf5_v17.0.2/page/nrf_radio_test_example.html for usage.
diff --git a/core/embed/projects/prodtest/cmd/prodtest_ble.c b/core/embed/projects/prodtest/cmd/prodtest_ble.c
index acea924e4..976c23476 100644
--- a/core/embed/projects/prodtest/cmd/prodtest_ble.c
+++ b/core/embed/projects/prodtest/cmd/prodtest_ble.c
@@ -255,6 +255,41 @@ static void prodtest_ble_get_bonds(cli_t* cli) {
cli_ok(cli, "");
}
+static void prodtest_ble_unpair(cli_t* cli) {
+ if (cli_arg_count(cli) > 1) {
+ cli_error_arg_count(cli);
+ return;
+ }
+
+ uint32_t index;
+
+ if (!cli_arg_uint32(cli, "index", &index)) {
+ cli_error(cli, CLI_ERROR, "Invalid index.");
+ return;
+ }
+
+ if (!ensure_ble_init(cli)) {
+ return;
+ }
+
+ bt_le_addr_t bonds[BLE_MAX_BONDS];
+ uint8_t cnt = ble_get_bond_list(bonds, BLE_MAX_BONDS);
+
+ if (index > cnt || index < 1) {
+ cli_error(cli, CLI_ERROR, "Invalid index.");
+ return;
+ }
+
+ bt_le_addr_t* addr = &bonds[index - 1];
+ if (!ble_unpair(addr)) {
+ cli_error(cli, CLI_ERROR, "Could not unpair.");
+ return;
+ }
+
+ cli_trace(cli, "Unpaired.");
+ cli_ok(cli, "");
+}
+
static void prodtest_ble_radio_test_cmd(cli_t* cli) {
if (cli_arg_count(cli) > 0) {
cli_error_arg_count(cli);
@@ -417,6 +452,13 @@ PRODTEST_CLI_CMD(
.args = ""
);
+PRODTEST_CLI_CMD(
+ .name = "ble-unpair",
+ .func = prodtest_ble_unpair,
+ .info = "Unpair device on given index. Use ble-get-bonds to get the index",
+ .args = "<index>"
+);
+
PRODTEST_CLI_CMD(
.name = "ble-radio-test",
diff --git a/core/embed/rust/build.rs b/core/embed/rust/build.rs
index 39d9b08d1..a66e44ac9 100644
--- a/core/embed/rust/build.rs
+++ b/core/embed/rust/build.rs
@@ -421,6 +421,7 @@ fn generate_trezorhal_bindings() {
.allowlist_function("ble_write")
.allowlist_function("ble_read")
.allowlist_function("ble_set_name")
+ .allowlist_function("ble_unpair")
.allowlist_type("ble_command_t")
.allowlist_type("ble_state_t")
.allowlist_type("ble_event_t")
diff --git a/core/embed/rust/src/trezorhal/ble/mod.rs b/core/embed/rust/src/trezorhal/ble/mod.rs
index da13bce79..57f333f7c 100644
--- a/core/embed/rust/src/trezorhal/ble/mod.rs
+++ b/core/embed/rust/src/trezorhal/ble/mod.rs
@@ -4,10 +4,9 @@ mod micropython;
#[cfg(feature = "ui")]
use crate::ui::event::BLEEvent;
-use crate::error::Error;
-use core::mem::size_of;
-
use super::ffi;
+use crate::error::Error;
+use core::{mem::size_of, ptr};
pub const ADV_NAME_LEN: usize = ffi::BLE_ADV_NAME_LEN as usize;
pub const PAIRING_CODE_LEN: usize = ffi::BLE_PAIRING_CODE_LEN as usize;
@@ -136,7 +135,12 @@ pub fn erase_bonds() -> Result<(), Error> {
}
pub fn unpair() -> Result<(), Error> {
- issue_command(ffi::ble_command_type_t_BLE_UNPAIR, data_none())
+ unsafe {
+ if !ffi::ble_unpair(ptr::null_mut()) {
+ return Err(COMMAND_FAILED);
+ }
+ Ok(())
+ }
}
pub fn disconnect() -> Result<(), Error> {
diff --git a/core/embed/sys/syscall/inc/sys/syscall_numbers.h b/core/embed/sys/syscall/inc/sys/syscall_numbers.h
index 61fa289fb..41c1a07e0 100644
--- a/core/embed/sys/syscall/inc/sys/syscall_numbers.h
+++ b/core/embed/sys/syscall/inc/sys/syscall_numbers.h
@@ -142,6 +142,7 @@ typedef enum {
SYSCALL_BLE_CAN_READ,
SYSCALL_BLE_READ,
SYSCALL_BLE_SET_NAME,
+ SYSCALL_BLE_UNPAIR,
SYSCALL_NRF_UPDATE_REQUIRED,
SYSCALL_NRF_UPDATE,
diff --git a/core/embed/sys/syscall/stm32/syscall_dispatch.c b/core/embed/sys/syscall/stm32/syscall_dispatch.c
index 269787dc8..186005ee5 100644
--- a/core/embed/sys/syscall/stm32/syscall_dispatch.c
+++ b/core/embed/sys/syscall/stm32/syscall_dispatch.c
@@ -733,6 +733,11 @@ __attribute((no_stack_protector)) void syscall_handler(uint32_t *args,
size_t len = args[1];
ble_set_name__verified(name, len);
} break;
+
+ case SYSCALL_BLE_UNPAIR: {
+ const bt_le_addr_t *addr = (const bt_le_addr_t *)args[0];
+ args[0] = ble_unpair__verified(addr);
+ } break;
#endif
#ifdef USE_NRF
diff --git a/core/embed/sys/syscall/stm32/syscall_stubs.c b/core/embed/sys/syscall/stm32/syscall_stubs.c
index 5802ca0b1..5ec82dffc 100644
--- a/core/embed/sys/syscall/stm32/syscall_stubs.c
+++ b/core/embed/sys/syscall/stm32/syscall_stubs.c
@@ -696,6 +696,10 @@ void ble_set_name(const uint8_t *name, size_t len) {
syscall_invoke2((uint32_t)name, len, SYSCALL_BLE_SET_NAME);
}
+bool ble_unpair(const bt_le_addr_t *addr) {
+ return (bool)syscall_invoke1((uint32_t)addr, SYSCALL_BLE_UNPAIR);
+}
+
#endif
#ifdef USE_NRF
diff --git a/core/embed/sys/syscall/stm32/syscall_verifiers.c b/core/embed/sys/syscall/stm32/syscall_verifiers.c
index b2802dd3e..660176fdb 100644
--- a/core/embed/sys/syscall/stm32/syscall_verifiers.c
+++ b/core/embed/sys/syscall/stm32/syscall_verifiers.c
@@ -855,6 +855,19 @@ access_violation:
apptask_access_violation();
}
+bool ble_unpair__verified(const bt_le_addr_t *addr) {
+ if (!probe_read_access(addr, sizeof(*addr))) {
+ goto access_violation;
+ }
+
+ return ble_unpair(addr);
+
+access_violation:
+ apptask_access_violation();
+
+ return false;
+}
+
#endif
// ---------------------------------------------------------------------
diff --git a/core/embed/sys/syscall/stm32/syscall_verifiers.h b/core/embed/sys/syscall/stm32/syscall_verifiers.h
index a2a621778..9f5445550 100644
--- a/core/embed/sys/syscall/stm32/syscall_verifiers.h
+++ b/core/embed/sys/syscall/stm32/syscall_verifiers.h
@@ -209,6 +209,8 @@ secbool ble_read__verified(uint8_t *data, size_t len);
void ble_set_name__verified(const uint8_t *name, size_t len);
+bool ble_unpair__verified(const bt_le_addr_t *addr);
+
#endif
// ---------------------------------------------------------------------
diff --git a/nordic/trezor/trezor-ble/src/ble/ble_management.c b/nordic/trezor/trezor-ble/src/ble/ble_management.c
index f1d83d115..eb838196a 100644
--- a/nordic/trezor/trezor-ble/src/ble/ble_management.c
+++ b/nordic/trezor/trezor-ble/src/ble/ble_management.c
@@ -182,7 +182,13 @@ static void process_command(uint8_t *data, uint16_t len) {
pairing_num_comp_reply(false, NULL);
break;
case INTERNAL_CMD_UNPAIR:
- success = bonds_erase_current();
+ if (len < (1 + sizeof(bt_addr_le_t))) {
+ success = bonds_erase_current();
+ } else {
+ bt_addr_le_t addr;
+ memcpy(&addr, &data[1], sizeof(addr));
+ success = bonds_erase_device(&addr);
+ }
break;
case INTERNAL_CMD_GET_MAC: {
uint8_t mac[BT_ADDR_SIZE] = {0};
diff --git a/nordic/trezor/trezor-ble/src/ble/bonds.c b/nordic/trezor/trezor-ble/src/ble/bonds.c
index 5789350e0..27065df4c 100644
--- a/nordic/trezor/trezor-ble/src/ble/bonds.c
+++ b/nordic/trezor/trezor-ble/src/ble/bonds.c
@@ -80,6 +80,42 @@ bool bonds_erase_current(void) {
return err == 0;
}
+bool bonds_erase_device(const bt_addr_le_t *addr) {
+ if (addr == NULL) {
+ return false;
+ }
+
+ bool erased = false;
+ bt_addr_le_t target;
+
+ // Copy MAC and try both address types (ignore the input type)
+ memcpy(target.a.val, addr->a.val, BT_ADDR_SIZE);
+
+ target.type = BT_ADDR_LE_PUBLIC;
+ if (bt_unpair(BT_ID_DEFAULT, &target) == 0) {
+ erased = true;
+ }
+
+ target.type = BT_ADDR_LE_RANDOM;
+ if (bt_unpair(BT_ID_DEFAULT, &target) == 0) {
+ erased = true;
+ }
+
+ // Best-effort: remove from accept list for both types (ignore errors)
+ target.type = BT_ADDR_LE_PUBLIC;
+ (void)bt_le_filter_accept_list_remove(&target);
+ target.type = BT_ADDR_LE_RANDOM;
+ (void)bt_le_filter_accept_list_remove(&target);
+
+ if (erased) {
+ LOG_INF("Bond(s) deleted for device MAC");
+ } else {
+ LOG_INF("No bonds found for device MAC");
+ }
+
+ return erased;
+}
+
typedef struct {
bt_addr_le_t *addr_list;
size_t max_count;
Why this scored 20/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.