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

1"""Encryption key management for advertising data parsing. 

2 

3This module provides protocols and implementations for managing 

4encryption keys used by encrypted BLE advertising protocols. 

5 

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""" 

10 

11from __future__ import annotations 

12 

13import logging 

14from collections.abc import Awaitable, Callable 

15from typing import TYPE_CHECKING, Protocol, runtime_checkable 

16 

17from bluetooth_sig.types.ead import EADKeyMaterial 

18 

19if TYPE_CHECKING: 

20 from typing_extensions import TypeAlias 

21 

22 AsyncKeyLookup: TypeAlias = Callable[[str], Awaitable[bytes | None]] 

23 

24logger = logging.getLogger(__name__) 

25 

26 

27@runtime_checkable 

28class EADKeyProvider(Protocol): 

29 """Protocol for EAD encryption key lookup. 

30 

31 Implement this protocol to provide EAD key material for devices 

32 that use BLE-standard Encrypted Advertising Data. 

33 

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 """ 

42 

43 def get_ead_key(self, mac_address: str) -> EADKeyMaterial | None: 

44 """Get EAD key material for a device. 

45 

46 Args: 

47 mac_address: Device MAC address (e.g., "AA:BB:CC:DD:EE:FF") 

48 

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 

54 

55 

56@runtime_checkable 

57class EncryptionKeyProvider(Protocol): 

58 """Protocol for encryption key lookup. 

59 

60 Implement this protocol to provide encryption keys for devices 

61 that use encrypted advertising (e.g., Xiaomi MiBeacon, BTHome). 

62 

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()) 

70 

71 """ 

72 

73 def get_key(self, mac_address: str) -> bytes | None: 

74 """Get encryption key for a device. 

75 

76 Args: 

77 mac_address: Device MAC address (e.g., "AA:BB:CC:DD:EE:FF") 

78 

79 Returns: 

80 Encryption key bytes (typically 16 bytes for AES-CCM), 

81 or None if no key is available for this device. 

82 

83 """ 

84 ... # pylint: disable=unnecessary-ellipsis 

85 

86 

87class DictKeyProvider: 

88 """Simple dictionary-based encryption key provider. 

89 

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. 

93 

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 

98 

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 

108 

109 """ 

110 

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. 

117 

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. 

122 

123 """ 

124 self.keys: dict[str, bytes] = {} 

125 self.ead_keys: dict[str, EADKeyMaterial] = {} 

126 self.warned_macs: set[str] = set() 

127 

128 if keys: 

129 # Normalize MAC addresses to uppercase 

130 for mac, key in keys.items(): 

131 self.keys[mac.upper()] = key 

132 

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 

137 

138 def get_key(self, mac_address: str) -> bytes | None: 

139 """Get encryption key for a device. 

140 

141 Args: 

142 mac_address: Device MAC address (e.g., "AA:BB:CC:DD:EE:FF") 

143 

144 Returns: 

145 Encryption key bytes, or None if no key available. 

146 

147 """ 

148 normalized_mac = mac_address.upper() 

149 key = self.keys.get(normalized_mac) 

150 

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) 

156 

157 return key 

158 

159 def get_ead_key(self, mac_address: str) -> EADKeyMaterial | None: 

160 """Get EAD key material for a device. 

161 

162 Args: 

163 mac_address: Device MAC address (e.g., "AA:BB:CC:DD:EE:FF") 

164 

165 Returns: 

166 EADKeyMaterial containing session key and IV, 

167 or None if no key is available for this device. 

168 

169 """ 

170 normalized_mac = mac_address.upper() 

171 key_material = self.ead_keys.get(normalized_mac) 

172 

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) 

178 

179 return key_material 

180 

181 def set_key(self, mac_address: str, key: bytes) -> None: 

182 """Set or update encryption key for a device. 

183 

184 Args: 

185 mac_address: Device MAC address 

186 key: Encryption key bytes (typically 16 bytes) 

187 

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) 

193 

194 def set_ead_key(self, mac_address: str, key_material: EADKeyMaterial) -> None: 

195 """Set or update EAD key material for a device. 

196 

197 Args: 

198 mac_address: Device MAC address 

199 key_material: EAD key material (session key + IV) 

200 

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) 

206 

207 def remove_key(self, mac_address: str) -> None: 

208 """Remove encryption key for a device. 

209 

210 Args: 

211 mac_address: Device MAC address 

212 

213 """ 

214 normalized_mac = mac_address.upper() 

215 self.keys.pop(normalized_mac, None) 

216 

217 def remove_ead_key(self, mac_address: str) -> None: 

218 """Remove EAD key material for a device. 

219 

220 Args: 

221 mac_address: Device MAC address 

222 

223 """ 

224 normalized_mac = mac_address.upper() 

225 self.ead_keys.pop(normalized_mac, None)