Skip to content

Commit 5d79550

Browse files
committed
Change master seed on each save
Fixes: #219
1 parent b94fee3 commit 5d79550

File tree

6 files changed

+90
-27
lines changed

6 files changed

+90
-27
lines changed

pykeepass/kdbx_parsing/common.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
Adapter,
1414
BitsSwapped,
1515
BitStruct,
16+
Bytes,
1617
Container,
1718
Flag,
1819
GreedyBytes,
@@ -31,6 +32,16 @@
3132
log = logging.getLogger(__name__)
3233

3334

35+
class RandomBytes(Bytes):
36+
"""Same as Bytes, but generate random bytes when building"""
37+
38+
def _build(self, obj, stream, context, path):
39+
length = self.length(context) if callable(self.length) else self.length
40+
data = get_random_bytes(length)
41+
stream_write(stream, data, length, path)
42+
return data
43+
44+
3445
class HeaderChecksumError(Exception):
3546
pass
3647

@@ -183,7 +194,7 @@ def compute_master(context):
183194

184195
# combine the transformed key with the header master seed to find the master_key
185196
master_key = hashlib.sha256(
186-
context._.header.value.dynamic_header.master_seed.data +
197+
context._.header.dynamic_header.master_seed.data +
187198
context.transformed_key).digest()
188199
return master_key
189200

@@ -312,7 +323,7 @@ class DecryptedPayload(Adapter):
312323
def _decode(self, payload_data, con, path):
313324
cipher = self.get_cipher(
314325
con.master_key,
315-
con._.header.value.dynamic_header.encryption_iv.data
326+
con._.header.dynamic_header.encryption_iv.data
316327
)
317328
payload_data = cipher.decrypt(payload_data)
318329
# FIXME: Construct ugliness. Fixes #244. First 32 bytes of decrypted kdbx3 payload
@@ -332,7 +343,7 @@ def _encode(self, payload_data, con, path):
332343
payload_data = self.pad(payload_data)
333344
cipher = self.get_cipher(
334345
con.master_key,
335-
con._.header.value.dynamic_header.encryption_iv.data
346+
con._.header.dynamic_header.encryption_iv.data
336347
)
337348
payload_data = cipher.encrypt(payload_data)
338349

pykeepass/kdbx_parsing/kdbx.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,39 @@
1-
from construct import Bytes, Check, Int16ul, RawCopy, Struct, Switch, this
2-
1+
from construct import Bytes, Check, Int16ul, RawCopy, Struct, Switch, this, stream_seek, stream_tell, stream_read, Subconstruct
32
from .kdbx3 import Body as Body3
43
from .kdbx3 import DynamicHeader as DynamicHeader3
54
from .kdbx4 import Body as Body4
65
from .kdbx4 import DynamicHeader as DynamicHeader4
76

87

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

