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

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 

16from dataclasses import dataclass 

17from enum import Enum 

18from typing import TYPE_CHECKING 

19 

20from argon2.low_level import Type as Argon2Type 

21from argon2.low_level import hash_secret_raw 

22 

23from kdbxtool.exceptions import KdfError, MissingCredentialsError 

24 

25from .keyfile import parse_keyfile 

26from .memory import SecureBytes 

27 

28if TYPE_CHECKING: 

29 pass 

30 

31 

32class KdfType(Enum): 

33 """Supported Key Derivation Functions in KDBX. 

34 

35 The UUID values are defined in the KDBX specification. 

36 """ 

37 

38 ARGON2D = bytes.fromhex("ef636ddf8c29444b91f7a9a403e30a0c") 

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

40 AES_KDF = bytes.fromhex("c9d9f39a628a4460bf740d08c18a4fea") 

41 

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] 

51 

52 @classmethod 

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

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

55 

56 Args: 

57 uuid_bytes: 16-byte KDF identifier from KDBX header 

58 

59 Returns: 

60 The corresponding KdfType enum value 

61 

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

69 

70 

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 

76 

77 

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

79class Argon2Config: 

80 """Configuration for Argon2 key derivation. 

81 

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

89 

90 memory_kib: int 

91 iterations: int 

92 parallelism: int 

93 salt: bytes 

94 variant: KdfType = KdfType.ARGON2D 

95 

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

102 

103 def validate_security(self) -> None: 

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

105 

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

122 

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. 

130 

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

132 

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. 

137 

138 Returns: 

139 Argon2Config with recommended security parameters 

140 """ 

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

142 

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. 

150 

151 Suitable for most use cases. Provides good security with 

152 reasonable unlock times on modern hardware. 

153 

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

155 

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. 

161 

162 Returns: 

163 Argon2Config with standard security parameters 

164 """ 

165 import os 

166 

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 ) 

176 

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. 

184 

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

186 Provides stronger protection against brute-force attacks. 

187 

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

189 

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. 

194 

195 Returns: 

196 Argon2Config with high security parameters 

197 """ 

198 import os 

199 

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 ) 

209 

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

217 

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

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

220 

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

222 

223 Args: 

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

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

226 

227 Returns: 

228 Argon2Config with minimal parameters 

229 """ 

230 import os 

231 

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 ) 

241 

242 

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

244class AesKdfConfig: 

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

246 

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. 

250 

251 Attributes: 

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

253 salt: 32-byte salt 

254 """ 

255 

256 rounds: int 

257 salt: bytes 

258 

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

265 

266 @classmethod 

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

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

269 

270 Uses 600,000 rounds which provides reasonable security while 

271 keeping unlock times acceptable on most hardware. 

272 

273 Args: 

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

275 

276 Returns: 

277 AesKdfConfig with standard parameters 

278 """ 

279 import os 

280 

281 if salt is None: 

282 salt = os.urandom(32) 

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

284 

285 @classmethod 

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

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

288 

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

290 longer unlock times. 

291 

292 Args: 

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

294 

295 Returns: 

296 AesKdfConfig with high security parameters 

297 """ 

298 import os 

299 

300 if salt is None: 

301 salt = os.urandom(32) 

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

303 

304 @classmethod 

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

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

307 

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

309 Only use for testing or development. 

310 

311 Args: 

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

313 

314 Returns: 

315 AesKdfConfig with minimal parameters 

316 """ 

317 import os 

318 

319 if salt is None: 

320 salt = os.urandom(32) 

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

322 

323 

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. 

331 

332 Args: 

333 password: Password bytes (usually composite key hash) 

334 config: Argon2 configuration parameters 

335 enforce_minimums: If True, reject weak parameters 

336 

337 Returns: 

338 32-byte derived key wrapped in SecureBytes 

339 

340 Raises: 

341 ValueError: If parameters are invalid or below minimums 

342 """ 

343 if enforce_minimums: 

344 config.validate_security() 

345 

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

347 

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) 

358 

359 

360def derive_key_aes_kdf( 

361 password: bytes, 

362 config: AesKdfConfig, 

363) -> SecureBytes: 

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

365 

366 This performs repeated AES-ECB encryption of the password 

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

368 

369 Args: 

370 password: 32-byte password hash 

371 config: AES-KDF configuration 

372 

373 Returns: 

374 32-byte derived key wrapped in SecureBytes 

375 

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

381 

382 from Cryptodome.Cipher import AES 

383 

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

385 

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

387 block1 = bytearray(password[:16]) 

388 block2 = bytearray(password[16:]) 

389 

390 for _ in range(config.rounds): 

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

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

393 

394 # Combine and hash 

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

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

397 

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 

404 

405 return SecureBytes(derived) 

406 

407 

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. 

414 

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

416 

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 

421 

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

423 

424 Args: 

425 password: Optional password string 

426 keyfile_data: Optional keyfile contents 

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

428 

429 Returns: 

430 32-byte composite key wrapped in SecureBytes 

431 

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

438 

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 ) 

443 

444 parts: list[bytes] = [] 

445 secure_parts: list[SecureBytes] = [] 

446 

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) 

453 

454 if keyfile_data is not None: 

455 key_bytes = parse_keyfile(keyfile_data) 

456 parts.append(key_bytes) 

457 

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) 

463 

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