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
« prev ^ index » next coverage.py v7.12.0, created at 2026-01-20 19:19 +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
30import logging
31from dataclasses import dataclass
32from typing import TYPE_CHECKING
34from kdbxtool.exceptions import (
35 YubiKeyError,
36 YubiKeyNotAvailableError,
37 YubiKeyNotFoundError,
38 YubiKeySlotError,
39 YubiKeyTimeoutError,
40)
42from .memory import SecureBytes
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 )
53 YUBIKEY_AVAILABLE = True
54except ImportError:
55 YUBIKEY_AVAILABLE = False
57if TYPE_CHECKING:
58 pass
60logger = logging.getLogger(__name__)
62# HMAC-SHA1 response is always 20 bytes
63HMAC_SHA1_RESPONSE_SIZE = 20
66@dataclass(frozen=True, slots=True)
67class YubiKeyConfig:
68 """Configuration for YubiKey challenge-response.
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 """
80 slot: int = 2
81 serial: int | None = None
82 timeout_seconds: float = 15.0
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")
92def list_yubikeys() -> list[dict[str, str | int]]:
93 """List connected YubiKey devices.
95 Returns:
96 List of dictionaries containing device info:
97 - serial: Device serial number (if available)
98 - name: Device name/model
100 Raises:
101 YubiKeyNotAvailableError: If yubikey-manager is not installed.
102 """
103 if not YUBIKEY_AVAILABLE:
104 raise YubiKeyNotAvailableError()
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}"
113 device_info: dict[str, str | int] = {"name": name}
114 if info.serial:
115 device_info["serial"] = info.serial
116 devices.append(device_info)
118 logger.debug("Found %d YubiKey devices", len(devices))
119 return devices
122def compute_challenge_response(
123 challenge: bytes,
124 config: YubiKeyConfig | None = None,
125) -> SecureBytes:
126 """Send challenge to YubiKey and return HMAC-SHA1 response.
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.
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.
138 Returns:
139 20-byte HMAC-SHA1 response wrapped in SecureBytes for automatic
140 zeroization when no longer needed.
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()
154 if not challenge:
155 raise ValueError("Challenge must not be empty")
157 if config is None:
158 config = YubiKeyConfig()
160 # Find connected YubiKey
161 devices = list_all_devices()
162 if not devices:
163 raise YubiKeyNotFoundError()
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]
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)
186 try:
187 # Connect via smartcard interface for challenge-response
188 connection = device.open_connection(OtpConnection)
189 try:
190 session = YubiOtpSession(connection)
192 # Calculate challenge response
193 # Note: yubikey-manager handles the timeout internally
194 response = session.calculate_hmac_sha1(slot, challenge)
196 logger.debug("YubiKey challenge-response complete")
197 return SecureBytes(bytes(response))
199 finally:
200 connection.close()
202 except Exception as e:
203 error_msg = str(e).lower()
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
213 # Generic YubiKey error for anything else
214 raise YubiKeyError(f"YubiKey challenge-response failed: {e}") from e
217def check_slot_configured(slot: int = 2, serial: int | None = None) -> bool:
218 """Check if a YubiKey slot is configured for HMAC-SHA1.
220 This is a convenience function to verify that a slot is properly
221 configured before attempting to use it.
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.
228 Returns:
229 True if the slot is configured for HMAC-SHA1, False otherwise.
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()
238 devices = list_all_devices()
239 if not devices:
240 raise YubiKeyNotFoundError()
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]
254 try:
255 connection = device.open_connection(OtpConnection)
256 try:
257 session = YubiOtpSession(connection)
258 config = session.get_config_state()
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))
264 finally:
265 connection.close()
267 except Exception:
268 return False