Coverage for src / kdbxtool / security / crypto.py: 95%
78 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"""Cryptographic primitives and utilities for kdbxtool.
3This module provides:
4- Constant-time comparison functions for authentication
5- Cipher abstractions for KDBX encryption
6- HMAC utilities for integrity verification
8All cryptographic operations use well-audited libraries (PyCryptodome).
9"""
11from __future__ import annotations
13import hmac
14import os
15from enum import Enum
16from typing import TYPE_CHECKING
18from Cryptodome.Cipher import AES, ChaCha20
20from kdbxtool.exceptions import TwofishNotAvailableError, UnknownCipherError
22# Optional Twofish support via oxifish
23try:
24 from oxifish import TwofishCBC
26 TWOFISH_AVAILABLE = True
27except ImportError:
28 TwofishCBC = None # type: ignore[misc,assignment]
29 TWOFISH_AVAILABLE = False
31if TYPE_CHECKING:
32 pass
35class Cipher(Enum):
36 """Supported ciphers for KDBX encryption.
38 KDBX supports three ciphers:
39 - AES-256-CBC: Traditional cipher, widely supported
40 - ChaCha20: Modern stream cipher, faster in software
41 - Twofish-256-CBC: Legacy cipher, requires oxifish package
43 Note: KDBX uses plain ChaCha20, not ChaCha20-Poly1305.
44 Authentication is provided by the HMAC block stream.
46 The UUID values are defined in the KDBX specification.
47 """
49 AES256_CBC = bytes.fromhex("31c1f2e6bf714350be5805216afc5aff")
50 CHACHA20 = bytes.fromhex("d6038a2b8b6f4cb5a524339a31dbb59a")
51 TWOFISH256_CBC = bytes.fromhex("ad68f29f576f4bb9a36ad47af965346c")
53 @property
54 def key_size(self) -> int:
55 """Return the key size in bytes for this cipher."""
56 return 32 # Both use 256-bit keys
58 @property
59 def iv_size(self) -> int:
60 """Return the IV/nonce size in bytes for this cipher."""
61 if self == Cipher.AES256_CBC:
62 return 16 # AES block size
63 if self == Cipher.TWOFISH256_CBC:
64 return 16 # Twofish block size
65 return 12 # ChaCha20 nonce
67 @property
68 def display_name(self) -> str:
69 """Human-readable cipher name."""
70 if self == Cipher.AES256_CBC:
71 return "AES-256-CBC"
72 if self == Cipher.TWOFISH256_CBC:
73 return "Twofish-256-CBC"
74 return "ChaCha20"
76 @classmethod
77 def from_uuid(cls, uuid_bytes: bytes) -> Cipher:
78 """Look up cipher by its KDBX UUID.
80 Args:
81 uuid_bytes: 16-byte cipher identifier from KDBX header
83 Returns:
84 The corresponding Cipher enum value
86 Raises:
87 ValueError: If the UUID doesn't match any known cipher
88 """
89 for cipher in cls:
90 if cipher.value == uuid_bytes:
91 return cipher
92 raise UnknownCipherError(uuid_bytes)
95def constant_time_compare(a: bytes | bytearray, b: bytes | bytearray) -> bool:
96 """Compare two byte sequences in constant time.
98 This prevents timing attacks where an attacker could measure
99 response time differences to deduce secret values.
101 Args:
102 a: First byte sequence
103 b: Second byte sequence
105 Returns:
106 True if sequences are equal, False otherwise
107 """
108 return hmac.compare_digest(a, b)
111def secure_random_bytes(n: int) -> bytes:
112 """Generate cryptographically secure random bytes.
114 Uses os.urandom which is suitable for cryptographic use.
116 Args:
117 n: Number of random bytes to generate
119 Returns:
120 n cryptographically random bytes
121 """
122 return os.urandom(n)
125def compute_hmac_sha256(key: bytes, data: bytes) -> bytes:
126 """Compute HMAC-SHA256 of data using the given key.
128 Args:
129 key: HMAC key
130 data: Data to authenticate
132 Returns:
133 32-byte HMAC-SHA256 digest
134 """
135 return hmac.new(key, data, "sha256").digest()
138def verify_hmac_sha256(key: bytes, data: bytes, expected_mac: bytes) -> bool:
139 """Verify HMAC-SHA256 in constant time.
141 Args:
142 key: HMAC key
143 data: Data that was authenticated
144 expected_mac: Expected MAC value to verify against
146 Returns:
147 True if MAC is valid, False otherwise
148 """
149 computed = compute_hmac_sha256(key, data)
150 return constant_time_compare(computed, expected_mac)
153class CipherContext:
154 """Context for encrypting or decrypting data with a KDBX cipher.
156 This class wraps PyCryptodome cipher implementations with a
157 consistent interface for KDBX operations.
158 """
160 def __init__(self, cipher: Cipher, key: bytes, iv: bytes) -> None:
161 """Initialize cipher context.
163 Args:
164 cipher: Which cipher algorithm to use
165 key: Encryption key (32 bytes)
166 iv: Initialization vector/nonce
168 Raises:
169 ValueError: If key or IV size is incorrect
170 TwofishNotAvailableError: If Twofish requested but oxifish not installed
171 """
172 if cipher == Cipher.TWOFISH256_CBC and not TWOFISH_AVAILABLE:
173 raise TwofishNotAvailableError()
175 if len(key) != cipher.key_size:
176 raise ValueError(
177 f"{cipher.display_name} requires {cipher.key_size}-byte key, got {len(key)}"
178 )
179 if len(iv) != cipher.iv_size:
180 raise ValueError(
181 f"{cipher.display_name} requires {cipher.iv_size}-byte IV, got {len(iv)}"
182 )
184 self._cipher = cipher
185 self._key = key
186 self._iv = iv
188 def encrypt(self, plaintext: bytes) -> bytes:
189 """Encrypt plaintext data.
191 For AES-CBC and Twofish-CBC, data must be padded to block size.
192 For ChaCha20, returns stream-encrypted ciphertext (same length as input).
194 Args:
195 plaintext: Data to encrypt
197 Returns:
198 Encrypted data (same length as input for ChaCha20)
199 """
200 if self._cipher == Cipher.AES256_CBC:
201 aes_cipher = AES.new(self._key, AES.MODE_CBC, iv=self._iv)
202 return aes_cipher.encrypt(plaintext)
203 elif self._cipher == Cipher.TWOFISH256_CBC:
204 twofish_cipher = TwofishCBC(self._key)
205 return twofish_cipher.encrypt(plaintext, self._iv)
206 else:
207 chacha_cipher = ChaCha20.new(key=self._key, nonce=self._iv)
208 return chacha_cipher.encrypt(plaintext)
210 def decrypt(self, ciphertext: bytes) -> bytes:
211 """Decrypt ciphertext data.
213 For AES-CBC and Twofish-CBC, returns decrypted data (caller must remove padding).
214 For ChaCha20, returns stream-decrypted plaintext.
216 Args:
217 ciphertext: Data to decrypt
219 Returns:
220 Decrypted plaintext
221 """
222 if self._cipher == Cipher.AES256_CBC:
223 aes_cipher = AES.new(self._key, AES.MODE_CBC, iv=self._iv)
224 return aes_cipher.decrypt(ciphertext)
225 elif self._cipher == Cipher.TWOFISH256_CBC:
226 twofish_cipher = TwofishCBC(self._key)
227 return twofish_cipher.decrypt(ciphertext, self._iv)
228 else:
229 chacha_cipher = ChaCha20.new(key=self._key, nonce=self._iv)
230 return chacha_cipher.decrypt(ciphertext)