From c8a0f4c3b99f25cf5018ebf6d3ba772556a1782b Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Tue, 28 Sep 2021 10:16:47 +1000 Subject: [PATCH 1/3] aioble/security: Add option to limit number of peers stored. --- .../bluetooth/aioble/aioble/security.py | 139 ++++++++++++++---- 1 file changed, 114 insertions(+), 25 deletions(-) diff --git a/micropython/bluetooth/aioble/aioble/security.py b/micropython/bluetooth/aioble/aioble/security.py index a0b46e6d6..6e09351d1 100644 --- a/micropython/bluetooth/aioble/aioble/security.py +++ b/micropython/bluetooth/aioble/aioble/security.py @@ -5,7 +5,7 @@ import uasyncio as asyncio import binascii import json - +from . import core from .core import log_info, log_warn, ble, register_irq_handler from .device import DeviceConnection @@ -26,27 +26,58 @@ _DEFAULT_PATH = "ble_secrets.json" +# Maintain list of known keys, newest at the bottom / end. _secrets = {} _modified = False _path = None +# If set, limit the pairing db to this many peers +limit_peers = None + +SEC_TYPES_SELF = (10, ) +SEC_TYPES_PEER = (1, 2, 3, 4) + # Must call this before stack startup. def load_secrets(path=None): - global _path, _secrets + global _path, _secrets, limit_peers # Use path if specified, otherwise use previous path, otherwise use # default path. _path = path or _path or _DEFAULT_PATH # Reset old secrets. - _secrets = {} + _secrets.clear() try: with open(_path, "r") as f: entries = json.load(f) + # Newest entries at at the end, load them first for sec_type, key, value in entries: + if sec_type not in _secrets: + _secrets[sec_type] = [] # Decode bytes from hex. - _secrets[sec_type, binascii.a2b_base64(key)] = binascii.a2b_base64(value) + _secrets[sec_type].append((binascii.a2b_base64(key), binascii.a2b_base64(value))) + + if limit_peers: + # If we need to limit loaded keys, ensure the same addresses of each type are loaded + keep_keys = None + for sec_type in SEC_TYPES_PEER: + if sec_type not in _secrets: + continue + secrets = _secrets[sec_type] + if len(secrets) > limit_peers: + if not keep_keys: + keep_keys = [key for key, _ in secrets[-limit_peers:]] + log_warn("Limiting keys to", keep_keys) + + keep_entries = [entry for entry in secrets if entry[0] in keep_keys] + while len(keep_entries) < limit_peers: + for entry in reversed(secrets): + if entry not in keep_entries: + keep_entries.append(entry) + _secrets[sec_type] = keep_entries + _log_peers("loaded") + except: log_warn("No secrets available") @@ -61,17 +92,48 @@ def _save_secrets(arg=None): # Only save if the secrets changed. return + _log_peers('save_secrets') + with open(_path, "w") as f: # Convert bytes to hex strings (otherwise JSON will treat them like # strings). json_secrets = [ (sec_type, binascii.b2a_base64(key), binascii.b2a_base64(value)) - for (sec_type, key), value in _secrets.items() + for sec_type in _secrets for key, value in _secrets[sec_type] ] json.dump(json_secrets, f) _modified = False +def _remove_entry(sec_type, key): + secrets = _secrets[sec_type] + + # Delete existing secrets matching the type and key. + deleted = False + for to_delete in [ + entry for entry in secrets if entry[0] == key + ]: + log_info("Removing existing secret matching key") + secrets.remove(to_delete) + deleted = True + + return deleted + + +def _log_peers(heading=""): + if core.log_level <= 2: + return + log_info("secrets:", heading) + for sec_type in SEC_TYPES_PEER: + log_info("-", sec_type) + + if sec_type not in _secrets: + continue + secrets = _secrets[sec_type] + for key, value in secrets: + log_info(" - %s: %s..." % (key, value[0:16])) + + def _security_irq(event, data): global _modified @@ -90,20 +152,43 @@ def _security_irq(event, data): elif event == _IRQ_SET_SECRET: sec_type, key, value = data - key = sec_type, bytes(key) + key = bytes(key) value = bytes(value) if value else None - log_info("set secret:", key, value) - - if value is None: - # Delete secret. - if key not in _secrets: - return False - - del _secrets[key] - else: - # Save secret. - _secrets[key] = value + is_saving = value is not None + is_deleting = not is_saving + + if core.log_level > 2: + if is_deleting: + log_info("del secret:", key) + else: + shortval = value + if len(value) > 16: + shortval = value[0:16] + b"..." + log_info("set secret:", sec_type, key, shortval) + + if sec_type not in _secrets: + _secrets[sec_type] = [] + secrets = _secrets[sec_type] + + # Delete existing secrets matching the type and key. + removed = _remove_entry(sec_type, key) + + if is_deleting and not removed: + # Delete mode, but no entries were deleted + return False + + if is_saving: + # Save new secret. + if limit_peers and sec_type in SEC_TYPES_PEER and len(secrets) >= limit_peers: + addr, _ = secrets[0] + log_warn("Removing old peer to make space for new one") + ble.gap_unpair(addr) + log_info("Removed:", addr) + # Add new value to database + secrets.append((key, value)) + + _log_peers("set_secret") # Queue up a save (don't synchronously write to flash). _modified = True @@ -116,19 +201,23 @@ def _security_irq(event, data): log_info("get secret:", sec_type, index, bytes(key) if key else None) + secrets = _secrets.get(sec_type, []) if key is None: # Return the index'th secret of this type. - i = 0 - for (t, _key), value in _secrets.items(): - if t == sec_type: - if i == index: - return value - i += 1 + # This is used when loading "all" secrets at startup + if len(secrets) > index: + key, val = secrets[index] + return val + return None else: # Return the secret for this key (or None). - key = sec_type, bytes(key) - return _secrets.get(key, None) + key = bytes(key) + + for k, v in secrets: + if k == key: + return v + return None elif event == _IRQ_PASSKEY_ACTION: conn_handle, action, passkey = data From 81591aee8b5dff8d61eab565b8557575bbd8e591 Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Wed, 1 Dec 2021 11:02:28 +1100 Subject: [PATCH 2/3] aioble/security: Add support for CCCD bonding. --- .../bluetooth/aioble/aioble/security.py | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/micropython/bluetooth/aioble/aioble/security.py b/micropython/bluetooth/aioble/aioble/security.py index 6e09351d1..b59e792f6 100644 --- a/micropython/bluetooth/aioble/aioble/security.py +++ b/micropython/bluetooth/aioble/aioble/security.py @@ -35,8 +35,8 @@ limit_peers = None SEC_TYPES_SELF = (10, ) -SEC_TYPES_PEER = (1, 2, 3, 4) - +SEC_TYPES_PEER = (1, 2, 4) +SEC_TYPES_CCCD = (3, ) # Must call this before stack startup. def load_secrets(path=None): @@ -124,7 +124,7 @@ def _log_peers(heading=""): if core.log_level <= 2: return log_info("secrets:", heading) - for sec_type in SEC_TYPES_PEER: + for sec_type in SEC_TYPES_PEER + SEC_TYPES_CCCD: log_info("-", sec_type) if sec_type not in _secrets: @@ -151,8 +151,10 @@ def _security_irq(event, data): connection._pair_event.set() elif event == _IRQ_SET_SECRET: - sec_type, key, value = data + sec_type, key, key2, value = data key = bytes(key) + if key2: + key += bytes(key2) value = bytes(value) if value else None is_saving = value is not None @@ -197,9 +199,12 @@ def _security_irq(event, data): return True elif event == _IRQ_GET_SECRET: - sec_type, index, key = data - - log_info("get secret:", sec_type, index, bytes(key) if key else None) + sec_type, index, key, key2 = data + key = bytes(key) if key else None + if key2: + assert key, "can't have key2 without key" + key += bytes(key2) + log_info("get secret:", sec_type, index, key) secrets = _secrets.get(sec_type, []) if key is None: @@ -212,10 +217,13 @@ def _security_irq(event, data): return None else: # Return the secret for this key (or None). - key = bytes(key) - for k, v in secrets: - if k == key: + # For CCCD, the requested key might be just handle at start of stored key + match = k.startswith(key) + if match: + if index: + index -= 1 + continue return v return None From 5d3101b776d58e7f11388b98559f4f3ecdf3e6d0 Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Wed, 1 Dec 2021 11:01:42 +1100 Subject: [PATCH 3/3] aioble/security: Defer saving of CCCD bond information. --- micropython/bluetooth/aioble/aioble/device.py | 4 ++++ .../bluetooth/aioble/aioble/security.py | 18 ++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/micropython/bluetooth/aioble/aioble/device.py b/micropython/bluetooth/aioble/aioble/device.py index ea87be35b..96efdc5d8 100644 --- a/micropython/bluetooth/aioble/aioble/device.py +++ b/micropython/bluetooth/aioble/aioble/device.py @@ -216,6 +216,7 @@ async def disconnect(self, timeout_ms=2000): await self.disconnected(timeout_ms, disconnect=True) async def disconnected(self, timeout_ms=60000, disconnect=False): + from . import security if not self.is_connected(): return @@ -231,6 +232,9 @@ async def disconnected(self, timeout_ms=60000, disconnect=False): with DeviceTimeout(None, timeout_ms): await self._task + # Ensure any cached secrets are written to disk + security.flush() + # Retrieve a single service matching this uuid. async def service(self, uuid, timeout_ms=2000): result = None diff --git a/micropython/bluetooth/aioble/aioble/security.py b/micropython/bluetooth/aioble/aioble/security.py index b59e792f6..2c06a8526 100644 --- a/micropython/bluetooth/aioble/aioble/security.py +++ b/micropython/bluetooth/aioble/aioble/security.py @@ -82,6 +82,14 @@ def load_secrets(path=None): log_warn("No secrets available") +# Writes secrets to disk if needed +def flush(): + if _modified: + schedule(_save_secrets, None) + else: + print("security flush not needed") + + # Call this whenever the secrets dict changes. def _save_secrets(arg=None): global _modified, _path @@ -189,8 +197,13 @@ def _security_irq(event, data): log_info("Removed:", addr) # Add new value to database secrets.append((key, value)) - - _log_peers("set_secret") + + if sec_type in SEC_TYPES_CCCD: + # Don't write immediately for CCCD, queue up for later flush when needed + log_info("set_cccd") + _modified = True + else: + _log_peers("set_secret") # Queue up a save (don't synchronously write to flash). _modified = True @@ -248,6 +261,7 @@ def _security_irq(event, data): def _security_shutdown(): global _secrets, _modified, _path + flush() _secrets = {} _modified = False _path = None