Coverage for src / bluetooth_sig / advertising / encryption.py: 100%
53 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 20:14 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 20:14 +0000
1"""Encryption key management for advertising data parsing.
3This module provides protocols and implementations for managing
4encryption keys used by encrypted BLE advertising protocols.
6Includes support for:
7- vendor-specific encryption (bindkeys for Xiaomi, BTHome, etc.)
8- BLE-standard Encrypted Advertising Data (EAD) per Core Spec Supplement 1.23
9"""
11from __future__ import annotations
13import logging
14from collections.abc import Awaitable, Callable
15from typing import TYPE_CHECKING, Protocol, runtime_checkable
17from bluetooth_sig.types.ead import EADKeyMaterial
19if TYPE_CHECKING:
20 from typing_extensions import TypeAlias
22 AsyncKeyLookup: TypeAlias = Callable[[str], Awaitable[bytes | None]]
24logger = logging.getLogger(__name__)
27@runtime_checkable
28class EADKeyProvider(Protocol):
29 """Protocol for EAD encryption key lookup.
31 Implement this protocol to provide EAD key material for devices
32 that use BLE-standard Encrypted Advertising Data.
34 Example:
35 >>> class MyEADKeyProvider:
36 ... def __init__(self, keys: dict[str, EADKeyMaterial]):
37 ... self._keys = keys
38 ...
39 ... def get_ead_key(self, mac_address: str) -> EADKeyMaterial | None:
40 ... return self._keys.get(mac_address.upper())
41 """
43 def get_ead_key(self, mac_address: str) -> EADKeyMaterial | None:
44 """Get EAD key material for a device.
46 Args:
47 mac_address: Device MAC address (e.g., "AA:BB:CC:DD:EE:FF")
49 Returns:
50 EADKeyMaterial containing session key and IV,
51 or None if no key is available for this device.
52 """
53 ... # pylint: disable=unnecessary-ellipsis
56@runtime_checkable
57class EncryptionKeyProvider(Protocol):
58 """Protocol for encryption key lookup.
60 Implement this protocol to provide encryption keys for devices
61 that use encrypted advertising (e.g., Xiaomi MiBeacon, BTHome).
63 Example:
64 >>> class MyKeyProvider:
65 ... def __init__(self, keys: dict[str, bytes]):
66 ... self._keys = keys
67 ...
68 ... def get_key(self, mac_address: str) -> bytes | None:
69 ... return self._keys.get(mac_address.upper())
71 """
73 def get_key(self, mac_address: str) -> bytes | None:
74 """Get encryption key for a device.
76 Args:
77 mac_address: Device MAC address (e.g., "AA:BB:CC:DD:EE:FF")
79 Returns:
80 Encryption key bytes (typically 16 bytes for AES-CCM),
81 or None if no key is available for this device.
83 """
84 ... # pylint: disable=unnecessary-ellipsis
87class DictKeyProvider:
88 """Simple dictionary-based encryption key provider.
90 Stores keys in a dictionary mapping MAC addresses to key bytes.
91 Supports both legacy bindkeys and EAD key material.
92 Logs a warning once per unknown MAC address.
94 Attributes:
95 keys: Dictionary mapping MAC addresses to encryption keys
96 ead_keys: Dictionary mapping MAC addresses to EAD key material
97 warned_macs: Set of MAC addresses that have already been warned about
99 Example:
100 >>> provider = DictKeyProvider(
101 ... {
102 ... "AA:BB:CC:DD:EE:FF": bytes.fromhex("0123456789abcdef0123456789abcdef"),
103 ... }
104 ... )
105 >>> key = provider.get_key("AA:BB:CC:DD:EE:FF")
106 >>> print(key.hex() if key else "No key")
107 0123456789abcdef0123456789abcdef
109 """
111 def __init__(
112 self,
113 keys: dict[str, bytes] | None = None,
114 ead_keys: dict[str, EADKeyMaterial] | None = None,
115 ) -> None:
116 """Initialize with optional key dictionaries.
118 Args:
119 keys: Dictionary mapping MAC addresses to encryption keys.
120 MAC addresses should be uppercase with colons.
121 ead_keys: Dictionary mapping MAC addresses to EAD key material.
123 """
124 self.keys: dict[str, bytes] = {}
125 self.ead_keys: dict[str, EADKeyMaterial] = {}
126 self.warned_macs: set[str] = set()
128 if keys:
129 # Normalize MAC addresses to uppercase
130 for mac, key in keys.items():
131 self.keys[mac.upper()] = key
133 if ead_keys:
134 # Normalize MAC addresses to uppercase
135 for mac, key_material in ead_keys.items():
136 self.ead_keys[mac.upper()] = key_material
138 def get_key(self, mac_address: str) -> bytes | None:
139 """Get encryption key for a device.
141 Args:
142 mac_address: Device MAC address (e.g., "AA:BB:CC:DD:EE:FF")
144 Returns:
145 Encryption key bytes, or None if no key available.
147 """
148 normalized_mac = mac_address.upper()
149 key = self.keys.get(normalized_mac)
151 if key is None and normalized_mac not in self.warned_macs:
152 # Mask MAC address in logs (show only last 4 chars) for privacy
153 masked_mac = f"**:**:**:**:{normalized_mac[-5:]}"
154 logger.debug("No encryption key for MAC %s", masked_mac)
155 self.warned_macs.add(normalized_mac)
157 return key
159 def get_ead_key(self, mac_address: str) -> EADKeyMaterial | None:
160 """Get EAD key material for a device.
162 Args:
163 mac_address: Device MAC address (e.g., "AA:BB:CC:DD:EE:FF")
165 Returns:
166 EADKeyMaterial containing session key and IV,
167 or None if no key is available for this device.
169 """
170 normalized_mac = mac_address.upper()
171 key_material = self.ead_keys.get(normalized_mac)
173 if key_material is None and normalized_mac not in self.warned_macs:
174 # Mask MAC address in logs (show only last 4 chars) for privacy
175 masked_mac = f"**:**:**:**:{normalized_mac[-5:]}"
176 logger.debug("No EAD key material for MAC %s", masked_mac)
177 self.warned_macs.add(normalized_mac)
179 return key_material
181 def set_key(self, mac_address: str, key: bytes) -> None:
182 """Set or update encryption key for a device.
184 Args:
185 mac_address: Device MAC address
186 key: Encryption key bytes (typically 16 bytes)
188 """
189 normalized_mac = mac_address.upper()
190 self.keys[normalized_mac] = key
191 # Clear warning status if we now have a key
192 self.warned_macs.discard(normalized_mac)
194 def set_ead_key(self, mac_address: str, key_material: EADKeyMaterial) -> None:
195 """Set or update EAD key material for a device.
197 Args:
198 mac_address: Device MAC address
199 key_material: EAD key material (session key + IV)
201 """
202 normalized_mac = mac_address.upper()
203 self.ead_keys[normalized_mac] = key_material
204 # Clear warning status if we now have a key
205 self.warned_macs.discard(normalized_mac)
207 def remove_key(self, mac_address: str) -> None:
208 """Remove encryption key for a device.
210 Args:
211 mac_address: Device MAC address
213 """
214 normalized_mac = mac_address.upper()
215 self.keys.pop(normalized_mac, None)
217 def remove_ead_key(self, mac_address: str) -> None:
218 """Remove EAD key material for a device.
220 Args:
221 mac_address: Device MAC address
223 """
224 normalized_mac = mac_address.upper()
225 self.ead_keys.pop(normalized_mac, None)