Coverage for src / kdbxtool / security / kdf.py: 100%
143 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"""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
16from dataclasses import dataclass
17from enum import Enum
18from typing import TYPE_CHECKING
20from argon2.low_level import Type as Argon2Type
21from argon2.low_level import hash_secret_raw
23from kdbxtool.exceptions import KdfError, MissingCredentialsError
25from .keyfile import parse_keyfile
26from .memory import SecureBytes
28if TYPE_CHECKING:
29 pass
32class KdfType(Enum):
33 """Supported Key Derivation Functions in KDBX.
35 The UUID values are defined in the KDBX specification.
36 """
38 ARGON2D = bytes.fromhex("ef636ddf8c29444b91f7a9a403e30a0c")
39 ARGON2ID = bytes.fromhex("9e298b1956db4773b23dfc3ec6f0a1e6")
40 AES_KDF = bytes.fromhex("c9d9f39a628a4460bf740d08c18a4fea")
42 @property
43 def display_name(self) -> str:
44 """Human-readable KDF name."""
45 names = {
46 KdfType.ARGON2D: "Argon2d",
47 KdfType.ARGON2ID: "Argon2id",
48 KdfType.AES_KDF: "AES-KDF",
49 }
50 return names[self]
52 @classmethod
53 def from_uuid(cls, uuid_bytes: bytes) -> KdfType:
54 """Look up KDF by its KDBX UUID.
56 Args:
57 uuid_bytes: 16-byte KDF identifier from KDBX header
59 Returns:
60 The corresponding KdfType enum value
62 Raises:
63 ValueError: If the UUID doesn't match any known KDF
64 """
65 for kdf in cls:
66 if kdf.value == uuid_bytes:
67 return kdf
68 raise KdfError(f"Unknown KDF UUID: {uuid_bytes.hex()}")
71# Minimum Argon2 parameters for security
72# Based on OWASP recommendations (as of 2024)
73ARGON2_MIN_MEMORY_KIB = 16 * 1024 # 16 MiB minimum
74ARGON2_MIN_ITERATIONS = 3
75ARGON2_MIN_PARALLELISM = 1
78@dataclass(frozen=True, slots=True)
79class Argon2Config:
80 """Configuration for Argon2 key derivation.
82 Attributes:
83 memory_kib: Memory usage in KiB
84 iterations: Number of iterations (time cost)
85 parallelism: Degree of parallelism
86 salt: Random salt (must be at least 16 bytes)
87 variant: Argon2 variant (Argon2d or Argon2id)
88 """
90 memory_kib: int
91 iterations: int
92 parallelism: int
93 salt: bytes
94 variant: KdfType = KdfType.ARGON2D
96 def __post_init__(self) -> None:
97 """Validate configuration parameters."""
98 if self.variant not in (KdfType.ARGON2D, KdfType.ARGON2ID):
99 raise KdfError(f"Invalid Argon2 variant: {self.variant}")
100 if len(self.salt) < 16:
101 raise KdfError("Argon2 salt must be at least 16 bytes")
103 def validate_security(self) -> None:
104 """Check that parameters meet minimum security requirements.
106 Raises:
107 ValueError: If parameters are below security minimums
108 """
109 issues = []
110 if self.memory_kib < ARGON2_MIN_MEMORY_KIB:
111 issues.append(
112 f"Memory {self.memory_kib} KiB is below minimum {ARGON2_MIN_MEMORY_KIB} KiB"
113 )
114 if self.iterations < ARGON2_MIN_ITERATIONS:
115 issues.append(f"Iterations {self.iterations} is below minimum {ARGON2_MIN_ITERATIONS}")
116 if self.parallelism < ARGON2_MIN_PARALLELISM:
117 issues.append(
118 f"Parallelism {self.parallelism} is below minimum {ARGON2_MIN_PARALLELISM}"
119 )
120 if issues:
121 raise KdfError("Weak Argon2 parameters: " + "; ".join(issues))
123 @classmethod
124 def default(
125 cls,
126 salt: bytes | None = None,
127 variant: KdfType = KdfType.ARGON2D,
128 ) -> Argon2Config:
129 """Create configuration with secure defaults.
131 Alias for standard(). Provides balanced security and performance.
133 Args:
134 salt: Optional salt (32 random bytes generated if not provided)
135 variant: Argon2 variant (ARGON2D or ARGON2ID). Default is ARGON2D
136 which provides better GPU resistance for local password databases.
138 Returns:
139 Argon2Config with recommended security parameters
140 """
141 return cls.standard(salt=salt, variant=variant)
143 @classmethod
144 def standard(
145 cls,
146 salt: bytes | None = None,
147 variant: KdfType = KdfType.ARGON2D,
148 ) -> Argon2Config:
149 """Create configuration with balanced security/performance.
151 Suitable for most use cases. Provides good security with
152 reasonable unlock times on modern hardware.
154 Parameters: 64 MiB memory, 3 iterations, 4 parallelism
156 Args:
157 salt: Optional salt (32 random bytes generated if not provided)
158 variant: Argon2 variant (ARGON2D or ARGON2ID). Default is ARGON2D
159 which provides better GPU resistance for local password databases.
160 Use ARGON2ID if timing attack resistance is needed.
162 Returns:
163 Argon2Config with standard security parameters
164 """
165 import os
167 if salt is None:
168 salt = os.urandom(32)
169 return cls(
170 memory_kib=64 * 1024, # 64 MiB
171 iterations=3,
172 parallelism=4,
173 salt=salt,
174 variant=variant,
175 )
177 @classmethod
178 def high_security(
179 cls,
180 salt: bytes | None = None,
181 variant: KdfType = KdfType.ARGON2D,
182 ) -> Argon2Config:
183 """Create configuration for high-security applications.
185 Use for sensitive data where longer unlock times are acceptable.
186 Provides stronger protection against brute-force attacks.
188 Parameters: 256 MiB memory, 10 iterations, 4 parallelism
190 Args:
191 salt: Optional salt (32 random bytes generated if not provided)
192 variant: Argon2 variant (ARGON2D or ARGON2ID). Default is ARGON2D
193 which provides better GPU resistance for local password databases.
195 Returns:
196 Argon2Config with high security parameters
197 """
198 import os
200 if salt is None:
201 salt = os.urandom(32)
202 return cls(
203 memory_kib=256 * 1024, # 256 MiB
204 iterations=10,
205 parallelism=4,
206 salt=salt,
207 variant=variant,
208 )
210 @classmethod
211 def fast(
212 cls,
213 salt: bytes | None = None,
214 variant: KdfType = KdfType.ARGON2D,
215 ) -> Argon2Config:
216 """Create configuration for fast operations (testing only).
218 WARNING: This provides minimal security and should only be used
219 for testing or development. Not suitable for production databases.
221 Parameters: 16 MiB memory, 3 iterations, 2 parallelism
223 Args:
224 salt: Optional salt (32 random bytes generated if not provided)
225 variant: Argon2 variant (ARGON2D or ARGON2ID). Default is ARGON2D.
227 Returns:
228 Argon2Config with minimal parameters
229 """
230 import os
232 if salt is None:
233 salt = os.urandom(32)
234 return cls(
235 memory_kib=16 * 1024, # 16 MiB (minimum secure)
236 iterations=3,
237 parallelism=2,
238 salt=salt,
239 variant=variant,
240 )
243@dataclass(frozen=True, slots=True)
244class AesKdfConfig:
245 """Configuration for AES-KDF key derivation.
247 AES-KDF is supported in both KDBX3 and KDBX4. While Argon2 is generally
248 recommended for new databases, AES-KDF may be preferred for compatibility
249 with older KeePass clients or on systems where Argon2 is slow.
251 Attributes:
252 rounds: Number of AES encryption rounds (higher = slower but more secure)
253 salt: 32-byte salt
254 """
256 rounds: int
257 salt: bytes
259 def __post_init__(self) -> None:
260 """Validate configuration."""
261 if len(self.salt) != 32:
262 raise KdfError("AES-KDF salt must be exactly 32 bytes")
263 if self.rounds < 1:
264 raise KdfError("AES-KDF rounds must be at least 1")
266 @classmethod
267 def standard(cls, salt: bytes | None = None) -> AesKdfConfig:
268 """Create configuration with balanced security/performance.
270 Uses 600,000 rounds which provides reasonable security while
271 keeping unlock times acceptable on most hardware.
273 Args:
274 salt: Optional salt (32 random bytes generated if not provided)
276 Returns:
277 AesKdfConfig with standard parameters
278 """
279 import os
281 if salt is None:
282 salt = os.urandom(32)
283 return cls(rounds=600_000, salt=salt)
285 @classmethod
286 def high_security(cls, salt: bytes | None = None) -> AesKdfConfig:
287 """Create configuration for high-security applications.
289 Uses 6,000,000 rounds for stronger protection at the cost of
290 longer unlock times.
292 Args:
293 salt: Optional salt (32 random bytes generated if not provided)
295 Returns:
296 AesKdfConfig with high security parameters
297 """
298 import os
300 if salt is None:
301 salt = os.urandom(32)
302 return cls(rounds=6_000_000, salt=salt)
304 @classmethod
305 def fast(cls, salt: bytes | None = None) -> AesKdfConfig:
306 """Create configuration for fast operations (testing only).
308 WARNING: Uses only 60,000 rounds which provides minimal security.
309 Only use for testing or development.
311 Args:
312 salt: Optional salt (32 random bytes generated if not provided)
314 Returns:
315 AesKdfConfig with minimal parameters
316 """
317 import os
319 if salt is None:
320 salt = os.urandom(32)
321 return cls(rounds=60_000, salt=salt)
324def derive_key_argon2(
325 password: bytes,
326 config: Argon2Config,
327 *,
328 enforce_minimums: bool = True,
329) -> SecureBytes:
330 """Derive a 32-byte key using Argon2.
332 Args:
333 password: Password bytes (usually composite key hash)
334 config: Argon2 configuration parameters
335 enforce_minimums: If True, reject weak parameters
337 Returns:
338 32-byte derived key wrapped in SecureBytes
340 Raises:
341 ValueError: If parameters are invalid or below minimums
342 """
343 if enforce_minimums:
344 config.validate_security()
346 argon2_type = Argon2Type.ID if config.variant == KdfType.ARGON2ID else Argon2Type.D
348 derived = hash_secret_raw(
349 secret=password,
350 salt=config.salt,
351 time_cost=config.iterations,
352 memory_cost=config.memory_kib,
353 parallelism=config.parallelism,
354 hash_len=32,
355 type=argon2_type,
356 )
357 return SecureBytes(derived)
360def derive_key_aes_kdf(
361 password: bytes,
362 config: AesKdfConfig,
363) -> SecureBytes:
364 """Derive a 32-byte key using legacy AES-KDF.
366 This performs repeated AES-ECB encryption of the password
367 using the salt as key. Only use for KDBX3 compatibility.
369 Args:
370 password: 32-byte password hash
371 config: AES-KDF configuration
373 Returns:
374 32-byte derived key wrapped in SecureBytes
376 Raises:
377 ValueError: If password is not 32 bytes
378 """
379 if len(password) != 32:
380 raise KdfError("AES-KDF requires 32-byte input")
382 from Cryptodome.Cipher import AES
384 cipher = AES.new(config.salt, AES.MODE_ECB)
386 # Split into two 16-byte blocks and encrypt repeatedly
387 block1 = bytearray(password[:16])
388 block2 = bytearray(password[16:])
390 for _ in range(config.rounds):
391 block1 = bytearray(cipher.encrypt(bytes(block1)))
392 block2 = bytearray(cipher.encrypt(bytes(block2)))
394 # Combine and hash
395 combined = bytearray(bytes(block1) + bytes(block2))
396 derived = hashlib.sha256(combined).digest()
398 # Zeroize all intermediate values
399 for i in range(16):
400 block1[i] = 0
401 block2[i] = 0
402 for i in range(32):
403 combined[i] = 0
405 return SecureBytes(derived)
408def derive_composite_key(
409 password: str | None = None,
410 keyfile_data: bytes | None = None,
411 yubikey_response: bytes | None = None,
412) -> SecureBytes:
413 """Create composite key from password, keyfile, and/or YubiKey response.
415 The composite key is SHA-256(password_hash || keyfile_key || challenge_result).
417 KeePassXC-compatible YubiKey handling:
418 - The YubiKey response (20 bytes HMAC-SHA1) is SHA-256 hashed
419 - This hash is appended to the other key components before final SHA-256
420 - The challenge used to obtain the response should be the KDF salt
422 The keyfile_key is processed according to KeePass keyfile format rules.
424 Args:
425 password: Optional password string
426 keyfile_data: Optional keyfile contents
427 yubikey_response: Optional 20-byte YubiKey HMAC-SHA1 response
429 Returns:
430 32-byte composite key wrapped in SecureBytes
432 Raises:
433 MissingCredentialsError: If no credentials are provided
434 ValueError: If yubikey_response is provided but wrong size
435 """
436 if password is None and keyfile_data is None and yubikey_response is None:
437 raise MissingCredentialsError()
439 if yubikey_response is not None and len(yubikey_response) != 20:
440 raise ValueError(
441 f"YubiKey response must be 20 bytes (HMAC-SHA1), got {len(yubikey_response)}"
442 )
444 parts: list[bytes] = []
445 secure_parts: list[SecureBytes] = []
447 try:
448 if password is not None:
449 # Wrap password hash in SecureBytes for proper zeroization
450 pwd_hash = SecureBytes(hashlib.sha256(password.encode("utf-8")).digest())
451 secure_parts.append(pwd_hash)
452 parts.append(pwd_hash.data)
454 if keyfile_data is not None:
455 key_bytes = parse_keyfile(keyfile_data)
456 parts.append(key_bytes)
458 if yubikey_response is not None:
459 # KeePassXC: challenge() returns SHA256 of all CR keys' rawKey
460 # rawKey for ChallengeResponseKey is the raw YubiKey response
461 challenge_result = hashlib.sha256(yubikey_response).digest()
462 parts.append(challenge_result)
464 composite = hashlib.sha256(b"".join(parts)).digest()
465 return SecureBytes(composite)
466 finally:
467 # Zeroize intermediate values
468 for sp in secure_parts:
469 sp.zeroize()