Coverage for src / kdbxtool / security / kdf.py: 100%
150 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"""Key Derivation Functions for KDBX databases.
3This module provides secure KDF implementations for:
4- Argon2d: Default KDF for KDBX4 (KeePassXC compatible, better GPU resistance)
5- Argon2id: Alternative Argon2 variant with timing attack resistance
6- AES-KDF: Legacy KDF for KDBX3 read support (not for new databases)
8Security considerations:
9- Argon2 enforces minimum parameters to prevent weak configurations
10- All derived keys are returned as SecureBytes for automatic zeroization
11"""
13from __future__ import annotations
15import hashlib
16import logging
17from dataclasses import dataclass
18from enum import Enum
19from typing import TYPE_CHECKING
21from argon2.low_level import Type as Argon2Type
22from argon2.low_level import hash_secret_raw
24from kdbxtool.exceptions import KdfError, MissingCredentialsError
26from .keyfile import parse_keyfile
27from .memory import SecureBytes
29if TYPE_CHECKING:
30 pass
32logger = logging.getLogger(__name__)
35class KdfType(Enum):
36 """Supported Key Derivation Functions in KDBX.
38 The UUID values are defined in the KDBX specification.
39 """
41 ARGON2D = bytes.fromhex("ef636ddf8c29444b91f7a9a403e30a0c")
42 ARGON2ID = bytes.fromhex("9e298b1956db4773b23dfc3ec6f0a1e6")
43 AES_KDF = bytes.fromhex("c9d9f39a628a4460bf740d08c18a4fea")
45 @property
46 def display_name(self) -> str:
47 """Human-readable KDF name."""
48 names = {
49 KdfType.ARGON2D: "Argon2d",
50 KdfType.ARGON2ID: "Argon2id",
51 KdfType.AES_KDF: "AES-KDF",
52 }
53 return names[self]
55 @classmethod
56 def from_uuid(cls, uuid_bytes: bytes) -> KdfType:
57 """Look up KDF by its KDBX UUID.
59 Args:
60 uuid_bytes: 16-byte KDF identifier from KDBX header
62 Returns:
63 The corresponding KdfType enum value
65 Raises:
66 ValueError: If the UUID doesn't match any known KDF
67 """
68 for kdf in cls:
69 if kdf.value == uuid_bytes:
70 return kdf
71 raise KdfError(f"Unknown KDF UUID: {uuid_bytes.hex()}")
74# Minimum Argon2 parameters for security
75# Based on OWASP recommendations (as of 2024)
76ARGON2_MIN_MEMORY_KIB = 16 * 1024 # 16 MiB minimum
77ARGON2_MIN_ITERATIONS = 3
78ARGON2_MIN_PARALLELISM = 1
81@dataclass(frozen=True, slots=True)
82class Argon2Config:
83 """Configuration for Argon2 key derivation.
85 Attributes:
86 memory_kib: Memory usage in KiB
87 iterations: Number of iterations (time cost)
88 parallelism: Degree of parallelism
89 salt: Random salt (must be at least 16 bytes)
90 variant: Argon2 variant (Argon2d or Argon2id)
91 """
93 memory_kib: int
94 iterations: int
95 parallelism: int
96 salt: bytes
97 variant: KdfType = KdfType.ARGON2D
99 def __post_init__(self) -> None:
100 """Validate configuration parameters."""
101 if self.variant not in (KdfType.ARGON2D, KdfType.ARGON2ID):
102 raise KdfError(f"Invalid Argon2 variant: {self.variant}")
103 if len(self.salt) < 16:
104 raise KdfError("Argon2 salt must be at least 16 bytes")
106 def validate_security(self) -> None:
107 """Check that parameters meet minimum security requirements.
109 Raises:
110 ValueError: If parameters are below security minimums
111 """
112 issues = []
113 if self.memory_kib < ARGON2_MIN_MEMORY_KIB:
114 issues.append(
115 f"Memory {self.memory_kib} KiB is below minimum {ARGON2_MIN_MEMORY_KIB} KiB"
116 )
117 if self.iterations < ARGON2_MIN_ITERATIONS:
118 issues.append(f"Iterations {self.iterations} is below minimum {ARGON2_MIN_ITERATIONS}")
119 if self.parallelism < ARGON2_MIN_PARALLELISM:
120 issues.append(
121 f"Parallelism {self.parallelism} is below minimum {ARGON2_MIN_PARALLELISM}"
122 )
123 if issues:
124 raise KdfError("Weak Argon2 parameters: " + "; ".join(issues))
126 @classmethod
127 def default(
128 cls,
129 salt: bytes | None = None,
130 variant: KdfType = KdfType.ARGON2D,
131 ) -> Argon2Config:
132 """Create configuration with secure defaults.
134 Alias for standard(). Provides balanced security and performance.
136 Args:
137 salt: Optional salt (32 random bytes generated if not provided)
138 variant: Argon2 variant (ARGON2D or ARGON2ID). Default is ARGON2D
139 which provides better GPU resistance for local password databases.
141 Returns:
142 Argon2Config with recommended security parameters
143 """
144 return cls.standard(salt=salt, variant=variant)
146 @classmethod
147 def standard(
148 cls,
149 salt: bytes | None = None,
150 variant: KdfType = KdfType.ARGON2D,
151 ) -> Argon2Config:
152 """Create configuration with balanced security/performance.
154 Suitable for most use cases. Provides good security with
155 reasonable unlock times on modern hardware.
157 Parameters: 64 MiB memory, 3 iterations, 4 parallelism
159 Args:
160 salt: Optional salt (32 random bytes generated if not provided)
161 variant: Argon2 variant (ARGON2D or ARGON2ID). Default is ARGON2D
162 which provides better GPU resistance for local password databases.
163 Use ARGON2ID if timing attack resistance is needed.
165 Returns:
166 Argon2Config with standard security parameters
167 """
168 import os
170 if salt is None:
171 salt = os.urandom(32)
172 return cls(
173 memory_kib=64 * 1024, # 64 MiB
174 iterations=3,
175 parallelism=4,
176 salt=salt,
177 variant=variant,
178 )
180 @classmethod
181 def high_security(
182 cls,
183 salt: bytes | None = None,
184 variant: KdfType = KdfType.ARGON2D,
185 ) -> Argon2Config:
186 """Create configuration for high-security applications.
188 Use for sensitive data where longer unlock times are acceptable.
189 Provides stronger protection against brute-force attacks.
191 Parameters: 256 MiB memory, 10 iterations, 4 parallelism
193 Args:
194 salt: Optional salt (32 random bytes generated if not provided)
195 variant: Argon2 variant (ARGON2D or ARGON2ID). Default is ARGON2D
196 which provides better GPU resistance for local password databases.
198 Returns:
199 Argon2Config with high security parameters
200 """
201 import os
203 if salt is None:
204 salt = os.urandom(32)
205 return cls(
206 memory_kib=256 * 1024, # 256 MiB
207 iterations=10,
208 parallelism=4,
209 salt=salt,
210 variant=variant,
211 )
213 @classmethod
214 def fast(
215 cls,
216 salt: bytes | None = None,
217 variant: KdfType = KdfType.ARGON2D,
218 ) -> Argon2Config:
219 """Create configuration for fast operations (testing only).
221 WARNING: This provides minimal security and should only be used
222 for testing or development. Not suitable for production databases.
224 Parameters: 16 MiB memory, 3 iterations, 2 parallelism
226 Args:
227 salt: Optional salt (32 random bytes generated if not provided)
228 variant: Argon2 variant (ARGON2D or ARGON2ID). Default is ARGON2D.
230 Returns:
231 Argon2Config with minimal parameters
232 """
233 import os
235 if salt is None:
236 salt = os.urandom(32)
237 return cls(
238 memory_kib=16 * 1024, # 16 MiB (minimum secure)
239 iterations=3,
240 parallelism=2,
241 salt=salt,
242 variant=variant,
243 )
246@dataclass(frozen=True, slots=True)
247class AesKdfConfig:
248 """Configuration for AES-KDF key derivation.
250 AES-KDF is supported in both KDBX3 and KDBX4. While Argon2 is generally
251 recommended for new databases, AES-KDF may be preferred for compatibility
252 with older KeePass clients or on systems where Argon2 is slow.
254 Attributes:
255 rounds: Number of AES encryption rounds (higher = slower but more secure)
256 salt: 32-byte salt
257 """
259 rounds: int
260 salt: bytes
262 def __post_init__(self) -> None:
263 """Validate configuration."""
264 if len(self.salt) != 32:
265 raise KdfError("AES-KDF salt must be exactly 32 bytes")
266 if self.rounds < 1:
267 raise KdfError("AES-KDF rounds must be at least 1")
269 @classmethod
270 def standard(cls, salt: bytes | None = None) -> AesKdfConfig:
271 """Create configuration with balanced security/performance.
273 Uses 600,000 rounds which provides reasonable security while
274 keeping unlock times acceptable on most hardware.
276 Args:
277 salt: Optional salt (32 random bytes generated if not provided)
279 Returns:
280 AesKdfConfig with standard parameters
281 """
282 import os
284 if salt is None:
285 salt = os.urandom(32)
286 return cls(rounds=600_000, salt=salt)
288 @classmethod
289 def high_security(cls, salt: bytes | None = None) -> AesKdfConfig:
290 """Create configuration for high-security applications.
292 Uses 6,000,000 rounds for stronger protection at the cost of
293 longer unlock times.
295 Args:
296 salt: Optional salt (32 random bytes generated if not provided)
298 Returns:
299 AesKdfConfig with high security parameters
300 """
301 import os
303 if salt is None:
304 salt = os.urandom(32)
305 return cls(rounds=6_000_000, salt=salt)
307 @classmethod
308 def fast(cls, salt: bytes | None = None) -> AesKdfConfig:
309 """Create configuration for fast operations (testing only).
311 WARNING: Uses only 60,000 rounds which provides minimal security.
312 Only use for testing or development.
314 Args:
315 salt: Optional salt (32 random bytes generated if not provided)
317 Returns:
318 AesKdfConfig with minimal parameters
319 """
320 import os
322 if salt is None:
323 salt = os.urandom(32)
324 return cls(rounds=60_000, salt=salt)
327def derive_key_argon2(
328 password: bytes,
329 config: Argon2Config,
330 *,
331 enforce_minimums: bool = True,
332) -> SecureBytes:
333 """Derive a 32-byte key using Argon2.
335 Args:
336 password: Password bytes (usually composite key hash)
337 config: Argon2 configuration parameters
338 enforce_minimums: If True, reject weak parameters
340 Returns:
341 32-byte derived key wrapped in SecureBytes
343 Raises:
344 ValueError: If parameters are invalid or below minimums
345 """
346 if enforce_minimums:
347 config.validate_security()
349 argon2_type = Argon2Type.ID if config.variant == KdfType.ARGON2ID else Argon2Type.D
350 logger.debug("Starting Argon2 derivation (%s)", config.variant.display_name)
352 derived = hash_secret_raw(
353 secret=password,
354 salt=config.salt,
355 time_cost=config.iterations,
356 memory_cost=config.memory_kib,
357 parallelism=config.parallelism,
358 hash_len=32,
359 type=argon2_type,
360 )
361 logger.debug("Argon2 derivation complete")
362 return SecureBytes(derived)
365def derive_key_aes_kdf(
366 password: bytes,
367 config: AesKdfConfig,
368) -> SecureBytes:
369 """Derive a 32-byte key using legacy AES-KDF.
371 This performs repeated AES-ECB encryption of the password
372 using the salt as key. Only use for KDBX3 compatibility.
374 Args:
375 password: 32-byte password hash
376 config: AES-KDF configuration
378 Returns:
379 32-byte derived key wrapped in SecureBytes
381 Raises:
382 ValueError: If password is not 32 bytes
383 """
384 if len(password) != 32:
385 raise KdfError("AES-KDF requires 32-byte input")
387 logger.debug("Starting AES-KDF with %d rounds", config.rounds)
389 from Cryptodome.Cipher import AES
391 cipher = AES.new(config.salt, AES.MODE_ECB)
393 # Split into two 16-byte blocks and encrypt repeatedly
394 block1 = bytearray(password[:16])
395 block2 = bytearray(password[16:])
397 for _ in range(config.rounds):
398 block1 = bytearray(cipher.encrypt(bytes(block1)))
399 block2 = bytearray(cipher.encrypt(bytes(block2)))
401 # Combine and hash
402 combined = bytearray(bytes(block1) + bytes(block2))
403 derived = hashlib.sha256(combined).digest()
405 # Zeroize all intermediate values
406 for i in range(16):
407 block1[i] = 0
408 block2[i] = 0
409 for i in range(32):
410 combined[i] = 0
412 logger.debug("AES-KDF complete")
413 return SecureBytes(derived)
416def derive_composite_key(
417 password: str | None = None,
418 keyfile_data: bytes | None = None,
419 yubikey_response: bytes | None = None,
420) -> SecureBytes:
421 """Create composite key from password, keyfile, and/or YubiKey response.
423 The composite key is SHA-256(password_hash || keyfile_key || challenge_result).
425 KeePassXC-compatible YubiKey handling:
426 - The YubiKey response (20 bytes HMAC-SHA1) is SHA-256 hashed
427 - This hash is appended to the other key components before final SHA-256
428 - The challenge used to obtain the response should be the KDF salt
430 The keyfile_key is processed according to KeePass keyfile format rules.
432 Args:
433 password: Optional password string
434 keyfile_data: Optional keyfile contents
435 yubikey_response: Optional 20-byte YubiKey HMAC-SHA1 response
437 Returns:
438 32-byte composite key wrapped in SecureBytes
440 Raises:
441 MissingCredentialsError: If no credentials are provided
442 ValueError: If yubikey_response is provided but wrong size
443 """
444 if password is None and keyfile_data is None and yubikey_response is None:
445 raise MissingCredentialsError()
447 if yubikey_response is not None and len(yubikey_response) != 20:
448 raise ValueError(
449 f"YubiKey response must be 20 bytes (HMAC-SHA1), got {len(yubikey_response)}"
450 )
452 logger.debug(
453 "Deriving composite key (password=%s, keyfile=%s, yubikey=%s)",
454 password is not None,
455 keyfile_data is not None,
456 yubikey_response is not None,
457 )
459 parts: list[bytes] = []
460 secure_parts: list[SecureBytes] = []
462 try:
463 if password is not None:
464 # Wrap password hash in SecureBytes for proper zeroization
465 pwd_hash = SecureBytes(hashlib.sha256(password.encode("utf-8")).digest())
466 secure_parts.append(pwd_hash)
467 parts.append(pwd_hash.data)
469 if keyfile_data is not None:
470 key_bytes = parse_keyfile(keyfile_data)
471 parts.append(key_bytes)
473 if yubikey_response is not None:
474 # KeePassXC: challenge() returns SHA256 of all CR keys' rawKey
475 # rawKey for ChallengeResponseKey is the raw YubiKey response
476 challenge_result = hashlib.sha256(yubikey_response).digest()
477 parts.append(challenge_result)
479 composite = hashlib.sha256(b"".join(parts)).digest()
480 return SecureBytes(composite)
481 finally:
482 # Zeroize intermediate values
483 for sp in secure_parts:
484 sp.zeroize()