diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f5b9d148..1450b430 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -4,25 +4,40 @@ on: [push, pull_request, workflow_dispatch] jobs: tests: - runs-on: ubuntu-20.04 + name: ${{ matrix.os }}-latest / ${{ matrix.python-version }} + runs-on: ${{ matrix.os }}-latest strategy: - matrix: - python-version: [3.6, 3.7, 3.8, 3.9, "3.10", "3.11"] fail-fast: false + matrix: + os: + - ubuntu + # - windows + # - macos + python-version: + # - "3.6" # not supported in ubuntu-latest (22.04) + - "3.7" + - "3.8" + - "3.9" + - "3.10" + - "3.11" + - "3.12" + steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + cache: pip + allow-prereleases: true - name: Install dependencies run: | sudo apt update sudo apt-get install -y libxml2-dev libxmlsec1-dev python -m pip install --upgrade pip - pip install -r requirements.txt + pip install .[test] - name: Run tests run: | diff --git a/.gitignore b/.gitignore index 73cf0b10..164a5c0e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,10 @@ dist/ *.egg-info/ build/ *.xml +Pipfile Pipfile.lock *.kdbx *.kdbx.out +.idea +.venv* +TMPNOTES diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a7631129..8cbaf0a0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,20 @@ +4.1.0 - 2024-06-26 +------------------ +- merged #389 - add PyKeePass.database_name and database_description +- merged #392, fixed #390 - fix pkg_resources dependency issue +- fixed #391 - Entry.tags returns empty list instead of None +- fixed #395 - set 'encoding' attribute when exporting as XML +- fixed #383 - parse datetimes using isoformat instead of strptime + +4.0.7 - 2024-02-29 +------------------ +- fixed #359 - PyKeePass has `decrypt` kwarg for accessing header info +- merged #347 - added Entry.index and Entry.move for moving entries +- merged #367 - added Entry.autotype_window setter +- merged #364 - allow filename/keyfile to be file-like objects +- merged #371 - drop dateutil dependency +- merged #348 - switch to pyproject.toml + 4.0.6 - 2023-08-22 ------------------ - fixed #350 - fixed all Python 2 deprecation FIXMEs (e.g. future, ) diff --git a/Makefile b/Makefile index 31986dec..a8d22b6b 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,48 @@ -version := $(shell python -c "exec(open('pykeepass/version.py').read());print(__version__)") +.ONESHELL: +.SHELLFLAGS = -ec +.SILENT: +version := $(shell python -c "import tomllib;print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") .PHONY: dist dist: - python setup.py sdist bdist_wheel + python -m build -.PHONY: pypi -pypi: dist - twine upload dist/pykeepass-$(version).tar.gz +.PHONY: release +release: lock dist + # check that changelog is updated. only look at first 3 parts of semver + version=$(version) + stripped=$$(echo $${version} | cut -d . -f -3 | cut -d '-' -f 1) + if ! grep $${stripped} CHANGELOG.rst + then + echo "Changelog doesn't seem to be updated! Quitting..." + exit 1 + fi + # generate release notes from changelog + awk "BEGIN{p=0}; /^$${stripped}/{next}; /---/{p=1;next}; /^$$/{exit}; p {print}" CHANGELOG.rst > TMPNOTES + # make github and pypi release + gh release create --latest --verify-tag v$(version) dist/pykeepass-$(version)* -F TMPNOTES + twine upload -u __token__ dist/pykeepass-$(version)* + +.PHONY: lock +lock: + # run tests then make a requirements.txt lockfile + rm -rf .venv_lock + virtualenv .venv_lock + . .venv_lock/bin/activate + pip install .[test] + python tests/tests.py + pip freeze > requirements.txt + +.PHONY: tag +tag: + # tag git commit + git add requirements.txt + git add pyproject.toml + git add CHANGELOG.rst + git commit -m "bump version" --allow-empty + git tag -a v$(version) -m "version $(version)" + git push --tags + git push .PHONY: docs docs: diff --git a/README.rst b/README.rst index bcd156fd..cbfe225c 100644 --- a/README.rst +++ b/README.rst @@ -1,8 +1,8 @@ pykeepass ============ -.. image:: https://github.com/libkeepass/pykeepass/workflows/CI/badge.svg - :target: https://github.com/libkeepass/pykeepass/actions?query=workflow%3ACI +.. image:: https://github.com/libkeepass/pykeepass/actions/workflows/ci.yaml/badge.svg + :target: https://github.com/libkeepass/pykeepass/actions/workflows/ci.yaml .. image:: https://readthedocs.org/projects/pykeepass/badge/?version=latest :target: https://pykeepass.readthedocs.io/en/latest/?badge=latest @@ -16,10 +16,17 @@ pykeepass This library allows you to write entries to a KeePass database. -Come chat at `#pykeepass`_ on Freenode or `#pykeepass:matrix.org`_ on Matrix. +Come chat at `#pykeepass:matrix.org`_ on Matrix. -.. _#pykeepass: irc://irc.freenode.net -.. _#pykeepass\:matrix.org: https://matrix.to/#/%23pykeepass:matrix.org +.. _#pykeepass\:matrix.org: https://matrix.to/#/%23pykeepass:matrix.org + +Installation +------------ + +.. code:: + + sudo apt install python3-lxml + pip install pykeepass Example ------- @@ -66,7 +73,7 @@ Finding Entries **find_entries** (title=None, username=None, password=None, url=None, notes=None, otp=None, path=None, uuid=None, tags=None, string=None, group=None, recursive=True, regex=False, flags=None, history=False, first=False) -Returns entries which match all provided parameters, where ``title``, ``username``, ``password``, ``url``, ``notes``, ``otp``, and ``autotype_sequence`` are strings, ``path`` is a list, ``string`` is a dict, ``autotype_enabled`` is a boolean, ``uuid`` is a ``uuid.UUID`` and ``tags`` is a list of strings. This function has optional ``regex`` boolean and ``flags`` string arguments, which means to interpret search strings as `XSLT style`_ regular expressions with `flags`_. +Returns entries which match all provided parameters, where ``title``, ``username``, ``password``, ``url``, ``notes``, ``otp``, ``autotype_window`` and ``autotype_sequence`` are strings, ``path`` is a list, ``string`` is a dict, ``autotype_enabled`` is a boolean, ``uuid`` is a ``uuid.UUID`` and ``tags`` is a list of strings. This function has optional ``regex`` boolean and ``flags`` string arguments, which means to interpret search strings as `XSLT style`_ regular expressions with `flags`_. .. _XSLT style: https://www.xml.com/pub/a/2003/06/04/tr.html .. _flags: https://www.w3.org/TR/xpath-functions/#flags @@ -163,8 +170,8 @@ a flattened list of all groups in the database Group: "/" -Entry Functions ---------------- +Entry Functions and Properties +------------------------------ **add_entry** (destination_group, title, username, password, url=None, notes=None, tags=None, expiry_time=None, icon=None, force_creation=False) **delete_entry** (entry) @@ -175,6 +182,18 @@ move a group to the recycle bin. The recycle bin is created if it does not exit **move_entry** (entry, destination_group) +**atime** + +access time + +**ctime** + +creation time + +**mtime** + +modification time + where ``destination_group`` is a ``Group`` instance. ``entry`` is an ``Entry`` instance. ``title``, ``username``, ``password``, ``url``, ``notes``, ``tags``, ``icon`` are strings. ``expiry_time`` is a ``datetime`` instance. If ``expiry_time`` is a naive datetime object (i.e. ``expiry_time.tzinfo`` is not set), the timezone is retrieved from ``dateutil.tz.gettz()``. @@ -202,8 +221,15 @@ If ``expiry_time`` is a naive datetime object (i.e. ``expiry_time.tzinfo`` is no # save the database >>> kp.save() -Group Functions ---------------- + # change creation time + >>> from datetime import datetime, timezone + >>> entry.ctime = datetime(2023, 1, 1, tzinfo=timezone.utc) + + # update modification or access time + >>> entry.touch(modify=True) + +Group Functions and Properties +------------------------------ **add_group** (destination_group, group_name, icon=None, notes=None) **delete_group** (group) @@ -218,6 +244,18 @@ delete all entries and subgroups of a group. ``group`` is an instance of ``Grou **move_group** (group, destination_group) +**atime** + +access time + +**ctime** + +creation time + +**mtime** + +modification time + ``destination_group`` and ``group`` are instances of ``Group``. ``group_name`` is a string .. code:: python @@ -241,6 +279,13 @@ delete all entries and subgroups of a group. ``group`` is an instance of ``Grou # save the database >>> kp.save() + # change creation time + >>> from datetime import datetime, timezone + >>> group.ctime = datetime(2023, 1, 1, tzinfo=timezone.utc) + + # update modification or access time + >>> group.touch(modify=True) + Attachments ----------- @@ -366,7 +411,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 +421,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** @@ -414,6 +459,21 @@ get database XML data as string pretty print database XML to file +TOTP +------- + +**Entry.otp** + +TOTP URI which can be passed to an OTP library to generate codes + +.. code:: python + + # find an entry which has otp attribute + >>> e = kp.find_entries(otp='.*', regex=True, first=True) + >>> import pyotp + >>> pyotp.parse_uri(e.otp).now() + 799270 + Tests and Debugging ------------------- diff --git a/pykeepass/__init__.py b/pykeepass/__init__.py index c39548a4..72135b4c 100644 --- a/pykeepass/__init__.py +++ b/pykeepass/__init__.py @@ -1,6 +1,4 @@ -from __future__ import absolute_import from .pykeepass import PyKeePass, create_database - from .version import __version__ __all__ = ["PyKeePass", "create_database", "__version__"] diff --git a/pykeepass/attachment.py b/pykeepass/attachment.py index 9833eb37..95ebf51f 100644 --- a/pykeepass/attachment.py +++ b/pykeepass/attachment.py @@ -1,7 +1,8 @@ from . import entry from .exceptions import BinaryError -class Attachment(object): + +class Attachment: def __init__(self, element=None, kp=None, id=None, filename=None): self._element = element self._kp = kp diff --git a/pykeepass/baseelement.py b/pykeepass/baseelement.py index 4a5230e6..048e5cf6 100644 --- a/pykeepass/baseelement.py +++ b/pykeepass/baseelement.py @@ -1,11 +1,13 @@ import base64 import uuid +from datetime import datetime, timezone + + from lxml import etree from lxml.builder import E -from datetime import datetime -class BaseElement(): +class BaseElement: """Entry and Group inherit from this class""" def __init__(self, element, kp=None, icon=None, expires=False, @@ -17,9 +19,9 @@ def __init__(self, element, kp=None, icon=None, expires=False, ) if icon: self._element.append(E.IconID(icon)) - current_time_str = self._kp._encode_time(datetime.now()) + current_time_str = self._kp._encode_time(datetime.now(timezone.utc)) if expiry_time: - expiry_time_str = self._kp._encode_time(expiry_time) + expiry_time_str = self._kp._encode_time(expiry_time.astimezone(timezone.utc)) else: expiry_time_str = current_time_str @@ -116,8 +118,8 @@ def expires(self, value): def expired(self): if self.expires: return ( - self._kp._datetime_to_utc(datetime.utcnow()) > - self._kp._datetime_to_utc(self.expiry_time) + datetime.now(timezone.utc) > + self.expiry_time ) return False @@ -132,6 +134,7 @@ def expiry_time(self, value): @property def ctime(self): + """(datetime.datetime): Creation time.""" return self._get_times_property('CreationTime') @ctime.setter @@ -140,6 +143,7 @@ def ctime(self, value): @property def atime(self): + """(datetime.datetime): Access time. Update with touch()""" return self._get_times_property('LastAccessTime') @atime.setter @@ -148,6 +152,7 @@ def atime(self, value): @property def mtime(self): + """(datetime.datetime): Access time. Update with touch(modify=True)""" return self._get_times_property('LastModificationTime') @mtime.setter @@ -178,7 +183,7 @@ def touch(self, modify=False): Args: modify (bool): update access time as well a modification time """ - now = datetime.now() + now = datetime.now(timezone.utc) self.atime = now if modify: self.mtime = now diff --git a/pykeepass/deprecated.py b/pykeepass/deprecated.py index 230cfbed..ece59831 100644 --- a/pykeepass/deprecated.py +++ b/pykeepass/deprecated.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python3 - - # ---------- Find functions --------------- # Use find_entries()/find_groups() instead diff --git a/pykeepass/entry.py b/pykeepass/entry.py index 66bd0b1a..17590294 100644 --- a/pykeepass/entry.py +++ b/pykeepass/entry.py @@ -26,7 +26,7 @@ class Entry(BaseElement): def __init__(self, title=None, username=None, password=None, url=None, notes=None, otp=None, tags=None, expires=False, expiry_time=None, - icon=None, autotype_sequence=None, autotype_enabled=True, + icon=None, autotype_sequence=None, autotype_enabled=True, autotype_window=None, element=None, kp=None): self._kp = kp @@ -54,13 +54,17 @@ def __init__(self, title=None, username=None, password=None, url=None, ) if tags: self._element.append( - E.Tags(';'.join(tags) if type(tags) is list else tags) + E.Tags(';'.join(tags) if isinstance(tags, list) else tags) ) self._element.append( E.AutoType( E.Enabled(str(autotype_enabled)), E.DataTransferObfuscation('0'), - E.DefaultSequence(str(autotype_sequence) if autotype_sequence else '') + E.DefaultSequence(str(autotype_sequence) if autotype_sequence else ''), + E.Association( + E.Window(str(autotype_window) if autotype_window else ''), + E.KeystrokeSequence('') + ) ) ) # FIXME: include custom_properties in constructor @@ -84,24 +88,39 @@ def _get_string_field(self, key): (str or None): field value """ - field = self._xpath('String/Key[text()="{}"]/../Value'.format(key), first=True) + field = self._xpath('String/Key[text()="{}"]/../Value'.format(key), history=True, first=True) if field is not None: return field.text - def _set_string_field(self, key, value, protected=True): + def _set_string_field(self, key, value, protected=None): """Create or overwrite a string field in an Entry Args: key (str): name of field value (str): value of field - protected (bool): mark whether the field should be protected in memory - in other tools. This property is ignored in PyKeePass and all - fields are decrypted immediately upon opening the database. + protected (bool or None): mark whether the field should be protected in memory + in other tools. If None, value is either copied from existing field or field + is created with protected property unset. + + Note: pykeepass does not support memory protection """ - field = self._xpath('String/Key[text()="{}"]/..'.format(key), first=True) + field = self._xpath('String/Key[text()="{}"]/..'.format(key), history=True, first=True) + + protected_str = None + if protected is None: + protected_field = self._xpath('String/Key[text()="{}"]/../Value'.format(key), first=True) + if protected_field is not None: + protected_str = protected_field.attrib.get("Protected") + else: + protected_str = str(protected) + if field is not None: self._element.remove(field) - self._element.append(E.String(E.Key(key), E.Value(value, Protected=str(protected)))) + + if protected_str is None: + self._element.append(E.String(E.Key(key), E.Value(value))) + else: + self._element.append(E.String(E.Key(key), E.Value(value, Protected=protected_str))) def _get_string_field_keys(self, exclude_reserved=False): results = [x.find('Key').text for x in self._element.findall('String')] @@ -110,6 +129,26 @@ def _get_string_field_keys(self, exclude_reserved=False): else: return results + @property + def index(self): + """int: get index of a entry within a group""" + group = self.group._element + children = group.getchildren() + first_index = self.group._first_entry + index = children.index(self._element) + return index - first_index + + def reindex(self, new_index): + """Move entry to a new index within a group + + Args: + new_index (int): new index for the entry starting at 0 + """ + group = self.group._element + first_index = self.group._first_entry + group.remove(self._element) + group.insert(new_index+first_index, self._element) + @property def attachments(self): return self._kp.find_attachments( @@ -159,7 +198,10 @@ def password(self): @password.setter def password(self, value): - return self._set_string_field('Password', value) + if self.password: + return self._set_string_field('Password', value) + else: + return self._set_string_field('Password', value, True) @property def url(self): @@ -192,12 +234,12 @@ def icon(self, value): def tags(self): """str: get or set entry tags""" val = self._get_subelement_text('Tags') - return val.replace(',', ';').split(';') if val else val + return val.replace(',', ';').split(';') if val else [] @tags.setter def tags(self, value, sep=';'): # Accept both str or list - v = sep.join(value if type(value) is list else [value]) + v = sep.join(value if isinstance(value, list) else [value]) return self._set_subelement_text('Tags', v) @property @@ -207,7 +249,10 @@ def otp(self): @otp.setter def otp(self, value): - return self._set_string_field('otp', value) + if self.otp: + return self._set_string_field('otp', value) + else: + return self._set_string_field('otp', value, True) @property def history(self): @@ -248,6 +293,18 @@ def autotype_sequence(self): def autotype_sequence(self, value): self._element.find('AutoType/DefaultSequence').text = value + @property + def autotype_window(self): + """str: get or set [autotype target window filter](https://keepass.info/help/base/autotype.html#autowindows)""" + sequence = self._element.find('AutoType/Association/Window') + if sequence is None or sequence.text == '': + return None + return sequence.text + + @autotype_window.setter + def autotype_window(self, value): + self._element.find('AutoType/Association/Window').text = value + @property def is_a_history_entry(self): """bool: check if entry is History entry""" @@ -302,6 +359,10 @@ def is_custom_property_protected(self, key): """ assert key not in reserved_keys, '{} is a reserved key'.format(key) + return self._is_property_protected(key) + + def _is_property_protected(self, key): + """Whether a property is protected.""" field = self._xpath('String/Key[text()="{}"]/../Value'.format(key), first=True) if field is not None: return field.attrib.get("Protected", "False") == "True" @@ -367,7 +428,7 @@ def delete_history(self, history_entry=None, all=False): def __str__(self): # filter out NoneTypes and join into string - pathstr = '/'.join('' if p==None else p for p in self.path) + pathstr = '/'.join('' if p is None else p for p in self.path) return 'Entry: "{} ({})"'.format(pathstr, self.username) diff --git a/pykeepass/exceptions.py b/pykeepass/exceptions.py index 65d7a65a..d627c47c 100644 --- a/pykeepass/exceptions.py +++ b/pykeepass/exceptions.py @@ -21,4 +21,4 @@ class BinaryError(Exception): # ----- RecycleBin exceptions ----- class UnableToSendToRecycleBin(Exception): - pass \ No newline at end of file + pass diff --git a/pykeepass/group.py b/pykeepass/group.py index cca5b111..2ce16b71 100644 --- a/pykeepass/group.py +++ b/pykeepass/group.py @@ -2,8 +2,8 @@ from lxml.etree import Element, _Element from lxml.objectify import ObjectifiedElement -from .entry import Entry from .baseelement import BaseElement +from .entry import Entry class Group(BaseElement): @@ -34,6 +34,12 @@ def __init__(self, name=None, element=None, icon=None, notes=None, 'element, but a {}'.format(element.tag) self._element = element + @property + def _first_entry(self): + children = self._element.getchildren() + first_element = next(e for e in children if e.tag == "Entry") + return children.index(first_element) + @property def name(self): """str: get or set group name""" @@ -87,7 +93,7 @@ def append(self, entries): Args: entries (:obj:`Entry` or :obj:`list` of :obj:`Entry`) """ - if type(entries) is list: + if isinstance(entries, list): for e in entries: self._element.append(e._element) else: @@ -95,5 +101,5 @@ def append(self, entries): def __str__(self): # filter out NoneTypes and join into string - pathstr = '/'.join('' if p==None else p for p in self.path) + pathstr = '/'.join('' if p is None else p for p in self.path) return 'Group: "{}"'.format(pathstr) diff --git a/pykeepass/kdbx_parsing/common.py b/pykeepass/kdbx_parsing/common.py index 1f014db8..3967af0c 100644 --- a/pykeepass/kdbx_parsing/common.py +++ b/pykeepass/kdbx_parsing/common.py @@ -1,22 +1,35 @@ -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 base64 +import codecs import hashlib +import io +import logging +import re +import zlib +from binascii import Error as BinasciiError +from collections import OrderedDict +from copy import deepcopy + from construct import ( - Adapter, BitStruct, BitsSwapped, Bytes, Container, Flag, Padding, ListContainer, Mapping, GreedyBytes, Int32ul, Switch, stream_write + Adapter, + BitsSwapped, + BitStruct, + Bytes, + Container, + Flag, + GreedyBytes, + Int32ul, + ListContainer, + Mapping, + Padding, + Switch, + stream_write, ) +from Cryptodome.Cipher import AES, ChaCha20, Salsa20 +from Cryptodome.Random import get_random_bytes +from Cryptodome.Util import Padding as CryptoPadding from lxml import etree -from copy import deepcopy -import base64 -from binascii import Error as BinasciiError -import unicodedata -import zlib -import re -import codecs -from io import BytesIO -from collections import OrderedDict -import logging + +from .twofish import Twofish log = logging.getLogger(__name__) @@ -127,41 +140,46 @@ def compute_key_composite(password=None, keyfile=None): password_composite = b'' # hash the keyfile if keyfile: + if hasattr(keyfile, "read"): + if hasattr(keyfile, "seekable") and keyfile.seekable(): + keyfile.seek(0) + 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" + else: + raise AttributeError("Invalid version in keyfile") # otherwise, try to read plain keyfile - except (etree.XMLSyntaxError, UnicodeDecodeError): + except (etree.XMLSyntaxError, UnicodeDecodeError, AttributeError): 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') @@ -191,7 +209,7 @@ class XML(Adapter): def _decode(self, data, con, path): parser = etree.XMLParser(remove_blank_text=True) - return etree.parse(BytesIO(data), parser) + return etree.parse(io.BytesIO(data), parser) def _encode(self, tree, con, path): return etree.tostring(tree) @@ -216,7 +234,7 @@ def _decode(self, tree, con, path): result = cipher.decrypt(base64.b64decode(elem.text)).decode('utf-8') # strip invalid XML characters - https://stackoverflow.com/questions/8733233 result = re.sub( - u'[^\u0020-\uD7FF\u0009\u000A\u000D\uE000-\uFFFD\U00010000-\U0010FFFF]+', + '[^\u0020-\uD7FF\u0009\u000A\u000D\uE000-\uFFFD\U00010000-\U0010FFFF]+', '', result ) diff --git a/pykeepass/kdbx_parsing/kdbx.py b/pykeepass/kdbx_parsing/kdbx.py index fd79c0a5..1506499d 100644 --- a/pykeepass/kdbx_parsing/kdbx.py +++ b/pykeepass/kdbx_parsing/kdbx.py @@ -1,8 +1,12 @@ -from construct import Struct, Switch, Bytes, Int16ul, RawCopy, Check, this, stream_seek, stream_tell, stream_read, Subconstruct -from .kdbx3 import DynamicHeader as DynamicHeader3 +from construct import ( + Bytes, Check, Int16ul, RawCopy, Struct, Switch, Subconstruct, + stream_tell, stream_seek, stream_read, this +) + from .kdbx3 import Body as Body3 -from .kdbx4 import DynamicHeader as DynamicHeader4 +from .kdbx3 import DynamicHeader as DynamicHeader3 from .kdbx4 import Body as Body4 +from .kdbx4 import DynamicHeader as DynamicHeader4 class Copy(Subconstruct): diff --git a/pykeepass/kdbx_parsing/kdbx3.py b/pykeepass/kdbx_parsing/kdbx3.py index 7e441d69..caf661d8 100644 --- a/pykeepass/kdbx_parsing/kdbx3.py +++ b/pykeepass/kdbx_parsing/kdbx3.py @@ -2,18 +2,49 @@ # keepass decrypt experimentation import hashlib + from construct import ( - Byte, Bytes, Int16ul, Int32ul, Int64ul, RepeatUntil, GreedyBytes, Struct, - this, Mapping, Switch, Prefixed, Padding, Checksum, Computed, IfThenElse, - Pointer, Tell, len_, If + Byte, + Bytes, + Checksum, + Computed, + GreedyBytes, + If, + IfThenElse, + Int16ul, + Int32ul, + Int64ul, + Mapping, + Padding, + Pointer, + Prefixed, + RepeatUntil, + Struct, + Switch, + Tell, + len_, + this, ) + from .common import ( - aes_kdf, AES256Payload, ChaCha20Payload, TwoFishPayload, Concatenated, - DynamicDict, compute_key_composite, Decompressed, Reparsed, - compute_master, CompressionFlags, XML, CipherId, ProtectedStreamId, Unprotect, RandomBytes + XML, + AES256Payload, + ChaCha20Payload, + CipherId, + CompressionFlags, + Concatenated, + Decompressed, + DynamicDict, + ProtectedStreamId, + RandomBytes, + Reparsed, + TwoFishPayload, + Unprotect, + aes_kdf, + compute_key_composite, + compute_master, ) - # -------------------- Key Derivation -------------------- # https://github.com/keepassxreboot/keepassxc/blob/8324d03f0a015e62b6182843b4478226a5197090/src/format/KeePass2.cpp#L24-L26 diff --git a/pykeepass/kdbx_parsing/kdbx4.py b/pykeepass/kdbx_parsing/kdbx4.py index d9a80601..1001a758 100644 --- a/pykeepass/kdbx_parsing/kdbx4.py +++ b/pykeepass/kdbx_parsing/kdbx4.py @@ -1,22 +1,56 @@ # Evan Widloski - 2018-04-11 # keepass decrypt experimentation -import struct import hashlib -import argon2 import hmac +import struct + +import argon2 from construct import ( - Byte, Bytes, Int32ul, RepeatUntil, GreedyBytes, Struct, this, Mapping, - Switch, Flag, Prefixed, Int64ul, Int32sl, Int64sl, GreedyString, Padding, - Peek, Checksum, Computed, IfThenElse, Pointer, Tell, If + Byte, + Bytes, + Checksum, + Computed, + Flag, + GreedyBytes, + GreedyString, + If, + IfThenElse, + Int32sl, + Int32ul, + Int64sl, + Int64ul, + Mapping, + Padding, + Peek, + Pointer, + Prefixed, + RepeatUntil, + Struct, + Switch, + Tell, + this, ) + from .common import ( - aes_kdf, Concatenated, AES256Payload, ChaCha20Payload, TwoFishPayload, - DynamicDict, RandomBytes, compute_key_composite, Reparsed, Decompressed, - compute_master, CompressionFlags, XML, CipherId, ProtectedStreamId, Unprotect + XML, + AES256Payload, + ChaCha20Payload, + CipherId, + CompressionFlags, + Concatenated, + Decompressed, + DynamicDict, + ProtectedStreamId, + RandomBytes, + Reparsed, + TwoFishPayload, + Unprotect, + aes_kdf, + compute_key_composite, + compute_master, ) - # -------------------- Key Derivation -------------------- # https://github.com/keepassxreboot/keepassxc/blob/bc55974ff304794e53c925442784c50a2fdaf6ee/src/format/KeePass2.cpp#L30-L33 diff --git a/pykeepass/kdbx_parsing/pytwofish.py b/pykeepass/kdbx_parsing/pytwofish.py index 44671867..92553771 100644 --- a/pykeepass/kdbx_parsing/pytwofish.py +++ b/pykeepass/kdbx_parsing/pytwofish.py @@ -138,6 +138,7 @@ def get_key_size(self): import struct + def rotr32(x, n): return (x >> n) | ((x << (32 - n)) & 0xFFFFFFFF) diff --git a/pykeepass/kdbx_parsing/twofish.py b/pykeepass/kdbx_parsing/twofish.py index c4dc7a7e..99db21a5 100644 --- a/pykeepass/kdbx_parsing/twofish.py +++ b/pykeepass/kdbx_parsing/twofish.py @@ -24,9 +24,10 @@ __all__ = ['Twofish'] -from . import pytwofish -from Cryptodome.Util.strxor import strxor from Cryptodome.Util.Padding import pad +from Cryptodome.Util.strxor import strxor + +from . import pytwofish MODE_ECB = 1 MODE_CBC = 2 diff --git a/pykeepass/pykeepass.py b/pykeepass/pykeepass.py index dbc890fa..b53d88cd 100644 --- a/pykeepass/pykeepass.py +++ b/pykeepass/pykeepass.py @@ -1,4 +1,3 @@ -# coding: utf-8 import base64 import logging import os @@ -7,18 +6,24 @@ import struct import uuid import zlib - from binascii import Error as BinasciiError +from datetime import datetime, timedelta, timezone +from pathlib import Path + from construct import Container, ChecksumError, CheckError -from dateutil import parser, tz -from datetime import datetime, timedelta + from lxml import etree from lxml.builder import E -from pathlib import Path from .attachment import Attachment from .entry import Entry -from .exceptions import * +from .exceptions import ( + BinaryError, + CredentialsError, + HeaderChecksumError, + PayloadChecksumError, + UnableToSendToRecycleBin, +) from .group import Group from .kdbx_parsing import KDBX, kdf_uuids from .xpath import attachment_xp, entry_xp, group_xp, path_xp @@ -30,8 +35,7 @@ BLANK_DATABASE_LOCATION = os.path.join(os.path.dirname(os.path.realpath(__file__)), BLANK_DATABASE_FILENAME) BLANK_DATABASE_PASSWORD = "password" - -class PyKeePass(): +class PyKeePass: """Open a KeePass database Args: @@ -226,10 +230,19 @@ def database_salt(self): kdf_parameters = self.kdbx.header.dynamic_header.kdf_parameters.data.dict return kdf_parameters['S'].value + @property + def payload(self): + """Encrypted payload of keepass database""" + # check if payload is decrypted + if self.kdbx.body.payload is None: + raise ValueError("Database is not decrypted") + else: + return self.kdbx.body.payload + @property def tree(self): """lxml.etree._ElementTree: database XML payload""" - return self.kdbx.body.payload.xml + return self.payload.xml @property def root_group(self): @@ -247,13 +260,50 @@ def recyclebin_group(self): def groups(self): """:obj:`list` of :obj:`Group`: list of all Group objects in database """ - return self._xpath('//Group', cast=True) + return self.find_groups() @property def entries(self): """:obj:`list` of :obj:`Entry`: list of all Entry objects in database, excluding history""" - return self._xpath('//Entry', cast=True) + return self.find_entries() + + @property + def database_name(self): + """Name of database""" + elem = self._xpath('/KeePassFile/Meta/DatabaseName', first=True) + return elem.text + + @database_name.setter + def database_name(self, name): + item = self._xpath('/KeePassFile/Meta/DatabaseName', first=True) + item.text = str(name) + + @property + def database_description(self): + """Description of the database""" + elem = self._xpath('/KeePassFile/Meta/DatabaseDescription', first=True) + return elem.text + + @database_description.setter + def database_description(self, name): + item = self._xpath('/KeePassFile/Meta/DatabaseDescription', first=True) + item.text = str(name) + + @property + def default_username(self): + """Default Username + + Returns: + user name or None if not set. + """ + elem = self._xpath('/KeePassFile/Meta/DefaultUserName', first=True) + return elem.text + + @default_username.setter + def default_username(self, name): + item = self._xpath('/KeePassFile/Meta/DefaultUserName', first=True) + item.text = str(name) def xml(self): """Get XML part of database as string @@ -265,7 +315,7 @@ def xml(self): self.tree, pretty_print=True, standalone=True, - encoding='unicode' + encoding='utf-8' ) def dump_xml(self, filename): @@ -275,17 +325,9 @@ def dump_xml(self, filename): filename (str): path to output file """ with open(filename, 'wb') as f: - f.write( - etree.tostring( - self.tree, - pretty_print=True, - standalone=True, - encoding='utf-8' - ) - ) + f.write(self.xml()) - def _xpath(self, xpath_str, tree=None, first=False, history=False, - cast=False, **kwargs): + def _xpath(self, xpath_str, tree=None, first=False, cast=False, **kwargs): """Look up elements in the XML payload and return corresponding object. Internal function which searches the payload lxml ElementTree for @@ -300,8 +342,6 @@ def _xpath(self, xpath_str, tree=None, first=False, history=False, first (bool): If True, function returns first result or None. If False, function returns list of matches or empty list. Default is False. - history (bool): If True, history entries are included in results. - Default is False. cast (bool): If True, matches are instead instantiated as pykeepass Group, Entry, or Attachment objects. An exception is raised if a match cannot be cast. Default is False. @@ -319,18 +359,17 @@ def _xpath(self, xpath_str, tree=None, first=False, history=False, res = [] for e in elements: - if history or e.getparent().tag != 'History': - if cast: - if e.tag == 'Entry': - res.append(Entry(element=e, kp=self)) - elif e.tag == 'Group': - res.append(Group(element=e, kp=self)) - elif e.tag == 'Binary' and e.getparent().tag == 'Entry': - res.append(Attachment(element=e, kp=self)) - else: - raise Exception('Could not cast element {}'.format(e)) + if cast: + if e.tag == 'Entry': + res.append(Entry(element=e, kp=self)) + elif e.tag == 'Group': + res.append(Group(element=e, kp=self)) + elif e.tag == 'Binary' and e.getparent().tag == 'Entry': + res.append(Attachment(element=e, kp=self)) else: - res.append(e) + raise Exception('Could not cast element {}'.format(e)) + else: + res.append(e) # return first object in list or None if first: @@ -344,6 +383,9 @@ def _find(self, prefix, keys_xp, path=None, tree=None, first=False, xp = '' + if not history: + prefix += '[not(ancestor::History)]' + if path is not None: first = True @@ -364,27 +406,27 @@ def _find(self, prefix, keys_xp, path=None, tree=None, first=False, if tree is not None: xp += '.' - if kwargs.keys(): - xp += prefix + xp += prefix # handle searching custom string fields - if 'string' in kwargs.keys(): + if 'string' in kwargs: for key, value in kwargs['string'].items(): xp += keys_xp[regex]['string'].format(key, value, flags=flags) kwargs.pop('string') # convert uuid to base64 form before building xpath - if 'uuid' in kwargs.keys(): + if 'uuid' in kwargs: kwargs['uuid'] = base64.b64encode(kwargs['uuid'].bytes).decode('utf-8') # convert tags to semicolon separated string before building xpath - if 'tags' in kwargs.keys(): - kwargs['tags'] = ';'.join(kwargs['tags']) + # FIXME: this isn't a reliable way to search tags. e.g. searching ['tag1', 'tag2'] will match 'tag1tag2 + if 'tags' in kwargs: + kwargs['tags'] = ' and '.join(f'contains(text(),"{t}")' for t in kwargs['tags']) # build xpath to filter results with specified attributes for key, value in kwargs.items(): - if key not in keys_xp[regex].keys(): + if key not in keys_xp[regex]: raise TypeError('Invalid keyword argument "{}"'.format(key)) if value is not None: xp += keys_xp[regex][key].format(value, flags=flags) @@ -393,7 +435,6 @@ def _find(self, prefix, keys_xp, path=None, tree=None, first=False, xp, tree=tree._element if tree else None, first=first, - history=history, cast=True, **kwargs ) @@ -407,15 +448,17 @@ def _can_be_moved_to_recyclebin(self, entry_or_group): if recyclebin_group is None: return True uuid_str = base64.b64encode( entry_or_group.uuid.bytes).decode('utf-8') - elem = self._xpath('./UUID[text()="{}"]/..'.format(uuid_str), tree=recyclebin_group._element, first=True, history=False, cast=False) + elem = self._xpath('./UUID[text()="{}"]/..'.format(uuid_str), tree=recyclebin_group._element, first=True, cast=False) return elem is None # ---------- Groups ---------- from .deprecated import ( - find_groups_by_name, find_groups_by_path, find_groups_by_uuid, - find_groups_by_notes + find_groups_by_name, + find_groups_by_notes, + find_groups_by_path, + find_groups_by_uuid, ) def find_groups(self, recursive=True, path=None, group=None, **kwargs): @@ -499,9 +542,14 @@ def empty_group(self, group): from .deprecated import ( - find_entries_by_title, find_entries_by_username, find_entries_by_password, - find_entries_by_url, find_entries_by_path, find_entries_by_notes, - find_entries_by_string, find_entries_by_uuid + find_entries_by_notes, + find_entries_by_password, + find_entries_by_path, + find_entries_by_string, + find_entries_by_title, + find_entries_by_url, + find_entries_by_username, + find_entries_by_uuid, ) def find_entries(self, recursive=True, path=None, group=None, **kwargs): @@ -583,7 +631,7 @@ def attachments(self): def binaries(self): if self.version >= (4, 0): # first byte is a prepended flag - binaries = [a.data[1:] for a in self.kdbx.body.payload.inner_header.binary] + binaries = [a.data[1:] for a in self.payload.inner_header.binary] else: binaries = [] for elem in self._xpath('/KeePassFile/Meta/Binaries/Binary'): @@ -604,13 +652,10 @@ def binaries(self): def add_binary(self, data, compressed=True, protected=True): if self.version >= (4, 0): # add protected flag byte - if protected: - data = b'\x01' + data - else: - data = b'\x00' + data + data = b'\x01' + data if protected else b'\x00' + data # add binary element to inner header c = Container(type='binary', data=data) - self.kdbx.body.payload.inner_header.binary.append(c) + self.payload.inner_header.binary.append(c) else: binaries = self._xpath( '/KeePassFile/Meta/Binaries', @@ -642,7 +687,7 @@ def delete_binary(self, id): try: if self.version >= (4, 0): # remove binary element from inner header - self.kdbx.body.payload.inner_header.binary.pop(id) + self.payload.inner_header.binary.pop(id) else: # remove binary element from XML binaries = self._xpath('/KeePassFile/Meta/Binaries', first=True) @@ -672,7 +717,7 @@ def deref(self, value): ref (str): KeePass reference string to another field Returns: - str or uuid.UUID + str, uuid.UUID or None if no match found [fieldref]: https://keepass.info/help/base/fieldrefs.html """ @@ -695,6 +740,8 @@ def deref(self, value): if search_in == 'uuid': search_value = uuid.UUID(search_value) ref_entry = self.find_entries(first=True, **{search_in: search_value}) + if ref_entry is None: + return None value = value.replace(ref, getattr(ref_entry, wanted_field)) return self.deref(value) @@ -709,7 +756,7 @@ def password(self): @password.setter def password(self, password): self._password = password - self.credchange_date = datetime.now() + self.credchange_date = datetime.now(timezone.utc) @property def keyfile(self): @@ -719,7 +766,7 @@ def keyfile(self): @keyfile.setter def keyfile(self, keyfile): self._keyfile = keyfile - self.credchange_date = datetime.now() + self.credchange_date = datetime.now(timezone.utc) @property def credchange_required_days(self): @@ -756,8 +803,8 @@ def credchange_date(self): @credchange_date.setter def credchange_date(self, date): - time = self._xpath('/KeePassFile/Meta/MasterKeyChanged', first=True) - time.text = self._encode_time(date) + mk_time = self._xpath('/KeePassFile/Meta/MasterKeyChanged', first=True) + mk_time.text = self._encode_time(date) @property def credchange_required(self): @@ -765,7 +812,7 @@ def credchange_required(self): change_date = self.credchange_date if change_date is None or self.credchange_required_days == -1: return False - now_date = self._datetime_to_utc(datetime.now()) + now_date = datetime.now(timezone.utc) return (now_date - change_date).days > self.credchange_required_days @property @@ -774,30 +821,23 @@ def credchange_recommended(self): change_date = self.credchange_date if change_date is None or self.credchange_recommended_days == -1: return False - now_date = self._datetime_to_utc(datetime.now()) + now_date = datetime.now(timezone.utc) return (now_date - change_date).days > self.credchange_recommended_days # ---------- Datetime Functions ---------- - def _datetime_to_utc(self, dt): - """Convert naive datetimes to UTC""" - - if not dt.tzinfo: - dt = dt.replace(tzinfo=tz.gettz()) - return dt.astimezone(tz.gettz('UTC')) - def _encode_time(self, value): """bytestring or plaintext string: Convert datetime to base64 or plaintext string""" if self.version >= (4, 0): diff_seconds = int( ( - self._datetime_to_utc(value) - + value - datetime( year=1, month=1, day=1, - tzinfo=tz.gettz('UTC') + tzinfo=timezone.utc ) ).total_seconds() ) @@ -805,7 +845,7 @@ def _encode_time(self, value): struct.pack('=2.10.53", + "argon2_cffi>=18.1.0", + "pycryptodomex>=3.6.2", + "lxml", +] +classifiers = [ + "Topic :: Security", + "Topic :: Software Development :: Libraries", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", +] + +[project.optional-dependencies] +test = ["pyotp"] + +[project.urls] +Homepage = "https://github.com/libkeepass/pykeepass" +Repository = "https://github.com/libkeepass/pykeepass" +Issues = "https://github.com/libkeepass/pykeepass/issues" +Changelog = "https://github.com/libkeepass/pykeepass/blob/master/CHANGELOG.rst" + +[tool.setuptools] +packages = ["pykeepass", "pykeepass.kdbx_parsing"] +include-package-data = true + +[build-system] +requires = ["setuptools>=59.0.0"] +build-backend = 'setuptools.build_meta' diff --git a/requirements-rtd.txt b/requirements-rtd.txt deleted file mode 100644 index d3882431..00000000 --- a/requirements-rtd.txt +++ /dev/null @@ -1,6 +0,0 @@ -lxml==4.8.0 -pycryptodomex==3.14.1 -construct==2.10.68 -argon2-cffi==21.3.0 -python-dateutil==2.8.2 -Sphinx>=3.2.1 diff --git a/requirements.txt b/requirements.txt index ca8e7a65..82ac901c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,9 @@ -lxml==4.8.0 -pycryptodomex==3.14.1 -construct==2.10.68 -argon2-cffi==21.3.0 -python-dateutil==2.8.2 +argon2-cffi==23.1.0 +argon2-cffi-bindings==21.2.0 +cffi==1.16.0 +construct==2.10.70 +lxml==5.2.2 +pycparser==2.22 +pycryptodomex==3.20.0 +pykeepass @ file:///home/evan/resources/pykeepass +pyotp==2.9.0 diff --git a/setup.py b/setup.py deleted file mode 100644 index b8d13a3c..00000000 --- a/setup.py +++ /dev/null @@ -1,30 +0,0 @@ -from setuptools import find_packages, setup - -with open("README.rst") as file: - README = file.read() - -version = {} -with open("pykeepass/version.py") as file: - exec(file.read(), version) - -setup( - name="pykeepass", - version=version["__version__"], - license="GPL3", - description="Python library to interact with keepass databases " - "(supports KDBX3 and KDBX4)", - long_description=README, - long_description_content_type='text/x-rst', - author="Philipp Schmitt", - author_email="philipp@schmitt.co", - url="https://github.com/libkeepass/pykeepass", - packages=find_packages(include=['pykeepass', 'pykeepass.*']), - install_requires=[ - "python-dateutil", - "construct", - "argon2_cffi", - "pycryptodomex>=3.6.2", - "lxml", - ], - include_package_data=True, -) diff --git a/tests/test.svg b/tests/test.svg new file mode 100644 index 00000000..a1f02fbf --- /dev/null +++ b/tests/test.svg @@ -0,0 +1,118 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/tests/test3.kdbx b/tests/test3.kdbx index ab02bbf7..72a7bb70 100644 Binary files a/tests/test3.kdbx and b/tests/test3.kdbx differ diff --git a/tests/test4.kdbx b/tests/test4.kdbx index 937d79b1..af6bb723 100644 Binary files a/tests/test4.kdbx and b/tests/test4.kdbx differ diff --git a/tests/test_invalidversion.key b/tests/test_invalidversion.key new file mode 100644 index 00000000..6b60cfa8 --- /dev/null +++ b/tests/test_invalidversion.key @@ -0,0 +1,9 @@ + + + + 3.00 + + + Qp9MrFM1RpSLO8iHZHGAiPbr8Z+hDFpp0cgtH+RM0hw= + + diff --git a/tests/tests.py b/tests/tests.py index af14b421..63eb7595 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -1,23 +1,16 @@ -# -*- coding: utf-8 -*- - import logging import os import shutil import unittest import uuid -from datetime import datetime, timedelta - -from dateutil import tz -from lxml.etree import Element -from pathlib import Path - +from datetime import datetime, timedelta, timezone from io import BytesIO +from pathlib import Path from pykeepass import PyKeePass, icons from pykeepass.entry import Entry -from pykeepass.group import Group -from pykeepass.kdbx_parsing import KDBX from pykeepass.exceptions import BinaryError, CredentialsError, HeaderChecksumError +from pykeepass.group import Group """ Missing Tests: @@ -33,32 +26,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): @@ -67,12 +60,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): @@ -144,14 +137,23 @@ def test_find_entries_by_autotype_sequence(self): self.assertEqual(len(results), 1) self.assertEqual(results[0].autotype_sequence, '{USERNAME}{TAB}{PASSWORD}{ENTER}') + def test_find_entries_by_autotype_window(self): + results = self.kp.find_entries(autotype_window='test', regex=True, flags="i") + self.assertEqual(len(results), 1) + self.assertEqual(results[0].autotype_window, 'TEST') + def test_find_entries_by_autotype_enabled(self): results = self.kp.find_entries(autotype_enabled=True) self.assertEqual(len(results), len(self.kp.entries) - 1) def test_find_entries_by_otp(self): - results = self.kp.find_entries(otp='otpsecret', regex=True, flags='i') + results = self.kp.find_entries(otp='nonmatch', regex=True, flags='i') + self.assertEqual(len(results), 0) + results = self.kp.find_entries(otp='OTPSECRETT', regex=True) self.assertEqual(len(results), 1) self.assertEqual('foobar_entry', results[0].title) + import pyotp + self.assertEqual(len(pyotp.parse_uri(results[0].otp).now()), 6) def test_find_entries(self): results = self.kp.find_entries(title='Root_entry', regex=True) @@ -219,7 +221,7 @@ def test_history_group(self): def test_add_delete_move_entry(self): unique_str = 'test_add_entry_' - expiry_time = datetime.now() + expiry_time = datetime.now(timezone.utc) entry = self.kp.add_entry( self.kp.root_group, unique_str + 'title', @@ -241,10 +243,8 @@ def test_add_delete_move_entry(self): self.assertEqual(results.url, unique_str + 'url') self.assertEqual(results.notes, unique_str + 'notes') self.assertEqual(len(results.tags), 6) - self.assertTrue(results.uuid != None) + self.assertTrue(results.uuid is not None) self.assertTrue(results.autotype_sequence is None) - # convert naive datetime to utc - expiry_time_utc = expiry_time.replace(tzinfo=tz.gettz()).astimezone(tz.gettz('UTC')) self.assertEqual(results.icon, icons.KEY) sub_group = self.kp.add_group(self.kp.root_group, 'sub_group') @@ -280,6 +280,52 @@ def test_raise_exception_entry(self): ) self.assertRaises(Exception, entry) + # ---------- Timezone test ----------- + + def test_expiration_time_tz(self): + # The expiration date is compared in UTC + # setting expiration date with tz offset 6 hours should result in expired entry + unique_str = 'test_exptime_tz_1_' + expiry_time = datetime.now(timezone(offset=timedelta(hours=6))).replace(microsecond=0) + self.kp.add_entry( + self.kp.root_group, + unique_str + 'title', + unique_str + 'user', + unique_str + 'pass', + expiry_time=expiry_time + ) + results = self.kp.find_entries_by_title(unique_str + 'title', first=True) + self.assertEqual(results.expired, True) + self.assertEqual(results.expiry_time, expiry_time.astimezone(timezone.utc)) + + # setting expiration date with UTC tz should result in expired entry + unique_str = 'test_exptime_tz_2_' + expiry_time = datetime.now(timezone.utc).replace(microsecond=0) + self.kp.add_entry( + self.kp.root_group, + unique_str + 'title', + unique_str + 'user', + unique_str + 'pass', + expiry_time=expiry_time + ) + results = self.kp.find_entries_by_title(unique_str + 'title', first=True) + self.assertEqual(results.expired, True) + self.assertEqual(results.expiry_time, expiry_time.astimezone(timezone.utc)) + + # setting expiration date with tz offset -6 hours while adding 6 hours should result in valid entry + unique_str = 'test_exptime_tz_3_' + expiry_time = datetime.now(timezone(offset=timedelta(hours=-6))).replace(microsecond=0) + timedelta(hours=6) + self.kp.add_entry( + self.kp.root_group, + unique_str + 'title', + unique_str + 'user', + unique_str + 'pass', + expiry_time=expiry_time + ) + results = self.kp.find_entries_by_title(unique_str + 'title', first=True) + self.assertEqual(results.expired, False) + self.assertEqual(results.expiry_time, expiry_time.astimezone(timezone.utc)) + # ---------- Entries representation ----------- def test_print_entries(self): @@ -352,7 +398,7 @@ def test_add_delete_move_group(self): results = self.kp.find_groups(path=['base_group', 'sub_group'], first=True) self.assertIsInstance(results, Group) self.assertEqual(results.name, sub_group.name) - self.assertTrue(results.uuid != None) + self.assertTrue(results.uuid is not None) self.kp.move_group(sub_group2, sub_group) results = self.kp.find_groups(path=['base_group', 'sub_group', 'sub_group2'], first=True) @@ -430,7 +476,7 @@ def test_recyclebinemptying(self): class EntryTests3(KDBX3Tests): def test_fields(self): - time = datetime.now().replace(microsecond=0) + expiry_time = datetime.now(timezone.utc).replace(microsecond=0) entry = Entry( 'title', 'username', @@ -440,7 +486,7 @@ def test_fields(self): tags='tags', otp='otp', expires=True, - expiry_time=time, + expiry_time=expiry_time, icon=icons.KEY, kp=self.kp ) @@ -453,8 +499,7 @@ def test_fields(self): self.assertEqual(entry.tags, ['tags']) self.assertEqual(entry.otp, 'otp') self.assertEqual(entry.expires, True) - self.assertEqual(entry.expiry_time, - time.replace(tzinfo=tz.gettz()).astimezone(tz.gettz('UTC'))) + self.assertEqual(entry.expiry_time, expiry_time) self.assertEqual(entry.icon, icons.KEY) self.assertEqual(entry.is_a_history_entry, False) self.assertEqual( @@ -483,8 +528,22 @@ def test_references(self): self.assertNotEqual(original_entry, clone1) self.assertNotEqual(clone1, clone2) + def test_broken_reference(self): + # TODO: move the entry into test databases + broken_entry_title = 'broken reference' + self.kp.add_entry( + self.kp.root_group, + title=broken_entry_title, + username='{REF:U@I:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA}', + password='{REF:P@I:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA}', + ) + broken_entry = self.kp.find_entries(title=broken_entry_title, first=True) + self.assertEqual(broken_entry.deref('username'), None) + self.assertEqual(broken_entry.deref('password'), None) + self.kp.delete_entry(broken_entry) + def test_set_and_get_fields(self): - time = datetime.now().replace(microsecond=0) + time = datetime.now(timezone.utc).replace(microsecond=0) changed_time = time + timedelta(hours=9) changed_string = 'changed_' entry = Entry( @@ -525,8 +584,7 @@ def test_set_and_get_fields(self): self.assertEqual(entry.get_custom_property('foo'), None) # test time properties self.assertEqual(entry.expires, False) - self.assertEqual(entry.expiry_time, - changed_time.replace(tzinfo=tz.gettz()).astimezone(tz.gettz('UTC'))) + self.assertEqual(entry.expiry_time, changed_time) entry.tags = 'changed_tags' self.assertEqual(entry.tags, ['changed_tags']) @@ -534,11 +592,13 @@ def test_set_and_get_fields(self): self.assertEqual(entry.tags, ['changed', 'tags']) entry.tags = ['changed', 'again', 'tags'] self.assertEqual(entry.tags, ['changed', 'again', 'tags']) + entry.tags = [] + self.assertEqual(entry.tags, []) def test_expired_datetime_offset(self): """Test for https://github.com/pschmitt/pykeepass/issues/115""" - future_time = datetime.now() + timedelta(days=1) - past_time = datetime.now() - timedelta(days=1) + future_time = datetime.now(timezone.utc) + timedelta(days=1) + past_time = datetime.now(timezone.utc) - timedelta(days=1) entry = Entry( 'title', 'username', @@ -611,6 +671,17 @@ def test_is_custom_property_protected(self): self.assertFalse(e.is_custom_property_protected('not-protected')) self.assertFalse(e.is_custom_property_protected('non-existent')) + def test_reindex(self): + e1 = self.kp.add_entry(self.kp.root_group, 'Test-Index1', 'user-index', 'pass') + e2 = self.kp.add_entry(self.kp.root_group, 'Test-Index2', 'user-index', 'pass') + e3 = self.kp.add_entry(self.kp.root_group, 'Test-Index3', 'user-index', 'pass') + e4 = self.kp.add_entry(self.kp.root_group, 'Test-Index4', 'user-index', 'pass') + e2.reindex(0) + e3.reindex(0) + e4.reindex(0) + entries = self.kp.find_entries(username="user-index") + self.assertEqual(entries, [e4,e3,e2,e1]) + class EntryHistoryTests3(KDBX3Tests): @@ -681,7 +752,7 @@ def test_find_history_entries(self): # change the active entries to test integrity of the history items backup = {} - now = datetime.now() + now = datetime.now(timezone.utc) for entry in res1: backup[entry.uuid] = {"atime": entry.atime, "mtime": entry.mtime, "ctime": entry.ctime} entry.title = changed + 'title' @@ -820,10 +891,24 @@ def test_fields(self): class PyKeePassTests3(KDBX3Tests): + def test_consecutives_saves_with_stream(self): + # https://github.com/libkeepass/pykeepass/pull/388 + self.setUp() + + with open(base_dir / self.keyfile_tmp, 'rb') as f: + keyfile = BytesIO(f.read()) + + for _i in range(5): + with PyKeePass( + base_dir / self.database_tmp, + password=self.password, + keyfile=keyfile, + ) as kp: + kp.save() 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, @@ -849,8 +934,8 @@ def test_credchange(self): required_days = 5 recommended_days = 5 - unexpired_date = datetime.now() - timedelta(days=1) - expired_date = datetime.now() - timedelta(days=10) + unexpired_date = datetime.now(timezone.utc) - timedelta(days=1) + expired_date = datetime.now(timezone.utc) - timedelta(days=10) self.kp.credchange_required_days = required_days self.kp.credchange_recommended_days = recommended_days @@ -960,9 +1045,42 @@ def test_issue308(self): def test_issue344(self): # accessing expiry_time throws exception when None - e = self.kp.find_entries(title='none_date', first=True) + e = self.kp_tmp.find_entries(title='none_date', first=True) + e._element.xpath('Times/ExpiryTime')[0].text = None self.assertEqual(e.expiry_time, None) + def test_issue376(self): + # Setting the properties of an entry should not change the Protected + # property + subgroup = self.kp.root_group + e = self.kp.add_entry(subgroup, 'banana_entry', 'user', 'pass') + + self.assertEqual(e._is_property_protected('Password'), True) + self.assertEqual(e._is_property_protected('Title'), False) + self.assertEqual(e.otp, None) + self.assertEqual(e._is_property_protected('otp'), False) + + e.title = 'pineapple' + e.password = 'pizza' + e.otp = 'aa' + + self.assertEqual(e._is_property_protected('Password'), True) + self.assertEqual(e._is_property_protected('Title'), False) + self.assertEqual(e._is_property_protected('otp'), True) + + # Using protected=None should not change the current status + e._set_string_field('XYZ', '1', protected=None) + self.assertEqual(e._is_property_protected('XYZ'), False) + + e._set_string_field('XYZ', '1', protected=True) + self.assertEqual(e._is_property_protected('XYZ'), True) + + e._set_string_field('XYZ', '1', protected=None) + self.assertEqual(e._is_property_protected('XYZ'), True) + + e._set_string_field('XYZ', '1', protected=False) + self.assertEqual(e._is_property_protected('XYZ'), False) + class EntryFindTests4(KDBX4Tests, EntryFindTests3): pass @@ -990,50 +1108,84 @@ 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) +class PyKeePassTests3(KDBX3Tests): + """Tests on PyKeePass class that don't involve attachments or finding entries/groups""" + + def test_database_info(self): + """Test database properties""" + + # Test name + self.assertEqual(self.kp_tmp.database_name, None) + self.kp_tmp.database_name = "Test Name" + self.assertEqual(self.kp_tmp.database_name, "Test Name") + + # Test Description + self.assertEqual(self.kp_tmp.database_description, None) + self.kp_tmp.database_description = "Test Description" + self.assertEqual(self.kp_tmp.database_description, "Test Description") + + # Test Default User Name + self.assertEqual(self.kp_tmp.default_username, None) + self.kp_tmp.default_username = "Test User" + self.assertEqual(self.kp_tmp.default_username, "Test User") + + self.kp_tmp.save() + self.kp_tmp.reload() + + self.assertEqual(self.kp_tmp.database_name, "Test Name") + self.assertEqual(self.kp_tmp.database_description, "Test Description") + self.assertEqual(self.kp_tmp.default_username, "Test User") + +class PyKeePassTests4(KDBX4Tests, PyKeePassTests3): + pass class KDBXTests(unittest.TestCase): + """Tests on KDBX parsing""" 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', @@ -1042,7 +1194,6 @@ def test_open_save(self): 'password', 'password', 'password', - 'password', None, None, 'password', @@ -1050,6 +1201,7 @@ def test_open_save(self): 'password', 'password', 'password', + 'password', ] transformed_keys = [ None, @@ -1058,7 +1210,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, @@ -1066,26 +1217,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', @@ -1098,11 +1249,11 @@ def test_open_save(self): 'twofish', 'chacha20', 'aes256', + 'chacha20', ] kdf_algorithms = [ 'aeskdf', 'argon2', - 'argon2', 'aeskdf', 'argon2', 'argon2', @@ -1114,6 +1265,7 @@ def test_open_save(self): 'argon2', 'argon2', 'argon2id', + 'argon2', ] versions = [ (3, 1), @@ -1122,7 +1274,6 @@ def test_open_save(self): (4, 0), (4, 0), (4, 0), - (4, 0), (3, 1), (4, 0), (3, 1), @@ -1130,6 +1281,7 @@ def test_open_save(self): (4, 0), (4, 0), (4, 0), + (4, 0), ] for (filename_in, filename_out, password, transformed_key, @@ -1140,7 +1292,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) @@ -1159,13 +1311,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): @@ -1175,6 +1328,8 @@ def test_open_error(self): 'test3.kdbx', 'test4.kdbx', 'test4.kdbx', + 'test4.kdbx', + 'test4.kdbx', 'test3.key', ] passwords = [ @@ -1183,12 +1338,15 @@ def test_open_error(self): 'invalid', 'password', 'password', + 'password', + 'password', ] keyfiles = [ 'test3.key', 'test4.key', 'test4.key', - 'test3.key', + 'test_invalidversion.key', + 'test.svg', 'test3.key', ] errors = [ @@ -1196,18 +1354,22 @@ def test_open_error(self): CredentialsError, CredentialsError, CredentialsError, + CredentialsError, + CredentialsError, HeaderChecksumError, ] 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', @@ -1217,18 +1379,23 @@ def test_open_no_decrypt(self): 'invalid_password', 'invalid_password', ] - salts = [ - b'\x82\xef\xf1\x05\x13\xbcQ\xa7\x8aG\x04b\xc7^o(\xf2R[\xc0\x0f\xa4?\xaa\xf9 Gi\xcf\xaf6\x0f', - b'\x82\xb0\xab/Bbn\x93\x90\xe0\x02m\x82\xaa\x9a\x9a\xd1\xc0k\x95\xbb\xc5kn\xe3\xeb\xd6GHg<$' + enc_algs = [ + 'aes256', + 'chacha20' ] - for database, password, salt in zip(databases, passwords, salts): + versions = [ + (3, 1), + (4, 0), + ] + for database, password, enc_alg, version in zip(databases, passwords, enc_algs, versions): kp = PyKeePass( os.path.join(base_dir, database), password, decrypt=False ) - self.assertEqual(kp.database_salt, salt) + self.assertEqual(kp.encryption_algorithm, enc_alg) + self.assertEqual(kp.version, version) def test_master_seed_differs(self): databases = [