1
0
mirror of https://github.com/trezor/trezor-firmware.git synced 2025-04-26 12:09:02 +00:00

feat(core/tools): BlueZ-emulator bridge

[no changelog]
This commit is contained in:
Martin Milata 2025-03-26 22:53:50 +01:00
parent 47c673f875
commit cb3d68be02
12 changed files with 757 additions and 1 deletions

197
core/tools/bluez-emu-bridge.py Executable file
View File

@ -0,0 +1,197 @@
#!/usr/bin/env python3
import asyncio
import atexit
import logging
import subprocess
from pathlib import Path
import click
from bluez_emu_bridge import MessageBus # normally lives in dbus_next.aio
from bluez_emu_bridge import Adapter1, Device1, GattCharacteristic1, GattService1
from trezorlib.transport.emu_ble import Command, Event
HERE = Path(__file__).parent.resolve()
SERVICE_UUID = "8c000001-a59b-4d58-a9ad-073df69fa1b1"
CHARACTERISTIC_RX = "8c000002-a59b-4d58-a9ad-073df69fa1b1"
CHARACTERISTIC_TX = "8c000003-a59b-4d58-a9ad-073df69fa1b1"
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
handlers=[logging.StreamHandler()],
)
LOG = logging.getLogger(__name__)
class TrezorUDP(asyncio.DatagramProtocol):
@classmethod
async def create(cls, ip, port):
loop = asyncio.get_running_loop()
addr = (ip, port)
return await loop.create_datagram_endpoint(
lambda: TrezorUDP(addr),
remote_addr=addr,
)
def __init__(self, addr):
self.addr = addr
self.transport = None
self.queue = asyncio.Queue()
def ipport(self):
return f"{self.addr[0]}:{self.addr[1]}"
def connection_made(self, transport: asyncio.DatagramTransport):
self.transport = transport
def connection_lost(self, exc: Exception | None):
# Does this ever happen?
LOG.error(f"{self.ipport()} Connection lost", exc_info=exc)
def datagram_received(self, data, addr):
if addr != self.addr:
LOG.error(f"{self.ipport()} Stray datagram from {addr}?")
return
LOG.debug(f"{self.ipport()} Received len={len(data)}")
self.queue.put_nowait(data)
def error_received(self, exc: Exception | None):
LOG.error(f"{self.ipport()} UDP error", exc_info=exc)
def write(self, value: bytes):
assert self.transport
LOG.debug(f"{self.ipport()} Sending len={len(value)}")
self.transport.sendto(value)
def close(self):
if self.transport:
self.transport.close()
self.queue.shutdown()
class TrezorEmulator:
def __init__(self, data_transport, data_protocol, event_transport, event_protocol):
self._data_transport = data_transport
self.data_protocol = data_protocol
self._event_transport = event_transport
self.event_protocol = event_protocol
def close(self):
self.data_transport.close()
self.event_transport.close()
async def print_commands(self):
while True:
data = await self.event_protocol.queue.get()
command = Command.parse(data)
LOG.info(f"Emulator command: {command}")
@classmethod
async def create(cls, emulator_port, device, char_tx, char_rx):
localhost = "127.0.0.1"
data_transport, data_protocol = await TrezorUDP.create(localhost, emulator_port)
char_rx.send_value = data_protocol.write
data_read_task = asyncio.create_task(
char_tx.update_from_queue(data_protocol.queue)
)
remote_addr = ("127.0.0.1", emulator_port + 1)
event_transport, event_protocol = await TrezorUDP.create(
localhost, emulator_port + 1
)
obj = cls(data_transport, data_protocol, event_transport, event_protocol)
event_read_task = asyncio.create_task(device.connection_state_task(event_protocol.write, event_protocol.queue))
# obj.event_protocol.write(Event.ping().build())
return obj
async def emulator_main(bus_address: str, emulator_port: int):
bus = await MessageBus(bus_address=bus_address).connect()
hci0 = Adapter1(bus, "hci0")
device = Device1(bus, hci0.path, "01:02:03:04:05:06")
service = GattService1(bus, device.path, 0, SERVICE_UUID)
char_tx = GattCharacteristic1(
bus, service.path, 0, CHARACTERISTIC_TX, flags=["read", "notify"]
)
char_rx = GattCharacteristic1(
bus,
service.path,
1,
CHARACTERISTIC_RX,
flags=["write", "write-without-response"],
)
service.add_characteristic(char_tx)
service.add_characteristic(char_rx)
device.add_service(service)
hci0.add_device(device)
hci0.export()
### might make it possible to drop the message_bus.py hack
# from dbus_next.service import ServiceInterface
# bus.export("/", ServiceInterface("io.trezor.Empty"))
emulator = await TrezorEmulator.create(emulator_port, device, char_tx, char_rx)
await bus.request_name("org.bluez")
await bus.wait_for_disconnect()
def start_bus() -> str:
daemon = subprocess.Popen(
(
"dbus-daemon",
"--print-address",
"--config-file",
HERE / "bluez_emu_bridge" / "dbus-daemon.conf",
),
stdout=subprocess.PIPE,
encoding="utf-8",
)
def callback():
daemon.terminate()
daemon.kill()
atexit.register(callback)
address = daemon.stdout.readline().strip()
LOG.info(f"dbus-daemon listening at {address}")
parts = address.split(",")
parts = filter(lambda p: not p.startswith("guid="), parts)
address = ",".join(parts)
return address
@click.command()
@click.option(
"--bus-address",
help="Connect to D-Bus address. If not provided, private D-Bus instance will be launched.",
)
@click.option("-v", "--verbose", is_flag=True, help="Show additional info.")
@click.option(
"-p",
"--emulator-port",
type=int,
default=21328,
help="Trezor emulated BLE port to connect to.",
)
def cli(verbose: bool, emulator_port: int, bus_address: str | None):
if verbose:
logging.getLogger().setLevel(logging.DEBUG)
if not bus_address:
bus_address = start_bus()
click.echo(f"DBUS_SYSTEM_BUS_ADDRESS={bus_address}")
# asyncio.get_event_loop().run_until_complete(emulator_main(bus_address, emulator_port))
asyncio.run(emulator_main(bus_address, emulator_port))
if __name__ == "__main__":
cli()

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 OpenBluetoothToolbox
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,36 @@
# bluez_emu_bridge
Most of the files in this directory are based on the
[bluez-dbus-emulator](https://pypi.org/project/bluez-dbus-emulator/) python package
(GitHub: [python_bluez_dbus_emulator](https://github.com/simpleble/python_bluez_dbus_emulator)).
Original README below.
# bluez_dbus_emulator
A simple set of libraries to allow emulating the behavior of a BlueZ
Bluetooth device over DBus.
## Prerequisites
Before you begin, ensure you have met the following requirements:
- [dbus_next](https://github.com/altdesktop/python-dbus-next)
## Installation
```
pip3 install bluez_dbus_emulator
```
## Usage
For usage instructions, just follow the examples provided in the `examples` folder.
## Contributors
Thanks to the following people who have contributed to this project:
* [@Andrey1994](https://github.com/Andrey1994)
## License
This project is licensed under the terms of the [MIT Licence](LICENCE.md).

View File

@ -0,0 +1,5 @@
from bluez_emu_bridge.adapter1 import Adapter1
from bluez_emu_bridge.device1 import Device1
from bluez_emu_bridge.gattcharacteristic1 import GattCharacteristic1
from bluez_emu_bridge.gattservice1 import GattService1
from bluez_emu_bridge.message_bus import MessageBus

View File

@ -0,0 +1,82 @@
import asyncio
import logging
import random
from dbus_next.service import PropertyAccess, ServiceInterface, dbus_property, method
LOG = logging.getLogger(__name__)
class Adapter1(ServiceInterface):
def __init__(self, bus, path, address="00:00:00:12:34:56"):
self.bus = bus
self.path = path
super().__init__("org.bluez.Adapter1")
self._discovering = False
self._address = address
self._devices = []
def export(self):
self.bus.export(f"/org/bluez/{self.path}", self)
def add_device(self, device):
self._devices.append(device)
@method()
def SetDiscoveryFilter(self, properties: "a{sv}"):
return
@method()
async def StartDiscovery(self):
LOG.debug("StartDiscovery")
await self._update_discoverying(True)
for device in self._devices:
await device.task_scanning_start()
return
@method()
async def StopDiscovery(self):
LOG.debug("StopDiscovery")
await self._update_discoverying(False)
for device in self._devices:
device.task_scanning_stop()
return
@dbus_property(access=PropertyAccess.READ)
def Discovering(self) -> "b":
return self._discovering
@dbus_property(access=PropertyAccess.READ)
def Address(self) -> "s":
return self._address
@dbus_property(access=PropertyAccess.READ)
def AddressType(self) -> "s":
return "public"
@dbus_property(access=PropertyAccess.READ)
def Modalias(self) -> "s":
return "usb:v1D6Bp0246d054F"
@dbus_property(access=PropertyAccess.READ)
def Name(self) -> "s":
return "fake-ble-adapter"
@dbus_property(access=PropertyAccess.READ)
def Alias(self) -> "s":
return "fake-ble-adapter-4real"
@dbus_property(access=PropertyAccess.READWRITE)
def Powered(self) -> "b":
return True
@Powered.setter
def Powered(self, value: "b"):
LOG.debug(f"Trying to set Powered to {value}")
async def _update_discoverying(self, new_value: bool):
await asyncio.sleep(random.uniform(0.5, 1.5))
self._discovering = new_value
self.emit_properties_changed({"Discovering": self._discovering})
LOG.debug(f"Discovering changed: {self._discovering}")

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE busconfig SYSTEM "busconfig.dtd">
<busconfig>
<!-- Our well-known bus type, don't change this -->
<type>session</type>
<!-- <listen>unix:tmpdir=/tmp</listen> -->
<listen>unix:path=/tmp/dbus-bluez-emu-bridge</listen>
<!-- On Unix systems, the most secure authentication mechanism is
EXTERNAL, which uses credential-passing over Unix sockets.
This authentication mechanism is not available on Windows,
is not suitable for use with the tcp: or nonce-tcp: transports,
and will not work on obscure flavours of Unix that do not have
a supported credentials-passing mechanism. On those platforms/transports,
comment out the <auth> element to allow fallback to DBUS_COOKIE_SHA1. -->
<auth>EXTERNAL</auth>
<allow_anonymous/>
<policy context="default">
<!-- Allow everything to be sent -->
<allow send_destination="*" eavesdrop="true"/>
<!-- Allow everything to be received -->
<allow eavesdrop="true"/>
<!-- Allow anyone to own anything -->
<allow own="*"/>
</policy>
</busconfig>

View File

@ -0,0 +1,202 @@
import asyncio
import logging
import random
from dbus_next import Variant
from dbus_next.service import PropertyAccess, ServiceInterface, dbus_property, method
from trezorlib.transport.emu_ble import Command, CommandType, Event, EventType
LOG = logging.getLogger(__name__)
class Device1(ServiceInterface):
def __init__(self, bus, parent_path, mac_address="00:00:00:00:00:00"):
self.bus = bus
self.path = f"{parent_path}/dev_{'_'.join(mac_address.split(':'))}"
super().__init__("org.bluez.Device1")
self._exported = False
self._connected = False
self._paired = False
self._pairing_result = asyncio.Queue(1)
self._services_resolved = False
self._rssi = -66
self._address = mac_address
self._name = "Trezor Emulator" # Suite looks for Trezor prefix
self._services = []
self.send_event_fn = None
self.command_queue = None
self.__task_scanning_active = False
async def export(self):
if not self._exported:
self._exported = True
await asyncio.sleep(random.uniform(0.5, 1.5))
self.bus.export(f"/org/bluez/{self.path}", self)
def add_service(self, service):
self._services.append(service)
async def task_scanning_start(self):
await self.export()
self.__task_scanning_active = True
asyncio.create_task(self._task_scanning_run())
def task_scanning_stop(self):
self.__task_scanning_active = False
async def _task_scanning_run(self):
await asyncio.sleep(random.uniform(0.02, 0.2))
# Execute scanning tasks
await self._update_rssi(random.uniform(-90, -60))
if self.__task_scanning_active:
asyncio.create_task(self._task_scanning_run())
@method()
async def Connect(self):
LOG.debug("Connect")
await self.do_connect()
async def do_connect(self):
if not self._connected:
self.send_event(Event.new(event_type=EventType.CONNECTED))
await self._update_connected(True)
for service in self._services:
service.export()
await self._update_services_resolved(True)
@method()
async def Disconnect(self):
LOG.debug("Disconnect")
await self.do_disconnect()
async def do_disconnect(self):
if self._connected:
self.send_event(Event.new(event_type=EventType.DISCONNECTED))
await self._update_services_resolved(False)
await self._update_connected(False)
@method()
async def Pair(self):
LOG.debug("Pair")
if not self._connected:
await self.do_connect()
if not self._paired:
self.send_event(Event.new(event_type=EventType.PAIRING_REQUEST, data=b"999999"))
is_paired_now = await self._pairing_result.get()
await self._update_paired(is_paired_now)
if not is_paired_now:
await self.do_disconnect()
@method()
async def CancelPairing(self):
LOG.debug("CancelPairing")
self.send_event(Event.new(event_type=EventType.PAIRING_CANCELLED))
@dbus_property(access=PropertyAccess.READ)
def ManufacturerData(self) -> "a{qv}":
return {65535: Variant("ay", b"\x01\x00\x54\x32\x57\x31")}
@dbus_property(access=PropertyAccess.READ)
def Connected(self) -> "b":
return self._connected
@dbus_property(access=PropertyAccess.READ)
def ServicesResolved(self) -> "b":
return self._services_resolved
@dbus_property(access=PropertyAccess.READ)
def RSSI(self) -> "n":
return self._rssi
@dbus_property(access=PropertyAccess.READ)
def Name(self) -> "s":
return self._name
@dbus_property(access=PropertyAccess.READ)
def Address(self) -> "s":
return self._address
@dbus_property(access=PropertyAccess.READ)
def UUIDs(self) -> "as":
uuids = []
for srv in self._services:
uuids.append(srv._uuid)
for chr in srv._characteristics:
uuids.append(chr._uuid)
return uuids
@dbus_property(access=PropertyAccess.READ)
def AddressType(self) -> "s":
return "random"
@dbus_property(access=PropertyAccess.READ)
def Paired(self) -> "b":
return self._paired
@dbus_property(access=PropertyAccess.READ)
def Trusted(self) -> "b":
return True
@dbus_property(access=PropertyAccess.READ)
def Blocked(self) -> "b":
return False
@dbus_property(access=PropertyAccess.READ)
def LegacyPairing(self) -> "b":
return False
async def _update_connected(self, new_value: bool):
await asyncio.sleep(random.uniform(0.5, 1.5))
self._connected = new_value
property_changed = {"Connected": self._connected}
self.emit_properties_changed(property_changed)
LOG.debug(f"Property changed: {property_changed}")
async def _update_services_resolved(self, new_value: bool):
await asyncio.sleep(random.uniform(0.0, 0.5))
self._services_resolved = new_value
property_changed = {"ServicesResolved": self._services_resolved}
self.emit_properties_changed(property_changed)
LOG.debug(f"Property changed: {property_changed}")
async def _update_paired(self, new_value: bool):
# TODO return if not changed?
self._paired = new_value
property_changed = {"Paired": self._paired}
self.emit_properties_changed(property_changed)
LOG.debug(f"Property changed: {property_changed}")
async def _update_rssi(self, new_value: int):
return # FIXME skip
self._rssi = int(new_value)
property_changed = {"RSSI": self._rssi}
self.emit_properties_changed(property_changed)
async def connection_state_task(self, write_fn, queue):
self.send_event_fn = write_fn
self.command_queue = queue
self.send_event(Event.ping())
while True:
command = await queue.get()
command = Command.parse(command)
LOG.debug(f"Emulator sent command: {command}")
t = command.command_type
if t == CommandType.ALLOW_PAIRING:
self._pairing_result.put_nowait(True)
elif t == CommandType.REJECT_PAIRING:
self._pairing_result.put_nowait(False)
elif t == CommandType.EMULATOR_PONG:
LOG.info("Emulator pong")
else:
LOG.error(f"Command not implemented: {command}")
def send_event(self, event):
if self.send_event_fn is None:
LOG.error(f"Cannot send event {event}")
else:
LOG.debug(f"Sending event {event}")
self.send_event_fn(event.build())

View File

@ -0,0 +1,93 @@
import asyncio
import logging
import random
from dbus_next.service import PropertyAccess, ServiceInterface, dbus_property, method
LOG = logging.getLogger(__name__)
class GattCharacteristic1(ServiceInterface):
def __init__(self, bus, parent_path, id_num, uuid, flags=None):
self.bus = bus
self.path = f"{parent_path}/char{id_num:04x}"
super().__init__("org.bluez.GattCharacteristic1")
self._service = f"/org/bluez/{parent_path}"
self._uuid = uuid
self._value = bytes()
self._flags = flags if flags is not None else []
self._notifying = False
self._exported = False
self.send_value = None
def export(self):
if not self._exported:
self.bus.export(f"/org/bluez/{self.path}", self)
self._exported = True
def update_value(self, new_value: bytes):
self._update_value(new_value)
@method()
async def StartNotify(self):
LOG.debug(f"{self.path}: StartNotify")
await self._update_notifying(True)
@method()
async def StopNotify(self):
LOG.debug(f"{self.path}: StartNotify")
await self._update_notifying(False)
@method()
def ReadValue(self, options: "a{sv}") -> "ay":
LOG.debug(f"{self.path}: ReadValue (len={len(self._value)})")
return self._value
@method()
def WriteValue(self, value: "ay", options: "a{sv}"):
LOG.debug(f"{self.path}: WriteValue (len={len(value)})")
if not self.send_value:
self._update_value(value)
else:
self.send_value(value)
# TODO: AcquireWrite, AcquireNotify for trezorlib+tealblue
@dbus_property(access=PropertyAccess.READ)
def Notifying(self) -> "b":
return self._notifying
@dbus_property(access=PropertyAccess.READ)
def UUID(self) -> "s":
return self._uuid
@dbus_property(access=PropertyAccess.READ)
def Value(self) -> "ay":
return self._value
@dbus_property(access=PropertyAccess.READ)
def Flags(self) -> "as":
return self._flags
@dbus_property(access=PropertyAccess.READ)
def Service(self) -> "s":
return self._service
def _update_value(self, new_value: bytes):
self._value = new_value
if self._notifying:
property_changed = {"Value": self._value}
self.emit_properties_changed(property_changed)
async def _update_notifying(self, new_value: bool):
# await asyncio.sleep(random.uniform(0.0, 0.2))
self._notifying = new_value
property_changed = {"Notifying": self._notifying}
self.emit_properties_changed(property_changed)
async def update_from_queue(self, queue):
while True:
val = await queue.get()
if not self._notifying:
LOG.warning("Got message from emulator while Notifying=false")
self.update_value(val)

View File

@ -0,0 +1,29 @@
from dbus_next.service import PropertyAccess, ServiceInterface, dbus_property
class GattService1(ServiceInterface):
def __init__(self, bus, parent_path, id_num, uuid):
self.bus = bus
self.path = f"{parent_path}/service{id_num:04x}"
super().__init__("org.bluez.GattService1")
self._uuid = uuid
self._exported = False
self._characteristics = []
def export(self):
if not self._exported:
self.bus.export(f"/org/bluez/{self.path}", self)
for char in self._characteristics:
char.export()
self._exported = True
def add_characteristic(self, characteristic):
self._characteristics.append(characteristic)
@dbus_property(access=PropertyAccess.READ)
def UUID(self) -> "s":
return self._uuid
@dbus_property(access=PropertyAccess.READ)
def Primary(self) -> "b":
return True

View File

@ -0,0 +1,49 @@
import logging
from dbus_next import aio
from dbus_next.message import Message
from dbus_next.service import ServiceInterface
LOG = logging.getLogger(__name__)
class MessageBus(aio.MessageBus):
def _emit_interface_added(self, path: str, interface: str) -> None:
if self._disconnected:
return
def get_properties_callback(interface, result, user_data, e):
if e is not None:
try:
raise e
except Exception:
logging.error(
"An exception ocurred when emitting ObjectManager.InterfacesAdded for %s. "
"Some properties will not be included in the signal.",
interface.name,
exc_info=True,
)
body = {interface.name: result}
# BlueZ's InterfacesAdded signal has different path in the message body and
# in the metadata. However with dbus-next they are always the same, and such
# signal will get ignored by btleplug and other BlueZ clients. Patch it here.
envelope_path = path
if "/dev_" in envelope_path:
envelope_path = "/"
LOG.debug(
f"InterfacesAdded: replacing path {path} with {envelope_path}"
)
self.send(
Message.new_signal(
path=envelope_path,
interface="org.freedesktop.DBus.ObjectManager",
member="InterfacesAdded",
signature="oa{sa{sv}}",
body=[path, body],
)
)
ServiceInterface._get_all_property_values(interface, get_properties_callback)

14
poetry.lock generated
View File

@ -402,6 +402,18 @@ ssh = ["bcrypt (>=3.1.5)"]
test = ["certifi", "cryptography-vectors (==43.0.1)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
test-randomorder = ["pytest-randomly"]
[[package]]
name = "dbus-next"
version = "0.2.3"
description = "A zero-dependency DBus library for Python with asyncio support"
category = "main"
optional = false
python-versions = ">=3.6.0"
files = [
{file = "dbus_next-0.2.3-py3-none-any.whl", hash = "sha256:58948f9aff9db08316734c0be2a120f6dc502124d9642f55e90ac82ffb16a18b"},
{file = "dbus_next-0.2.3.tar.gz", hash = "sha256:f4eae26909332ada528c0a3549dda8d4f088f9b365153952a408e28023a626a5"},
]
[[package]]
name = "demjson3"
version = "3.0.5"
@ -2342,4 +2354,4 @@ test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-it
[metadata]
lock-version = "2.1"
python-versions = "^3.9"
content-hash = "3cf7446762b2697275d563511071e6368edafb8c86eeb88a402bbe7bfb9ba6ac"
content-hash = "4c701042885cd1939fda08e842ddd65a0d44fca5c7ad82cc373fb05f40b0e8d1"

View File

@ -78,6 +78,7 @@ trezor-core-tools = {path = "./core/tools", develop = true}
flake8-annotations = "^3.1.1"
pyelftools = "^0.32"
pytest-retry = "^1.7.0"
dbus-next = "^0.2.3"
[tool.poetry.dev-dependencies]
scan-build = "*"