Skip to content

Commit

Permalink
Allow keyfile to be passed as bytes (#364)
Browse files Browse the repository at this point in the history
* Allow keyfile to be passed as bytes

In order to prevent application to create temporary files,
provide keyfile as bytes.

Fixes: #363

* support file-like keyfile on read()

---------

Co-authored-by: Jan-Michael Brummer <[email protected]>
Co-authored-by: evan <[email protected]>
  • Loading branch information
3 people authored Nov 28, 2023
1 parent 0a9e071 commit efef0c4
Show file tree
Hide file tree
Showing 3 changed files with 102 additions and 95 deletions.
4 changes: 2 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ Miscellaneous
-------------
**read** (filename=None, password=None, keyfile=None, transformed_key=None, decrypt=False)
where ``filename``, ``password``, and ``keyfile`` are strings. ``filename`` is the path to the database, ``password`` is the master password string, and ``keyfile`` is the path to the database keyfile. At least one of ``password`` and ``keyfile`` is required. Alternatively, the derived key can be supplied directly through ``transformed_key``. ``decrypt`` tells whether the file should be decrypted or not.
where ``filename``, ``password``, and ``keyfile`` are strings ( ``filename`` and ``keyfile`` may also be file-like objects). ``filename`` is the path to the database, ``password`` is the master password string, and ``keyfile`` is the path to the database keyfile. At least one of ``password`` and ``keyfile`` is required. Alternatively, the derived key can be supplied directly through ``transformed_key``. ``decrypt`` tells whether the file should be decrypted or not.
Can raise ``CredentialsError``, ``HeaderChecksumError``, or ``PayloadChecksumError``.
Expand All @@ -376,7 +376,7 @@ reload database from disk using previous credentials
**save** (filename=None)
where ``filename`` is the path of the file to save to. If ``filename`` is not given, the path given in ``read`` will be used.
where ``filename`` is the path of the file to save to (``filename`` may also be file-like object). If ``filename`` is not given, the path given in ``read`` will be used.
**password**
Expand Down
61 changes: 31 additions & 30 deletions pykeepass/kdbx_parsing/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,41 +116,42 @@ def compute_key_composite(password=None, keyfile=None):
password_composite = b''
# hash the keyfile
if keyfile:
if hasattr(keyfile, "read"):
keyfile_bytes = keyfile.read()
else:
with open(keyfile, 'rb') as f:
keyfile_bytes = f.read()
# try to read XML keyfile
try:
with open(keyfile, 'r') as f:
tree = etree.parse(f).getroot()
version = tree.find('Meta/Version').text
data_element = tree.find('Key/Data')
if version.startswith('1.0'):
keyfile_composite = base64.b64decode(data_element.text)
elif version.startswith('2.0'):
# read keyfile data and convert to bytes
keyfile_composite = bytes.fromhex(data_element.text.strip())
# validate bytes against hash
hash = bytes.fromhex(data_element.attrib['Hash'])
hash_computed = hashlib.sha256(keyfile_composite).digest()[:4]
assert hash == hash_computed, "Keyfile has invalid hash"
tree = etree.fromstring(keyfile_bytes)
version = tree.find('Meta/Version').text
data_element = tree.find('Key/Data')
if version.startswith('1.0'):
keyfile_composite = base64.b64decode(data_element.text)
elif version.startswith('2.0'):
# read keyfile data and convert to bytes
keyfile_composite = bytes.fromhex(data_element.text.strip())
# validate bytes against hash
hash = bytes.fromhex(data_element.attrib['Hash'])
hash_computed = hashlib.sha256(keyfile_composite).digest()[:4]
assert hash == hash_computed, "Keyfile has invalid hash"
# otherwise, try to read plain keyfile
except (etree.XMLSyntaxError, UnicodeDecodeError):
try:
with open(keyfile, 'rb') as f:
key = f.read()

try:
int(key, 16)
is_hex = True
except ValueError:
is_hex = False
# if the length is 32 bytes we assume it is the key
if len(key) == 32:
keyfile_composite = key
# if the length is 64 bytes we assume the key is hex encoded
elif len(key) == 64 and is_hex:
keyfile_composite = codecs.decode(key, 'hex')
# anything else may be a file to hash for the key
else:
keyfile_composite = hashlib.sha256(key).digest()
try:
int(keyfile_bytes, 16)
is_hex = True
except ValueError:
is_hex = False
# if the length is 32 bytes we assume it is the key
if len(keyfile_bytes) == 32:
keyfile_composite = keyfile_bytes
# if the length is 64 bytes we assume the key is hex encoded
elif len(keyfile_bytes) == 64 and is_hex:
keyfile_composite = codecs.decode(keyfile_bytes, 'hex')
# anything else may be a file to hash for the key
else:
keyfile_composite = hashlib.sha256(keyfile_bytes).digest()
except:
raise IOError('Could not read keyfile')

Expand Down
132 changes: 69 additions & 63 deletions tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,32 +31,32 @@
- expiry_time - get/set
"""

base_dir = os.path.dirname(os.path.realpath(__file__))
base_dir = Path(os.path.dirname(os.path.realpath(__file__)))
logger = logging.getLogger("pykeepass")


class KDBX3Tests(unittest.TestCase):
database = os.path.join(base_dir, 'test3.kdbx')
database = base_dir / 'test3.kdbx'
password = 'password'
keyfile = os.path.join(base_dir, 'test3.key')
keyfile = base_dir / 'test3.key'

database_tmp = os.path.join(base_dir, 'test3_tmp.kdbx')
keyfile_tmp = os.path.join(base_dir, 'test3_tmp.key')
database_tmp = base_dir / 'test3_tmp.kdbx'
keyfile_tmp = base_dir / 'test3_tmp.key'

# get some things ready before testing
def setUp(self):
shutil.copy(self.database, self.database_tmp)
shutil.copy(self.keyfile, self.keyfile_tmp)
self.kp = PyKeePass(
os.path.join(base_dir, self.database),
base_dir / self.database,
password=self.password,
keyfile=os.path.join(base_dir, self.keyfile)
keyfile=base_dir / self.keyfile
)
# for tests which modify the database, use this
self.kp_tmp = PyKeePass(
os.path.join(base_dir, self.database_tmp),
base_dir / self.database_tmp,
password=self.password,
keyfile=os.path.join(base_dir, self.keyfile_tmp)
keyfile=base_dir / self.keyfile_tmp
)

def tearDown(self):
Expand All @@ -65,12 +65,12 @@ def tearDown(self):


class KDBX4Tests(KDBX3Tests):
database = os.path.join(base_dir, 'test4.kdbx')
database = base_dir / 'test4.kdbx'
password = 'password'
keyfile = os.path.join(base_dir, 'test4.key')
keyfile = base_dir / 'test4.key'

database_tmp = os.path.join(base_dir, 'test4_tmp.kdbx')
keyfile_tmp = os.path.join(base_dir, 'test4_tmp.key')
database_tmp = base_dir / 'test4_tmp.kdbx'
keyfile_tmp = base_dir / 'test4_tmp.key'


class EntryFindTests3(KDBX3Tests):
Expand Down Expand Up @@ -837,7 +837,7 @@ class PyKeePassTests3(KDBX3Tests):

def test_set_credentials(self):
self.kp_tmp.password = 'f00bar'
self.kp_tmp.keyfile = os.path.join(base_dir, 'change.key')
self.kp_tmp.keyfile = base_dir / 'change.key'
self.kp_tmp.save()
self.kp_tmp = PyKeePass(
self.kp_tmp.filename,
Expand Down Expand Up @@ -1004,7 +1004,7 @@ class BugRegressionTests4(KDBX4Tests, BugRegressionTests3):

class CtxManagerTests(unittest.TestCase):
def test_ctx_manager(self):
with PyKeePass(os.path.join(base_dir, 'test4.kdbx'), password='password', keyfile=base_dir + '/test4.key') as kp:
with PyKeePass(base_dir / 'test4.kdbx', password='password', keyfile=base_dir / 'test4.key') as kp:
results = kp.find_entries_by_username('foobar_user', first=True)
self.assertEqual('foobar_user', results.username)

Expand All @@ -1014,40 +1014,43 @@ class KDBXTests(unittest.TestCase):
def test_open_save(self):
"""try to open all databases, save them, then open the result"""

with open(os.path.join(base_dir, 'test3.kdbx'), 'rb') as file:
# for database stream open test
with open(base_dir / 'test3.kdbx', 'rb') as file:
stream = BytesIO(file.read())
# for keyfile file descriptor test
keyfile_fd = open(base_dir / 'test4.key', 'rb')

filenames_in = [
os.path.join(base_dir, 'test3.kdbx'), # KDBX v3
Path(base_dir).joinpath('test4.kdbx'), # KDBX v4 (and test pathlib)
os.path.join(base_dir, 'test4_aes.kdbx'), # KDBX v4 AES
os.path.join(base_dir, 'test4_aeskdf.kdbx'), # KDBX v3 AESKDF
os.path.join(base_dir, 'test4_chacha20.kdbx'), # KDBX v4 ChaCha
os.path.join(base_dir, 'test4_twofish.kdbx'), # KDBX v4 Twofish
os.path.join(base_dir, 'test4_hex.kdbx'), # legacy 64 byte hexadecimal keyfile
os.path.join(base_dir, 'test3_transformed.kdbx'), # KDBX v3 transformed_key open
os.path.join(base_dir, 'test4_transformed.kdbx'), # KDBX v4 transformed_key open
base_dir / 'test3.kdbx', # KDBX v3
base_dir / 'test4_aes.kdbx', # KDBX v4 AES
base_dir / 'test4_aeskdf.kdbx', # KDBX v3 AESKDF
base_dir / 'test4_chacha20.kdbx', # KDBX v4 ChaCha
base_dir / 'test4_twofish.kdbx', # KDBX v4 Twofish
base_dir / 'test4_hex.kdbx', # legacy 64 byte hexadecimal keyfile
base_dir / 'test3_transformed.kdbx', # KDBX v3 transformed_key open
base_dir / 'test4_transformed.kdbx', # KDBX v4 transformed_key open
stream, # test stream opening
os.path.join(base_dir, 'test4_aes_uncompressed.kdbx'),# KDBX v4 AES uncompressed
os.path.join(base_dir, 'test4_twofish_uncompressed.kdbx'),# KDBX v4 Twofish uncompressed
os.path.join(base_dir, 'test4_chacha20_uncompressed.kdbx'),# KDBX v4 ChaCha uncompressed
os.path.join(base_dir, 'test4_argon2id.kdbx'), # KDBX v4 Argon2id
base_dir / 'test4_aes_uncompressed.kdbx',# KDBX v4 AES uncompressed
base_dir / 'test4_twofish_uncompressed.kdbx',# KDBX v4 Twofish uncompressed
base_dir / 'test4_chacha20_uncompressed.kdbx',# KDBX v4 ChaCha uncompressed
base_dir / 'test4_argon2id.kdbx', # KDBX v4 Argon2id
base_dir / 'test4.kdbx', # KDBX v4 with keyfile file descriptor
]
filenames_out = [
os.path.join(base_dir, 'test3.kdbx.out'),
Path(base_dir).joinpath('test4.kdbx.out'),
os.path.join(base_dir, 'test4_aes.kdbx.out'),
os.path.join(base_dir, 'test4_aeskdf.kdbx.out'),
os.path.join(base_dir, 'test4_chacha20.kdbx.out'),
os.path.join(base_dir, 'test4_twofish.kdbx.out'),
os.path.join(base_dir, 'test4_hex.kdbx.out'),
os.path.join(base_dir, 'test3_transformed.kdbx.out'),
os.path.join(base_dir, 'test4_transformed.kdbx.out'),
base_dir / 'test3.kdbx.out',
base_dir / 'test4_aes.kdbx.out',
base_dir / 'test4_aeskdf.kdbx.out',
base_dir / 'test4_chacha20.kdbx.out',
base_dir / 'test4_twofish.kdbx.out',
base_dir / 'test4_hex.kdbx.out',
base_dir / 'test3_transformed.kdbx.out',
base_dir / 'test4_transformed.kdbx.out',
BytesIO(),
os.path.join(base_dir, 'test4_aes_uncompressed.kdbx.out'),
os.path.join(base_dir, 'test4_twofish_uncompressed.kdbx.out'),# KDBX v4 Twofish uncompressed
os.path.join(base_dir, 'test4_chacha20_uncompressed.kdbx.out'),# KDBX v4 ChaCha uncompressed
os.path.join(base_dir, 'test4_argon2id.kdbx.out'),
base_dir / 'test4_aes_uncompressed.kdbx.out',
base_dir / 'test4_twofish_uncompressed.kdbx.out',# KDBX v4 Twofish uncompressed
base_dir / 'test4_chacha20_uncompressed.kdbx.out',# KDBX v4 ChaCha uncompressed
base_dir / 'test4_argon2id.kdbx.out',
base_dir / 'test4.kdbx.out', # KDBX v4 with keyfile file descriptor
]
passwords = [
'password',
Expand All @@ -1056,14 +1059,14 @@ def test_open_save(self):
'password',
'password',
'password',
'password',
None,
None,
'password',
'password',
'password',
'password',
'password',
'password',
]
transformed_keys = [
None,
Expand All @@ -1072,34 +1075,33 @@ def test_open_save(self):
None,
None,
None,
None,
b'\xfb\xb1!\x0e0\x94\xd4\x868\xa5\x04\xe6T\x9b<\xf9+\xb8\x82EN\xbc\xbe\xbc\xc8\xd3\xbbf\xfb\xde\xff.',
b'\x95\x0be\x9ca\x9e<\xe0\x07\x02\x7f\xc3\xd8\xa1\xa6&\x985\x8f!\xa6\x18k\x13\xa2\xd2\r=\xf3\xebd\xc5',
None,
None,
None,
None,
None,
]
None,
]
keyfiles = [
'test3.key',
Path('test4.key'),
'test4.key',
'test4.key',
'test4.key',
'test4.key',
'test4_hex.key',
base_dir / 'test3.key',
base_dir / 'test4.key',
base_dir / 'test4.key',
base_dir / 'test4.key',
base_dir / 'test4.key',
base_dir / 'test4_hex.key',
None,
None,
'test3.key',
base_dir / 'test3.key',
None,
None,
None,
None,
keyfile_fd
]
encryption_algorithms = [
'aes256',
'chacha20',
'aes256',
'aes256',
'chacha20',
Expand All @@ -1112,11 +1114,11 @@ def test_open_save(self):
'twofish',
'chacha20',
'aes256',
'chacha20',
]
kdf_algorithms = [
'aeskdf',
'argon2',
'argon2',
'aeskdf',
'argon2',
'argon2',
Expand All @@ -1128,6 +1130,7 @@ def test_open_save(self):
'argon2',
'argon2',
'argon2id',
'argon2',
]
versions = [
(3, 1),
Expand All @@ -1136,14 +1139,14 @@ def test_open_save(self):
(4, 0),
(4, 0),
(4, 0),
(4, 0),
(3, 1),
(4, 0),
(3, 1),
(4, 0),
(4, 0),
(4, 0),
(4, 0),
(4, 0),
]

for (filename_in, filename_out, password, transformed_key,
Expand All @@ -1154,7 +1157,7 @@ def test_open_save(self):
kp = PyKeePass(
filename_in,
password,
None if keyfile is None else os.path.join(base_dir, keyfile),
keyfile,
transformed_key=transformed_key
)
self.assertEqual(kp.encryption_algorithm, encryption_algorithm)
Expand All @@ -1173,13 +1176,14 @@ def test_open_save(self):
kp = PyKeePass(
filename_out,
password,
None if keyfile is None else os.path.join(base_dir, keyfile),
keyfile,
transformed_key=transformed_key
)

for filename in os.listdir(base_dir):
if filename.endswith('.out'):
os.remove(os.path.join(base_dir, filename))
for filename in base_dir.glob('*.out'):
os.remove(filename)

keyfile_fd.close()


def test_open_error(self):
Expand Down Expand Up @@ -1215,13 +1219,15 @@ def test_open_error(self):
for database, password, keyfile, error in zip(databases, passwords, keyfiles, errors):
with self.assertRaises(error):
PyKeePass(
os.path.join(base_dir, database),
base_dir / database,
password,
os.path.join(base_dir, keyfile)
base_dir / keyfile
)


def test_open_no_decrypt(self):
"""Open database but do not decrypt payload. Needed for reading header data for OTP tokens"""


databases = [
'test3.kdbx',
Expand Down

0 comments on commit efef0c4

Please sign in to comment.