diff --git a/apps/pair.py b/apps/pair.py index a7844fe..162442a 100644 --- a/apps/pair.py +++ b/apps/pair.py @@ -207,7 +207,7 @@ def on_connection(connection, request): # Listen for pairing events connection.on('pairing_start', on_pairing_start) - connection.on('pairing', on_pairing) + connection.on('pairing', lambda keys: on_pairing(connection.peer_address, keys)) connection.on('pairing_failure', on_pairing_failure) # Listen for encryption changes @@ -242,9 +242,9 @@ def on_pairing_start(): # ----------------------------------------------------------------------------- -def on_pairing(keys): +def on_pairing(address, keys): print(color('***-----------------------------------', 'cyan')) - print(color('*** Paired!', 'cyan')) + print(color(f'*** Paired! (peer identity={address})', 'cyan')) keys.print(prefix=color('*** ', 'cyan')) print(color('***-----------------------------------', 'cyan')) Waiter.instance.terminate() @@ -283,17 +283,6 @@ async def pair( # Create a device to manage the host device = Device.from_config_file_with_hci(device_config, hci_source, hci_sink) - # Set a custom keystore if specified on the command line - if keystore_file: - device.keystore = JsonKeyStore(namespace=None, filename=keystore_file) - - # Print the existing keys before pairing - if print_keys and device.keystore: - print(color('@@@-----------------------------------', 'blue')) - print(color('@@@ Pairing Keys:', 'blue')) - await device.keystore.print(prefix=color('@@@ ', 'blue')) - print(color('@@@-----------------------------------', 'blue')) - # Expose a GATT characteristic that can be used to trigger pairing by # responding with an authentication error when read if mode == 'le': @@ -323,6 +312,17 @@ async def pair( # Get things going await device.power_on() + # Set a custom keystore if specified on the command line + if keystore_file: + device.keystore = JsonKeyStore.from_device(device, filename=keystore_file) + + # Print the existing keys before pairing + if print_keys and device.keystore: + print(color('@@@-----------------------------------', 'blue')) + print(color('@@@ Pairing Keys:', 'blue')) + await device.keystore.print(prefix=color('@@@ ', 'blue')) + print(color('@@@-----------------------------------', 'blue')) + # Set up a pairing config factory device.pairing_config_factory = lambda connection: PairingConfig( sc, mitm, bond, Delegate(mode, connection, io, prompt) diff --git a/apps/scan.py b/apps/scan.py index dac7a2c..268912f 100644 --- a/apps/scan.py +++ b/apps/scan.py @@ -133,15 +133,16 @@ async def scan( 'Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink ) + await device.power_on() + if keystore_file: - keystore = JsonKeyStore(namespace=None, filename=keystore_file) - device.keystore = keystore - else: - resolver = None + device.keystore = JsonKeyStore.from_device(device, filename=keystore_file) if device.keystore: resolving_keys = await device.keystore.get_resolving_keys() resolver = AddressResolver(resolving_keys) + else: + resolver = None printer = AdvertisementPrinter(min_rssi, resolver) if raw: @@ -149,8 +150,6 @@ async def scan( else: device.on('advertisement', printer.on_advertisement) - await device.power_on() - if phy is None: scanning_phys = [HCI_LE_1M_PHY, HCI_LE_CODED_PHY] else: diff --git a/apps/unbond.py b/apps/unbond.py index 105d9a4..5ffd746 100644 --- a/apps/unbond.py +++ b/apps/unbond.py @@ -22,40 +22,58 @@ import click from bumble.device import Device from bumble.keys import JsonKeyStore +from bumble.transport import open_transport + +# ----------------------------------------------------------------------------- +async def unbond_with_keystore(keystore, address): + if address is None: + return await keystore.print() + + try: + await keystore.delete(address) + except KeyError: + print('!!! pairing not found') # ----------------------------------------------------------------------------- -async def unbond(keystore_file, device_config, address): - # Create a device to manage the host - device = Device.from_config_file(device_config) - - # Get all entries in the keystore +async def unbond(keystore_file, device_config, hci_transport, address): + # With a keystore file, we can instantiate the keystore directly if keystore_file: - keystore = JsonKeyStore(None, keystore_file) - else: - keystore = device.keystore + return await unbond_with_keystore(JsonKeyStore(None, keystore_file), address) - if keystore is None: - print('no keystore') - return + # Without a keystore file, we need to obtain the keystore from the device + async with await open_transport(hci_transport) as (hci_source, hci_sink): + # Create a device to manage the host + device = Device.from_config_file_with_hci(device_config, hci_source, hci_sink) - if address is None: - await keystore.print() - else: - try: - await keystore.delete(address) - except KeyError: - print('!!! pairing not found') + # Power-on the device to ensure we have a key store + await device.power_on() + + return await unbond_with_keystore(device.keystore, address) # ----------------------------------------------------------------------------- @click.command() -@click.option('--keystore-file', help='File in which to store the pairing keys') -@click.argument('device-config') +@click.option('--keystore-file', help='File in which the pairing keys are stored') +@click.option('--hci-transport', help='HCI transport for the controller') +@click.argument('device-config', required=False) @click.argument('address', required=False) -def main(keystore_file, device_config, address): +def main(keystore_file, hci_transport, device_config, address): + """ + Remove pairing keys for a device, given its address. + + If no keystore file is specified, the --hci-transport option must be used to + connect to a controller, so that the keystore for that controller can be + instantiated. + If no address is passed, the existing pairing keys for all addresses are printed. + """ logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper()) - asyncio.run(unbond(keystore_file, device_config, address)) + + if not keystore_file and not hci_transport: + print('either --keystore-file or --hci-transport must be specified.') + return + + asyncio.run(unbond(keystore_file, device_config, hci_transport, address)) # ----------------------------------------------------------------------------- diff --git a/bumble/device.py b/bumble/device.py index cbe9992..62ec8c2 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -3094,7 +3094,16 @@ class Device(CompositeEventEmitter): def on_pairing_start(self, connection: Connection) -> None: connection.emit('pairing_start') - def on_pairing(self, connection: Connection, keys: PairingKeys, sc: bool) -> None: + def on_pairing( + self, + connection: Connection, + identity_address: Optional[Address], + keys: PairingKeys, + sc: bool, + ) -> None: + if identity_address is not None: + connection.peer_resolvable_address = connection.peer_address + connection.peer_address = identity_address connection.sc = sc connection.authenticated = True connection.emit('pairing', keys) diff --git a/bumble/keys.py b/bumble/keys.py index a30e753..198d5c4 100644 --- a/bumble/keys.py +++ b/bumble/keys.py @@ -190,10 +190,44 @@ class KeyStore: # ----------------------------------------------------------------------------- class JsonKeyStore(KeyStore): + """ + KeyStore implementation that is backed by a JSON file. + + This implementation supports storing a hierarchy of key sets in a single file. + A key set is a representation of a PairingKeys object. Each key set is stored + in a map, with the address of paired peer as the key. Maps are themselves grouped + into namespaces, grouping pairing keys by controller addresses. + The JSON object model looks like: + { + "": { + "peer-address": { + "address_type": , + "irk" : { + "authenticated": , + "value": "hex-encoded-key" + }, + ... other keys ... + }, + ... other peers ... + } + ... other namespaces ... + } + + A namespace is typically the BD_ADDR of a controller, since that is a convenient + unique identifier, but it may be something else. + A special namespace, called the "default" namespace, is used when instantiating this + class without a namespace. With the default namespace, reading from a file will + load an existing namespace if there is only one, which may be convenient for reading + from a file with a single key set and for which the namespace isn't known. If the + file does not include any existing key set, or if there are more than one and none + has the default name, a new one will be created with the name "__DEFAULT__". + """ + APP_NAME = 'Bumble' APP_AUTHOR = 'Google' KEYS_DIR = 'Pairing' DEFAULT_NAMESPACE = '__DEFAULT__' + DEFAULT_BASE_NAME = "keys" def __init__(self, namespace, filename=None): self.namespace = namespace if namespace is not None else self.DEFAULT_NAMESPACE @@ -208,8 +242,9 @@ class JsonKeyStore(KeyStore): self.directory_name = os.path.join( appdirs.user_data_dir(self.APP_NAME, self.APP_AUTHOR), self.KEYS_DIR ) + base_name = self.DEFAULT_BASE_NAME if namespace is None else self.namespace json_filename = ( - f'{self.namespace}.json'.lower().replace(':', '-').replace('/p', '-p') + f'{base_name}.json'.lower().replace(':', '-').replace('/p', '-p') ) self.filename = os.path.join(self.directory_name, json_filename) else: @@ -219,11 +254,13 @@ class JsonKeyStore(KeyStore): logger.debug(f'JSON keystore: {self.filename}') @staticmethod - def from_device(device: Device) -> Optional[JsonKeyStore]: - if not device.config.keystore: - return None - - params = device.config.keystore.split(':', 1)[1:] + def from_device(device: Device, filename=None) -> Optional[JsonKeyStore]: + if not filename: + # Extract the filename from the config if there is one + if device.config.keystore is not None: + params = device.config.keystore.split(':', 1)[1:] + if params: + filename = params[0] # Use a namespace based on the device address if device.public_address not in (Address.ANY, Address.ANY_RANDOM): @@ -232,19 +269,31 @@ class JsonKeyStore(KeyStore): namespace = str(device.random_address) else: namespace = JsonKeyStore.DEFAULT_NAMESPACE - if params: - filename = params[0] - else: - filename = None return JsonKeyStore(namespace, filename) async def load(self): + # Try to open the file, without failing. If the file does not exist, it + # will be created upon saving. try: with open(self.filename, 'r', encoding='utf-8') as json_file: - return json.load(json_file) + db = json.load(json_file) except FileNotFoundError: - return {} + db = {} + + # First, look for a namespace match + if self.namespace in db: + return (db, db[self.namespace]) + + # Then, if the namespace is the default namespace, and there's + # only one entry in the db, use that + if self.namespace == self.DEFAULT_NAMESPACE and len(db) == 1: + return next(iter(db.items())) + + # Finally, just create an empty key map for the namespace + key_map = {} + db[self.namespace] = key_map + return (db, key_map) async def save(self, db): # Create the directory if it doesn't exist @@ -260,53 +309,30 @@ class JsonKeyStore(KeyStore): os.replace(temp_filename, self.filename) async def delete(self, name: str) -> None: - db = await self.load() - - namespace = db.get(self.namespace) - if namespace is None: - raise KeyError(name) - - del namespace[name] + db, key_map = await self.load() + del key_map[name] await self.save(db) async def update(self, name, keys): - db = await self.load() - - namespace = db.setdefault(self.namespace, {}) - namespace.setdefault(name, {}).update(keys.to_dict()) - + db, key_map = await self.load() + key_map.setdefault(name, {}).update(keys.to_dict()) await self.save(db) async def get_all(self): - db = await self.load() - - namespace = db.get(self.namespace) - if namespace is None: - return [] - - return [ - (name, PairingKeys.from_dict(keys)) for (name, keys) in namespace.items() - ] + _, key_map = await self.load() + return [(name, PairingKeys.from_dict(keys)) for (name, keys) in key_map.items()] async def delete_all(self): - db = await self.load() - - db.pop(self.namespace, None) - + db, key_map = await self.load() + key_map.clear() await self.save(db) async def get(self, name: str) -> Optional[PairingKeys]: - db = await self.load() - - namespace = db.get(self.namespace) - if namespace is None: + _, key_map = await self.load() + if name not in key_map: return None - keys = namespace.get(name) - if keys is None: - return None - - return PairingKeys.from_dict(keys) + return PairingKeys.from_dict(key_map[name]) # ----------------------------------------------------------------------------- diff --git a/bumble/smp.py b/bumble/smp.py index f3fbf27..3cdcae1 100644 --- a/bumble/smp.py +++ b/bumble/smp.py @@ -1805,7 +1805,7 @@ class Manager(EventEmitter): self.device.abort_on('flush', store_keys()) # Notify the device - self.device.on_pairing(session.connection, keys, session.sc) + self.device.on_pairing(session.connection, identity_address, keys, session.sc) def on_pairing_failure(self, session: Session, reason: int) -> None: self.device.on_pairing_failure(session.connection, reason) diff --git a/tests/keystore_test.py b/tests/keystore_test.py new file mode 100644 index 0000000..2e73039 --- /dev/null +++ b/tests/keystore_test.py @@ -0,0 +1,179 @@ +# 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 json +import logging +import tempfile +import os + +from bumble.keys import JsonKeyStore, PairingKeys + + +# ----------------------------------------------------------------------------- +# Logging +# ----------------------------------------------------------------------------- +logger = logging.getLogger(__name__) + + +# ----------------------------------------------------------------------------- +# Tests +# ----------------------------------------------------------------------------- + +JSON1 = """ + { + "my_namespace": { + "14:7D:DA:4E:53:A8/P": { + "address_type": 0, + "irk": { + "authenticated": false, + "value": "e7b2543b206e4e46b44f9e51dad22bd1" + }, + "link_key": { + "authenticated": false, + "value": "0745dd9691e693d9dca740f7d8dfea75" + }, + "ltk": { + "authenticated": false, + "value": "d1897ee10016eb1a08e4e037fd54c683" + } + } + } + } + """ + +JSON2 = """ + { + "my_namespace1": { + }, + "my_namespace2": { + } + } + """ + +JSON3 = """ + { + "my_namespace1": { + }, + "__DEFAULT__": { + "14:7D:DA:4E:53:A8/P": { + "address_type": 0, + "irk": { + "authenticated": false, + "value": "e7b2543b206e4e46b44f9e51dad22bd1" + } + } + } + } + """ + + +# ----------------------------------------------------------------------------- +async def test_basic(): + with tempfile.NamedTemporaryFile(mode="r+", encoding='utf-8') as file: + keystore = JsonKeyStore('my_namespace', file.name) + file.write("{}") + file.flush() + + keys = await keystore.get_all() + assert len(keys) == 0 + + keys = PairingKeys() + await keystore.update('foo', keys) + foo = await keystore.get('foo') + assert foo is not None + assert foo.ltk is None + ltk = bytes([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]) + keys.ltk = PairingKeys.Key(ltk) + await keystore.update('foo', keys) + foo = await keystore.get('foo') + assert foo is not None + assert foo.ltk is not None + assert foo.ltk.value == ltk + + file.flush() + with open(file.name, "r", encoding="utf-8") as json_file: + json_data = json.load(json_file) + assert 'my_namespace' in json_data + assert 'foo' in json_data['my_namespace'] + assert 'ltk' in json_data['my_namespace']['foo'] + + +# ----------------------------------------------------------------------------- +async def test_parsing(): + with tempfile.NamedTemporaryFile(mode="w", encoding='utf-8') as file: + keystore = JsonKeyStore('my_namespace', file.name) + file.write(JSON1) + file.flush() + + foo = await keystore.get('14:7D:DA:4E:53:A8/P') + assert foo is not None + assert foo.ltk.value == bytes.fromhex('d1897ee10016eb1a08e4e037fd54c683') + + +# ----------------------------------------------------------------------------- +async def test_default_namespace(): + with tempfile.NamedTemporaryFile(mode="w", encoding='utf-8') as file: + keystore = JsonKeyStore(None, file.name) + file.write(JSON1) + file.flush() + + all_keys = await keystore.get_all() + assert len(all_keys) == 1 + name, keys = all_keys[0] + assert name == '14:7D:DA:4E:53:A8/P' + assert keys.irk.value == bytes.fromhex('e7b2543b206e4e46b44f9e51dad22bd1') + + with tempfile.NamedTemporaryFile(mode="w", encoding='utf-8') as file: + keystore = JsonKeyStore(None, file.name) + file.write(JSON2) + file.flush() + + keys = PairingKeys() + ltk = bytes([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]) + keys.ltk = PairingKeys.Key(ltk) + await keystore.update('foo', keys) + file.flush() + with open(file.name, "r", encoding="utf-8") as json_file: + json_data = json.load(json_file) + assert '__DEFAULT__' in json_data + assert 'foo' in json_data['__DEFAULT__'] + assert 'ltk' in json_data['__DEFAULT__']['foo'] + + with tempfile.NamedTemporaryFile(mode="w", encoding='utf-8') as file: + keystore = JsonKeyStore(None, file.name) + file.write(JSON3) + file.flush() + + all_keys = await keystore.get_all() + assert len(all_keys) == 1 + name, keys = all_keys[0] + assert name == '14:7D:DA:4E:53:A8/P' + assert keys.irk.value == bytes.fromhex('e7b2543b206e4e46b44f9e51dad22bd1') + + +# ----------------------------------------------------------------------------- +async def run_tests(): + await test_basic() + await test_parsing() + await test_default_namespace() + + +# ----------------------------------------------------------------------------- +if __name__ == '__main__': + logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper()) + asyncio.run(run_tests())