diff --git a/README.rst b/README.rst index bcdce39b..6877d4b6 100644 --- a/README.rst +++ b/README.rst @@ -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``. @@ -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** diff --git a/pykeepass/kdbx_parsing/common.py b/pykeepass/kdbx_parsing/common.py index 5b547366..2ef3d267 100644 --- a/pykeepass/kdbx_parsing/common.py +++ b/pykeepass/kdbx_parsing/common.py @@ -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') diff --git a/tests/tests.py b/tests/tests.py index 99f56dd3..e2c353eb 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -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): @@ -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): @@ -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, @@ -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) @@ -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', @@ -1056,7 +1059,6 @@ def test_open_save(self): 'password', 'password', 'password', - 'password', None, None, 'password', @@ -1064,6 +1066,7 @@ def test_open_save(self): 'password', 'password', 'password', + 'password', ] transformed_keys = [ None, @@ -1072,7 +1075,6 @@ 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, @@ -1080,26 +1082,26 @@ def test_open_save(self): 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', @@ -1112,11 +1114,11 @@ def test_open_save(self): 'twofish', 'chacha20', 'aes256', + 'chacha20', ] kdf_algorithms = [ 'aeskdf', 'argon2', - 'argon2', 'aeskdf', 'argon2', 'argon2', @@ -1128,6 +1130,7 @@ def test_open_save(self): 'argon2', 'argon2', 'argon2id', + 'argon2', ] versions = [ (3, 1), @@ -1136,7 +1139,6 @@ def test_open_save(self): (4, 0), (4, 0), (4, 0), - (4, 0), (3, 1), (4, 0), (3, 1), @@ -1144,6 +1146,7 @@ def test_open_save(self): (4, 0), (4, 0), (4, 0), + (4, 0), ] for (filename_in, filename_out, password, transformed_key, @@ -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) @@ -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): @@ -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',