1335
KDBX = Struct(
14-
"header" / RawCopy(
36+
"header" / Copy(
1537
Struct(
1638
"sig1" / Bytes(4),
1739
"sig2" / Bytes(4),
@@ -27,7 +49,7 @@ def check_signature(ctx):
2749
)
2850
),
2951
"body" / Switch(
30-
this.header.value.major_version,
52+
this.header.major_version,
3153
{3: Body3,
3254
4: Body4
3355
}

pykeepass/kdbx_parsing/kdbx3.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
Reparsed,
4040
TwoFishPayload,
4141
Unprotect,
42+
RandomBytes,
4243
aes_kdf,
4344
compute_key_composite,
4445
compute_master,
@@ -63,8 +64,8 @@ def compute_transformed(context):
6364
keyfile=context._._.keyfile
6465
)
6566
transformed_key = aes_kdf(
66-
context._.header.value.dynamic_header.transform_seed.data,
67-
context._.header.value.dynamic_header.transform_rounds.data,
67+
context._.header.dynamic_header.transform_seed.data,
68+
context._.header.dynamic_header.transform_rounds.data,
6869
key_composite
6970
)
7071

@@ -97,6 +98,7 @@ def compute_transformed(context):
9798
{'compression_flags': CompressionFlags,
9899
'cipher_id': CipherId,
99100
'transform_rounds': Int64ul,
101+
'master_seed': RandomBytes(32),
100102
'protected_stream_id': ProtectedStreamId
101103
},
102104
default=GreedyBytes
@@ -160,16 +162,16 @@ def compute_transformed(context):
160162
# validate payload decryption
161163
"cred_check" / Checksum(
162164
Bytes(32),
163-
lambda this: this._._.header.value.dynamic_header.stream_start_bytes.data,
165+
lambda this: this._._.header.dynamic_header.stream_start_bytes.data,
164166
this,
165167
# exception=CredentialsError
166168
),
167169
"xml" / Unprotect(
168-
this._._.header.value.dynamic_header.protected_stream_id.data,
169-
this._._.header.value.dynamic_header.protected_stream_key.data,
170+
this._._.header.dynamic_header.protected_stream_id.data,
171+
this._._.header.dynamic_header.protected_stream_key.data,
170172
XML(
171173
IfThenElse(
172-
this._._.header.value.dynamic_header.compression_flags.data.compression,
174+
this._._.header.dynamic_header.compression_flags.data.compression,
173175
Decompressed(Concatenated(PayloadBlocks)),
174176
Concatenated(PayloadBlocks)
175177
)
@@ -187,7 +189,7 @@ def compute_transformed(context):
187189
"payload" / If(this._._.decrypt,
188190
UnpackedPayload(
189191
Switch(
190-
this._.header.value.dynamic_header.cipher_id.data,
192+
this._.header.dynamic_header.cipher_id.data,
191193
{'aes256': AES256Payload(GreedyBytes),
192194
'chacha20': ChaCha20Payload(GreedyBytes),
193195
'twofish': TwoFishPayload(GreedyBytes),

pykeepass/kdbx_parsing/kdbx4.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
Decompressed,
4343
DynamicDict,
4444
ProtectedStreamId,
45+
RandomBytes,
4546
Reparsed,
4647
TwoFishPayload,
4748
Unprotect,
@@ -67,7 +68,7 @@ def compute_transformed(context):
6768
password=context._._.password,
6869
keyfile=context._._.keyfile
6970
)
70-
kdf_parameters = context._.header.value.dynamic_header.kdf_parameters.data.dict
71+
kdf_parameters = context._.header.dynamic_header.kdf_parameters.data.dict
7172

7273
if context._._.transformed_key is not None:
7374
transformed_key = context._._.transformed_key
@@ -106,12 +107,12 @@ def compute_header_hmac_hash(context):
106107
hashlib.sha512(
107108
b'\xff' * 8 +
108109
hashlib.sha512(
109-
context._.header.value.dynamic_header.master_seed.data +
110+
context._.header.dynamic_header.master_seed.data +
110111
context.transformed_key +
111112
b'\x01'
112113
).digest()
113114
).digest(),
114-
context._.header.data,
115+
context._.header._data,
115116
hashlib.sha256
116117
).digest()
117118

@@ -173,6 +174,8 @@ def compute_header_hmac_hash(context):
173174
this.id,
174175
{'compression_flags': CompressionFlags,
175176
'kdf_parameters': VariantDictionary,
177+
'master_seed': RandomBytes(32),
178+
'encryption_iv': RandomBytes(12),
176179
'cipher_id': CipherId
177180
},
178181
default=GreedyBytes
@@ -198,7 +201,7 @@ def compute_payload_block_hash(this):
198201
hashlib.sha512(
199202
struct.pack('<Q', this._index) +
200203
hashlib.sha512(
201-
this._._.header.value.dynamic_header.master_seed.data +
204+
this._._.header.dynamic_header.master_seed.data +
202205
this._.transformed_key + b'\x01'
203206
).digest()
204207
).digest(),
@@ -233,7 +236,7 @@ def compute_payload_block_hash(this):
233236
))
234237

235238
DecryptedPayload = Switch(
236-
this._.header.value.dynamic_header.cipher_id.data,
239+
this._.header.dynamic_header.cipher_id.data,
237240
{'aes256': AES256Payload(EncryptedPayload),
238241
'chacha20': ChaCha20Payload(EncryptedPayload),
239242
'twofish': TwoFishPayload(EncryptedPayload)
@@ -289,7 +292,7 @@ def compute_payload_block_hash(this):
289292
"sha256" / Checksum(
290293
Bytes(32),
291294
lambda data: hashlib.sha256(data).digest(),
292-
this._.header.data,
295+
this._.header._data,
293296
# exception=HeaderChecksumError,
294297
),
295298
"cred_check" / If(this._._.decrypt,
@@ -303,7 +306,7 @@ def compute_payload_block_hash(this):
303306
"payload" / If(this._._.decrypt,
304307
UnpackedPayload(
305308
IfThenElse(
306-
this._.header.value.dynamic_header.compression_flags.data.compression,
309+
this._.header.dynamic_header.compression_flags.data.compression,
307310
Decompressed(DecryptedPayload),
308311
DecryptedPayload
309312
)

pykeepass/pykeepass.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -188,15 +188,15 @@ def version(self):
188188
"""tuple: Length 2 tuple of ints containing major and minor versions.
189189
Generally (3, 1) or (4, 0)."""
190190
return (
191-
self.kdbx.header.value.major_version,
192-
self.kdbx.header.value.minor_version
191+
self.kdbx.header.major_version,
192+
self.kdbx.header.minor_version
193193
)
194194

195195
@property
196196
def encryption_algorithm(self):
197197
"""str: encryption algorithm used by database during decryption.
198198
Can be one of 'aes256', 'chacha20', or 'twofish'."""
199-
return self.kdbx.header.value.dynamic_header.cipher_id.data
199+
return self.kdbx.header.dynamic_header.cipher_id.data
200200

201201
@property
202202
def kdf_algorithm(self):
@@ -205,7 +205,7 @@ def kdf_algorithm(self):
205205
if self.version == (3, 1):
206206
return 'aeskdf'
207207
elif self.version == (4, 0):
208-
kdf_parameters = self.kdbx.header.value.dynamic_header.kdf_parameters.data.dict
208+
kdf_parameters = self.kdbx.header.dynamic_header.kdf_parameters.data.dict
209209
if kdf_parameters['$UUID'].value == kdf_uuids['argon2']:
210210
return 'argon2'
211211
elif kdf_parameters['$UUID'].value == kdf_uuids['argon2id']:
@@ -225,9 +225,9 @@ def database_salt(self):
225225
credentials which are used in extension to current keyfile."""
226226

227227
if self.version == (3, 1):
228-
return self.kdbx.header.value.dynamic_header.transform_seed.data
228+
return self.kdbx.header.dynamic_header.transform_seed.data
229229

230-
kdf_parameters = self.kdbx.header.value.dynamic_header.kdf_parameters.data.dict
230+
kdf_parameters = self.kdbx.header.dynamic_header.kdf_parameters.data.dict
231231
return kdf_parameters['S'].value
232232

233233
@property

tests/tests.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1397,6 +1397,31 @@ def test_open_no_decrypt(self):
13971397
self.assertEqual(kp.encryption_algorithm, enc_alg)
13981398
self.assertEqual(kp.version, version)
13991399

1400+
def test_master_seed_differs(self):
1401+
databases = [
1402+
# 'test3.kdbx',
1403+
'test4.kdbx',
1404+
]
1405+
keyfiles = [
1406+
# 'test3.key',
1407+
'test4.key',
1408+
]
1409+
for database, keyfile in zip(databases, keyfiles):
1410+
path = os.path.join(base_dir, database)
1411+
keyfile = os.path.join(base_dir, keyfile)
1412+
kp = PyKeePass(path, password='password', keyfile=keyfile)
1413+
master_seed = kp.kdbx.header.dynamic_header.master_seed.data
1414+
vector_iv = kp.kdbx.header.dynamic_header.vector_iv.data
1415+
stream = BytesIO()
1416+
kp.save(stream)
1417+
stream.seek(0)
1418+
new_kp = PyKeePass(stream, password='password', keyfile=keyfile)
1419+
new_master_seed = new_kp.kdbx.header.dynamic_header.master_seed.data
1420+
new_vector_iv = new_kp.kdbx.header.dynamic_header.vector_iv.data
1421+
1422+
self.assertNotEqual(master_seed, new_master_seed)
1423+
self.assertNotEqual(vector_iv, new_vector_iv)
1424+
14001425
if __name__ == '__main__':
14011426
unittest.main()
14021427

0 commit comments

Comments
 (0)