Coverage for src / kdbxtool / security / yubikey.py: 29%

99 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-12-19 21:22 +0000

1"""YubiKey HMAC-SHA1 challenge-response support. 

2 

3This module provides hardware-backed key derivation using YubiKey devices 

4configured with HMAC-SHA1 challenge-response in slot 1 or 2. 

5 

6The implementation follows the KeePassXC approach: 

71. Database's KDF salt (32 bytes) is used as the challenge 

82. YubiKey computes HMAC-SHA1(challenge, hardware_secret) 

93. 20-byte response is SHA-256 hashed and incorporated into composite key 

10 

11This provides hardware-backed security: the database cannot be decrypted 

12without physical access to the configured YubiKey, even if the password 

13is known. 

14 

15Requirements: 

16 - yubikey-manager package (install with: pip install kdbxtool[yubikey]) 

17 - YubiKey 2.2+ with HMAC-SHA1 configured in slot 1 or 2 

18 - Linux: udev rules for YubiKey access (usually automatic) 

19 - Windows: May require administrator privileges 

20 - macOS: Works out of box 

21 

22Security Notes: 

23 - The YubiKey's HMAC secret is never extracted or stored 

24 - Response is wrapped in SecureBytes for automatic zeroization 

25 - YubiKey loss = data loss (unless backup credentials exist) 

26""" 

27 

28from __future__ import annotations 

29 

30from dataclasses import dataclass 

31from typing import TYPE_CHECKING 

32 

33from kdbxtool.exceptions import ( 

34 YubiKeyError, 

35 YubiKeyNotAvailableError, 

36 YubiKeyNotFoundError, 

37 YubiKeySlotError, 

38 YubiKeyTimeoutError, 

39) 

40 

41from .memory import SecureBytes 

42 

43# Optional yubikey-manager support 

44try: 

45 from ykman.device import list_all_devices # type: ignore[import-not-found] 

46 from yubikit.core.otp import OtpConnection # type: ignore[import-not-found] 

47 from yubikit.yubiotp import ( # type: ignore[import-not-found] 

48 SLOT, 

49 YubiOtpSession, 

50 ) 

51 

52 YUBIKEY_AVAILABLE = True 

53except ImportError: 

54 YUBIKEY_AVAILABLE = False 

55 

56if TYPE_CHECKING: 

57 pass 

58 

59 

60# HMAC-SHA1 response is always 20 bytes 

61HMAC_SHA1_RESPONSE_SIZE = 20 

62 

63 

64@dataclass(frozen=True, slots=True) 

65class YubiKeyConfig: 

66 """Configuration for YubiKey challenge-response. 

67 

68 Attributes: 

69 slot: YubiKey slot to use (1 or 2). Slot 2 is typically used for 

70 challenge-response as slot 1 is often used for OTP. 

71 serial: Optional serial number to select a specific YubiKey when 

72 multiple devices are connected. If None, uses the first device. 

73 Use list_yubikeys() to discover available devices and serials. 

74 timeout_seconds: Timeout for challenge-response operation in seconds. 

75 If touch is required, this is the time to wait for the button press. 

76 """ 

77 

78 slot: int = 2 

79 serial: int | None = None 

80 timeout_seconds: float = 15.0 

81 

82 def __post_init__(self) -> None: 

83 """Validate configuration.""" 

84 if self.slot not in (1, 2): 

85 raise ValueError("YubiKey slot must be 1 or 2") 

86 if self.timeout_seconds <= 0: 

87 raise ValueError("Timeout must be positive") 

88 

89 

90def list_yubikeys() -> list[dict[str, str | int]]: 

91 """List connected YubiKey devices. 

92 

93 Returns: 

94 List of dictionaries containing device info: 

95 - serial: Device serial number (if available) 

96 - name: Device name/model 

97 

98 Raises: 

99 YubiKeyNotAvailableError: If yubikey-manager is not installed. 

100 """ 

101 if not YUBIKEY_AVAILABLE: 

102 raise YubiKeyNotAvailableError() 

103 

104 devices = [] 

105 for _device, info in list_all_devices(): 

106 # Build a descriptive name from version and form factor 

107 version_str = f"{info.version.major}.{info.version.minor}.{info.version.patch}" 

108 form_factor = str(info.form_factor) if info.form_factor else "Unknown" 

109 name = f"YubiKey {version_str} {form_factor}" 

110 

111 device_info: dict[str, str | int] = {"name": name} 

112 if info.serial: 

113 device_info["serial"] = info.serial 

114 devices.append(device_info) 

115 

116 return devices 

117 

118 

119def compute_challenge_response( 

120 challenge: bytes, 

121 config: YubiKeyConfig | None = None, 

122) -> SecureBytes: 

