Skip to content

Commit c87e7a8

Browse files
committed
Add encryption_algorithm and kdf_algorithm setters
Resolves libkeepass#233 - adds the ability to change encryption cipher and KDF algorithm on existing databases. - encryption_algorithm setter: change cipher (aes256, chacha20, twofish) with automatic IV regeneration for correct size - kdf_algorithm setter: change KDF on KDBX4 (argon2id, argon2d, aeskdf) with new salt generation and default parameters - Add build_kdf_parameters_aeskdf helper for AES-KDF parameter structure - Normalize kdf_algorithm getter to return 'argon2d' instead of 'argon2' - Add comprehensive tests for both setters
1 parent 21b9c6c commit c87e7a8

4 files changed

Lines changed: 203 additions & 19 deletions

File tree

pykeepass/kdbx_parsing/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44
build_kdbx_structure,
55
build_kdbx3_structure,
66
build_kdbx4_structure,
7+
build_kdf_parameters_argon2,
8+
build_kdf_parameters_aeskdf,
79
Argon2Config,
810
AesKdfConfig,
911
Cipher,
1012
KdfAlgorithm,
13+
IV_SIZES,
1114
)
1215

1316
__all__ = [
@@ -16,8 +19,11 @@
1619
"build_kdbx_structure",
1720
"build_kdbx3_structure",
1821
"build_kdbx4_structure",
22+
"build_kdf_parameters_argon2",
23+
"build_kdf_parameters_aeskdf",
1924
"Argon2Config",
2025
"AesKdfConfig",
2126
"Cipher",
2227
"KdfAlgorithm",
28+
"IV_SIZES",
2329
]

pykeepass/kdbx_parsing/builder.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,26 @@ def build_kdf_parameters_argon2(kdf: Argon2Config, salt: bytes) -> Container:
296296
)
297297

298298

299+
def build_kdf_parameters_aeskdf(kdf: AesKdfConfig, salt: bytes) -> Container:
300+
"""Build KDBX4 KDF parameters VariantDictionary for AES-KDF.
301+
302+
Args:
303+
kdf: AES-KDF configuration
304+
salt: 32-byte random salt
305+
306+
Returns:
307+
Container with VariantDictionary structure.
308+
"""
309+
return Container(
310+
version=b'\x00\x01',
311+
dict={
312+
'$UUID': Container(type=VD_BYTES, key='$UUID', value=kdf_uuids['aeskdf'], next_byte=VD_UINT64),
313+
'R': Container(type=VD_UINT64, key='R', value=kdf.rounds, next_byte=VD_BYTES),
314+
'S': Container(type=VD_BYTES, key='S', value=salt, next_byte=0x00),
315+
}
316+
)
317+
318+
299319
# -------------------- KDBX Structure Builders --------------------
300320

301321
def build_kdbx4_structure(

pykeepass/pykeepass.py

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,13 @@
2929
KDBX,
3030
kdf_uuids,
3131
build_kdbx_structure,
32+
build_kdf_parameters_argon2,
33+
build_kdf_parameters_aeskdf,
3234
Argon2Config,
3335
AesKdfConfig,
3436
Cipher,
3537
KdfAlgorithm,
38+
IV_SIZES,
3639
)
3740
from .xpath import attachment_xp, entry_xp, group_xp, path_xp
3841

@@ -196,26 +199,61 @@ def version(self):
196199
)
197200

198201
@property
199-
def encryption_algorithm(self):
200-
"""`str`: encryption algorithm used by database during decryption.
201-
Can be one of 'aes256', 'chacha20', or 'twofish'."""
202+
def encryption_algorithm(self) -> str:
203+
"""Encryption algorithm used by database. One of the Cipher enum values."""
202204
return self.kdbx.header.value.dynamic_header.cipher_id.data
203205

206+
@encryption_algorithm.setter
207+
def encryption_algorithm(self, value: Cipher | str) -> None:
208+
"""Set encryption algorithm. Generates new IV with appropriate size."""
209+
cipher = str(Cipher(value))
210+
self.kdbx.header.value.dynamic_header.cipher_id.data = cipher
211+
self.kdbx.header.value.dynamic_header.encryption_iv.data = os.urandom(IV_SIZES[cipher])
212+
self._invalidate_header_cache()
213+
204214
@property
205-
def kdf_algorithm(self):
206-
"""`str`: key derivation algorithm used by database during decryption.
207-
Can be one of 'aeskdf', 'argon2', or 'argon2id'"""
215+
def kdf_algorithm(self) -> str:
216+
"""KDF algorithm used by database. One of the KdfAlgorithm enum values."""
208217
if self.version == (3, 1):
209218
return 'aeskdf'
210219
elif self.version == (4, 0):
211220
kdf_parameters = self.kdbx.header.value.dynamic_header.kdf_parameters.data.dict
212221
if kdf_parameters['$UUID'].value == kdf_uuids['argon2']:
213-
return 'argon2'
222+
return 'argon2d'
214223
elif kdf_parameters['$UUID'].value == kdf_uuids['argon2id']:
215224
return 'argon2id'
216225
elif kdf_parameters['$UUID'].value == kdf_uuids['aeskdf']:
217226
return 'aeskdf'
218227

