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

1"""Key Derivation Functions for KDBX databases. 

2 

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) 

7 

8Security considerations: 

9- Argon2 enforces minimum parameters to prevent weak configurations 

10- All derived keys are returned as SecureBytes for automatic zeroization 

11""" 

12 

13from __future__ import annotations 

14 

15import hashlib 

16import logging 

17from dataclasses import dataclass 

18from enum import Enum 

19from typing import TYPE_CHECKING 

20 

21from argon2.low_level import Type as Argon2Type 

22from argon2.low_level import hash_secret_raw 

23 

24from kdbxtool.exceptions import KdfError, MissingCredentialsError 

25 

26from .keyfile import parse_keyfile 

27from .memory import SecureBytes 

28 

29if TYPE_CHECKING: 

30 pass 

31 

32logger = logging.getLogger(__name__) 

33 

34 

35class KdfType(Enum): 

36 """Supported Key Derivation Functions in KDBX. 

37 

38 The UUID values are defined in the KDBX specification. 

39 """ 

40 

41 ARGON2D = bytes.fromhex("ef636ddf8c29444b91f7a9a403e30a0c") 

42 ARGON2ID = bytes.fromhex("9e298b1956db4773b23dfc3ec6f0a1e6") 

43 AES_KDF = bytes.fromhex("c9d9f39a628a4460bf740d08c18a4fea") 

44 

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] 

54 

55 @classmethod 

56 def from_uuid(cls, uuid_bytes: bytes) -> KdfType: 

57 """Look up KDF by its KDBX UUID. 

58 

59 Args: 

60 uuid_bytes: 16-byte KDF identifier from KDBX header 

61 

62 Returns: 

63 The corresponding KdfType enum value 

64 

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()}") 

72 

73 

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 

79 

80 

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

82class Argon2Config: 

83 """Configuration for Argon2 key derivation. 

84 

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 """ 

92 

93 memory_kib: int 

94 iterations: int 

95 parallelism: int 

96 salt: bytes 

97 variant: KdfType = KdfType.ARGON2D 

98 

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") 

105 

106 def validate_security(self) -> None: 

107 """Check that parameters meet minimum security requirements. 

108 

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)) 

125 

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. 

133 

134 Alias for standard(). Provides balanced security and performance. 

135 

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. 

140 

141 Returns: 

142 Argon2Config with recommended security parameters 

143 """ 

144 return cls.standard(salt=salt, variant=variant) 

145 

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. 

153 

154 Suitable for most use cases. Provides good security with 

155 reasonable unlock times on modern hardware. 

156 

157 Parameters: 64 MiB memory, 3 iterations, 4 parallelism 

158 

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. 

164 

165 Returns: 

166 Argon2Config with standard security parameters 

167 """ 

168 import os 

169 

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 ) 

179 

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. 

187 

188 Use for sensitive data where longer unlock times are acceptable. 

189 Provides stronger protection against brute-force attacks. 

190 

191 Parameters: 256 MiB memory, 10 iterations, 4 parallelism 

192 

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. 

197 

198 Returns: 

199 Argon2Config with high security parameters 

200 """ 

201 import os 

202 

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 ) 

212 

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). 

220 

221 WARNING: This provides minimal security and should only be used 

222 for testing or development. Not suitable for production databases. 

223 

224 Parameters: 16 MiB memory, 3 iterations, 2 parallelism 

225 

226 Args: 

227 salt: Optional salt (32 random bytes generated if not provided) 

228 variant: Argon2 variant (ARGON2D or ARGON2ID). Default is ARGON2D. 

229 

230 Returns: 

231 Argon2Config with minimal parameters 

232 """ 

233 import os 

234 

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 ) 

244 

245 

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

247class AesKdfConfig: 

248 """Configuration for AES-KDF key derivation. 

249 

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. 

253 

254 Attributes: 

255 rounds: Number of AES encryption rounds (higher = slower but more secure) 

256 salt: 32-byte salt 

257 """ 

258 

259 rounds: int 

260 salt: bytes 

261 

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") 

268 

269 @classmethod 

270 def standard(cls, salt: bytes | None = None) -> AesKdfConfig: 

271 """Create configuration with balanced security/performance. 

272 

273 Uses 600,000 rounds which provides reasonable security while 

274 keeping unlock times acceptable on most hardware. 

275 

276 Args: 

277 salt: Optional salt (32 random bytes generated if not provided) 

278 

279 Returns: 

280 AesKdfConfig with standard parameters 

281 """ 

