Skip to content

Commit 5272378

Browse files
committed
Change master seed on each save
Fixes: libkeepass#219
1 parent 8fc9708 commit 5272378

File tree

6 files changed

+86
-29
lines changed

6 files changed

+86
-29
lines changed

pykeepass/kdbx_parsing/common.py

+15-4
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from Cryptodome.Cipher import AES, ChaCha20, Salsa20
22
from .twofish import Twofish
3+
from Cryptodome.Random import get_random_bytes
34
from Cryptodome.Util import Padding as CryptoPadding
45
import hashlib
56
from construct import (
6-
Adapter, BitStruct, BitsSwapped, Container, Flag, Padding, ListContainer, Mapping, GreedyBytes, Int32ul, Switch
7+
Adapter, BitStruct, BitsSwapped, Bytes, Container, Flag, Padding, ListContainer, Mapping, GreedyBytes, Int32ul, Switch, stream_write
78
)
89
from lxml import etree
910
from copy import deepcopy
@@ -20,6 +21,16 @@
2021
log = logging.getLogger(__name__)
2122

2223

24+
class RandomBytes(Bytes):
25+
"""Same as Bytes, but generate random bytes when building"""
26+
27+
def _build(self, obj, stream, context, path):
28+
length = self.length(context) if callable(self.length) else self.length
29+
data = get_random_bytes(length)
30+
stream_write(stream, data, length, path)
31+
return data
32+
33+
2334
class HeaderChecksumError(Exception):
2435
pass
2536

@@ -167,7 +178,7 @@ def compute_master(context):
167178

168179
# combine the transformed key with the header master seed to find the master_key
169180
master_key = hashlib.sha256(
170-
context._.header.value.dynamic_header.master_seed.data +
181+
context._.header.dynamic_header.master_seed.data +
171182
context.transformed_key).digest()
172183
return master_key
173184

@@ -296,7 +307,7 @@ class DecryptedPayload(Adapter):
296307
def _decode(self, payload_data, con, path):
297308
cipher = self.get_cipher(
298309
con.master_key,
299-
con._.header.value.dynamic_header.encryption_iv.data
310+
con._.header.dynamic_header.encryption_iv.data
300311
)
301312
payload_data = cipher.decrypt(payload_data)
302313
# FIXME: Construct ugliness. Fixes #244. First 32 bytes of decrypted kdbx3 payload
@@ -316,7 +327,7 @@ def _encode(self, payload_data, con, path):
316327
payload_data = self.pad(payload_data)
317328
cipher = self.get_cipher(
318329
con.master_key,
319-
con._.header.value.dynamic_header.encryption_iv.data
330+
con._.header.dynamic_header.encryption_iv.data
320331
)
321332
payload_data = cipher.encrypt(payload_data)
322333

pykeepass/kdbx_parsing/kdbx.py

+25-3
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,37 @@
1-
from construct import Struct, Switch, Bytes, Int16ul, RawCopy, Check, this
1+
from construct import Struct, Switch, Bytes, Int16ul, RawCopy, Check, this, stream_seek, stream_tell, stream_read, Subconstruct
22
from .kdbx3 import DynamicHeader as DynamicHeader3
33
from .kdbx3 import Body as Body3
44
from .kdbx4 import DynamicHeader as DynamicHeader4
55
from .kdbx4 import Body as Body4
66

7+
8+
class Copy(Subconstruct):
9+
"""Same as RawCopy, but don't create parent container when parsing.
10+
Instead store data in ._data attribute of subconstruct, and never rebuild from data
11+
"""
12+
13+
def _parse(self, stream, context, path):
14+
offset1 = stream_tell(stream, path)
15+
obj = self.subcon._parsereport(stream, context, path)
16+
offset2 = stream_tell(stream, path)
17+
stream_seek(stream, offset1, 0, path)
18+
obj._data = stream_read(stream, offset2 - offset1, path)
19+
return obj
20+
21+
def _build(self, obj, stream, context, path):
22+
offset1 = stream_tell(stream, path)
23+
obj = self.subcon._build(obj, stream, context, path)
24+
offset2 = stream_tell(stream, path)
25+
stream_seek(stream, offset1, 0, path)
26+
obj._data = stream_read(stream, offset2 - offset1, path)
27+
28+
729
# verify file signature
830
def check_signature(ctx):
931
return ctx.sig1 == b'\x03\xd9\xa2\x9a' and ctx.sig2 == b'\x67\xFB\x4B\xB5'
1032

