What changed, and why it matters
This commit adds Bluetooth Low Energy (BLE) support to the Trezor Python library so it can discover and connect to Trezor hardware wallets over Bluetooth. It is a feature addition, not a security fix. The new code runs BLE operations in a separate process and communicates with it through a pipe. There are no obvious severe vulnerabilities in the diff, but the code introduces new attack surface: it trusts nearby BLE devices advertising a specific service UUID, performs Bluetooth pairing, and forwards raw USB-like protocol chunks over BLE. Because this is brand-new code, its security posture is unproven and partial (for example, chunk-size validation is only logged, not enforced).
Treat this as a new feature with increased attack surface rather than a vulnerability. If reviewing for security, focus on: validating that BLE pairing is authenticated and encrypted, ensuring chunk sizes are enforced before forwarding to the device, fuzzing/property-testing the multiprocess pipe dispatch, and confirming that a malicious or spoofed BLE advertisement cannot cause the library to connect to a non-Trezor device. No immediate patch is required based solely on this diff.
Security signals we found
New network-adjacent transport added to default transport enumeration
Relies on BLE service UUID and device name for device selection without additional authentication beyond Bluetooth pairing
Raw protocol chunks forwarded between host and device over BLE GATT characteristics
Unexpected chunk sizes are only logged, not rejected
Multiprocess pipe dispatch uses getattr on arbitrary method names from the parent process
Pairing failure surfaces a system-dialog requirement to the user but does not enforce it
No vendor disclosure of a security issue in commit or supplied references
Evidence from the diff
The change adds BleTransport and a BleProxy/BleAsync multiprocess wrapper around the bleak BLE library. It registers the new transport in all_transports(), adds a CLI trezorctl ble connect, and adds BLE protobuf definitions to the Makefile. The async subprocess scans for devices advertising TREZOR_SERVICE_UUID, pairs via client.pair(), subscribes to the TX characteristic, and writes chunks to the RX characteristic. The parent process exposes these operations over a multiprocessing Pipe using getattr dispatch. Notable implementation details: read_chunk logs unexpected sizes (64 vs 244) but does not raise; write uses response=False; pairing failure is logged and re-raised; disconnect is mostly handled via atexit; the transport is enabled unconditionally (ENABLED = True) even when bleak is not installed, though instantiation will then raise at runtime.
Changed components
python/src/trezorlib/transport/ble.py (new)python/src/trezorlib/transport/__init__.pypython/src/trezorlib/cli/ble.pypython/pyproject.tomlcommon/protob/Makefilepython/.changelog.d/4948.addedInspect captured patch +390 / −2
diff --git a/common/protob/Makefile b/common/protob/Makefile
index db07acd43..399665b0c 100644
--- a/common/protob/Makefile
+++ b/common/protob/Makefile
@@ -1,4 +1,4 @@
-check: messages.pb messages-bitcoin.pb messages-bootloader.pb messages-cardano.pb messages-common.pb messages-crypto.pb messages-debug.pb messages-ethereum.pb messages-management.pb messages-monero.pb messages-nem.pb messages-ripple.pb messages-stellar.pb messages-tezos.pb messages-eos.pb messages-solana.pb messages-definitions.pb
+check: messages.pb messages-bitcoin.pb messages-ble.pb messages-bootloader.pb messages-cardano.pb messages-common.pb messages-crypto.pb messages-debug.pb messages-ethereum.pb messages-management.pb messages-monero.pb messages-nem.pb messages-ripple.pb messages-stellar.pb messages-tezos.pb messages-eos.pb messages-solana.pb messages-definitions.pb
%.pb: %.proto
protoc -I/usr/include -I. $< -o $@
diff --git a/python/.changelog.d/4948.added b/python/.changelog.d/4948.added
new file mode 100644
index 000000000..e2239ad6b
--- /dev/null
+++ b/python/.changelog.d/4948.added
@@ -0,0 +1 @@
+Added support for Bluetooth Low Energy transport.
diff --git a/python/pyproject.toml b/python/pyproject.toml
index 91e718917..a7cadb697 100644
--- a/python/pyproject.toml
+++ b/python/pyproject.toml
@@ -40,12 +40,14 @@ ethereum = ["web3>=5"]
qt-widgets = ["PyQt5"]
extra = ["Pillow>=10"]
stellar = ["stellar-sdk>=6"]
+bleak = ["bleak>=1.1.0"]
full = [
"hidapi>=0.7.99.post20",
"web3>=5",
"PyQt5",
"Pillow>=10",
"stellar-sdk>=6",
+ "bleak>=1.1.0",
]
[project.urls]
diff --git a/python/src/trezorlib/cli/ble.py b/python/src/trezorlib/cli/ble.py
index e958eda2a..8bc35d639 100644
--- a/python/src/trezorlib/cli/ble.py
+++ b/python/src/trezorlib/cli/ble.py
@@ -20,6 +20,7 @@ from typing import TYPE_CHECKING
import click
from .. import ble, exceptions
+from ..transport.ble import BleProxy
from . import with_session
if TYPE_CHECKING:
@@ -43,7 +44,7 @@ def unpair(
session: "Session",
all: bool,
) -> None:
- """Erase bond of currently connected device, or all devices (on device side)"""
+ """Erase bond of currently connected device, or all devices (on device side)."""
try:
ble.unpair(session, all)
@@ -53,3 +54,29 @@ def unpair(
except exceptions.TrezorException as e:
click.echo(f"Unpair failed: {e}")
sys.exit(3)
+
+
+@cli.command()
+def connect() -> None:
+ """Connect to the device via BLE. Device has to be disconnected beforehand.
+
+ If the device hasn't been paired you also need to have system bluetooth pairing dialog open.
+ """
+ ble = BleProxy()
+
+ click.echo("Scanning...")
+ devices = ble.scan()
+
+ if len(devices) == 0:
+ click.echo("No BLE devices found")
+ return
+ else:
+ click.echo(f"Found {len(devices)} BLE device(s)")
+
+ for address, name in devices:
+ click.echo(f"Device: {name}, {address}")
+
+ device = devices[0]
+ click.echo(f"Connecting to {device[1]}...")
+ ble.connect(device[0])
+ click.echo("Connected")
diff --git a/python/src/trezorlib/transport/__init__.py b/python/src/trezorlib/transport/__init__.py
index 0cebe91a1..a95571e13 100644
--- a/python/src/trezorlib/transport/__init__.py
+++ b/python/src/trezorlib/transport/__init__.py
@@ -96,6 +96,7 @@ class Transport:
def all_transports() -> t.Iterable[t.Type["Transport"]]:
+ from .ble import BleTransport
from .bridge import BridgeTransport
from .hid import HidTransport
from .udp import UdpTransport
@@ -106,6 +107,7 @@ def all_transports() -> t.Iterable[t.Type["Transport"]]:
HidTransport,
UdpTransport,
WebUsbTransport,
+ BleTransport,
)
return set(t for t in transports if t.ENABLED)
diff --git a/python/src/trezorlib/transport/ble.py b/python/src/trezorlib/transport/ble.py
new file mode 100644
index 000000000..d4acaee1b
--- /dev/null
+++ b/python/src/trezorlib/transport/ble.py
@@ -0,0 +1,356 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) 2012-2025 SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+from __future__ import annotations
+
+import asyncio
+import atexit
+import logging
+from dataclasses import dataclass
+from multiprocessing import Pipe, Process
+from multiprocessing.connection import Connection
+from typing import TYPE_CHECKING, Any, Iterable
+
+from ..log import DUMP_PACKETS
+from ..models import T3W1
+from . import Timeout, Transport, TransportException
+from .udp import UdpTransport
+
+if TYPE_CHECKING:
+ from ..models import TrezorModel
+
+try:
+ from bleak import BleakClient, BleakScanner
+ from bleak.backends.characteristic import BleakGATTCharacteristic
+ from bleak.backends.device import BLEDevice
+ from bleak.backends.scanner import AdvertisementData
+ from bleak.exc import BleakError
+
+ BLEAK_IMPORTED = True
+except ImportError:
+ BLEAK_IMPORTED = False
+
+LOG = logging.getLogger(__name__)
+
+TREZOR_SERVICE_UUID = "8c000001-a59b-4d58-a9ad-073df69fa1b1"
+TREZOR_CHARACTERISTIC_RX = "8c000002-a59b-4d58-a9ad-073df69fa1b1"
+TREZOR_CHARACTERISTIC_TX = "8c000003-a59b-4d58-a9ad-073df69fa1b1"
+
+SCAN_INTERVAL_SECONDS = 3
+SHUTDOWN_TIMEOUT_SECONDS = 10
+
+
+class BleTransport(Transport):
+ ENABLED = True
+ PATH_PREFIX = "ble"
+ CHUNK_SIZE = 244
+
+ _ble = None
+
+ def __init__(self, address: str) -> None:
+ self.device = address
+ super().__init__()
+
+ def get_path(self) -> str:
+ return "{}:{}".format(self.PATH_PREFIX, self.device)
+
+ def find_debug(self) -> UdpTransport:
+ return UdpTransport("127.0.0.1:27315")
+
+ @classmethod
+ def enumerate(
+ cls, models: Iterable[TrezorModel] | None = None
+ ) -> Iterable[BleTransport]:
+ # TODO use manufacturer_data
+ if models and T3W1 not in models:
+ return []
+ devices = cls.ble_proxy().scan()
+ return [BleTransport(device[0]) for device in devices]
+
+ @classmethod
+ def _try_path(cls, path: str) -> BleTransport:
+ devices = cls.enumerate(None)
+ devices = [d for d in devices if d.device == path]
+ if len(devices) == 0:
+ raise TransportException(f"No BLE device: {path}")
+ return devices[0]
+
+ @classmethod
+ def find_by_path(cls, path: str, prefix_search: bool = False) -> BleTransport:
+ if not prefix_search:
+ raise TransportException
+
+ if prefix_search:
+ return super().find_by_path(path, prefix_search)
+ else:
+ raise TransportException(f"No BLE device: {path}")
+
+ def open(self) -> None:
+ self.ble_proxy().connect(self.device)
+
+ def close(self) -> None:
+ # would be a logical place to call self.ble_proxy().disconnect()
+ # instead we rely on atexit handler to avoid reconnecting
+ pass
+
+ def write_chunk(self, chunk: bytes) -> None:
+ LOG.log(DUMP_PACKETS, f"sending packet: {chunk.hex()}")
+ self.ble_proxy().write(self.device, chunk)
+
+ def read_chunk(self, timeout: float | None = None) -> bytes:
+ chunk = self.ble_proxy().read(self.device, timeout)
+ LOG.log(DUMP_PACKETS, f"received packet: {chunk.hex()}")
+ if len(chunk) not in (64, 244):
+ LOG.error(f"{__name__}: unexpected chunk size: {len(chunk)}")
+ return bytearray(chunk)
+
+ @classmethod
+ def ble_proxy(cls) -> BleProxy:
+ if cls._ble is None:
+ cls._ble = BleProxy()
+ return cls._ble
+
+
+class BleProxy:
+ pipe: Connection | None = None
+ process: Process | None = None
+
+ def __init__(self):
+ if not BLEAK_IMPORTED:
+ raise RuntimeError("Bleak library not available, BLE support disabled")
+
+ if self.pipe is not None:
+ return
+
+ parent_pipe, child_pipe = Pipe()
+ self.pipe = parent_pipe
+ self.process = Process(target=BleAsync, args=(child_pipe,), daemon=True)
+ self.process.start()
+
+ atexit.register(self._shutdown)
+
+ def __getattr__(self, name: str):
+ def f(*args: Any, **kwargs: Any):
+ assert self.pipe is not None
+ self.pipe.send((name, args, kwargs))
+ result = self.pipe.recv()
+ if isinstance(result, BaseException):
+ raise result
+ return result
+
+ return f
+
+ def _shutdown(self):
+ if self.pipe is not None:
+ try:
+ self.pipe.send(("shutdown", [], {}))
+ except BrokenPipeError:
+ LOG.debug(f"{__name__}: broken pipe")
+ self.pipe = None
+ if self.process is not None:
+ self.process.join(SHUTDOWN_TIMEOUT_SECONDS)
+ self.process = None
+
+
+@dataclass
+class Peripheral:
+ device: BLEDevice
+ adv_data: AdvertisementData
+ client: BleakClient | None = None
+ queue: asyncio.Queue | None = None
+
+ @property
+ def address(self):
+ return self.device.address
+
+
+class BleAsync:
+ class Shutdown(Exception):
+ pass
+
+ def __init__(self, pipe: Connection):
+ asyncio.run(self.main(pipe))
+
+ async def main(self, pipe: Connection):
+ self.devices = {}
+ self.did_scan = False
+ LOG.debug("async BLE process started")
+
+ try:
+ await self._main_loop(pipe)
+ finally:
+ for address in self.devices.keys():
+ await self.disconnect(address)
+
+ # returns after shutdown, or raises an exception
+ async def _main_loop(self, pipe: Connection):
+ while True:
+ await ready(pipe)
+ cmd, args, kwargs = pipe.recv()
+ try:
+ result = await getattr(self, cmd)(*args, **kwargs)
+ except self.Shutdown:
+ LOG.debug("async BLE exit loop")
+ return
+ except Timeout as e:
+ await ready(pipe, write=True)
+ pipe.send(e)
+ except Exception as e:
+ LOG.exception("Error in async BLE process:")
+ await ready(pipe, write=True)
+ pipe.send(e)
+ else:
+ await ready(pipe, write=True)
+ pipe.send(result)
+
+ # throws exception when no adapters found
+ async def scan(self) -> list[tuple[str, str]]:
+ LOG.debug("scanning BLE")
+
+ # NOTE BleakScanner.discover(service_uuids=[TREZOR_SERVICE_UUID]) is broken
+ # problem possibly on the bluez side
+
+ devices = await BleakScanner.discover(
+ timeout=SCAN_INTERVAL_SECONDS,
+ return_adv=True,
+ )
+
+ # throw away non connected peripherals
+ self.devices = {
+ addr: periph for addr, periph in self.devices.values() if periph.client
+ }
+ for address, (dev, adv_data) in devices.items():
+ if TREZOR_SERVICE_UUID not in adv_data.service_uuids:
+ continue
+ LOG.debug(
+ f"scan: {dev.address}: {dev.name} rssi={adv_data.rssi} manufacturer_data={adv_data.manufacturer_data}"
+ )
+ if address in self.devices:
+ self.devices[address].device = dev
+ self.devices[address].adv_data = adv_data
+ else:
+ self.devices[address] = Peripheral(dev, adv_data)
+ self.did_scan = True
+ return [
+ (periph.address, periph.device.name) for periph in self.devices.values()
+ ]
+
+ async def connect(self, address: str):
+ if not self.did_scan:
+ await self.scan()
+
+ periph = self.devices.get(address)
+ if not periph:
+ raise RuntimeError("device not found")
+
+ if periph.client:
+ LOG.debug(f"Already connected to {periph.address}")
+ return
+
+ async def disconnect_callback(client: BleakClient):
+ LOG.error(f"Got disconnected from {periph.address}")
+ self.devices[address].client = None
+ self.devices[address].queue = None
+
+ LOG.debug(f"Connecting to {address}...")
+ client = BleakClient(
+ periph.device,
+ services=[TREZOR_SERVICE_UUID],
+ timeout=SCAN_INTERVAL_SECONDS,
+ disconnect_callback=disconnect_callback,
+ )
+ await client.connect()
+
+ # here we should set up the pairing agent
+ # https://github.com/hbldh/bleak/pull/1100
+ # or do what Suite does and try to launch some native gui
+ # import subprocess
+ # subprocess.Popen("gnome-control-center bluetooth", shell=True)
+
+ # if there is no pairing agent we get (on linux)
+ # bleak.exc.BleakDBusError: [org.bluez.Error.AuthenticationFailed] Authentication Failed
+ try:
+ await client.pair()
+ except BleakError:
+ LOG.error("BLE pairing failed - make sure to open system pairing dialog")
+ raise
+
+ queue = asyncio.Queue()
+
+ async def read_callback(characteristic: BleakGATTCharacteristic, data: bytes):
+ await queue.put(data)
+
+ await client.start_notify(TREZOR_CHARACTERISTIC_TX, read_callback)
+ periph.client = client
+ periph.queue = queue
+ LOG.info(f"Connected to {client.address}")
+
+ async def disconnect(self, address: str):
+ periph = self.devices.get(address)
+ if not periph or not periph.client:
+ return
+
+ try:
+ await periph.client.stop_notify(TREZOR_CHARACTERISTIC_TX)
+ await periph.client.disconnect()
+ LOG.info(f"Disconnected from {periph.address}")
+ except EOFError:
+ LOG.debug(f"EOF when disconnecting from {periph.address}")
+ except Exception as ex:
+ LOG.error(f"Failed to disconnect from {periph.address}")
+ LOG.exception(ex)
+ finally:
+ periph.client = None
+ periph.queue = None
+
+ async def read(self, address: str, timeout: float | None):
+ periph = self.devices[address]
+ try:
+ return await asyncio.wait_for(periph.queue.get(), timeout=timeout)
+ except (TimeoutError, asyncio.TimeoutError):
+ raise Timeout(f"Timeout reading BLE packet ({timeout}s)")
+
+ async def write(self, address: str, chunk: bytes):
+ periph = self.devices[address]
+ await periph.client.write_gatt_char(
+ TREZOR_CHARACTERISTIC_RX, chunk, response=False
+ )
+
+ async def shutdown(self):
+ raise self.Shutdown
+
+
+async def ready(f: Any, write: bool = False):
+ """Asynchronously wait for file-like object to become ready for reading or writing."""
+ fd = f.fileno()
+ loop = asyncio.get_event_loop()
+ event = asyncio.Event()
+
+ if write:
+
+ def callback():
+ event.set()
+ loop.remove_writer(fd)
+
+ loop.add_writer(fd, callback)
+ else:
+
+ def callback():
+ event.set()
+ loop.remove_reader(fd)
+
+ loop.add_reader(fd, callback)
+
+ await event.wait()
Why this scored 15/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.