123 """Send challenge to YubiKey and return HMAC-SHA1 response. 

124 

125 This function sends the challenge (the database's KDF salt) to the 

126 YubiKey and returns the HMAC-SHA1 response. The response is computed 

127 by the YubiKey hardware using a secret that never leaves the device. 

128 

129 Args: 

130 challenge: Challenge bytes (32-byte KDF salt from KDBX header). 

131 Must be at least 1 byte. 

132 config: Optional YubiKey configuration. If not provided, uses 

133 slot 2 with 15 second timeout. 

134 

135 Returns: 

136 20-byte HMAC-SHA1 response wrapped in SecureBytes for automatic 

137 zeroization when no longer needed. 

138 

139 Raises: 

140 YubiKeyNotAvailableError: If yubikey-manager is not installed. 

141 YubiKeyNotFoundError: If no YubiKey is connected. 

142 YubiKeySlotError: If the specified slot is not configured for 

143 HMAC-SHA1 challenge-response. 

144 YubiKeyTimeoutError: If the operation times out (e.g., touch 

145 was required but not received). 

146 YubiKeyError: For other YubiKey communication errors. 

147 """ 

148 if not YUBIKEY_AVAILABLE: 

149 raise YubiKeyNotAvailableError() 

150 

151 if not challenge: 

152 raise ValueError("Challenge must not be empty") 

153 

154 if config is None: 

155 config = YubiKeyConfig() 

156 

157 # Find connected YubiKey 

158 devices = list_all_devices() 

159 if not devices: 

160 raise YubiKeyNotFoundError() 

161 

162 # Select device by serial number if specified, otherwise use first device 

163 device = None 

164 info = None 

165 if config.serial is not None: 

166 for dev, dev_info in devices: 

167 if dev_info.serial == config.serial: 

168 device = dev 

169 info = dev_info 

170 break 

171 if device is None: 

172 raise YubiKeyNotFoundError( 

173 f"No YubiKey with serial {config.serial} found. " 

174 f"Available serials: {[d[1].serial for d in devices if d[1].serial]}" 

175 ) 

176 else: 

177 device, info = devices[0] 

178 

179 # Convert slot number to SLOT enum 

180 slot = SLOT.ONE if config.slot == 1 else SLOT.TWO 

181 

182 try: 

183 # Connect via smartcard interface for challenge-response 

184 connection = device.open_connection(OtpConnection) 

185 try: 

186 session = YubiOtpSession(connection) 

187 

188 # Calculate challenge response 

189 # Note: yubikey-manager handles the timeout internally 

190 response = session.calculate_hmac_sha1(slot, challenge) 

191 

192 return SecureBytes(bytes(response)) 

193 

194 finally: 

195 connection.close() 

196 

197 except Exception as e: 

198 error_msg = str(e).lower() 

199 

200 # Translate common errors to specific exceptions 

201 if "timeout" in error_msg or "timed out" in error_msg: 

202 raise YubiKeyTimeoutError(config.timeout_seconds) from e 

203 if "not configured" in error_msg or "not programmed" in error_msg: 

204 raise YubiKeySlotError(config.slot) from e 

205 if "no device" in error_msg or "not found" in error_msg: 

206 raise YubiKeyNotFoundError() from e 

207 

208 # Generic YubiKey error for anything else 

209 raise YubiKeyError(f"YubiKey challenge-response failed: {e}") from e 

210 

211 

212def check_slot_configured(slot: int = 2, serial: int | None = None) -> bool: 

213 """Check if a YubiKey slot is configured for HMAC-SHA1. 

214 

215 This is a convenience function to verify that a slot is properly 

216 configured before attempting to use it. 

217 

218 Args: 

219 slot: YubiKey slot to check (1 or 2). 

220 serial: Optional serial number to select a specific YubiKey when 

221 multiple devices are connected. 

222 

223 Returns: 

224 True if the slot is configured for HMAC-SHA1, False otherwise. 

225 

226 Raises: 

227 YubiKeyNotAvailableError: If yubikey-manager is not installed. 

228 YubiKeyNotFoundError: If no YubiKey is connected (or specified serial not found). 

229 """ 

230 if not YUBIKEY_AVAILABLE: 

231 raise YubiKeyNotAvailableError() 

232 

233 devices = list_all_devices() 

234 if not devices: 

235 raise YubiKeyNotFoundError() 

236 

237 # Select device by serial or use first 

238 device = None 

239 if serial is not None: 

240 for dev, dev_info in devices: 

241 if dev_info.serial == serial: 

242 device = dev 

243 break 

244 if device is None: 

245 raise YubiKeyNotFoundError(f"No YubiKey with serial {serial} found") 

246 else: 

247 device, _info = devices[0] 

248 

249 try: 

250 connection = device.open_connection(OtpConnection) 

251 try: 

252 session = YubiOtpSession(connection) 

253 config = session.get_config_state() 

254 

255 # Check if the slot is configured (not empty) 

256 slot_enum = SLOT.ONE if slot == 1 else SLOT.TWO 

257 return bool(config.is_configured(slot_enum)) 

258 

259 finally: 

260 connection.close() 

261 

262 except Exception: 

263 return False