1133
KDBX = Struct(
12-
"header" / RawCopy(
34+
"header" / Copy(
1335
Struct(
1436
"sig1" / Bytes(4),
1537
"sig2" / Bytes(4),
@@ -25,7 +47,7 @@ def check_signature(ctx):
2547
)
2648
),
2749
"body" / Switch(
28-
this.header.value.major_version,
50+
this.header.major_version,
2951
{3: Body3,
3052
4: Body4
3153
}

pykeepass/kdbx_parsing/kdbx3.py

+9-8
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from .common import (
1111
aes_kdf, AES256Payload, ChaCha20Payload, TwoFishPayload, Concatenated,
1212
DynamicDict, compute_key_composite, Decompressed, Reparsed,
13-
compute_master, CompressionFlags, XML, CipherId, ProtectedStreamId, Unprotect
13+
compute_master, CompressionFlags, XML, CipherId, ProtectedStreamId, Unprotect, RandomBytes
1414
)
1515

1616

@@ -33,8 +33,8 @@ def compute_transformed(context):
3333
keyfile=context._._.keyfile
3434
)
3535
transformed_key = aes_kdf(
36-
context._.header.value.dynamic_header.transform_seed.data,
37-
context._.header.value.dynamic_header.transform_rounds.data,
36+
context._.header.dynamic_header.transform_seed.data,
37+
context._.header.dynamic_header.transform_rounds.data,
3838
key_composite
3939
)
4040

@@ -67,6 +67,7 @@ def compute_transformed(context):
6767
{'compression_flags': CompressionFlags,
6868
'cipher_id': CipherId,
6969
'transform_rounds': Int64ul,
70+
'master_seed': RandomBytes(32),
7071
'protected_stream_id': ProtectedStreamId
7172
},
7273
default=GreedyBytes
@@ -130,16 +131,16 @@ def compute_transformed(context):
130131
# validate payload decryption
131132
"cred_check" / Checksum(
132133
Bytes(32),
133-
lambda this: this._._.header.value.dynamic_header.stream_start_bytes.data,
134+
lambda this: this._._.header.dynamic_header.stream_start_bytes.data,
134135
this,
135136
# exception=CredentialsError
136137
),
137138
"xml" / Unprotect(
138-
this._._.header.value.dynamic_header.protected_stream_id.data,
139-
this._._.header.value.dynamic_header.protected_stream_key.data,
139+
this._._.header.dynamic_header.protected_stream_id.data,
140+
this._._.header.dynamic_header.protected_stream_key.data,
140141
XML(
141142
IfThenElse(
142-
this._._.header.value.dynamic_header.compression_flags.data.compression,
143+
this._._.header.dynamic_header.compression_flags.data.compression,
143144
Decompressed(Concatenated(PayloadBlocks)),
144145
Concatenated(PayloadBlocks)
145146
)
@@ -157,7 +158,7 @@ def compute_transformed(context):
157158
"payload" / If(this._._.decrypt,
158159
UnpackedPayload(
159160
Switch(
160-
this._.header.value.dynamic_header.cipher_id.data,
161+
this._.header.dynamic_header.cipher_id.data,
161162
{'aes256': AES256Payload(GreedyBytes),
162163
'chacha20': ChaCha20Payload(GreedyBytes),
163164
'twofish': TwoFishPayload(GreedyBytes),

pykeepass/kdbx_parsing/kdbx4.py

+9-8
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
)
1313
from .common import (
1414
aes_kdf, Concatenated, AES256Payload, ChaCha20Payload, TwoFishPayload,
15-
DynamicDict, compute_key_composite, Reparsed, Decompressed,
15+
DynamicDict, RandomBytes, compute_key_composite, Reparsed, Decompressed,
1616
compute_master, CompressionFlags, XML, CipherId, ProtectedStreamId, Unprotect
1717
)
1818

@@ -34,7 +34,7 @@ def compute_transformed(context):
3434
password=context._._.password,
3535
keyfile=context._._.keyfile
3636
)
37-
kdf_parameters = context._.header.value.dynamic_header.kdf_parameters.data.dict
37+
kdf_parameters = context._.header.dynamic_header.kdf_parameters.data.dict
3838

3939
if context._._.transformed_key is not None:
4040
transformed_key = context._._.transformed_key
@@ -73,12 +73,12 @@ def compute_header_hmac_hash(context):
7373
hashlib.sha512(
7474
b'\xff' * 8 +
7575
hashlib.sha512(
76-
context._.header.value.dynamic_header.master_seed.data +
76+
context._.header.dynamic_header.master_seed.data +
7777
context.transformed_key +
7878
b'\x01'
7979
).digest()
8080
).digest(),
81-
context._.header.data,
81+
context._.header._data,
8282
hashlib.sha256
8383
).digest()
8484

@@ -140,6 +140,7 @@ def compute_header_hmac_hash(context):
140140
this.id,
141141
{'compression_flags': CompressionFlags,
142142
'kdf_parameters': VariantDictionary,
143+
'master_seed': RandomBytes(32),
143144
'cipher_id': CipherId
144145
},
145146
default=GreedyBytes
@@ -165,7 +166,7 @@ def compute_payload_block_hash(this):
165166
hashlib.sha512(
166167
struct.pack('<Q', this._index) +
167168
hashlib.sha512(
168-
this._._.header.value.dynamic_header.master_seed.data +
169+
this._._.header.dynamic_header.master_seed.data +
169170
this._.transformed_key + b'\x01'
170171
).digest()
171172
).digest(),
@@ -200,7 +201,7 @@ def compute_payload_block_hash(this):
200201
))
201202

202203
DecryptedPayload = Switch(
203-
this._.header.value.dynamic_header.cipher_id.data,
204+
this._.header.dynamic_header.cipher_id.data,
204205
{'aes256': AES256Payload(EncryptedPayload),
205206
'chacha20': ChaCha20Payload(EncryptedPayload),
206207
'twofish': TwoFishPayload(EncryptedPayload)
@@ -256,7 +257,7 @@ def compute_payload_block_hash(this):
256257
"sha256" / Checksum(
257258
Bytes(32),
258259
lambda data: hashlib.sha256(data).digest(),
259-
this._.header.data,
260+
this._.header._data,
260261
# exception=HeaderChecksumError,
261262
),
262263
"cred_check" / If(this._._.decrypt,
@@ -270,7 +271,7 @@ def compute_payload_block_hash(this):
270271
"payload" / If(this._._.decrypt,
271272
UnpackedPayload(
272273
IfThenElse(
273-
this._.header.value.dynamic_header.compression_flags.data.compression,
274+
this._.header.dynamic_header.compression_flags.data.compression,
274275
Decompressed(DecryptedPayload),
275276
DecryptedPayload
276277
)

pykeepass/pykeepass.py

+6-6
Original file line numberDiff line numberDiff line change
@@ -184,15 +184,15 @@ def version(self):
184184
"""tuple: Length 2 tuple of ints containing major and minor versions.
185185
Generally (3, 1) or (4, 0)."""
186186
return (
187-
self.kdbx.header.value.major_version,
188-
self.kdbx.header.value.minor_version
187+
self.kdbx.header.major_version,
188+
self.kdbx.header.minor_version
189189
)
190190

191191
@property
192192
def encryption_algorithm(self):
193193
"""str: encryption algorithm used by database during decryption.
194194
Can be one of 'aes256', 'chacha20', or 'twofish'."""
195-
return self.kdbx.header.value.dynamic_header.cipher_id.data
195+
return self.kdbx.header.dynamic_header.cipher_id.data
196196

197197
@property
198198
def kdf_algorithm(self):
@@ -201,7 +201,7 @@ def kdf_algorithm(self):
201201
if self.version == (3, 1):
202202
return 'aeskdf'
203203
elif self.version == (4, 0):
204-
kdf_parameters = self.kdbx.header.value.dynamic_header.kdf_parameters.data.dict
204+
kdf_parameters = self.kdbx.header.dynamic_header.kdf_parameters.data.dict
205205
if kdf_parameters['$UUID'].value == kdf_uuids['argon2']:
206206
return 'argon2'
207207
elif kdf_parameters['$UUID'].value == kdf_uuids['argon2id']:
@@ -221,9 +221,9 @@ def database_salt(self):
221221
credentials which are used in extension to current keyfile."""
222222

223223
if self.version == (3, 1):
224-
return self.kdbx.header.value.dynamic_header.transform_seed.data
224+
return self.kdbx.header.dynamic_header.transform_seed.data
225225

226-
kdf_parameters = self.kdbx.header.value.dynamic_header.kdf_parameters.data.dict
226+
kdf_parameters = self.kdbx.header.dynamic_header.kdf_parameters.data.dict
227227
return kdf_parameters['S'].value
228228

229229
@property

tests/tests.py

+22
Original file line numberDiff line numberDiff line change
@@ -1230,6 +1230,28 @@ def test_open_no_decrypt(self):
12301230

12311231
self.assertEqual(kp.database_salt, salt)
12321232

1233+
def test_master_key_differs(self):
1234+
databases = [
1235+
# 'test3.kdbx',
1236+
'test4.kdbx',
1237+
]
1238+
keyfiles = [
1239+
# 'test3.key',
1240+
'test4.key',
1241+
]
1242+
for database, keyfile in zip(databases, keyfiles):
1243+
path = os.path.join(base_dir, database)
1244+
keyfile = os.path.join(base_dir, keyfile)
1245+
kp = PyKeePass(path, password='password', keyfile=keyfile)
1246+
master_seed = kp.kdbx.header.dynamic_header.master_seed.data
1247+
stream = BytesIO()
1248+
kp.save(stream)
1249+
stream.seek(0)
1250+
new_kp = PyKeePass(stream, password='password', keyfile=keyfile)
1251+
new_master_seed = new_kp.kdbx.header.dynamic_header.master_seed.data
1252+
1253+
self.assertNotEqual(master_seed, new_master_seed)
1254+
12331255
if __name__ == '__main__':
12341256
unittest.main()
12351257

0 commit comments

Comments
 (0)