Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Change master seed on each save #366

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions pykeepass/kdbx_parsing/common.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from Cryptodome.Cipher import AES, ChaCha20, Salsa20
from .twofish import Twofish
from Cryptodome.Random import get_random_bytes
from Cryptodome.Util import Padding as CryptoPadding
import hashlib
from construct import (
Adapter, BitStruct, BitsSwapped, Container, Flag, Padding, ListContainer, Mapping, GreedyBytes, Int32ul, Switch
Adapter, BitStruct, BitsSwapped, Bytes, Container, Flag, Padding, ListContainer, Mapping, GreedyBytes, Int32ul, Switch, stream_write
)
from lxml import etree
from copy import deepcopy
Expand All @@ -20,6 +21,16 @@
log = logging.getLogger(__name__)


class RandomBytes(Bytes):
"""Same as Bytes, but generate random bytes when building"""

def _build(self, obj, stream, context, path):
length = self.length(context) if callable(self.length) else self.length
data = get_random_bytes(length)
stream_write(stream, data, length, path)
return data


class HeaderChecksumError(Exception):
pass

Expand Down Expand Up @@ -167,7 +178,7 @@ def compute_master(context):

# combine the transformed key with the header master seed to find the master_key
master_key = hashlib.sha256(
context._.header.value.dynamic_header.master_seed.data +
context._.header.dynamic_header.master_seed.data +
context.transformed_key).digest()
return master_key

Expand Down Expand Up @@ -296,7 +307,7 @@ class DecryptedPayload(Adapter):
def _decode(self, payload_data, con, path):
cipher = self.get_cipher(
con.master_key,
con._.header.value.dynamic_header.encryption_iv.data
con._.header.dynamic_header.encryption_iv.data
)
payload_data = cipher.decrypt(payload_data)
# FIXME: Construct ugliness. Fixes #244. First 32 bytes of decrypted kdbx3 payload
Expand All @@ -316,7 +327,7 @@ def _encode(self, payload_data, con, path):
payload_data = self.pad(payload_data)
cipher = self.get_cipher(
con.master_key,
con._.header.value.dynamic_header.encryption_iv.data
con._.header.dynamic_header.encryption_iv.data
)
payload_data = cipher.encrypt(payload_data)

Expand Down
29 changes: 26 additions & 3 deletions pykeepass/kdbx_parsing/kdbx.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,38 @@
from construct import Struct, Switch, Bytes, Int16ul, RawCopy, Check, this
from construct import Struct, Switch, Bytes, Int16ul, RawCopy, Check, this, stream_seek, stream_tell, stream_read, Subconstruct
from .kdbx3 import DynamicHeader as DynamicHeader3
from .kdbx3 import Body as Body3
from .kdbx4 import DynamicHeader as DynamicHeader4
from .kdbx4 import Body as Body4


class Copy(Subconstruct):
"""Same as RawCopy, but don't create parent container when parsing.
Instead store data in ._data attribute of subconstruct, and never rebuild from data
"""

def _parse(self, stream, context, path):
offset1 = stream_tell(stream, path)
obj = self.subcon._parsereport(stream, context, path)
offset2 = stream_tell(stream, path)
stream_seek(stream, offset1, 0, path)
obj._data = stream_read(stream, offset2 - offset1, path)
return obj

def _build(self, obj, stream, context, path):
offset1 = stream_tell(stream, path)
obj = self.subcon._build(obj, stream, context, path)
offset2 = stream_tell(stream, path)
stream_seek(stream, offset1, 0, path)
obj._data = stream_read(stream, offset2 - offset1, path)
return obj


# verify file signature
def check_signature(ctx):
return ctx.sig1 == b'\x03\xd9\xa2\x9a' and ctx.sig2 == b'\x67\xFB\x4B\xB5'