228+
@kdf_algorithm.setter
229+
def kdf_algorithm(self, value: KdfAlgorithm | str) -> None:
230+
"""Set the KDF algorithm for the database. KDBX4 only.
231+
232+
Note:
233+
Generates new KDF salt. For Argon2, uses default parameters.
234+
For AES-KDF, uses default rounds. Adjust parameters after setting.
235+
"""
236+
if self.version != (4, 0):
237+
raise ValueError("KDF algorithm can only be changed on KDBX4 databases")
238+
239+
# Normalize value
240+
kdf_str = str(value)
241+
if kdf_str == 'argon2':
242+
kdf_str = 'argon2d' # Normalize legacy name
243+
244+
kdf = KdfAlgorithm(kdf_str)
245+
salt = os.urandom(32)
246+
247+
if kdf in (KdfAlgorithm.ARGON2ID, KdfAlgorithm.ARGON2D):
248+
config = Argon2Config(variant=kdf)
249+
new_params = build_kdf_parameters_argon2(config, salt)
250+
else:
251+
config = AesKdfConfig()
252+
new_params = build_kdf_parameters_aeskdf(config, salt)
253+
254+
self.kdbx.header.value.dynamic_header.kdf_parameters.data = new_params
255+
self._invalidate_header_cache()
256+
219257
def _get_kdf_parameters(self):
220258
"""Get KDF parameters dict, raising error if not KDBX4 with Argon2."""
221259
if self.version != (4, 0):

tests/tests.py

Lines changed: 132 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1261,20 +1261,20 @@ def test_open_save(self):
12611261
]
12621262
kdf_algorithms = [
12631263
'aeskdf',
1264-
'argon2',
1264+
'argon2d',
12651265
'aeskdf',
1266-
'argon2',
1267-
'argon2',
1268-
'argon2',
1266+
'argon2d',
1267+
'argon2d',
1268+
'argon2d',
12691269
'aeskdf',
1270-
'argon2',
1270+
'argon2d',
12711271
'aeskdf',
1272-
'argon2',
1273-
'argon2',
1274-
'argon2',
1272+
'argon2d',
1273+
'argon2d',
1274+
'argon2d',
12751275
'argon2id',
1276-
'argon2',
1277-
'argon2',
1276+
'argon2d',
1277+
'argon2d',
12781278
]
12791279
versions = [
12801280
(3, 1),
@@ -1455,7 +1455,7 @@ def test_create_database_argon2d(self):
14551455
password='testpass',
14561456
kdf=Argon2Config(variant=KdfAlgorithm.ARGON2D)
14571457
)
1458-
self.assertEqual(kp.kdf_algorithm, 'argon2')
1458+
self.assertEqual(kp.kdf_algorithm, 'argon2d')
14591459

14601460
def test_create_database_entries_persist(self):
14611461
"""Test that entries persist after save/reload"""
@@ -1572,7 +1572,7 @@ def test_argon2_variant_getter_setter(self):
15721572

15731573
kp2 = PyKeePass(db_path, password='testpass')
15741574
self.assertEqual(kp2.argon2_variant, 'argon2d')
1575-
self.assertEqual(kp2.kdf_algorithm, 'argon2')
1575+
self.assertEqual(kp2.kdf_algorithm, 'argon2d')
15761576
finally:
15771577
os.unlink(db_path)
15781578

@@ -1584,6 +1584,126 @@ def test_argon2_variant_invalid_value(self):
15841584
kp.argon2_variant = 'invalid'
15851585

15861586

