Skip to content

Commit fbef7fb

Browse files
authored
Add keyfile creation support (#48)
Add keyfile creation support Add new security/keyfile.py module with support for creating KeePass keyfiles in all supported formats: - XML v2.0 (.keyx): Recommended format with hex-encoded key and SHA-256 hash verification - XML v1.0 (.key): Legacy format with base64-encoded key - RAW_32: Raw 32-byte binary key - HEX_64: 64-character hex string New public API: - create_keyfile(path, version) - create keyfile at path - create_keyfile_bytes(version) - return keyfile as bytes - parse_keyfile(data) - parse keyfile and extract key - KeyFileVersion enum for format selection Refactored _process_keyfile() from kdf.py to parse_keyfile() in the new keyfile module for better organization. Closes #47
1 parent 83391d6 commit fbef7fb

File tree

5 files changed

+504
-64
lines changed

5 files changed

+504
-64
lines changed

src/kdbxtool/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@
5454
)
5555
from .models import Attachment, Entry, Group, HistoryEntry, Times
5656
from .security import AesKdfConfig, Argon2Config, Cipher, KdfType
57+
from .security.keyfile import (
58+
KeyFileVersion,
59+
create_keyfile,
60+
create_keyfile_bytes,
61+
parse_keyfile,
62+
)
5763
from .security.yubikey import (
5864
YubiKeyConfig,
5965
check_slot_configured,
@@ -74,6 +80,11 @@
7480
"Times",
7581
"Cipher",
7682
"KdfType",
83+
# Keyfile support
84+
"KeyFileVersion",
85+
"create_keyfile",
86+
"create_keyfile_bytes",
87+
"parse_keyfile",
7788
# YubiKey support
7889
"YubiKeyConfig",
7990
"check_slot_configured",

src/kdbxtool/security/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@
2828
derive_key_aes_kdf,
2929
derive_key_argon2,
3030
)
31+
from .keyfile import (
32+
KeyFileVersion,
33+
create_keyfile,
34+
create_keyfile_bytes,
35+
parse_keyfile,
36+
)
3137
from .memory import SecureBytes
3238
from .yubikey import (
3339
HMAC_SHA1_RESPONSE_SIZE,
@@ -59,6 +65,11 @@
5965
"derive_composite_key",
6066
"derive_key_aes_kdf",
6167
"derive_key_argon2",
68+
# Keyfile
69+
"KeyFileVersion",
70+
"create_keyfile",
71+
"create_keyfile_bytes",
72+
"parse_keyfile",
6273
# YubiKey
6374
"HMAC_SHA1_RESPONSE_SIZE",
6475
"YUBIKEY_AVAILABLE",

src/kdbxtool/security/kdf.py

Lines changed: 3 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@
2020
from argon2.low_level import Type as Argon2Type
2121
from argon2.low_level import hash_secret_raw
2222

23-
from kdbxtool.exceptions import InvalidKeyFileError, KdfError, MissingCredentialsError
23+
from kdbxtool.exceptions import KdfError, MissingCredentialsError
2424

25-
from .crypto import constant_time_compare
25+
from .keyfile import parse_keyfile
2626
from .memory import SecureBytes
2727

2828
if TYPE_CHECKING:
@@ -405,67 +405,6 @@ def derive_key_aes_kdf(
405405
return SecureBytes(derived)
406406

407407

408-
def _process_keyfile(keyfile_data: bytes) -> bytes:
409-
"""Process keyfile data according to KeePass keyfile format.
410-
411-
KeePass supports several keyfile formats:
412-
1. XML keyfile (v1.0 or v2.0) - key is base64/hex encoded in XML
413-
2. 32-byte raw binary - used directly
414-
3. 64-byte hex string - decoded from hex
415-
4. Any other size - SHA-256 hashed
416-
417-
Args:
418-
keyfile_data: Raw keyfile contents
419-
420-
Returns:
421-
32-byte key derived from keyfile
422-
"""
423-
# Try parsing as XML keyfile
424-
try:
425-
import base64
426-
427-
import defusedxml.ElementTree as ET
428-
429-
tree = ET.fromstring(keyfile_data)
430-
version_elem = tree.find("Meta/Version")
431-
data_elem = tree.find("Key/Data")
432-
433-
if version_elem is not None and data_elem is not None:
434-
version = version_elem.text or ""
435-
if version.startswith("1.0"):
436-
# Version 1.0: base64 encoded
437-
return base64.b64decode(data_elem.text or "")
438-
elif version.startswith("2.0"):
439-
# Version 2.0: hex encoded with hash verification
440-
key_hex = (data_elem.text or "").strip()
441-
key_bytes = bytes.fromhex(key_hex)
442-
# Verify hash if present (constant-time comparison)
443-
if "Hash" in data_elem.attrib:
444-
expected_hash = bytes.fromhex(data_elem.attrib["Hash"])
445-
computed_hash = hashlib.sha256(key_bytes).digest()[:4]
446-
if not constant_time_compare(expected_hash, computed_hash):
447-
raise InvalidKeyFileError("Keyfile hash verification failed")
448-
return key_bytes
449-
except (ET.ParseError, ValueError, AttributeError):
450-
pass # Not an XML keyfile
451-
452-
# Check for raw 32-byte key
453-
if len(keyfile_data) == 32:
454-
return keyfile_data
455-
456-
# Check for 64-byte hex-encoded key
457-
if len(keyfile_data) == 64:
458-
try:
459-
# Verify it's valid hex
460-
int(keyfile_data, 16)
461-
return bytes.fromhex(keyfile_data.decode("ascii"))
462-
except (ValueError, UnicodeDecodeError):
463-
pass # Not hex
464-
465-
# Hash anything else
466-
return hashlib.sha256(keyfile_data).digest()
467-
468-
469408
def derive_composite_key(
470409
password: str | None = None,
471410
keyfile_data: bytes | None = None,
@@ -513,7 +452,7 @@ def derive_composite_key(
513452
parts.append(pwd_hash.data)
514453

515454
if keyfile_data is not None:
516-
key_bytes = _process_keyfile(keyfile_data)
455+
key_bytes = parse_keyfile(keyfile_data)
517456
parts.append(key_bytes)
518457

519458
if yubikey_response is not None:

src/kdbxtool/security/keyfile.py

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
"""KeePass keyfile creation and parsing.
2+
3+
This 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+
9+
Example:
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+
19+
from __future__ import annotations
20+
21+
import hashlib
22+
import os
23+
from enum import StrEnum
24+
from pathlib import Path
25+
26+
from kdbxtool.exceptions import InvalidKeyFileError
27+
28+
from .crypto import constant_time_compare
29+
30+
31+
class KeyFileVersion(StrEnum):
32+
"""Supported KeePass keyfile formats.
33+
34+
Attributes:
35+
XML_V2: XML format v2.0 with hex-encoded key and SHA-256 hash verification.
36+
This is the recommended format for new keyfiles. Uses .keyx extension.
37+
XML_V1: Legacy XML format v1.0 with base64-encoded key.
38+
Supported for compatibility. Uses .key extension.
39+
RAW_32: Raw 32-byte binary key. Simple but no integrity verification.
40+
HEX_64: 64-character hex string (32 bytes encoded as hex).
41+
"""
42+
43+
XML_V2 = "xml_v2"
44+
XML_V1 = "xml_v1"
45+
RAW_32 = "raw_32"
46+
HEX_64 = "hex_64"
47+
48+
49+
def create_keyfile_bytes(version: KeyFileVersion = KeyFileVersion.XML_V2) -> bytes:
50+
"""Create a new keyfile and return its contents as bytes.
51+
52+
Generates a cryptographically secure 32-byte random key and encodes it
53+
in the specified format.
54+
55+
Args:
56+
version: Keyfile format to use. Defaults to XML_V2 (recommended).
57+
58+
Returns:
59+
Keyfile contents as bytes, ready to write to a file.
60+
61+
Example:
62+
keyfile_data = create_keyfile_bytes(KeyFileVersion.XML_V2)
63+
with open("my.keyx", "wb") as f:
64+
f.write(keyfile_data)
65+
"""
66+
# Generate 32 bytes of cryptographically secure random data
67+
key_bytes = os.urandom(32)
68+
69+
if version == KeyFileVersion.XML_V2:
70+
return _create_xml_v2(key_bytes)
71+
elif version == KeyFileVersion.XML_V1:
72+
return _create_xml_v1(key_bytes)
73+
elif version == KeyFileVersion.RAW_32:
74+
return key_bytes
75+
elif version == KeyFileVersion.HEX_64:
76+
return key_bytes.hex().encode("ascii")
77+
else:
78+
raise ValueError(f"Unknown keyfile version: {version}")
79+
80+
81+
def create_keyfile(
82+
path: str | Path,
83+
version: KeyFileVersion = KeyFileVersion.XML_V2,
84+
) -> None:
85+
"""Create a new keyfile at the specified path.
86+
87+
Generates a cryptographically secure 32-byte random key and saves it
88+
in the specified format.
89+
90+
Args:
91+
path: Path where the keyfile will be created.
92+
version: Keyfile format to use. Defaults to XML_V2 (recommended).
93+
94+
Raises:
95+
OSError: If the file cannot be written.
96+
97+
Example:
98+
# Create XML v2.0 keyfile (recommended)
99+
create_keyfile("vault.keyx")
100+
101+
# Create raw binary keyfile
102+
create_keyfile("vault.key", version=KeyFileVersion.RAW_32)
103+
"""
104+
keyfile_data = create_keyfile_bytes(version)
105+
Path(path).write_bytes(keyfile_data)
106+
107+
108+
def parse_keyfile(keyfile_data: bytes) -> bytes:
109+
"""Parse keyfile data and extract the 32-byte key.
110+
111+
KeePass supports several keyfile formats:
112+
1. XML keyfile (v1.0 or v2.0) - key is base64/hex encoded in XML
113+
2. 32-byte raw binary - used directly
114+
3. 64-byte hex string - decoded from hex
115+
4. Any other size - SHA-256 hashed
116+
117+
Args:
118+
keyfile_data: Raw keyfile contents.
119+
120+
Returns:
121+
32-byte key derived from keyfile.
122+
123+
Raises:
124+
InvalidKeyFileError: If keyfile format is invalid or hash verification fails.
125+
"""
126+
# Try parsing as XML keyfile
127+
try:
128+
import base64
129+
130+
import defusedxml.ElementTree as ET
131+
132+
tree = ET.fromstring(keyfile_data)
133+
version_elem = tree.find("Meta/Version")
134+
data_elem = tree.find("Key/Data")
135+
136+
if version_elem is not None and data_elem is not None:
137+
version = version_elem.text or ""
138+
if version.startswith("1.0"):
139+
# Version 1.0: base64 encoded
140+
return base64.b64decode(data_elem.text or "")
141+
elif version.startswith("2.0"):
142+
# Version 2.0: hex encoded with hash verification
143+
key_hex = (data_elem.text or "").strip()
144+
key_bytes = bytes.fromhex(key_hex)
145+
# Verify hash if present (constant-time comparison)
146+
if "Hash" in data_elem.attrib:
147+
expected_hash = bytes.fromhex(data_elem.attrib["Hash"])
148+
computed_hash = hashlib.sha256(key_bytes).digest()[:4]
149+
if not constant_time_compare(expected_hash, computed_hash):
150+
raise InvalidKeyFileError("Keyfile hash verification failed")
151+
return key_bytes
152+
except (ET.ParseError, ValueError, AttributeError):
153+
pass # Not an XML keyfile
154+
155+
# Check for raw 32-byte key
156+
if len(keyfile_data) == 32:
157+
return keyfile_data
158+
159+
# Check for 64-byte hex-encoded key
160+
if len(keyfile_data) == 64:
161+
try:
162+
# Verify it's valid hex
163+
int(keyfile_data, 16)
164+
return bytes.fromhex(keyfile_data.decode("ascii"))
165+
except (ValueError, UnicodeDecodeError):
166+
pass # Not hex
167+
168+
# Hash anything else
169+
return hashlib.sha256(keyfile_data).digest()
170+
171+
172+
def _create_xml_v2(key_bytes: bytes) -> bytes:
173+
"""Create XML v2.0 keyfile content.
174+
175+
Format:
176+
<?xml version="1.0" encoding="utf-8"?>
177+
<KeyFile>
178+
<Meta>
179+
<Version>2.0</Version>
180+
</Meta>
181+
<Key>
182+
<Data Hash="XXXXXXXX">hex-encoded-key</Data>
183+
</Key>
184+
</KeyFile>
185+
186+
The Hash attribute contains the first 4 bytes of SHA-256(key) as hex.
187+
"""
188+
key_hex = key_bytes.hex().upper()
189+
hash_hex = hashlib.sha256(key_bytes).digest()[:4].hex().upper()
190+
191+
xml = f"""<?xml version="1.0" encoding="utf-8"?>
192+
<KeyFile>
193+
\t<Meta>
194+
\t\t<Version>2.0</Version>
195+
\t</Meta>
196+
\t<Key>
197+
\t\t<Data Hash="{hash_hex}">{key_hex}</Data>
198+
\t</Key>
199+
</KeyFile>
200+
"""
201+
return xml.encode("utf-8")
202+
203+
204+
def _create_xml_v1(key_bytes: bytes) -> bytes:
205+
"""Create XML v1.0 keyfile content.
206+
207+
Format:
208+
<?xml version="1.0" encoding="utf-8"?>
209+
<KeyFile>
210+
<Meta>
211+
<Version>1.00</Version>
212+
</Meta>
213+
<Key>
214+
<Data>base64-encoded-key</Data>
215+
</Key>
216+
</KeyFile>
217+
"""
218+
import base64
219+
220+
key_b64 = base64.b64encode(key_bytes).decode("ascii")
221+
222+
xml = f"""<?xml version="1.0" encoding="utf-8"?>
223+
<KeyFile>
224+
\t<Meta>
225+
\t\t<Version>1.00</Version>
226+
\t</Meta>
227+
\t<Key>
228+
\t\t<Data>{key_b64}</Data>
229+
\t</Key>
230+
</KeyFile>
231+
"""
232+
return xml.encode("utf-8")

0 commit comments

Comments
 (0)