diff --git a/bumble/device.py b/bumble/device.py index 6176d89f..f935af42 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -35,6 +35,7 @@ import secrets import sys from typing import ( Any, + Awaitable, Callable, ClassVar, Optional, @@ -84,6 +85,7 @@ from bumble.profiles import gatt_service if TYPE_CHECKING: from bumble.transport.common import TransportSource, TransportSink +_T = TypeVar('_T') # ----------------------------------------------------------------------------- # Logging @@ -1883,6 +1885,12 @@ class Connection(utils.CompositeEventEmitter): def data_packet_queue(self) -> DataPacketQueue | None: return self.device.host.get_data_packet_queue(self.handle) + def cancel_on_disconnection(self, awaitable: Awaitable[_T]) -> Awaitable[_T]: + """ + Helper method to call `utils.cancel_on_event` for the 'disconnection' event + """ + return utils.cancel_on_event(self, self.EVENT_DISCONNECTION, awaitable) + async def __aenter__(self): return self @@ -4358,9 +4366,7 @@ class Device(utils.CompositeEventEmitter): raise hci.HCI_StatusError(result) # Wait for the authentication to complete - await utils.cancel_on_event( - connection, Connection.EVENT_DISCONNECTION, pending_authentication - ) + await connection.cancel_on_disconnection(pending_authentication) finally: connection.remove_listener( connection.EVENT_CONNECTION_AUTHENTICATION, on_authentication @@ -4447,9 +4453,7 @@ class Device(utils.CompositeEventEmitter): raise hci.HCI_StatusError(result) # Wait for the result - await utils.cancel_on_event( - connection, Connection.EVENT_DISCONNECTION, pending_encryption - ) + await connection.cancel_on_disconnection(pending_encryption) finally: connection.remove_listener( connection.EVENT_CONNECTION_ENCRYPTION_CHANGE, on_encryption_change @@ -4493,9 +4497,7 @@ class Device(utils.CompositeEventEmitter): f'{hci.HCI_Constant.error_name(result.status)}' ) raise hci.HCI_StatusError(result) - await utils.cancel_on_event( - connection, Connection.EVENT_DISCONNECTION, pending_role_change - ) + await connection.cancel_on_disconnection(pending_role_change) finally: connection.remove_listener(connection.EVENT_ROLE_CHANGE, on_role_change) connection.remove_listener( @@ -5727,9 +5729,7 @@ class Device(utils.CompositeEventEmitter): async def reply() -> None: try: - if await utils.cancel_on_event( - connection, Connection.EVENT_DISCONNECTION, method() - ): + if await connection.cancel_on_disconnection(method()): await self.host.send_command( hci.HCI_User_Confirmation_Request_Reply_Command( bd_addr=connection.peer_address @@ -5756,10 +5756,8 @@ class Device(utils.CompositeEventEmitter): async def reply() -> None: try: - number = await utils.cancel_on_event( - connection, - Connection.EVENT_DISCONNECTION, - pairing_config.delegate.get_number(), + number = await connection.cancel_on_disconnection( + pairing_config.delegate.get_number() ) if number is not None: await self.host.send_command( @@ -5792,10 +5790,8 @@ class Device(utils.CompositeEventEmitter): if io_capability == hci.HCI_KEYBOARD_ONLY_IO_CAPABILITY: # Ask the user to enter a string async def get_pin_code(): - pin_code = await utils.cancel_on_event( - connection, - Connection.EVENT_DISCONNECTION, - pairing_config.delegate.get_string(16), + pin_code = await connection.cancel_on_disconnection( + pairing_config.delegate.get_string(16) ) if pin_code is not None: @@ -5833,10 +5829,8 @@ class Device(utils.CompositeEventEmitter): pairing_config = self.pairing_config_factory(connection) # Show the passkey to the user - utils.cancel_on_event( - connection, - Connection.EVENT_DISCONNECTION, - pairing_config.delegate.display_number(passkey, digits=6), + connection.cancel_on_disconnection( + pairing_config.delegate.display_number(passkey, digits=6) ) # [Classic only] diff --git a/bumble/l2cap.py b/bumble/l2cap.py index 4fb664f5..eac65d62 100644 --- a/bumble/l2cap.py +++ b/bumble/l2cap.py @@ -818,9 +818,7 @@ class ClassicChannel(utils.EventEmitter): # Wait for the connection to succeed or fail try: - return await utils.cancel_on_event( - self.connection, 'disconnection', self.connection_result - ) + return await self.connection.cancel_on_disconnection(self.connection_result) finally: self.connection_result = None diff --git a/bumble/pairing.py b/bumble/pairing.py index 392231b1..7d777df2 100644 --- a/bumble/pairing.py +++ b/bumble/pairing.py @@ -18,6 +18,7 @@ from __future__ import annotations import enum from dataclasses import dataclass +import secrets from typing import Optional from bumble.hci import ( @@ -222,6 +223,14 @@ class PairingDelegate: ), ) + async def generate_passkey(self) -> int: + """ + Return a passkey value between 0 and 999999 (inclusive). + """ + + # By default, generate a random passkey. + return secrets.randbelow(1000000) + # ----------------------------------------------------------------------------- class PairingConfig: diff --git a/bumble/profiles/hap.py b/bumble/profiles/hap.py index 97edb496..cd5047f5 100644 --- a/bumble/profiles/hap.py +++ b/bumble/profiles/hap.py @@ -335,7 +335,7 @@ class HearingAccessService(gatt.TemplateService): # Update the active preset index if needed await self.notify_active_preset_for_connection(connection) - utils.cancel_on_event(connection, 'disconnection', on_connection_async()) + connection.cancel_on_disconnection(on_connection_async()) def _on_read_active_preset_index(self, connection: Connection) -> bytes: del connection # Unused diff --git a/bumble/profiles/vcs.py b/bumble/profiles/vcs.py index e6d1e5c0..73acdb11 100644 --- a/bumble/profiles/vcs.py +++ b/bumble/profiles/vcs.py @@ -161,10 +161,8 @@ class VolumeControlService(gatt.TemplateService): handler = getattr(self, '_on_' + opcode.name.lower()) if handler(*value[2:]): self.change_counter = (self.change_counter + 1) % 256 - utils.cancel_on_event( - connection, - 'disconnection', - connection.device.notify_subscribers(attribute=self.volume_state), + connection.cancel_on_disconnection( + connection.device.notify_subscribers(attribute=self.volume_state) ) self.emit(self.EVENT_VOLUME_STATE_CHANGE) diff --git a/bumble/smp.py b/bumble/smp.py index cae2d623..4a2f7104 100644 --- a/bumble/smp.py +++ b/bumble/smp.py @@ -26,7 +26,6 @@ from __future__ import annotations import logging import asyncio import enum -import secrets from dataclasses import dataclass from typing import ( TYPE_CHECKING, @@ -896,7 +895,7 @@ class Session: self.send_pairing_failed(SMP_CONFIRM_VALUE_FAILED_ERROR) - utils.cancel_on_event(self.connection, 'disconnection', prompt()) + self.connection.cancel_on_disconnection(prompt()) def prompt_user_for_numeric_comparison( self, code: int, next_steps: Callable[[], None] @@ -915,7 +914,7 @@ class Session: self.send_pairing_failed(SMP_CONFIRM_VALUE_FAILED_ERROR) - utils.cancel_on_event(self.connection, 'disconnection', prompt()) + self.connection.cancel_on_disconnection(prompt()) def prompt_user_for_number(self, next_steps: Callable[[int], None]) -> None: async def prompt() -> None: @@ -932,12 +931,11 @@ class Session: logger.warning(f'exception while prompting: {error}') self.send_pairing_failed(SMP_PASSKEY_ENTRY_FAILED_ERROR) - utils.cancel_on_event(self.connection, 'disconnection', prompt()) + self.connection.cancel_on_disconnection(prompt()) - def display_passkey(self) -> None: - # Generate random Passkey/PIN code - self.passkey = secrets.randbelow(1000000) - assert self.passkey is not None + async def display_passkey(self) -> None: + # Get the passkey value from the delegate + self.passkey = await self.pairing_config.delegate.generate_passkey() logger.debug(f'Pairing PIN CODE: {self.passkey:06}') self.passkey_ready.set() @@ -946,14 +944,7 @@ class Session: self.tk = self.passkey.to_bytes(16, byteorder='little') logger.debug(f'TK from passkey = {self.tk.hex()}') - try: - utils.cancel_on_event( - self.connection, - 'disconnection', - self.pairing_config.delegate.display_number(self.passkey, digits=6), - ) - except Exception as error: - logger.warning(f'exception while displaying number: {error}') + await self.pairing_config.delegate.display_number(self.passkey, digits=6) def input_passkey(self, next_steps: Optional[Callable[[], None]] = None) -> None: # Prompt the user for the passkey displayed on the peer @@ -975,9 +966,16 @@ class Session: self, next_steps: Optional[Callable[[], None]] = None ) -> None: if self.passkey_display: - self.display_passkey() - if next_steps is not None: - next_steps() + + async def display_passkey(): + await self.display_passkey() + if next_steps is not None: + next_steps() + + try: + self.connection.cancel_on_disconnection(display_passkey()) + except Exception as error: + logger.warning(f'exception while displaying passkey: {error}') else: self.input_passkey(next_steps) @@ -1047,7 +1045,7 @@ class Session: ) # Perform the next steps asynchronously in case we need to wait for input - utils.cancel_on_event(self.connection, 'disconnection', next_steps()) + self.connection.cancel_on_disconnection(next_steps()) else: confirm_value = crypto.c1( self.tk, @@ -1170,8 +1168,8 @@ class Session: self.connection.transport == PhysicalTransport.BR_EDR and self.initiator_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG ): - self.ctkd_task = utils.cancel_on_event( - self.connection, 'disconnection', self.get_link_key_and_derive_ltk() + self.ctkd_task = self.connection.cancel_on_disconnection( + self.get_link_key_and_derive_ltk() ) elif not self.sc: # Distribute the LTK, EDIV and RAND @@ -1209,8 +1207,8 @@ class Session: self.connection.transport == PhysicalTransport.BR_EDR and self.responder_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG ): - self.ctkd_task = utils.cancel_on_event( - self.connection, 'disconnection', self.get_link_key_and_derive_ltk() + self.ctkd_task = self.connection.cancel_on_disconnection( + self.get_link_key_and_derive_ltk() ) # Distribute the LTK, EDIV and RAND elif not self.sc: @@ -1302,9 +1300,7 @@ class Session: # Wait for the pairing process to finish assert self.pairing_result - await utils.cancel_on_event( - self.connection, 'disconnection', self.pairing_result - ) + await self.connection.cancel_on_disconnection(self.pairing_result) def on_disconnection(self, _: int) -> None: self.connection.remove_listener( @@ -1325,7 +1321,7 @@ class Session: if self.is_initiator: self.distribute_keys() - utils.cancel_on_event(self.connection, 'disconnection', self.on_pairing()) + self.connection.cancel_on_disconnection(self.on_pairing()) def on_connection_encryption_change(self) -> None: if self.connection.is_encrypted and not self.completed: @@ -1436,10 +1432,8 @@ class Session: def on_smp_pairing_request_command( self, command: SMP_Pairing_Request_Command ) -> None: - utils.cancel_on_event( - self.connection, - 'disconnection', - self.on_smp_pairing_request_command_async(command), + self.connection.cancel_on_disconnection( + self.on_smp_pairing_request_command_async(command) ) async def on_smp_pairing_request_command_async( @@ -1503,7 +1497,7 @@ class Session: # Display a passkey if we need to if not self.sc: if self.pairing_method == PairingMethod.PASSKEY and self.passkey_display: - self.display_passkey() + await self.display_passkey() # Respond self.send_pairing_response_command() @@ -1685,7 +1679,7 @@ class Session: ): return elif self.pairing_method == PairingMethod.PASSKEY: - assert self.passkey and self.confirm_value + assert self.passkey is not None and self.confirm_value is not None # Check that the random value matches what was committed to earlier confirm_verifier = crypto.f4( self.pkb, @@ -1714,7 +1708,7 @@ class Session: ): self.send_pairing_random_command() elif self.pairing_method == PairingMethod.PASSKEY: - assert self.passkey and self.confirm_value + assert self.passkey is not None and self.confirm_value is not None # Check that the random value matches what was committed to earlier confirm_verifier = crypto.f4( self.pka, @@ -1751,7 +1745,7 @@ class Session: ra = bytes(16) rb = ra elif self.pairing_method == PairingMethod.PASSKEY: - assert self.passkey + assert self.passkey is not None ra = self.passkey.to_bytes(16, byteorder='little') rb = ra elif self.pairing_method == PairingMethod.OOB: @@ -1850,19 +1844,23 @@ class Session: elif self.pairing_method == PairingMethod.PASSKEY: self.send_pairing_confirm_command() else: + + def next_steps() -> None: + # Send our public key back to the initiator + self.send_public_key_command() + + if self.pairing_method in ( + PairingMethod.JUST_WORKS, + PairingMethod.NUMERIC_COMPARISON, + PairingMethod.OOB, + ): + # We can now send the confirmation value + self.send_pairing_confirm_command() + if self.pairing_method == PairingMethod.PASSKEY: - self.display_or_input_passkey() - - # Send our public key back to the initiator - self.send_public_key_command() - - if self.pairing_method in ( - PairingMethod.JUST_WORKS, - PairingMethod.NUMERIC_COMPARISON, - PairingMethod.OOB, - ): - # We can now send the confirmation value - self.send_pairing_confirm_command() + self.display_or_input_passkey(next_steps) + else: + next_steps() def on_smp_pairing_dhkey_check_command( self, command: SMP_Pairing_DHKey_Check_Command @@ -1884,7 +1882,7 @@ class Session: self.wait_before_continuing = None self.send_pairing_dhkey_check_command() - utils.cancel_on_event(self.connection, 'disconnection', next_steps()) + self.connection.cancel_on_disconnection(next_steps()) else: self.send_pairing_dhkey_check_command() else: diff --git a/examples/run_cig_setup.py b/examples/run_cig_setup.py index 6fa6092a..75abb7cd 100644 --- a/examples/run_cig_setup.py +++ b/examples/run_cig_setup.py @@ -75,9 +75,7 @@ async def main() -> None: def on_cis_request( connection: Connection, cis_handle: int, _cig_id: int, _cis_id: int ): - utils.cancel_on_event( - connection, 'disconnection', devices[0].accept_cis_request(cis_handle) - ) + connection.cancel_on_disconnection(devices[0].accept_cis_request(cis_handle)) devices[0].on('cis_request', on_cis_request) diff --git a/examples/run_gatt_server_with_pairing_delegate.py b/examples/run_gatt_server_with_pairing_delegate.py new file mode 100644 index 00000000..6e1f3e7b --- /dev/null +++ b/examples/run_gatt_server_with_pairing_delegate.py @@ -0,0 +1,111 @@ +# Copyright 2021-2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# ----------------------------------------------------------------------------- +# Imports +# ----------------------------------------------------------------------------- +import asyncio +import sys +import os +import logging + +from bumble.device import Device +from bumble.transport import open_transport_or_link +from bumble.gatt import ( + Service, + Characteristic, +) +from bumble.pairing import PairingConfig, PairingDelegate + + +# ----------------------------------------------------------------------------- +class FixedPinPairingDelegate(PairingDelegate): + """ + A PairingDelegate that declares that the device only has the ability to display + a passkey but not to enter or confirm one. When asked for the passkey to use for + pairing, this delegate returns a fixed value (instead of the default, which is + to generate a random value each time). This is obviously not a secure way to do + pairing, but it used here as an illustration of how a delegate can override the + default passkey generation. + """ + + def __init__(self, passkey: int) -> None: + super().__init__(io_capability=PairingDelegate.IoCapability.DISPLAY_OUTPUT_ONLY) + self.passkey = passkey + + async def generate_passkey(self) -> int: + return self.passkey + + +# ----------------------------------------------------------------------------- +async def main() -> None: + if len(sys.argv) < 3: + print( + 'Usage: run_gatt_server_with_pairing_delegate.py ' + ) + print('example: run_gatt_server_with_pairing_delegate.py device1.json usb:0') + return + + print('<<< connecting to HCI...') + async with await open_transport_or_link(sys.argv[2]) as hci_transport: + print('<<< connected') + + # Create a device to manage the host + device = Device.from_config_file_with_hci( + sys.argv[1], hci_transport.source, hci_transport.sink + ) + + # Add a service with a single characteristic. + # The characteristic requires authentication, so reading it on a non-paired + # connection will return an error. + custom_service1 = Service( + '50DB505C-8AC4-4738-8448-3B1D9CC09CC5', + [ + Characteristic( + '486F64C6-4B5F-4B3B-8AFF-EDE134A8446A', + Characteristic.Properties.READ, + Characteristic.READABLE + | Characteristic.READ_REQUIRES_AUTHENTICATION, + bytes('hello', 'utf-8'), + ), + ], + ) + device.add_services([custom_service1]) + + # Debug print + for attribute in device.gatt_server.attributes: + print(attribute) + + # Setup pairing + device.pairing_config_factory = lambda connection: PairingConfig( + delegate=FixedPinPairingDelegate(123456) + ) + + # Get things going + await device.power_on() + + # Connect to a peer + if len(sys.argv) > 3: + target_address = sys.argv[3] + print(f'=== Connecting to {target_address}...') + await device.connect(target_address) + else: + await device.start_advertising(auto_restart=True) + + await hci_transport.source.wait_for_termination() + + +# ----------------------------------------------------------------------------- +logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper()) +asyncio.run(main()) diff --git a/examples/run_hfp_handsfree.py b/examples/run_hfp_handsfree.py index 2f9f205c..331ca7b7 100644 --- a/examples/run_hfp_handsfree.py +++ b/examples/run_hfp_handsfree.py @@ -61,14 +61,12 @@ def on_dlc(dlc: rfcomm.DLC, configuration: hfp.HfConfiguration): else: raise RuntimeError("unknown active codec") - utils.cancel_on_event( - connection, - 'disconnection', + connection.cancel_on_disconnection( connection.device.send_command( hci.HCI_Enhanced_Accept_Synchronous_Connection_Request_Command( bd_addr=connection.peer_address, **esco_parameters.asdict() ) - ), + ) ) handler = functools.partial(on_sco_request, protocol=hf_protocol) diff --git a/examples/run_mcp_client.py b/examples/run_mcp_client.py index aba329fd..9ec30ecd 100644 --- a/examples/run_mcp_client.py +++ b/examples/run_mcp_client.py @@ -170,7 +170,7 @@ async def main() -> None: mcp.on('track_position', on_track_position) await mcp.subscribe_characteristics() - utils.cancel_on_event(connection, 'disconnection', on_connection_async()) + connection.cancel_on_disconnection(on_connection_async()) device.on('connection', on_connection) diff --git a/tests/device_test.py b/tests/device_test.py index 2b88c2ca..36e9415c 100644 --- a/tests/device_test.py +++ b/tests/device_test.py @@ -486,8 +486,8 @@ async def test_cis(): _cig_id: int, _cis_id: int, ): - utils.cancel_on_event( - acl_connection, 'disconnection', devices[1].accept_cis_request(cis_handle) + acl_connection.cancel_on_disconnection( + devices[1].accept_cis_request(cis_handle) ) peripheral_cis_futures[cis_handle] = asyncio.get_running_loop().create_future()