KDBX = Struct(
"header" / RawCopy(
"header" / Copy(
Struct(
"sig1" / Bytes(4),
"sig2" / Bytes(4),
Expand All @@ -25,7 +48,7 @@ def check_signature(ctx):
)
),
"body" / Switch(
this.header.value.major_version,
this.header.major_version,
{3: Body3,
4: Body4
}
Expand Down
17 changes: 9 additions & 8 deletions pykeepass/kdbx_parsing/kdbx3.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from .common import (
aes_kdf, AES256Payload, ChaCha20Payload, TwoFishPayload, Concatenated,
DynamicDict, compute_key_composite, Decompressed, Reparsed,
compute_master, CompressionFlags, XML, CipherId, ProtectedStreamId, Unprotect
compute_master, CompressionFlags, XML, CipherId, ProtectedStreamId, Unprotect, RandomBytes
)


Expand All @@ -33,8 +33,8 @@ def compute_transformed(context):
keyfile=context._._.keyfile
)
transformed_key = aes_kdf(
context._.header.value.dynamic_header.transform_seed.data,
context._.header.value.dynamic_header.transform_rounds.data,
context._.header.dynamic_header.transform_seed.data,
context._.header.dynamic_header.transform_rounds.data,
key_composite
)

Expand Down Expand Up @@ -67,6 +67,7 @@ def compute_transformed(context):
{'compression_flags': CompressionFlags,
'cipher_id': CipherId,
'transform_rounds': Int64ul,
'master_seed': RandomBytes(32),
'protected_stream_id': ProtectedStreamId
},
default=GreedyBytes
Expand Down Expand Up @@ -130,16 +131,16 @@ def compute_transformed(context):
# validate payload decryption
"cred_check" / Checksum(
Bytes(32),
lambda this: this._._.header.value.dynamic_header.stream_start_bytes.data,
lambda this: this._._.header.dynamic_header.stream_start_bytes.data,
this,
# exception=CredentialsError
),
"xml" / Unprotect(
this._._.header.value.dynamic_header.protected_stream_id.data,
this._._.header.value.dynamic_header.protected_stream_key.data,
this._._.header.dynamic_header.protected_stream_id.data,
this._._.header.dynamic_header.protected_stream_key.data,
XML(
IfThenElse(
this._._.header.value.dynamic_header.compression_flags.data.compression,
this._._.header.dynamic_header.compression_flags.data.compression,
Decompressed(Concatenated(PayloadBlocks)),
Concatenated(PayloadBlocks)
)
Expand All @@ -157,7 +158,7 @@ def compute_transformed(context):
"payload" / If(this._._.decrypt,
UnpackedPayload(
Switch(
this._.header.value.dynamic_header.cipher_id.data,
this._.header.dynamic_header.cipher_id.data,
{'aes256': AES256Payload(GreedyBytes),
'chacha20': ChaCha20Payload(GreedyBytes),
'twofish': TwoFishPayload(GreedyBytes),
Expand Down
18 changes: 10 additions & 8 deletions pykeepass/kdbx_parsing/kdbx4.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
)
from .common import (
aes_kdf, Concatenated, AES256Payload, ChaCha20Payload, TwoFishPayload,
DynamicDict, compute_key_composite, Reparsed, Decompressed,
DynamicDict, RandomBytes, compute_key_composite, Reparsed, Decompressed,
compute_master, CompressionFlags, XML, CipherId, ProtectedStreamId, Unprotect
)

Expand All @@ -34,7 +34,7 @@ def compute_transformed(context):
password=context._._.password,
keyfile=context._._.keyfile
)
kdf_parameters = context._.header.value.dynamic_header.kdf_parameters.data.dict
kdf_parameters = context._.header.dynamic_header.kdf_parameters.data.dict

if context._._.transformed_key is not None:
transformed_key = context._._.transformed_key
Expand Down Expand Up @@ -73,12 +73,12 @@ def compute_header_hmac_hash(context):
hashlib.sha512(
b'\xff' * 8 +
hashlib.sha512(
context._.header.value.dynamic_header.master_seed.data +
context._.header.dynamic_header.master_seed.data +
context.transformed_key +
b'\x01'
).digest()
).digest(),
context._.header.data,
context._.header._data,
hashlib.sha256
).digest()

Expand Down Expand Up @@ -140,6 +140,8 @@ def compute_header_hmac_hash(context):
this.id,
{'compression_flags': CompressionFlags,
'kdf_parameters': VariantDictionary,
'master_seed': RandomBytes(32),
'encryption_iv': RandomBytes(12),
'cipher_id': CipherId
},
default=GreedyBytes
Expand All @@ -165,7 +167,7 @@ def compute_payload_block_hash(this):
hashlib.sha512(
struct.pack('<Q', this._index) +
hashlib.sha512(
this._._.header.value.dynamic_header.master_seed.data +
this._._.header.dynamic_header.master_seed.data +
this._.transformed_key + b'\x01'
).digest()
).digest(),
Expand Down Expand Up @@ -200,7 +202,7 @@ def compute_payload_block_hash(this):
))

DecryptedPayload = Switch(
this._.header.value.dynamic_header.cipher_id.data,
this._.header.dynamic_header.cipher_id.data,
{'aes256': AES256Payload(EncryptedPayload),
'chacha20': ChaCha20Payload(EncryptedPayload),
'twofish': TwoFishPayload(EncryptedPayload)
Expand Down Expand Up @@ -256,7 +258,7 @@ def compute_payload_block_hash(this):
"sha256" / Checksum(
Bytes(32),
lambda data: hashlib.sha256(data).digest(),
this._.header.data,
this._.header._data,
# exception=HeaderChecksumError,
),
"cred_check" / If(this._._.decrypt,
Expand All @@ -270,7 +272,7 @@ def compute_payload_block_hash(this):
"payload" / If(this._._.decrypt,
UnpackedPayload(
IfThenElse(
this._.header.value.dynamic_header.compression_flags.data.compression,
this._.header.dynamic_header.compression_flags.data.compression,
Decompressed(DecryptedPayload),
DecryptedPayload
)
Expand Down
12 changes: 6 additions & 6 deletions pykeepass/pykeepass.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,15 +184,15 @@ def version(self):
"""tuple: Length 2 tuple of ints containing major and minor versions.
Generally (3, 1) or (4, 0)."""
return (
self.kdbx.header.value.major_version,
self.kdbx.header.value.minor_version
self.kdbx.header.major_version,
self.kdbx.header.minor_version
)

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

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

if self.version == (3, 1):
return self.kdbx.header.value.dynamic_header.transform_seed.data
return self.kdbx.header.dynamic_header.transform_seed.data

kdf_parameters = self.kdbx.header.value.dynamic_header.kdf_parameters.data.dict
kdf_parameters = self.kdbx.header.dynamic_header.kdf_parameters.data.dict
return kdf_parameters['S'].value

@property
Expand Down
25 changes: 25 additions & 0 deletions tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -1230,6 +1230,31 @@ def test_open_no_decrypt(self):

self.assertEqual(kp.database_salt, salt)

def test_master_seed_differs(self):
databases = [
# 'test3.kdbx',
'test4.kdbx',
]
keyfiles = [
# 'test3.key',
'test4.key',
]
for database, keyfile in zip(databases, keyfiles):
path = os.path.join(base_dir, database)
keyfile = os.path.join(base_dir, keyfile)
kp = PyKeePass(path, password='password', keyfile=keyfile)
master_seed = kp.kdbx.header.dynamic_header.master_seed.data
vector_iv = kp.kdbx.header.dynamic_header.vector_iv.data
stream = BytesIO()
kp.save(stream)
stream.seek(0)
new_kp = PyKeePass(stream, password='password', keyfile=keyfile)
new_master_seed = new_kp.kdbx.header.dynamic_header.master_seed.data
new_vector_iv = new_kp.kdbx.header.dynamic_header.vector_iv.data

self.assertNotEqual(master_seed, new_master_seed)
self.assertNotEqual(vector_iv, new_vector_iv)

if __name__ == '__main__':
unittest.main()

Loading