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

104 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-01-20 19:19 +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 

30import logging 

31from dataclasses import dataclass 

32from typing import TYPE_CHECKING 

33 

34from kdbxtool.exceptions import ( 

35 YubiKeyError, 

36 YubiKeyNotAvailableError, 

37 YubiKeyNotFoundError, 

38 YubiKeySlotError, 

39 YubiKeyTimeoutError, 

40) 

41 

42from .memory import SecureBytes 

43 

44# Optional yubikey-manager support 

45try: 

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

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

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

49 SLOT, 

50 YubiOtpSession, 

51 ) 

52 

53 YUBIKEY_AVAILABLE = True 

54except ImportError: 

55 YUBIKEY_AVAILABLE = False 

56 

57if TYPE_CHECKING: 

58 pass 

59 

60logger = logging.getLogger(__name__) 

61 

62# HMAC-SHA1 response is always 20 bytes 

63HMAC_SHA1_RESPONSE_SIZE = 20 

64 

65 

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

67class YubiKeyConfig: 

68 """Configuration for YubiKey challenge-response. 

69 

70 Attributes: 

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

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

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

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

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

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

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

78 """ 

79 

80 slot: int = 2 

81 serial: int | None = None 

82 timeout_seconds: float = 15.0 

83 

84 def __post_init__(self) -> None: 

85 """Validate configuration.""" 

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

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

88 if self.timeout_seconds <= 0: 

89 raise ValueError("Timeout must be positive") 

90 

91 

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

93 """List connected YubiKey devices. 

94 

95 Returns: 

96 List of dictionaries containing device info: 

97 - serial: Device serial number (if available) 

98 - name: Device name/model 

99 

100 Raises: 

101 YubiKeyNotAvailableError: If yubikey-manager is not installed. 

102 """ 

103 if not YUBIKEY_AVAILABLE: 

104 raise YubiKeyNotAvailableError() 

105 

106 devices = [] 

107 for _device, info in list_all_devices(): 

108 # Build a descriptive name from version and form factor 

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

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

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

112 

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

114 if info.serial: 

115 device_info["serial"] = info.serial 

116 devices.append(device_info) 

117 

118 logger.debug("Found %d YubiKey devices", len(devices)) 

119 return devices 

120 

121 

122def compute_challenge_response( 

123 challenge: bytes, 

124 config: YubiKeyConfig | None = None, 

125) -> SecureBytes: 

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

127 

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

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

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

131 

132 Args: 

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

134 Must be at least 1 byte. 

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

136 slot 2 with 15 second timeout. 

137 

138 Returns: 

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

140 zeroization when no longer needed. 

141 

142 Raises: 

143 YubiKeyNotAvailableError: If yubikey-manager is not installed. 

144 YubiKeyNotFoundError: If no YubiKey is connected. 

145 YubiKeySlotError: If the specified slot is not configured for 

146 HMAC-SHA1 challenge-response. 

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

148 was required but not received). 

149 YubiKeyError: For other YubiKey communication errors. 

150 """ 

151 if not YUBIKEY_AVAILABLE: 

152 raise YubiKeyNotAvailableError() 

153 

154 if not challenge: 

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

156 

157 if config is None: 

158 config = YubiKeyConfig() 

159 

160 # Find connected YubiKey 

161 devices = list_all_devices() 

162 if not devices: 

163 raise YubiKeyNotFoundError() 

164 

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

166 device = None 

167 info = None 

168 if config.serial is not None: 

169 for dev, dev_info in devices: 

170 if dev_info.serial == config.serial: 

171 device = dev 

172 info = dev_info 

173 break 

174 if device is None: 

175 raise YubiKeyNotFoundError( 

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

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

178 ) 

179 else: 

180 device, info = devices[0] 

181 

182 # Convert slot number to SLOT enum 

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

184 logger.debug("Starting YubiKey challenge-response on slot %d", config.slot) 

185 

186 try: 

187 # Connect via smartcard interface for challenge-response 

188 connection = device.open_connection(OtpConnection) 

189 try: 

190 session = YubiOtpSession(connection) 

191 

192 # Calculate challenge response 

193 # Note: yubikey-manager handles the timeout internally 

194 response = session.calculate_hmac_sha1(slot, challenge) 

195 

196 logger.debug("YubiKey challenge-response complete") 

197 return SecureBytes(bytes(response)) 

198 

199 finally: 

200 connection.close() 

201 

202 except Exception as e: 

203 error_msg = str(e).lower() 

204 

205 # Translate common errors to specific exceptions 

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

207 raise YubiKeyTimeoutError(config.timeout_seconds) from e 

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

209 raise YubiKeySlotError(config.slot) from e 

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

211 raise YubiKeyNotFoundError() from e 

212 

213 # Generic YubiKey error for anything else 

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

215 

216 

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

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

219 

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

221 configured before attempting to use it. 

222 

223 Args: 

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

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

226 multiple devices are connected. 

227 

228 Returns: 

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

230 

231 Raises: 

232 YubiKeyNotAvailableError: If yubikey-manager is not installed. 

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

234 """ 

235 if not YUBIKEY_AVAILABLE: 

236 raise YubiKeyNotAvailableError() 

237 

238 devices = list_all_devices() 

239 if not devices: 

240 raise YubiKeyNotFoundError() 

241 

242 # Select device by serial or use first 

243 device = None 

244 if serial is not None: 

245 for dev, dev_info in devices: 

246 if dev_info.serial == serial: 

247 device = dev 

248 break 

249 if device is None: 

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

251 else: 

252 device, _info = devices[0] 

253 

254 try: 

255 connection = device.open_connection(OtpConnection) 

256 try: 

257 session = YubiOtpSession(connection) 

258 config = session.get_config_state() 

259 

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

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

262 return bool(config.is_configured(slot_enum)) 

263 

264 finally: 

265 connection.close() 

266 

267 except Exception: 

268 return False