Skip to content

Commit 17b200b

Browse files
author
Evidlo
committed
regenerate seeds on save
1 parent 34d6abf commit 17b200b

6 files changed

Lines changed: 65 additions & 6 deletions

File tree

CHANGELOG.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
- 2025-03-04
2+
------------------
3+
- fixed #219 - seeds not regenerated on save
4+
15
4.1.1 - 2025-03-04
26
------------------
37
- fixed #410 - support empty string as password

pykeepass/kdbx_parsing/common.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,21 @@
1313
Adapter,
1414
BitsSwapped,
1515
BitStruct,
16+
Construct,
1617
Container,
1718
Flag,
1819
GreedyBytes,
1920
Int32ul,
2021
ListContainer,
2122
Mapping,
2223
Padding,
24+
SizeofError,
2325
Switch,
26+
stream_read_entire,
27+
stream_write,
2428
)
2529
from Cryptodome.Cipher import AES, ChaCha20, Salsa20
30+
from Cryptodome.Random import get_random_bytes
2631
from Cryptodome.Util import Padding as CryptoPadding
2732
from lxml import etree
2833

@@ -81,6 +86,28 @@ def _encode(self, obj, context, path):
8186

8287
return ListContainer(l)
8388

89+
def _build(self, obj, stream, context, path):
90+
obj2 = self._encode(obj, context, path)
91+
buildret = self.subcon._build(obj2, stream, context, path)
92+
if buildret is not None:
93+
return self._decode(buildret, context, path)
94+
return obj
95+
96+
97+
class RandomGreedyBytes(Construct):
98+
"""Parses like GreedyBytes, but generates random bytes of same length during build."""
99+
100+
def _parse(self, stream, context, path):
101+
return stream_read_entire(stream, path)
102+
103+
def _build(self, obj, stream, context, path):
104+
data = get_random_bytes(len(obj))
105+
stream_write(stream, data, len(data), path)
106+
return data
107+
108+
def _sizeof(self, context, path):
109+
raise SizeofError(path=path)
110+
84111

85112
def Reparsed(subcon_out):
86113
class Reparsed(Adapter):

pykeepass/kdbx_parsing/kdbx.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from construct import Bytes, Check, Int16ul, RawCopy, Struct, Switch, this
2+
from construct import stream_seek, stream_tell, stream_read, stream_write
23

34
from .kdbx3 import Body as Body3
45
from .kdbx3 import DynamicHeader as DynamicHeader3
@@ -10,8 +11,19 @@
1011
def check_signature(ctx):
1112
return ctx.sig1 == b'\x03\xd9\xa2\x9a' and ctx.sig2 == b'\x67\xFB\x4B\xB5'
1213

14+
15+
class RawCopyRebuild(RawCopy):
16+
"""RawCopy that always rebuilds from .value, ignoring cached .data.
17+
This ensures subcons like RandomGreedyBytes run on every build."""
18+
19+
def _build(self, obj, stream, context, path):
20+
if 'data' in obj:
21+
del obj['data']
22+
return super()._build(obj, stream, context, path)
23+
24+
1325
KDBX = Struct(
14-
"header" / RawCopy(
26+
"header" / RawCopyRebuild(
1527
Struct(
1628
"sig1" / Bytes(4),
1729
"sig2" / Bytes(4),

pykeepass/kdbx_parsing/kdbx3.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
Decompressed,
3737
DynamicDict,
3838
ProtectedStreamId,
39+
RandomGreedyBytes,
3940
Reparsed,
4041
TwoFishPayload,
4142
Unprotect,
@@ -97,7 +98,12 @@ def compute_transformed(context):
9798
{'compression_flags': CompressionFlags,
9899
'cipher_id': CipherId,
99100
'transform_rounds': Int64ul,
100-
'protected_stream_id': ProtectedStreamId
101+
'protected_stream_id': ProtectedStreamId,
102+
'master_seed': RandomGreedyBytes(),
103+
'transform_seed': RandomGreedyBytes(),
104+
'encryption_iv': RandomGreedyBytes(),
105+
'protected_stream_key': RandomGreedyBytes(),
106+
'stream_start_bytes': RandomGreedyBytes(),
101107
},
102108
default=GreedyBytes
103109
)

pykeepass/kdbx_parsing/kdbx4.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
Decompressed,
4343
DynamicDict,
4444
ProtectedStreamId,
45+
RandomGreedyBytes,
4546
Reparsed,
4647
TwoFishPayload,
4748
Unprotect,
@@ -129,7 +130,7 @@ def compute_header_hmac_hash(context):
129130
0x08: Flag,
130131
0x0C: Int32sl,
131132
0x0D: Int64sl,
132-
0x42: GreedyBytes,
133+
0x42: Switch(this.key, {'S': RandomGreedyBytes()}, default=GreedyBytes),
133134
0x18: GreedyString('utf-8')
134135
}
135136
)
@@ -173,7 +174,9 @@ def compute_header_hmac_hash(context):
173174
this.id,
174175
{'compression_flags': CompressionFlags,
175176
'kdf_parameters': VariantDictionary,
176-
'cipher_id': CipherId
177+
'cipher_id': CipherId,
178+
'master_seed': RandomGreedyBytes(),
179+
'encryption_iv': RandomGreedyBytes(),
177180
},
178181
default=GreedyBytes
179182
)
@@ -254,7 +257,9 @@ def compute_payload_block_hash(this):
254257
Int32ul,
255258
Switch(
256259
this.type,
257-
{'protected_stream_id': ProtectedStreamId},
260+
{'protected_stream_id': ProtectedStreamId,
261+
'protected_stream_key': RandomGreedyBytes(),
262+
},
258263
default=GreedyBytes
259264
)
260265
)

pykeepass/pykeepass.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import base64
2+
import io
23
import logging
34
import os
45
import re
@@ -155,14 +156,18 @@ def save(self, filename=None, transformed_key=None):
155156
filename = self.filename
156157

157158
if hasattr(filename, "write"):
159+
# buffer stream writes to prevent corruption on failure
160+
# (same principle as temp file for disk saves)
161+
buffer = io.BytesIO()
158162
KDBX.build_stream(
159163
self.kdbx,
160-
filename,
164+
buffer,
161165
password=self.password,
162166
keyfile=self.keyfile,
163167
transformed_key=transformed_key,
164168
decrypt=True
165169
)
170+
filename.write(buffer.getvalue())
166171
else:
167172
# save to temporary file to prevent database clobbering
168173
# see issues 223, 101

0 commit comments

Comments
 (0)