1587+
class EncryptionAlgorithmSetterTests(unittest.TestCase):
1588+
"""Tests for encryption_algorithm setter (resolves libkeepass/pykeepass#233)"""
1589+
1590+
def test_encryption_algorithm_setter_kdbx4(self):
1591+
"""Test changing cipher on KDBX4 database"""
1592+
import tempfile
1593+
with tempfile.NamedTemporaryFile(suffix='.kdbx', delete=False) as f:
1594+
db_path = f.name
1595+
try:
1596+
kp = create_database(db_path, password='testpass')
1597+
kp.add_entry(kp.root_group, 'Test Entry', 'user', 'secret123')
1598+
self.assertEqual(kp.encryption_algorithm, 'aes256')
1599+
1600+
kp.encryption_algorithm = 'chacha20'
1601+
kp.save()
1602+
1603+
kp2 = PyKeePass(db_path, password='testpass')
1604+
self.assertEqual(kp2.encryption_algorithm, 'chacha20')
1605+
entry = kp2.find_entries(title='Test Entry', first=True)
1606+
self.assertEqual(entry.password, 'secret123')
1607+
finally:
1608+
os.unlink(db_path)
1609+
1610+
def test_encryption_algorithm_setter_kdbx3(self):
1611+
"""Test changing cipher on KDBX3 database"""
1612+
import tempfile
1613+
with tempfile.NamedTemporaryFile(suffix='.kdbx', delete=False) as f:
1614+
db_path = f.name
1615+
try:
1616+
kp = create_database(db_path, password='testpass', version=3)
1617+
kp.add_entry(kp.root_group, 'Test Entry', 'user', 'secret123')
1618+
self.assertEqual(kp.encryption_algorithm, 'aes256')
1619+
1620+
kp.encryption_algorithm = 'twofish'
1621+
kp.save()
1622+
1623+
kp2 = PyKeePass(db_path, password='testpass')
1624+
self.assertEqual(kp2.encryption_algorithm, 'twofish')
1625+
entry = kp2.find_entries(title='Test Entry', first=True)
1626+
self.assertEqual(entry.password, 'secret123')
1627+
finally:
1628+
os.unlink(db_path)
1629+
1630+
def test_encryption_algorithm_setter_with_enum(self):
1631+
"""Test setting cipher using Cipher enum"""
1632+
with BytesIO() as stream:
1633+
kp = create_database(stream, password='testpass')
1634+
kp.encryption_algorithm = Cipher.CHACHA20
1635+
self.assertEqual(kp.encryption_algorithm, 'chacha20')
1636+
1637+
def test_encryption_algorithm_setter_invalid(self):
1638+
"""Test that invalid cipher raises error"""
1639+
with BytesIO() as stream:
1640+
kp = create_database(stream, password='testpass')
1641+
with self.assertRaises(ValueError):
1642+
kp.encryption_algorithm = 'invalid'
1643+
1644+
1645+
class KdfAlgorithmSetterTests(unittest.TestCase):
1646+
"""Tests for kdf_algorithm setter (resolves libkeepass/pykeepass#233)"""
1647+
1648+
def test_kdf_algorithm_setter_to_aeskdf(self):
1649+
"""Test switching from Argon2 to AES-KDF on KDBX4"""
1650+
import tempfile
1651+
with tempfile.NamedTemporaryFile(suffix='.kdbx', delete=False) as f:
1652+
db_path = f.name
1653+
try:
1654+
kp = create_database(db_path, password='testpass')
1655+
kp.add_entry(kp.root_group, 'Test Entry', 'user', 'secret123')
1656+
self.assertEqual(kp.kdf_algorithm, 'argon2id')
1657+
1658+
kp.kdf_algorithm = 'aeskdf'
1659+
kp.save()
1660+
1661+
kp2 = PyKeePass(db_path, password='testpass')
1662+
self.assertEqual(kp2.kdf_algorithm, 'aeskdf')
1663+
entry = kp2.find_entries(title='Test Entry', first=True)
1664+
self.assertEqual(entry.password, 'secret123')
1665+
finally:
1666+
os.unlink(db_path)
1667+
1668+
def test_kdf_algorithm_setter_to_argon2d(self):
1669+
"""Test switching to Argon2d on KDBX4"""
1670+
import tempfile
1671+
with tempfile.NamedTemporaryFile(suffix='.kdbx', delete=False) as f:
1672+
db_path = f.name
1673+
try:
1674+
kp = create_database(db_path, password='testpass')
1675+
self.assertEqual(kp.kdf_algorithm, 'argon2id')
1676+
1677+
kp.kdf_algorithm = 'argon2d'
1678+
kp.save()
1679+
1680+
kp2 = PyKeePass(db_path, password='testpass')
1681+
self.assertEqual(kp2.kdf_algorithm, 'argon2d')
1682+
finally:
1683+
os.unlink(db_path)
1684+
1685+
def test_kdf_algorithm_setter_with_enum(self):
1686+
"""Test setting KDF using KdfAlgorithm enum"""
1687+
with BytesIO() as stream:
1688+
kp = create_database(stream, password='testpass')
1689+
kp.kdf_algorithm = KdfAlgorithm.AES_KDF
1690+
self.assertEqual(kp.kdf_algorithm, 'aeskdf')
1691+
1692+
def test_kdf_algorithm_setter_invalid_on_kdbx3(self):
1693+
"""Test that changing KDF on KDBX3 raises error"""
1694+
with BytesIO() as stream:
1695+
kp = create_database(stream, password='testpass', version=3)
1696+
with self.assertRaises(ValueError):
1697+
kp.kdf_algorithm = 'argon2id'
1698+
1699+
def test_kdf_algorithm_setter_invalid_value(self):
1700+
"""Test that invalid KDF raises error"""
1701+
with BytesIO() as stream:
1702+
kp = create_database(stream, password='testpass')
1703+
with self.assertRaises(ValueError):
1704+
kp.kdf_algorithm = 'invalid'
1705+
1706+
15871707
class KDBX3CreateDatabaseTests(unittest.TestCase):
15881708
"""Tests for KDBX3 database creation"""
15891709

0 commit comments

Comments
 (0)