282 import os 

283 

284 if salt is None: 

285 salt = os.urandom(32) 

286 return cls(rounds=600_000, salt=salt) 

287 

288 @classmethod 

289 def high_security(cls, salt: bytes | None = None) -> AesKdfConfig: 

290 """Create configuration for high-security applications. 

291 

292 Uses 6,000,000 rounds for stronger protection at the cost of 

293 longer unlock times. 

294 

295 Args: 

296 salt: Optional salt (32 random bytes generated if not provided) 

297 

298 Returns: 

299 AesKdfConfig with high security parameters 

300 """ 

301 import os 

302 

303 if salt is None: 

304 salt = os.urandom(32) 

305 return cls(rounds=6_000_000, salt=salt) 

306 

307 @classmethod 

308 def fast(cls, salt: bytes | None = None) -> AesKdfConfig: 

309 """Create configuration for fast operations (testing only). 

310 

311 WARNING: Uses only 60,000 rounds which provides minimal security. 

312 Only use for testing or development. 

313 

314 Args: 

315 salt: Optional salt (32 random bytes generated if not provided) 

316 

317 Returns: 

318 AesKdfConfig with minimal parameters 

319 """ 

320 import os 

321 

322 if salt is None: 

323 salt = os.urandom(32) 

324 return cls(rounds=60_000, salt=salt) 

325 

326 

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. 

334 

335 Args: 

336 password: Password bytes (usually composite key hash) 

337 config: Argon2 configuration parameters 

338 enforce_minimums: If True, reject weak parameters 

339 

340 Returns: 

341 32-byte derived key wrapped in SecureBytes 

342 

343 Raises: 

344 ValueError: If parameters are invalid or below minimums 

345 """ 

346 if enforce_minimums: 

347 config.validate_security() 

348 

349 argon2_type = Argon2Type.ID if config.variant == KdfType.ARGON2ID else Argon2Type.D 

350 logger.debug("Starting Argon2 derivation (%s)", config.variant.display_name) 

351 

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) 

363 

364 

365def derive_key_aes_kdf( 

366 password: bytes, 

367 config: AesKdfConfig, 

368) -> SecureBytes: 

369 """Derive a 32-byte key using legacy AES-KDF. 

370 

371 This performs repeated AES-ECB encryption of the password 

372 using the salt as key. Only use for KDBX3 compatibility. 

373 

374 Args: 

375 password: 32-byte password hash 

376 config: AES-KDF configuration 

377 

378 Returns: 

379 32-byte derived key wrapped in SecureBytes 

380 

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") 

386 

387 logger.debug("Starting AES-KDF with %d rounds", config.rounds) 

388 

389 from Cryptodome.Cipher import AES 

390 

391 cipher = AES.new(config.salt, AES.MODE_ECB) 

392 

393 # Split into two 16-byte blocks and encrypt repeatedly 

394 block1 = bytearray(password[:16]) 

395 block2 = bytearray(password[16:]) 

396 

397 for _ in range(config.rounds): 

398 block1 = bytearray(cipher.encrypt(bytes(block1))) 

399 block2 = bytearray(cipher.encrypt(bytes(block2))) 

400 

401 # Combine and hash 

402 combined = bytearray(bytes(block1) + bytes(block2)) 

403 derived = hashlib.sha256(combined).digest() 

404 

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 

411 

412 logger.debug("AES-KDF complete") 

413 return SecureBytes(derived) 

414 

415 

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. 

422 

423 The composite key is SHA-256(password_hash || keyfile_key || challenge_result). 

424 

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 

429 

430 The keyfile_key is processed according to KeePass keyfile format rules. 

431 

432 Args: 

433 password: Optional password string 

434 keyfile_data: Optional keyfile contents 

435 yubikey_response: Optional 20-byte YubiKey HMAC-SHA1 response 

436 

437 Returns: 

438 32-byte composite key wrapped in SecureBytes 

439 

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() 

446 

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 ) 

451 

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 ) 

458 

459 parts: list[bytes] = [] 

460 secure_parts: list[SecureBytes] = [] 

461 

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) 

468 

469 if keyfile_data is not None: 

470 key_bytes = parse_keyfile(keyfile_data) 

471 parts.append(key_bytes) 

472 

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) 

478 

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()