Coverage for src / bluetooth_sig / advertising / encryption.py: 100%
54 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-18 11:17 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-18 11:17 +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 Protocol, TypeAlias, runtime_checkable
17from bluetooth_sig.types.ead import EADKeyMaterial
19AsyncKeyLookup: TypeAlias = Callable[[str], Awaitable[bytes | None]]
21logger = logging.getLogger(__name__)
24@runtime_checkable
25class EADKeyProvider(Protocol):
26 """Protocol for EAD encryption key lookup.
28 Implement this protocol to provide EAD key material for devices
29 that use BLE-standard Encrypted Advertising Data.
31 Example::
32 >>> class MyEADKeyProvider:
33 ... def __init__(self, keys: dict[str, EADKeyMaterial]):
34 ... self._keys = keys
35 ...
36 ... def get_ead_key(self, mac_address: str) -> EADKeyMaterial | None:
37 ... return self._keys.get(mac_address.upper())
38 """
40 def get_ead_key(self, mac_address: str) -> EADKeyMaterial | None:
41 """Get EAD key material for a device.
43 Args:
44 mac_address: Device MAC address (e.g., "AA:BB:CC:DD:EE:FF")
46 Returns:
47 EADKeyMaterial containing session key and IV,
48 or None if no key is available for this device.
49 """
50 ... # pylint: disable=unnecessary-ellipsis
53@runtime_checkable
54class EncryptionKeyProvider(Protocol):
55 """Protocol for encryption key lookup.
57 Implement this protocol to provide encryption keys for devices
58 that use encrypted advertising (e.g., Xiaomi MiBeacon, BTHome).
60 Example::
61 >>> class MyKeyProvider:
62 ... def __init__(self, keys: dict[str, bytes]):
63 ... self._keys = keys
64 ...
65 ... def get_key(self, mac_address: str) -> bytes | None:
66 ... return self._keys.get(mac_address.upper())
68 """
70 def get_key(self, mac_address: str) -> bytes | None:
71 """Get encryption key for a device.
73 Args:
74 mac_address: Device MAC address (e.g., "AA:BB:CC:DD:EE:FF")
76 Returns:
77 Encryption key bytes (typically 16 bytes for AES-CCM),
78 or None if no key is available for this device.
80 """
81 ... # pylint: disable=unnecessary-ellipsis
84class DictKeyProvider:
85 """Simple dictionary-based encryption key provider.
87 Stores keys in a dictionary mapping MAC addresses to key bytes.
88 Supports both legacy bindkeys and EAD key material.
89 Logs a warning once per unknown MAC address.
91 Attributes:
92 keys: Dictionary mapping MAC addresses to encryption keys
93 ead_keys: Dictionary mapping MAC addresses to EAD key material
94 warned_macs: Set of MAC addresses that have already been warned about
96 Example::
97 >>> provider = DictKeyProvider(
98 ... {
99 ... "AA:BB:CC:DD:EE:FF": bytes.fromhex("0123456789abcdef0123456789abcdef"),
100 ... }
101 ... )
102 >>> key = provider.get_key("AA:BB:CC:DD:EE:FF")
103 >>> print(key.hex() if key else "No key")
104 0123456789abcdef0123456789abcdef
106 """
108 def __init__(
109 self,
110 keys: dict[str, bytes] | None = None,
111 ead_keys: dict[str, EADKeyMaterial] | None = None,
112 ) -> None:
113 """Initialize with optional key dictionaries.
115 Args:
116 keys: Dictionary mapping MAC addresses to encryption keys.
117 MAC addresses should be uppercase with colons.
118 ead_keys: Dictionary mapping MAC addresses to EAD key material.
120 """
121 self.keys: dict[str, bytes] = {}
122 self.ead_keys: dict[str, EADKeyMaterial] = {}
123 self.warned_macs: set[str] = set()
125 if keys:
126 # Normalize MAC addresses to uppercase
127 for mac, key in keys.items():
128 self.keys[mac.upper()] = key
130 if ead_keys:
131 # Normalize MAC addresses to uppercase
132 for mac, key_material in ead_keys.items():
133 self.ead_keys[mac.upper()] = key_material
135 def get_key(self, mac_address: str) -> bytes | None:
136 """Get encryption key for a device.
138 Args:
139 mac_address: Device MAC address (e.g., "AA:BB:CC:DD:EE:FF")
141 Returns:
142 Encryption key bytes, or None if no key available.
144 """
145 normalized_mac = mac_address.upper()
146 key = self.keys.get(normalized_mac)
148 if key is None and normalized_mac not in self.warned_macs:
149 # Mask MAC address in logs (show only last 4 chars) for privacy
150 masked_mac = f"**:**:**:**:{normalized_mac[-5:]}"
151 logger.debug("No encryption key for MAC %s", masked_mac)
152 self.warned_macs.add(normalized_mac)
154 return key
156 def get_ead_key(self, mac_address: str) -> EADKeyMaterial | None:
157 """Get EAD key material for a device.
159 Args:
160 mac_address: Device MAC address (e.g., "AA:BB:CC:DD:EE:FF")
162 Returns:
163 EADKeyMaterial containing session key and IV,
164 or None if no key is available for this device.
166 """
167 normalized_mac = mac_address.upper()
168 key_material = self.ead_keys.get(normalized_mac)
170 if key_material is None and normalized_mac not in self.warned_macs:
171 # Mask MAC address in logs (show only last 4 chars) for privacy
172 masked_mac = f"**:**:**:**:{normalized_mac[-5:]}"
173 logger.debug("No EAD key material for MAC %s", masked_mac)
174 self.warned_macs.add(normalized_mac)
176 return key_material
178 def set_key(self, mac_address: str, key: bytes) -> None:
179 """Set or update encryption key for a device.
181 Args:
182 mac_address: Device MAC address
183 key: Encryption key bytes (typically 16 bytes)
185 """
186 normalized_mac = mac_address.upper()
187 self.keys[normalized_mac] = key
188 # Clear warning status if we now have a key
189 self.warned_macs.discard(normalized_mac)
191 def set_ead_key(self, mac_address: str, key_material: EADKeyMaterial) -> None:
192 """Set or update EAD key material for a device.
194 Args:
195 mac_address: Device MAC address
196 key_material: EAD key material (session key + IV)
198 """
199 normalized_mac = mac_address.upper()
200 self.ead_keys[normalized_mac] = key_material
201 # Clear warning status if we now have a key
202 self.warned_macs.discard(normalized_mac)
204 def remove_key(self, mac_address: str) -> None:
205 """Remove encryption key for a device.
207 Args:
208 mac_address: Device MAC address
210 """
211 normalized_mac = mac_address.upper()
212 self.keys.pop(normalized_mac, None)
214 def remove_ead_key(self, mac_address: str) -> None:
215 """Remove EAD key material for a device.
217 Args:
218 mac_address: Device MAC address
220 """
221 normalized_mac = mac_address.upper()
222 self.ead_keys.pop(normalized_mac, None)