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
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-19 21:22 +0000
1"""YubiKey HMAC-SHA1 challenge-response support.
3This module provides hardware-backed key derivation using YubiKey devices
4configured with HMAC-SHA1 challenge-response in slot 1 or 2.
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
11This provides hardware-backed security: the database cannot be decrypted
12without physical access to the configured YubiKey, even if the password
13is known.
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
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"""
28from __future__ import annotations
30from dataclasses import dataclass
31from typing import TYPE_CHECKING
33from kdbxtool.exceptions import (
34 YubiKeyError,
35 YubiKeyNotAvailableError,
36 YubiKeyNotFoundError,
37 YubiKeySlotError,
38 YubiKeyTimeoutError,
39)
41from .memory import SecureBytes
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 )
52 YUBIKEY_AVAILABLE = True
53except ImportError:
54 YUBIKEY_AVAILABLE = False
56if TYPE_CHECKING:
57 pass
60# HMAC-SHA1 response is always 20 bytes
61HMAC_SHA1_RESPONSE_SIZE = 20
64@dataclass(frozen=True, slots=True)
65class YubiKeyConfig:
66 """Configuration for YubiKey challenge-response.
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 """
78 slot: int = 2
79 serial: int | None = None
80 timeout_seconds: float = 15.0
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")
90def list_yubikeys() -> list[dict[str, str | int]]:
91 """List connected YubiKey devices.
93 Returns:
94 List of dictionaries containing device info:
95 - serial: Device serial number (if available)
96 - name: Device name/model
98 Raises:
99 YubiKeyNotAvailableError: If yubikey-manager is not installed.
100 """
101 if not YUBIKEY_AVAILABLE:
102 raise YubiKeyNotAvailableError()
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}"
111 device_info: dict[str, str | int] = {"name": name}
112 if info.serial:
113 device_info["serial"] = info.serial
114 devices.append(device_info)
116 return devices
119def compute_challenge_response(
120 challenge: bytes,
121 config: YubiKeyConfig | None = None,
122) -> SecureBytes:
123 """Send challenge to YubiKey and return HMAC-SHA1 response.
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.
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.
135 Returns:
136 20-byte HMAC-SHA1 response wrapped in SecureBytes for automatic
137 zeroization when no longer needed.
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()
151 if not challenge:
152 raise ValueError("Challenge must not be empty")
154 if config is None:
155 config = YubiKeyConfig()
157 # Find connected YubiKey
158 devices = list_all_devices()
159 if not devices:
160 raise YubiKeyNotFoundError()
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]
179 # Convert slot number to SLOT enum
180 slot = SLOT.ONE if config.slot == 1 else SLOT.TWO
182 try:
183 # Connect via smartcard interface for challenge-response
184 connection = device.open_connection(OtpConnection)
185 try:
186 session = YubiOtpSession(connection)
188 # Calculate challenge response
189 # Note: yubikey-manager handles the timeout internally
190 response = session.calculate_hmac_sha1(slot, challenge)
192 return SecureBytes(bytes(response))
194 finally:
195 connection.close()
197 except Exception as e:
198 error_msg = str(e).lower()
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
208 # Generic YubiKey error for anything else
209 raise YubiKeyError(f"YubiKey challenge-response failed: {e}") from e
212def check_slot_configured(slot: int = 2, serial: int | None = None) -> bool:
213 """Check if a YubiKey slot is configured for HMAC-SHA1.
215 This is a convenience function to verify that a slot is properly
216 configured before attempting to use it.
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.
223 Returns:
224 True if the slot is configured for HMAC-SHA1, False otherwise.
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()
233 devices = list_all_devices()
234 if not devices:
235 raise YubiKeyNotFoundError()
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]
249 try:
250 connection = device.open_connection(OtpConnection)
251 try:
252 session = YubiOtpSession(connection)
253 config = session.get_config_state()
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))
259 finally:
260 connection.close()
262 except Exception:
263 return False