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

1"""Cryptographic primitives and utilities for kdbxtool. 

2 

3This module provides: 

4- Constant-time comparison functions for authentication 

5- Cipher abstractions for KDBX encryption 

6- HMAC utilities for integrity verification 

7 

8All cryptographic operations use well-audited libraries (PyCryptodome). 

9""" 

10 

11from __future__ import annotations 

12 

13import hmac 

14import logging 

15import os 

16from enum import Enum 

17from typing import TYPE_CHECKING 

18 

19from Cryptodome.Cipher import AES, ChaCha20 

20 

21from kdbxtool.exceptions import TwofishNotAvailableError, UnknownCipherError 

22 

23# Optional Twofish support via oxifish 

24try: 

25 from oxifish import TwofishCBC 

26 

27 TWOFISH_AVAILABLE = True 

28except ImportError: 

29 TwofishCBC = None # type: ignore[misc,assignment] 

30 TWOFISH_AVAILABLE = False 

31 

32if TYPE_CHECKING: 

33 pass 

34 

35logger = logging.getLogger(__name__) 

36 

37 

38class Cipher(Enum): 

39 """Supported ciphers for KDBX encryption. 

40 

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 

45 

46 Note: KDBX uses plain ChaCha20, not ChaCha20-Poly1305. 

47 Authentication is provided by the HMAC block stream. 

48 

49 The UUID values are defined in the KDBX specification. 

50 """ 

51 

52 AES256_CBC = bytes.fromhex("31c1f2e6bf714350be5805216afc5aff") 

53 CHACHA20 = bytes.fromhex("d6038a2b8b6f4cb5a524339a31dbb59a") 

54 TWOFISH256_CBC = bytes.fromhex("ad68f29f576f4bb9a36ad47af965346c") 

55 

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 

60 

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 

69 

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" 

78 

79 @classmethod 

80 def from_uuid(cls, uuid_bytes: bytes) -> Cipher: 

81 """Look up cipher by its KDBX UUID. 

82 

83 Args: 

84 uuid_bytes: 16-byte cipher identifier from KDBX header 

85 

86 Returns: 

87 The corresponding Cipher enum value 

88 

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) 

96 

97 

98def constant_time_compare(a: bytes | bytearray, b: bytes | bytearray) -> bool: 

99 """Compare two byte sequences in constant time. 

100 

101 This prevents timing attacks where an attacker could measure 

102 response time differences to deduce secret values. 

103 

104 Args: 

105 a: First byte sequence 

106 b: Second byte sequence 

107 

108 Returns: 

109 True if sequences are equal, False otherwise 

110 """ 

111 return hmac.compare_digest(a, b) 

112 

113 

114def secure_random_bytes(n: int) -> bytes: 

115 """Generate cryptographically secure random bytes. 

116 

117 Uses os.urandom which is suitable for cryptographic use. 

118 

119 Args: 

120 n: Number of random bytes to generate 

121 

122 Returns: 

123 n cryptographically random bytes 

124 """ 

125 return os.urandom(n) 

126 

127 

128def compute_hmac_sha256(key: bytes, data: bytes) -> bytes: 

129 """Compute HMAC-SHA256 of data using the given key. 

130 

131 Args: 

132 key: HMAC key 

133 data: Data to authenticate 

134 

135 Returns: 

136 32-byte HMAC-SHA256 digest 

137 """ 

138 return hmac.new(key, data, "sha256").digest() 

139 

140 

141def verify_hmac_sha256(key: bytes, data: bytes, expected_mac: bytes) -> bool: 

142 """Verify HMAC-SHA256 in constant time. 

143 

144 Args: 

145 key: HMAC key 

146 data: Data that was authenticated 

147 expected_mac: Expected MAC value to verify against 

148 

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) 

154 

155 

156class CipherContext: 

157 """Context for encrypting or decrypting data with a KDBX cipher. 

158 

159 This class wraps PyCryptodome cipher implementations with a 

160 consistent interface for KDBX operations. 

161 """ 

162 

163 def __init__(self, cipher: Cipher, key: bytes, iv: bytes) -> None: 

164 """Initialize cipher context. 

165 

166 Args: 

167 cipher: Which cipher algorithm to use 

168 key: Encryption key (32 bytes) 

169 iv: Initialization vector/nonce 

170 

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

177 

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 ) 

186 

187 self._cipher = cipher 

188 self._key = key 

189 self._iv = iv 

190 logger.debug("Cipher initialized: %s", cipher.display_name) 

191 

192 def encrypt(self, plaintext: bytes) -> bytes: 

193 """Encrypt plaintext data. 

194 

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

197 

198 Args: 

199 plaintext: Data to encrypt 

200 

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) 

213 

214 def decrypt(self, ciphertext: bytes) -> bytes: 

215 """Decrypt ciphertext data. 

216 

217 For AES-CBC and Twofish-CBC, returns decrypted data (caller must remove padding). 

218 For ChaCha20, returns stream-decrypted plaintext. 

219 

220 Args: 

221 ciphertext: Data to decrypt 

222 

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)