Coverage for src / kdbxtool / security / keyfile.py: 99%

75 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-01-20 19:19 +0000

1"""KeePass keyfile creation and parsing. 

2 

3This module provides support for all KeePass keyfile formats: 

4- XML v2.0: Recommended format with hex-encoded key and SHA-256 hash verification 

5- XML v1.0: Legacy format with base64-encoded key 

6- RAW_32: Raw 32-byte binary key 

7- HEX_64: 64-character hex string 

8 

9Example: 

10 from kdbxtool import create_keyfile, KeyFileVersion 

11 

12 # Create recommended XML v2.0 keyfile 

13 create_keyfile("my.keyx", version=KeyFileVersion.XML_V2) 

14 

15 # Create raw 32-byte keyfile 

16 create_keyfile("my.key", version=KeyFileVersion.RAW_32) 

17""" 

18 

19from __future__ import annotations 

20 

21import hashlib 

22import logging 

23import os 

24from enum import StrEnum 

25from pathlib import Path 

26 

27from kdbxtool.exceptions import InvalidKeyFileError 

28 

29from .crypto import constant_time_compare 

30 

31logger = logging.getLogger(__name__) 

32 

33 

34class KeyFileVersion(StrEnum): 

35 """Supported KeePass keyfile formats. 

36 

37 Attributes: 

38 XML_V2: XML format v2.0 with hex-encoded key and SHA-256 hash verification. 

39 This is the recommended format for new keyfiles. Uses .keyx extension. 

40 XML_V1: Legacy XML format v1.0 with base64-encoded key. 

41 Supported for compatibility. Uses .key extension. 

42 RAW_32: Raw 32-byte binary key. Simple but no integrity verification. 

43 HEX_64: 64-character hex string (32 bytes encoded as hex). 

44 """ 

45 

46 XML_V2 = "xml_v2" 

47 XML_V1 = "xml_v1" 

48 RAW_32 = "raw_32" 

49 HEX_64 = "hex_64" 

50 

51 

52def create_keyfile_bytes(version: KeyFileVersion = KeyFileVersion.XML_V2) -> bytes: 

53 """Create a new keyfile and return its contents as bytes. 

54 

55 Generates a cryptographically secure 32-byte random key and encodes it 

56 in the specified format. 

57 

58 Args: 

59 version: Keyfile format to use. Defaults to XML_V2 (recommended). 

60 

61 Returns: 

62 Keyfile contents as bytes, ready to write to a file. 

63 

64 Example: 

65 keyfile_data = create_keyfile_bytes(KeyFileVersion.XML_V2) 

66 with open("my.keyx", "wb") as f: 

67 f.write(keyfile_data) 

68 """ 

69 # Generate 32 bytes of cryptographically secure random data 

70 key_bytes = os.urandom(32) 

71 

72 if version == KeyFileVersion.XML_V2: 

73 return _create_xml_v2(key_bytes) 

74 elif version == KeyFileVersion.XML_V1: 

75 return _create_xml_v1(key_bytes) 

76 elif version == KeyFileVersion.RAW_32: 

77 return key_bytes 

78 elif version == KeyFileVersion.HEX_64: 

79 return key_bytes.hex().encode("ascii") 

80 else: 

81 raise ValueError(f"Unknown keyfile version: {version}") 

82 

83 

84def create_keyfile( 

85 path: str | Path, 

86 version: KeyFileVersion = KeyFileVersion.XML_V2, 

87) -> None: 

88 """Create a new keyfile at the specified path. 

89 

90 Generates a cryptographically secure 32-byte random key and saves it 

91 in the specified format. 

92 

93 Args: 

94 path: Path where the keyfile will be created. 

95 version: Keyfile format to use. Defaults to XML_V2 (recommended). 

96 

97 Raises: 

98 OSError: If the file cannot be written. 

99 

100 Example: 

101 # Create XML v2.0 keyfile (recommended) 

102 create_keyfile("vault.keyx") 

103 

104 # Create raw binary keyfile 

105 create_keyfile("vault.key", version=KeyFileVersion.RAW_32) 

106 """ 

107 logger.info("Creating %s keyfile", version.value) 

108 keyfile_data = create_keyfile_bytes(version) 

109 Path(path).write_bytes(keyfile_data) 

110 

111 

112def parse_keyfile(keyfile_data: bytes) -> bytes: 

