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

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 os 

15from enum import Enum 

16from typing import TYPE_CHECKING 

17 

18from Cryptodome.Cipher import AES, ChaCha20 

19 

20from kdbxtool.exceptions import TwofishNotAvailableError, UnknownCipherError 

21 

22# Optional Twofish support via oxifish 

23try: 

24 from oxifish import TwofishCBC 

25 

26 TWOFISH_AVAILABLE = True 

27except ImportError: 

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

29 TWOFISH_AVAILABLE = False 

30 

31if TYPE_CHECKING: 

32 pass 

33 

34 

35class Cipher(Enum): 

36 """Supported ciphers for KDBX encryption. 

37 

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 

42 

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

44 Authentication is provided by the HMAC block stream. 

45 

46 The UUID values are defined in the KDBX specification. 

47 """ 

48 

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

50 CHACHA20 = bytes.fromhex("d6038a2b8b6f4cb5a524339a31dbb59a") 

51 TWOFISH256_CBC = bytes.fromhex("ad68f29f576f4bb9a36ad47af965346c") 

52 

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 

57 

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 

66 

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" 

75 

76 @classmethod 

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

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

79 

80 Args: 

81 uuid_bytes: 16-byte cipher identifier from KDBX header 

82 

83 Returns: 

84 The corresponding Cipher enum value 

85 

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) 

93 

94 

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

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

97 

98 This prevents timing attacks where an attacker could measure 

99 response time differences to deduce secret values. 

100 

101 Args: 

102 a: First byte sequence 

103 b: Second byte sequence 

104 

105 Returns: 

106 True if sequences are equal, False otherwise 

107 """ 

108 return hmac.compare_digest(a, b) 

109 

110 

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

112 """Generate cryptographically secure random bytes. 

113 

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

115 

116 Args: 

117 n: Number of random bytes to generate 

118 

119 Returns: 

120 n cryptographically random bytes 

121 """ 

122 return os.urandom(n) 

123 

124 

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

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

127 

128 Args: 

129 key: HMAC key 

130 data: Data to authenticate 

131 

132 Returns: 

133 32-byte HMAC-SHA256 digest 

134 """ 

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

136 

137 

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

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

140 

141 Args: 

142 key: HMAC key 

143 data: Data that was authenticated 

144 expected_mac: Expected MAC value to verify against 

145 

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) 

151 

152 

153class CipherContext: 

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

155 

156 This class wraps PyCryptodome cipher implementations with a 

157 consistent interface for KDBX operations. 

158 """ 

159 

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

161 """Initialize cipher context. 

162 

163 Args: 

164 cipher: Which cipher algorithm to use 

165 key: Encryption key (32 bytes) 

166 iv: Initialization vector/nonce 

167 

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

174 

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 ) 

183 

184 self._cipher = cipher 

185 self._key = key 

186 self._iv = iv 

187 

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

189 """Encrypt plaintext data. 

190 

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

193 

194 Args: 

195 plaintext: Data to encrypt 

196 

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) 

209 

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

211 """Decrypt ciphertext data. 

212 

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

214 For ChaCha20, returns stream-decrypted plaintext. 

215 

216 Args: 

217 ciphertext: Data to decrypt 

218 

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)