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

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 Protocol, TypeAlias, runtime_checkable 

16 

17from bluetooth_sig.types.ead import EADKeyMaterial 

18 

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

20 

21logger = logging.getLogger(__name__) 

22 

23 

24@runtime_checkable 

25class EADKeyProvider(Protocol): 

26 """Protocol for EAD encryption key lookup. 

27 

28 Implement this protocol to provide EAD key material for devices 

29 that use BLE-standard Encrypted Advertising Data. 

30 

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

39 

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

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

42 

43 Args: 

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

45 

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 

51 

52 

53@runtime_checkable 

54class EncryptionKeyProvider(Protocol): 

55 """Protocol for encryption key lookup. 

56 

57 Implement this protocol to provide encryption keys for devices 

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

59 

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

67 

68 """ 

69 

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

71 """Get encryption key for a device. 

72 

73 Args: 

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

75 

76 Returns: 

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

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

79 

80 """ 

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

82 

83 

84class DictKeyProvider: 

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

86 

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. 

90 

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 

95 

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 

105 

106 """ 

107 

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. 

114 

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. 

119 

120 """ 

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

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

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

124 

125 if keys: 

126 # Normalize MAC addresses to uppercase 

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

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

129 

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 

134 

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

136 """Get encryption key for a device. 

137 

138 Args: 

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

140 

141 Returns: 

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

143 

144 """ 

145 normalized_mac = mac_address.upper() 

146 key = self.keys.get(normalized_mac) 

147 

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) 

153 

154 return key 

155 

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

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

158 

159 Args: 

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

161 

162 Returns: 

163 EADKeyMaterial containing session key and IV, 

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

165 

166 """ 

167 normalized_mac = mac_address.upper() 

168 key_material = self.ead_keys.get(normalized_mac) 

169 

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) 

175 

176 return key_material 

177 

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

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

180 

181 Args: 

182 mac_address: Device MAC address 

183 key: Encryption key bytes (typically 16 bytes) 

184 

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) 

190 

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

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

193 

194 Args: 

195 mac_address: Device MAC address 

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

197 

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) 

203 

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

205 """Remove encryption key for a device. 

206 

207 Args: 

208 mac_address: Device MAC address 

209 

210 """ 

211 normalized_mac = mac_address.upper() 

212 self.keys.pop(normalized_mac, None) 

213 

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

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

216 

217 Args: 

218 mac_address: Device MAC address 

219 

220 """ 

221 normalized_mac = mac_address.upper() 

222 self.ead_keys.pop(normalized_mac, None)