113 """Parse keyfile data and extract the 32-byte key. 

114 

115 KeePass supports several keyfile formats: 

116 1. XML keyfile (v1.0 or v2.0) - key is base64/hex encoded in XML 

117 2. 32-byte raw binary - used directly 

118 3. 64-byte hex string - decoded from hex 

119 4. Any other size - SHA-256 hashed 

120 

121 Args: 

122 keyfile_data: Raw keyfile contents. 

123 

124 Returns: 

125 32-byte key derived from keyfile. 

126 

127 Raises: 

128 InvalidKeyFileError: If keyfile format is invalid or hash verification fails. 

129 """ 

130 # Try parsing as XML keyfile 

131 try: 

132 import base64 

133 

134 import defusedxml.ElementTree as ET 

135 

136 tree = ET.fromstring(keyfile_data) 

137 version_elem = tree.find("Meta/Version") 

138 data_elem = tree.find("Key/Data") 

139 

140 if version_elem is not None and data_elem is not None: 

141 version = version_elem.text or "" 

142 if version.startswith("1.0"): 

143 # Version 1.0: base64 encoded 

144 logger.debug("Parsing XML v1.0 keyfile") 

145 return base64.b64decode(data_elem.text or "") 

146 elif version.startswith("2.0"): 

147 # Version 2.0: hex encoded with hash verification 

148 logger.debug("Parsing XML v2.0 keyfile") 

149 key_hex = (data_elem.text or "").strip() 

150 key_bytes = bytes.fromhex(key_hex) 

151 # Verify hash if present (constant-time comparison) 

152 if "Hash" in data_elem.attrib: 

153 expected_hash = bytes.fromhex(data_elem.attrib["Hash"]) 

154 computed_hash = hashlib.sha256(key_bytes).digest()[:4] 

155 if not constant_time_compare(expected_hash, computed_hash): 

156 raise InvalidKeyFileError("Keyfile hash verification failed") 

157 return key_bytes 

158 except (ET.ParseError, ValueError, AttributeError): 

159 pass # Not an XML keyfile 

160 

161 # Check for raw 32-byte key 

162 if len(keyfile_data) == 32: 

163 logger.debug("Using raw 32-byte keyfile") 

164 return keyfile_data 

165 

166 # Check for 64-byte hex-encoded key 

167 if len(keyfile_data) == 64: 

168 try: 

169 # Verify it's valid hex 

170 int(keyfile_data, 16) 

171 logger.debug("Using 64-byte hex keyfile") 

172 return bytes.fromhex(keyfile_data.decode("ascii")) 

173 except (ValueError, UnicodeDecodeError): 

174 pass # Not hex 

175 

176 # Hash anything else 

177 logger.debug("Hashing %d-byte keyfile", len(keyfile_data)) 

178 return hashlib.sha256(keyfile_data).digest() 

179 

180 

181def _create_xml_v2(key_bytes: bytes) -> bytes: 

182 """Create XML v2.0 keyfile content. 

183 

184 Format: 

185 <?xml version="1.0" encoding="utf-8"?> 

186 <KeyFile> 

187 <Meta> 

188 <Version>2.0</Version> 

189 </Meta> 

190 <Key> 

191 <Data Hash="XXXXXXXX">hex-encoded-key</Data> 

192 </Key> 

193 </KeyFile> 

194 

195 The Hash attribute contains the first 4 bytes of SHA-256(key) as hex. 

196 """ 

197 key_hex = key_bytes.hex().upper() 

198 hash_hex = hashlib.sha256(key_bytes).digest()[:4].hex().upper() 

199 

200 xml = f"""<?xml version="1.0" encoding="utf-8"?> 

201<KeyFile> 

202\t<Meta> 

203\t\t<Version>2.0</Version> 

204\t</Meta> 

205\t<Key> 

206\t\t<Data Hash="{hash_hex}">{key_hex}</Data> 

207\t</Key> 

208</KeyFile> 

209""" 

210 return xml.encode("utf-8") 

211 

212 

213def _create_xml_v1(key_bytes: bytes) -> bytes: 

214 """Create XML v1.0 keyfile content. 

215 

216 Format: 

217 <?xml version="1.0" encoding="utf-8"?> 

218 <KeyFile> 

219 <Meta> 

220 <Version>1.00</Version> 

221 </Meta> 

222 <Key> 

223 <Data>base64-encoded-key</Data> 

224 </Key> 

225 </KeyFile> 

226 """ 

227 import base64 

228 

229 key_b64 = base64.b64encode(key_bytes).decode("ascii") 

230 

231 xml = f"""<?xml version="1.0" encoding="utf-8"?> 

232<KeyFile> 

233\t<Meta> 

234\t\t<Version>1.00</Version> 

235\t</Meta> 

236\t<Key> 

237\t\t<Data>{key_b64}</Data> 

238\t</Key> 

239</KeyFile> 

240""" 

241 return xml.encode("utf-8")