Coverage for src / kdbxtool / security / crypto.py: 95%
81 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"""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 logging
15import os
16from enum import Enum
17from typing import TYPE_CHECKING
19from Cryptodome.Cipher import AES, ChaCha20
21from kdbxtool.exceptions import TwofishNotAvailableError, UnknownCipherError
23# Optional Twofish support via oxifish
24try:
25 from oxifish import TwofishCBC
27 TWOFISH_AVAILABLE = True
28except ImportError:
29 TwofishCBC = None # type: ignore[misc,assignment]
30 TWOFISH_AVAILABLE = False
32if TYPE_CHECKING:
33 pass
35logger = logging.getLogger(__name__)
38class Cipher(Enum):
39 """Supported ciphers for KDBX encryption.
41 KDBX supports three ciphers:
42 - AES-256-CBC: Traditional cipher, widely supported
43 - ChaCha20: Modern stream cipher, faster in software
44 - Twofish-256-CBC: Legacy cipher, requires oxifish package
46 Note: KDBX uses plain ChaCha20, not ChaCha20-Poly1305.
47 Authentication is provided by the HMAC block stream.
49 The UUID values are defined in the KDBX specification.
50 """
52 AES256_CBC = bytes.fromhex("31c1f2e6bf714350be5805216afc5aff")
53 CHACHA20 = bytes.fromhex("d6038a2b8b6f4cb5a524339a31dbb59a")
54 TWOFISH256_CBC = bytes.fromhex("ad68f29f576f4bb9a36ad47af965346c")
56 @property
57 def key_size(self) -> int:
58 """Return the key size in bytes for this cipher."""
59 return 32 # Both use 256-bit keys
61 @property
62 def iv_size(self) -> int:
63 """Return the IV/nonce size in bytes for this cipher."""
64 if self == Cipher.AES256_CBC:
65 return 16 # AES block size
66 if self == Cipher.TWOFISH256_CBC:
67 return 16 # Twofish block size
68 return 12 # ChaCha20 nonce
70 @property
71 def display_name(self) -> str:
72 """Human-readable cipher name."""
73 if self == Cipher.AES256_CBC:
74 return "AES-256-CBC"
75 if self == Cipher.TWOFISH256_CBC:
76 return "Twofish-256-CBC"
77 return "ChaCha20"
79 @classmethod
80 def from_uuid(cls, uuid_bytes: bytes) -> Cipher:
81 """Look up cipher by its KDBX UUID.
83 Args:
84 uuid_bytes: 16-byte cipher identifier from KDBX header
86 Returns:
87 The corresponding Cipher enum value
89 Raises:
90 ValueError: If the UUID doesn't match any known cipher
91 """
92 for cipher in cls:
93 if cipher.value == uuid_bytes:
94 return cipher
95 raise UnknownCipherError(uuid_bytes)
98def constant_time_compare(a: bytes | bytearray, b: bytes | bytearray) -> bool:
99 """Compare two byte sequences in constant time.
101 This prevents timing attacks where an attacker could measure
102 response time differences to deduce secret values.
104 Args:
105 a: First byte sequence
106 b: Second byte sequence
108 Returns:
109 True if sequences are equal, False otherwise
110 """
111 return hmac.compare_digest(a, b)
114def secure_random_bytes(n: int) -> bytes:
115 """Generate cryptographically secure random bytes.
117 Uses os.urandom which is suitable for cryptographic use.
119 Args:
120 n: Number of random bytes to generate
122 Returns:
123 n cryptographically random bytes
124 """
125 return os.urandom(n)
128def compute_hmac_sha256(key: bytes, data: bytes) -> bytes:
129 """Compute HMAC-SHA256 of data using the given key.
131 Args:
132 key: HMAC key
133 data: Data to authenticate
135 Returns:
136 32-byte HMAC-SHA256 digest
137 """
138 return hmac.new(key, data, "sha256").digest()
141def verify_hmac_sha256(key: bytes, data: bytes, expected_mac: bytes) -> bool:
142 """Verify HMAC-SHA256 in constant time.
144 Args:
145 key: HMAC key
146 data: Data that was authenticated
147 expected_mac: Expected MAC value to verify against
149 Returns:
150 True if MAC is valid, False otherwise
151 """
152 computed = compute_hmac_sha256(key, data)
153 return constant_time_compare(computed, expected_mac)
156class CipherContext:
157 """Context for encrypting or decrypting data with a KDBX cipher.
159 This class wraps PyCryptodome cipher implementations with a
160 consistent interface for KDBX operations.
161 """
163 def __init__(self, cipher: Cipher, key: bytes, iv: bytes) -> None:
164 """Initialize cipher context.
166 Args:
167 cipher: Which cipher algorithm to use
168 key: Encryption key (32 bytes)
169 iv: Initialization vector/nonce
171 Raises:
172 ValueError: If key or IV size is incorrect
173 TwofishNotAvailableError: If Twofish requested but oxifish not installed
174 """
175 if cipher == Cipher.TWOFISH256_CBC and not TWOFISH_AVAILABLE:
176 raise TwofishNotAvailableError()
178 if len(key) != cipher.key_size:
179 raise ValueError(
180 f"{cipher.display_name} requires {cipher.key_size}-byte key, got {len(key)}"
181 )
182 if len(iv) != cipher.iv_size:
183 raise ValueError(
184 f"{cipher.display_name} requires {cipher.iv_size}-byte IV, got {len(iv)}"
185 )
187 self._cipher = cipher
188 self._key = key
189 self._iv = iv
190 logger.debug("Cipher initialized: %s", cipher.display_name)
192 def encrypt(self, plaintext: bytes) -> bytes:
193 """Encrypt plaintext data.
195 For AES-CBC and Twofish-CBC, data must be padded to block size.
196 For ChaCha20, returns stream-encrypted ciphertext (same length as input).
198 Args:
199 plaintext: Data to encrypt
201 Returns:
202 Encrypted data (same length as input for ChaCha20)
203 """
204 if self._cipher == Cipher.AES256_CBC:
205 aes_cipher = AES.new(self._key, AES.MODE_CBC, iv=self._iv)
206 return aes_cipher.encrypt(plaintext)
207 elif self._cipher == Cipher.TWOFISH256_CBC:
208 twofish_cipher = TwofishCBC(self._key)
209 return twofish_cipher.encrypt(plaintext, self._iv)
210 else:
211 chacha_cipher = ChaCha20.new(key=self._key, nonce=self._iv)
212 return chacha_cipher.encrypt(plaintext)
214 def decrypt(self, ciphertext: bytes) -> bytes:
215 """Decrypt ciphertext data.
217 For AES-CBC and Twofish-CBC, returns decrypted data (caller must remove padding).
218 For ChaCha20, returns stream-decrypted plaintext.
220 Args:
221 ciphertext: Data to decrypt
223 Returns:
224 Decrypted plaintext
225 """
226 if self._cipher == Cipher.AES256_CBC:
227 aes_cipher = AES.new(self._key, AES.MODE_CBC, iv=self._iv)
228 return aes_cipher.decrypt(ciphertext)
229 elif self._cipher == Cipher.TWOFISH256_CBC:
230 twofish_cipher = TwofishCBC(self._key)
231 return twofish_cipher.decrypt(ciphertext, self._iv)
232 else:
233 chacha_cipher = ChaCha20.new(key=self._key, nonce=self._iv)
234 return chacha_cipher.decrypt(ciphertext)