What changed, and why it matters
This commit adds a new factory-testing command called ble-monitor to Trezor hardware wallets. It lets a technician make the device advertise over Bluetooth, watch connection events, and manually approve or reject pairing requests. The change is confined to the production-test firmware (prodtest), not the normal user wallet firmware, and requires physical CLI access to use. It introduces a global flag that stops the periodic Bluetooth timer from auto-handling events while the monitor is running, so pairing must be confirmed by an operator typing y/n.
Treat as a low-risk feature addition in the production-test environment. Reviewers should confirm that g_ble_monitor_active is always cleared on exit paths (including errors and CLI abort), that ble_switch_off() reliably stops advertising and disconnects, and that the shared CLI read path cannot block other critical tasks. No immediate security patch is indicated.
Security signals we found
New Bluetooth pairing/connection surface added to prodtest firmware
Manual pairing confirmation replaces automatic pairing acceptance while monitor is active
Global flag g_ble_monitor_active suppresses periodic timer event handling
Operator-controlled advertising name and mode switching
No input validation beyond length truncation for advertising name
Change is scoped to production-test project, not main firmware
Evidence from the diff
The patch adds prodtest_ble_monitor() in core/embed/projects/prodtest/cmd/prodtest_ble.c, registered as the ble-monitor CLI command. It calls ble_enter_pairing_mode() with an operator-supplied advertising name (default Trezor BLE), sets g_ble_monitor_active = true to suppress the existing ble_timer_cb() event pump, then loops reading CLI input and BLE events. Mode changes (p/c/o) and pairing confirmation (y/n) are read from the same CLI channel. On disconnect it restarts advertising in the selected mode. New error code PRODTEST_ERR_BLE_MONITOR_ENTER_PAIRING_MODE is added. No user-facing wallet code is modified.
Changed components
core/embed/projects/prodtest/cmd/prodtest_ble.ccore/embed/projects/prodtest/error_codes.jsoncore/embed/projects/prodtest/prodtest_error_codes.hcore/embed/projects/prodtest/README.mdInspect captured patch +229 / −0
diff --git a/core/embed/projects/prodtest/.changelog.d/+ble-monitor.added b/core/embed/projects/prodtest/.changelog.d/+ble-monitor.added
new file mode 100644
index 00000000..896b6f8b
--- /dev/null
+++ b/core/embed/projects/prodtest/.changelog.d/+ble-monitor.added
@@ -0,0 +1 @@
+Add `ble-monitor` command for monitoring BLE connect/disconnect/pairing events, with interactive pairing confirmation and runtime advertising-mode switching.
diff --git a/core/embed/projects/prodtest/README.md b/core/embed/projects/prodtest/README.md
index 8df8e674..300d77cd 100644
--- a/core/embed/projects/prodtest/README.md
+++ b/core/embed/projects/prodtest/README.md
@@ -266,6 +266,41 @@ ble-unpair 1
OK
```
+### ble-monitor
+Starts advertising under the given name and enters a monitoring loop that reports BLE connect, disconnect and pairing events as `#` traces. Advertising is automatically restarted after a disconnect (in the currently selected mode) so the device keeps accepting new connections.
+
+While the loop runs, the advertising mode can be changed by sending a single character:
+
+* `p` - pairing mode (advertise and accept new pairings)
+* `c` - connectable mode (advertise to bonded devices only)
+* `o` - advertising off
+
+When a pairing request arrives, the 6-digit pairing code is displayed and the command waits for the operator to confirm it: send `y` to accept the pairing (`ble_allow_pairing`) or `n` to reject it (`ble_reject_pairing`). The loop runs until interrupted with CTRL+C, after which advertising is stopped.
+
+`ble-monitor [<name>]`
+
+* `name` - The advertising name to use. Defaults to `Trezor BLE` if omitted or empty.
+
+Example:
+```
+ble-monitor TrezorTest
+# Initializing the BLE...
+# Advertising as 'TrezorTest'.
+# Monitoring BLE events. Controls:
+# p = pairing mode, c = connectable mode, o = advertising off
+# CTRL+C = stop
+# Connected.
+# Pairing requested, code: 123456
+# Confirm pairing? [y/n]
+y
+# Pairing confirmed.
+# Pairing completed.
+# Disconnected.
+# Pairing mode.
+# Monitoring stopped.
+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 fe26cd74..ed0fb76c 100644
--- a/core/embed/projects/prodtest/cmd/prodtest_ble.c
+++ b/core/embed/projects/prodtest/cmd/prodtest_ble.c
@@ -32,7 +32,16 @@
#include "prodtest_error_codes.h"
+// When set, the BLE monitoring loop (ble-monitor) is the sole consumer of BLE
+// events and handles pairing confirmation manually, so the periodic timer must
+// not drain the event queue nor auto-accept pairing requests.
+static volatile bool g_ble_monitor_active = false;
+
void ble_timer_cb(void* context) {
+ if (g_ble_monitor_active) {
+ return;
+ }
+
ble_event_t e = {0};
bool event_received = ble_get_event(&e);
@@ -280,6 +289,177 @@ static void prodtest_ble_unpair(cli_t* cli) {
cli_ok(cli, "");
}
+// Blocks until the operator sends 'y'/'n' (case-insensitive) over the CLI or
+// the command is aborted. Returns true on confirmation, false on rejection or
+// abort.
+static bool prodtest_ble_wait_confirmation(cli_t* cli) {
+ while (!cli_aborted(cli)) {
+ char ch = 0;
+ if (cli->read(cli->callback_context, &ch, 1) == 1) {
+ if (ch == 'y' || ch == 'Y') {
+ return true;
+ }
+ if (ch == 'n' || ch == 'N') {
+ return false;
+ }
+ }
+ }
+
+ return false;
+}
+
+// Applies the requested advertising mode and traces the outcome.
+static void prodtest_ble_apply_mode(cli_t* cli, ble_mode_t mode,
+ const uint8_t* name, size_t name_len) {
+ switch (mode) {
+ case BLE_MODE_PAIRING:
+ cli_trace(cli, ble_enter_pairing_mode(name, name_len)
+ ? "Pairing mode."
+ : "Could not enter pairing mode.");
+ break;
+
+ case BLE_MODE_CONNECTABLE:
+ cli_trace(cli, ble_switch_on() ? "Connectable mode."
+ : "Could not enter connectable mode.");
+ break;
+
+ case BLE_MODE_OFF:
+ default:
+ cli_trace(cli, ble_switch_off() ? "Advertising off."
+ : "Could not switch off advertising.");
+ break;
+ }
+}
+
+static void prodtest_ble_monitor(cli_t* cli) {
+ const char* name = cli_arg(cli, "name");
+
+ if (cli_arg_count(cli) > 1) {
+ cli_error_arg_count(cli);
+ return;
+ }
+
+ if (strlen(name) == 0) {
+ name = "Trezor BLE";
+ }
+
+ if (!ensure_ble_init(cli)) {
+ return;
+ }
+
+ uint16_t name_len =
+ strlen(name) > BLE_ADV_NAME_LEN ? BLE_ADV_NAME_LEN : strlen(name);
+
+ ble_set_static_mac(true);
+ if (!ble_enter_pairing_mode((const uint8_t*)name, name_len)) {
+ cli_error(cli, PRODTEST_ERR_BLE_MONITOR_ENTER_PAIRING_MODE,
+ "Could not start advertising.");
+ return;
+ }
+
+ // Take over event handling from the periodic timer so pairing requests are
+ // confirmed manually instead of being auto-accepted.
+ g_ble_monitor_active = true;
+
+ char adv_name[BLE_ADV_NAME_LEN + 1] = {0};
+ ble_get_advertising_name(adv_name, sizeof(adv_name));
+
+ // Mode advertising is (re)started with; starts in pairing mode (above).
+ ble_mode_t mode = BLE_MODE_PAIRING;
+
+ cli_trace(cli, "Advertising as '%s'.", adv_name);
+ cli_trace(cli, "Monitoring BLE events. Controls:");
+ cli_trace(cli,
+ " p = pairing mode, c = connectable mode, o = advertising off");
+ cli_trace(cli, " CTRL+C = stop");
+
+ while (!cli_aborted(cli)) {
+ // Handle operator mode-change commands.
+ char ch = 0;
+ if (cli->read(cli->callback_context, &ch, 1) == 1) {
+ switch (ch) {
+ case 'p':
+ case 'P':
+ mode = BLE_MODE_PAIRING;
+ prodtest_ble_apply_mode(cli, mode, (const uint8_t*)name, name_len);
+ break;
+ case 'c':
+ case 'C':
+ mode = BLE_MODE_CONNECTABLE;
+ prodtest_ble_apply_mode(cli, mode, (const uint8_t*)name, name_len);
+ break;
+ case 'o':
+ case 'O':
+ mode = BLE_MODE_OFF;
+ prodtest_ble_apply_mode(cli, mode, (const uint8_t*)name, name_len);
+ break;
+ default:
+ break;
+ }
+ }
+
+ ble_event_t e = {0};
+ if (!ble_get_event(&e)) {
+ continue;
+ }
+
+ switch (e.type) {
+ case BLE_CONNECTED:
+ cli_trace(cli, "Connected.");
+ break;
+
+ case BLE_DISCONNECTED:
+ cli_trace(cli, "Disconnected.");
+ // Advertising stops on disconnect, restart it in the selected mode.
+ if (mode != BLE_MODE_OFF) {
+ prodtest_ble_apply_mode(cli, mode, (const uint8_t*)name, name_len);
+ }
+ break;
+
+ case BLE_CONNECTION_CHANGED:
+ cli_trace(cli, "Connection changed.");
+ break;
+
+ case BLE_PAIRING_REQUEST:
+ cli_trace(cli, "Pairing requested, code: %.*s", BLE_PAIRING_CODE_LEN,
+ (const char*)e.data);
+ cli_trace(cli, "Confirm pairing? [y/n]");
+
+ if (prodtest_ble_wait_confirmation(cli)) {
+ ble_allow_pairing(e.data);
+ cli_trace(cli, "Pairing confirmed.");
+ } else if (!cli_aborted(cli)) {
+ ble_reject_pairing();
+ cli_trace(cli, "Pairing rejected.");
+ }
+ break;
+
+ case BLE_PAIRING_COMPLETED:
+ cli_trace(cli, "Pairing completed.");
+ break;
+
+ case BLE_PAIRING_CANCELLED:
+ cli_trace(cli, "Pairing cancelled.");
+ break;
+
+ case BLE_PAIRING_NOT_NEEDED:
+ cli_trace(cli, "Pairing not needed.");
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ g_ble_monitor_active = false;
+
+ // Stop advertising and disconnect.
+ ble_switch_off();
+
+ cli_trace(cli, "Monitoring stopped.");
+ cli_ok(cli, "");
+}
+
static void prodtest_ble_radio_test_cmd(cli_t* cli) {
if (cli_arg_count(cli) > 0) {
cli_error_arg_count(cli);
@@ -456,6 +636,13 @@ PRODTEST_CLI_CMD(
.args = "<index>"
);
+PRODTEST_CLI_CMD(
+ .name = "ble-monitor",
+ .func = prodtest_ble_monitor,
+ .info = "Advertise and monitor BLE events (connect/disconnect/pairing), confirming pairing requests interactively. Press CTRL+C to stop",
+ .args = "[<name>]"
+);
+
PRODTEST_CLI_CMD(
.name = "ble-radio-test",
diff --git a/core/embed/projects/prodtest/error_codes.json b/core/embed/projects/prodtest/error_codes.json
index d65639f6..19baaa3c 100644
--- a/core/embed/projects/prodtest/error_codes.json
+++ b/core/embed/projects/prodtest/error_codes.json
@@ -271,6 +271,11 @@
"name": "PRODTEST_ERR_BLE_UART_INIT",
"module": "ble"
},
+ {
+ "code": 2023,
+ "name": "PRODTEST_ERR_BLE_MONITOR_ENTER_PAIRING_MODE",
+ "module": "ble"
+ },
{
"code": 3010,
"name": "PRODTEST_ERR_BOOTLOADER_NO_AUTH_HEADER",
diff --git a/core/embed/projects/prodtest/prodtest_error_codes.h b/core/embed/projects/prodtest/prodtest_error_codes.h
index 7028c82c..04329423 100644
--- a/core/embed/projects/prodtest/prodtest_error_codes.h
+++ b/core/embed/projects/prodtest/prodtest_error_codes.h
@@ -88,6 +88,7 @@ typedef enum {
PRODTEST_ERR_BLE_UNPAIR_INDEX_RANGE = 2020,
PRODTEST_ERR_BLE_UNPAIR = 2021,
PRODTEST_ERR_BLE_UART_INIT = 2022,
+ PRODTEST_ERR_BLE_MONITOR_ENTER_PAIRING_MODE = 2023,
// === bootloader (3000–3999) ===
PRODTEST_ERR_BOOTLOADER_NO_AUTH_HEADER = 3010,
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.