Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-pykeepass for openSUSE:Factory checked in at 2022-06-03 14:17:06 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-pykeepass (Old) and /work/SRC/openSUSE:Factory/.python-pykeepass.new.1548 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-pykeepass" Fri Jun 3 14:17:06 2022 rev:7 rq:980605 version:4.0.2 Changes: -------- --- /work/SRC/openSUSE:Factory/python-pykeepass/python-pykeepass.changes 2021-06-01 10:38:08.108881835 +0200 +++ /work/SRC/openSUSE:Factory/.python-pykeepass.new.1548/python-pykeepass.changes 2022-06-03 14:17:18.597366665 +0200 @@ -1,0 +2,8 @@ +Sun May 29 14:46:58 UTC 2022 - Atri Bhattacharya <badshah...@gmail.com> + +- Update to version 4.0.2: + * Added support for argon2id key derivation function. + * Added credential expiry functions. + * Fixes gh#libkeepass/pykeepass#223 - safe saving. + +------------------------------------------------------------------- Old: ---- pykeepass-4.0.1.tar.gz New: ---- pykeepass-4.0.2.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-pykeepass.spec ++++++ --- /var/tmp/diff_new_pack.sqizFS/_old 2022-06-03 14:17:19.057367263 +0200 +++ /var/tmp/diff_new_pack.sqizFS/_new 2022-06-03 14:17:19.061367268 +0200 @@ -1,7 +1,7 @@ # # spec file for package python-pykeepass # -# Copyright (c) 2021 SUSE LLC +# Copyright (c) 2022 SUSE LLC # # All modifications and additions to the file contributed by third parties # remain the property of their copyright owners, unless otherwise agreed @@ -18,13 +18,13 @@ %{?!python_module:%define python_module() python-%{**} python3-%{**}} Name: python-pykeepass -Version: 4.0.1 +Version: 4.0.2 Release: 0 Summary: Low-level library to interact with keepass databases License: GPL-3.0-only Group: Development/Languages/Python URL: https://github.com/libkeepass/pykeepass -Source: https://github.com/libkeepass/pykeepass/archive/%{version}.tar.gz#/pykeepass-%{version}.tar.gz +Source: https://github.com/libkeepass/pykeepass/archive/refs/tags/v%{version}.tar.gz#/pykeepass-%{version}.tar.gz BuildRequires: %{python_module devel} BuildRequires: %{python_module setuptools} BuildRequires: fdupes @@ -59,7 +59,6 @@ %install %python_install -%python_expand rm -r %{buildroot}%{$python_sitelib}/tests %python_expand %fdupes %{buildroot}%{$python_sitelib} %check ++++++ pykeepass-4.0.1.tar.gz -> pykeepass-4.0.2.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pykeepass-4.0.1/.github/workflows/ci.yaml new/pykeepass-4.0.2/.github/workflows/ci.yaml --- old/pykeepass-4.0.1/.github/workflows/ci.yaml 2021-05-22 09:07:02.000000000 +0200 +++ new/pykeepass-4.0.2/.github/workflows/ci.yaml 2022-05-22 01:06:31.000000000 +0200 @@ -7,7 +7,7 @@ runs-on: ubuntu-latest strategy: matrix: - python-version: [2.7, 3.6, 3.7, 3.8, 3.9] + python-version: [3.6, 3.7, 3.8, 3.9] fail-fast: false steps: - uses: actions/checkout@v2 @@ -19,6 +19,7 @@ - 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 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pykeepass-4.0.1/.gitignore new/pykeepass-4.0.2/.gitignore --- old/pykeepass-4.0.1/.gitignore 2021-05-22 09:07:02.000000000 +0200 +++ new/pykeepass-4.0.2/.gitignore 2022-05-22 01:06:31.000000000 +0200 @@ -6,3 +6,4 @@ *.xml Pipfile.lock *.kdbx +*.kdbx.out diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pykeepass-4.0.1/CHANGELOG.rst new/pykeepass-4.0.2/CHANGELOG.rst --- old/pykeepass-4.0.1/CHANGELOG.rst 2021-05-22 09:07:02.000000000 +0200 +++ new/pykeepass-4.0.2/CHANGELOG.rst 2022-05-22 01:06:31.000000000 +0200 @@ -1,3 +1,21 @@ +4.0.2 - +------------------ +- added support for argon2id key derivation function +- added credential expiry functions +- fixes #223 - safe saving + + +4.0.1 - 2021-05-22 +------------------ +- added Entry.delete_history() +- added HistoryEntry class +- added Group.touch() +- support 2.0 keyfiles +- added PyKeePass.reload() +- dropped python2 tests +- fixed #284 - autotype_sequence returns string 'None' +- fixed #244 - incorrect PKCS padding error + 4.0.0 - 2021-01-15 ------------------ - paths changed from strings to lists diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pykeepass-4.0.1/Makefile new/pykeepass-4.0.2/Makefile --- old/pykeepass-4.0.1/Makefile 1970-01-01 01:00:00.000000000 +0100 +++ new/pykeepass-4.0.2/Makefile 2022-05-22 01:06:31.000000000 +0200 @@ -0,0 +1,9 @@ +version := $(shell python -c "exec(open('pykeepass/version.py').read());print(__version__)") + +.PHONY: dist +dist: + python setup.py sdist bdist_wheel + +.PHONY: pypi +pypi: dist + twine upload dist/pykeepass-$(version).tar.gz diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pykeepass-4.0.1/README.rst new/pykeepass-4.0.2/README.rst --- old/pykeepass-4.0.1/README.rst 2021-05-22 09:07:02.000000000 +0200 +++ new/pykeepass-4.0.2/README.rst 2022-05-22 01:06:31.000000000 +0200 @@ -155,12 +155,16 @@ Group: "/" -Adding Entries +Entry Functions -------------- **add_entry** (destination_group, title, username, password, url=None, notes=None, tags=None, expiry_time=None, icon=None, force_creation=False) **delete_entry** (entry) +**trash_entry** (entry) + +move a group to the recycle bin. The recycle bin is created if it does not exit. ``entry`` must be an empty Entry. + **move_entry** (entry, destination_group) 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. @@ -174,7 +178,7 @@ Entry: "testing (foo_user)" # add a new entry to the social group - >>> group = find_groups(name='social', first=True) + >>> group = kp.find_groups(name='social', first=True) >>> entry = kp.add_entry(group, 'testing', 'foo_user', 'passw0rd') Entry: "testing (foo_user)" @@ -190,12 +194,20 @@ # save the database >>> kp.save() -Adding Groups --------------- +Group Functions +--------------- **add_group** (destination_group, group_name, icon=None, notes=None) **delete_group** (group) +**trash_group** (group) + +move a group to the recycle bin. The recycle bin is created if it does not exit. ``group`` must be an empty Group. + +**empty_group** (group) + +delete all entries and subgroups of a group. ``group`` is an instance of ``Group**. + **move_group** (group, destination_group) ``destination_group`` and ``group`` are instances of ``Group``. ``group_name`` is a string @@ -310,13 +322,37 @@ # search attachments >>> kp.find_attachments(filename='hello.txt') - [Attachment: 'hello.txt' -> 0] + [Attachment: 'hello.txt** -> 0] # delete attachment reference >>> e.delete_attachment(a) # or, delete both attachment reference and binary - >>> kp.delete_binary(binary_id) + >>> kp.delete_binary(binary_id** + +Credential Expiry +----------------- + +**credchange_date** + +datetime object with date of last credentials change + +**credchange_required** + +boolean whether database credentials have expired and are required to change + +**credchange_recommended** + +boolean whether database credentials have expired and are recommended to change + +**credchange_required_days** + +days after **credchange_date** that credential update is required + +**credchange_recommended_days** + +days after **credchange_date** that credential update is recommended + Miscellaneous ------------- @@ -324,7 +360,11 @@ 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``. -Can raise ``CredentialsError``, ``HeaderChecksumError``, or ``PayloadChecksumError``. +Can raise ``CredentialsError``, ``HeaderChecksumError**, or ``PayloadChecksumError**. + +**reload** () + +reload database from disk using previous credentials **save** (filename=None) @@ -350,13 +390,17 @@ create a new database at ``filename`` with supplied credentials. Returns ``PyKeePass`` object -**trash_group** (group) +**tree** -move a group to the recycle bin. The recycle bin is created if it does not exit. ``group`` must be an empty Group. +database lxml tree -**empty_group** (group) +**xml** + +get database XML data as string + +**dump_xml** (filename) -delete all entries and subgroups of a group. ``group`` is an instance of ``Group``. +pretty print database XML to file Tests ------------- diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pykeepass-4.0.1/pykeepass/baseelement.py new/pykeepass-4.0.2/pykeepass/baseelement.py --- old/pykeepass-4.0.1/pykeepass/baseelement.py 2021-05-22 09:07:02.000000000 +0200 +++ new/pykeepass-4.0.2/pykeepass/baseelement.py 2022-05-22 01:06:31.000000000 +0200 @@ -3,12 +3,9 @@ import base64 import struct import uuid -from binascii import Error as BinasciiError -from datetime import datetime, timedelta - -from dateutil import parser, tz from lxml import etree from lxml.builder import E +from datetime import datetime class BaseElement(object): @@ -23,9 +20,9 @@ ) if icon: self._element.append(E.IconID(icon)) - current_time_str = self._encode_time(datetime.now()) + current_time_str = self._kp._encode_time(datetime.now()) if expiry_time: - expiry_time_str = self._encode_time(expiry_time) + expiry_time_str = self._kp._encode_time(expiry_time) else: expiry_time_str = current_time_str @@ -92,70 +89,19 @@ def _path(self): return self._element.getroottree().getpath(self._element) - 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): - """Convert datetime to base64 or plaintext string""" - - if self._kp.version >= (4, 0): - diff_seconds = int( - ( - self._datetime_to_utc(value) - - datetime( - year=1, - month=1, - day=1, - tzinfo=tz.gettz('UTC') - ) - ).total_seconds() - ) - return base64.b64encode( - struct.pack('<Q', diff_seconds) - ).decode('utf-8') - else: - return self._datetime_to_utc(value).isoformat() - - def _decode_time(self, text): - """Convert base64 time or plaintext time to datetime""" - - if self._kp.version >= (4, 0): - # decode KDBX4 date from b64 format - try: - return ( - datetime(year=1, month=1, day=1, tzinfo=tz.gettz('UTC')) + - timedelta( - seconds=struct.unpack('<Q', base64.b64decode(text))[0] - ) - ) - except BinasciiError: - return parser.parse( - text, - tzinfos={'UTC': tz.gettz('UTC')} - ) - else: - return parser.parse( - text, - tzinfos={'UTC': tz.gettz('UTC')} - ) - def _get_times_property(self, prop): times = self._element.find('Times') if times is not None: prop = times.find(prop) if prop is not None: - return self._decode_time(prop.text) + return self._kp._decode_time(prop.text) def _set_times_property(self, prop, value): times = self._element.find('Times') if times is not None: prop = times.find(prop) if prop is not None: - prop.text = self._encode_time(value) + prop.text = self._kp._encode_time(value) @property def expires(self): @@ -172,7 +118,11 @@ @property def expired(self): if self.expires: - return self._datetime_to_utc(datetime.utcnow()) > self._datetime_to_utc(self.expiry_time) + return ( + self._kp._datetime_to_utc(datetime.utcnow()) > + self._kp._datetime_to_utc(self.expiry_time) + ) + return False @property diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pykeepass-4.0.1/pykeepass/entry.py new/pykeepass-4.0.2/pykeepass/entry.py --- old/pykeepass-4.0.1/pykeepass/entry.py 2021-05-22 09:07:02.000000000 +0200 +++ new/pykeepass-4.0.2/pykeepass/entry.py 2022-05-22 01:06:31.000000000 +0200 @@ -39,7 +39,7 @@ self._kp = kp if element is None: - super(Entry, self).__init__( + super().__init__( element=Element('Entry'), kp=kp, expires=expires, @@ -63,7 +63,7 @@ E.AutoType( E.Enabled(str(autotype_enabled)), E.DataTransferObfuscation('0'), - E.DefaultSequence(str(autotype_sequence)) + E.DefaultSequence(str(autotype_sequence) if autotype_sequence else '') ) ) @@ -180,7 +180,7 @@ @property def history(self): if self._element.find('History') is not None: - return [Entry(element=x, kp=self._kp) for x in self._element.find('History').findall('Entry')] + return [HistoryEntry(element=x, kp=self._kp) for x in self._element.find('History').findall('Entry')] else: return [] @@ -205,7 +205,9 @@ @property def autotype_sequence(self): sequence = self._element.find('AutoType/DefaultSequence') - return sequence.text if sequence is not None else None + if sequence is None or sequence.text == '': + return None + return sequence.text @autotype_sequence.setter def autotype_sequence(self, value): @@ -284,9 +286,32 @@ history.append(archive) self._element.append(history) + def delete_history(self, history_entry=None, all=False): + """ + Delete entries from history + + Args: + history_entry (Entry): history item to delete + all (bool): delete all entries from history. Default is False + """ + + if all: + self._element.remove(self._element.find('History')) + else: + self._element.find('History').remove(history_entry._element) + def __str__(self): # filter out NoneTypes and join into string pathstr = '/'.join('' if p==None else p for p in self.path) - if self.is_a_history_entry: - return '[History of: {}]'.format(pathstr) return 'Entry: "{} ({})"'.format(pathstr, self.username) + + +class HistoryEntry(Entry): + + def __str__(self): + pathstr = super().__str__() + return 'HistoryEntry: {}'.format(pathstr) + + def __eq__(self, other): + # all history items share the same uuid, so examine xml directly + return self._element == other._element diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pykeepass-4.0.1/pykeepass/group.py new/pykeepass-4.0.2/pykeepass/group.py --- old/pykeepass-4.0.1/pykeepass/group.py 2021-05-22 09:07:02.000000000 +0200 +++ new/pykeepass-4.0.2/pykeepass/group.py 2022-05-22 01:06:31.000000000 +0200 @@ -20,7 +20,7 @@ self._kp = kp if element is None: - super(Group, self).__init__( + super().__init__( element=Element('Group'), kp=kp, expires=expires, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pykeepass-4.0.1/pykeepass/icons.py new/pykeepass-4.0.2/pykeepass/icons.py --- old/pykeepass-4.0.1/pykeepass/icons.py 2021-05-22 09:07:02.000000000 +0200 +++ new/pykeepass-4.0.2/pykeepass/icons.py 2022-05-22 01:06:31.000000000 +0200 @@ -10,6 +10,7 @@ BUSINESS_CARD = '9' GREEN_AND_WHITE_THINGY = '10' CAMERA = '11' +INFRARED = '12' KEYS = '13' POWER_PLUG = '14' FLATBED_SCANNER = '15' diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pykeepass-4.0.1/pykeepass/kdbx_parsing/common.py new/pykeepass-4.0.2/pykeepass/kdbx_parsing/common.py --- old/pykeepass-4.0.1/pykeepass/kdbx_parsing/common.py 2021-05-22 09:07:02.000000000 +0200 +++ new/pykeepass-4.0.2/pykeepass/kdbx_parsing/common.py 2022-05-22 01:06:31.000000000 +0200 @@ -42,7 +42,7 @@ """ def __init__(self, key, subcon, lump=[]): - super(DynamicDict, self).__init__(subcon) + super().__init__(subcon) self.key = key self.lump = lump @@ -194,7 +194,7 @@ protected_xpath = '//Value[@Protected=\'True\']' def __init__(self, protected_stream_key, subcon): - super(UnprotectedStream, self).__init__(subcon) + super().__init__(subcon) self.protected_stream_key = protected_stream_key def _decode(self, tree, con, path): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pykeepass-4.0.1/pykeepass/kdbx_parsing/kdbx4.py new/pykeepass-4.0.2/pykeepass/kdbx_parsing/kdbx4.py --- old/pykeepass-4.0.1/pykeepass/kdbx_parsing/kdbx4.py 2021-05-22 09:07:02.000000000 +0200 +++ new/pykeepass-4.0.2/pykeepass/kdbx_parsing/kdbx4.py 2022-05-22 01:06:31.000000000 +0200 @@ -19,9 +19,10 @@ # -------------------- Key Derivation -------------------- -# https://github.com/keepassxreboot/keepassxc/blob/8324d03f0a015e62b6182843b4478226a5197090/src/format/KeePass2.cpp#L24-L26 +# https://github.com/keepassxreboot/keepassxc/blob/bc55974ff304794e53c925442784c50a2fdaf6ee/src/format/KeePass2.cpp#L30-L33 kdf_uuids = { 'argon2': b'\xefcm\xdf\x8c)DK\x91\xf7\xa9\xa4\x03\xe3\n\x0c', + 'argon2id': b'\x9e)\x8b\x19V\xdbGs\xb2=\xfc>\xc6\xf0\xa1\xe6', 'aeskdf': b'\xc9\xd9\xf3\x9ab\x8aD`\xbft\r\x08\xc1\x8aO\xea', } @@ -37,12 +38,12 @@ if context._._.transformed_key is not None: transformed_key = context._._.transformed_key - elif kdf_parameters['$UUID'].value == kdf_uuids['argon2']: + elif kdf_parameters['$UUID'].value in (kdf_uuids['argon2'], kdf_uuids['argon2id']): transformed_key = argon2.low_level.hash_secret_raw( secret=key_composite, salt=kdf_parameters['S'].value, hash_len=32, - type=argon2.low_level.Type.D, + type=(argon2.low_level.Type.ID if kdf_parameters['$UUID'].value == kdf_uuids['argon2id'] else argon2.low_level.Type.D), time_cost=kdf_parameters['I'].value, memory_cost=kdf_parameters['M'].value // 1024, parallelism=kdf_parameters['P'].value, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pykeepass-4.0.1/pykeepass/pykeepass.py new/pykeepass-4.0.2/pykeepass/pykeepass.py --- old/pykeepass-4.0.1/pykeepass/pykeepass.py 2021-05-22 09:07:02.000000000 +0200 +++ new/pykeepass-4.0.2/pykeepass/pykeepass.py 2022-05-22 01:06:31.000000000 +0200 @@ -8,11 +8,16 @@ import logging import os import re +import shutil +import struct import uuid import zlib -from copy import deepcopy +from binascii import Error as BinasciiError from construct import Container, ChecksumError +from copy import deepcopy +from dateutil import parser, tz +from datetime import datetime, timedelta from lxml import etree from lxml.builder import E @@ -84,8 +89,8 @@ Todo: - raise, no filename provided, database not open """ - self.password = password - self.keyfile = keyfile + self._password = password + self._keyfile = keyfile if filename: self.filename = filename else: @@ -138,12 +143,12 @@ transformed_key (:obj:`bytes`, optional): precomputed transformed key. """ - output = None + if not filename: filename = self.filename if hasattr(filename, "write"): - output = KDBX.build_stream( + KDBX.build_stream( self.kdbx, filename, password=self.password, @@ -151,14 +156,22 @@ transformed_key=transformed_key ) else: - output = KDBX.build_file( - self.kdbx, - filename, - password=self.password, - keyfile=self.keyfile, - transformed_key=transformed_key - ) - return output + # save to temporary file to prevent database clobbering + # see issues 223, 101 + # FIXME python2 - use pathlib.Path.withsuffix + filename_tmp = filename + '.tmp' + try: + KDBX.build_file( + self.kdbx, + filename_tmp, + password=self.password, + keyfile=self.keyfile, + transformed_key=transformed_key + ) + except Exception as e: + os.remove(filename_tmp) + raise e + shutil.move(filename_tmp, filename) @property def version(self): @@ -185,6 +198,8 @@ kdf_parameters = self.kdbx.header.value.dynamic_header.kdf_parameters.data.dict if kdf_parameters['$UUID'].value == kdf_uuids['argon2']: return 'argon2' + elif kdf_parameters['$UUID'].value == kdf_uuids['argon2id']: + return 'argon2id' elif kdf_parameters['$UUID'].value == kdf_uuids['aeskdf']: return 'aeskdf' @@ -743,6 +758,145 @@ for reference in binaries_gt: reference.id = reference.id - 1 + # ---------- Credential Changing and Expiry ---------- + + # make password/keyfile into property instead of attribute so + # MasterKeyChanged can be set correctly + @property + def password(self): + return self._password + + @password.setter + def password(self, password): + self._password = password + self.credchange_date = datetime.now() + + @property + def keyfile(self): + return self._keyfile + + @keyfile.setter + def keyfile(self, keyfile): + self._keyfile = keyfile + self.credchange_date = datetime.now() + + @property + def credchange_required_days(self): + """Days until password update should be required""" + e = self._xpath('/KeePassFile/Meta/MasterKeyChangeForce', first=True) + if e is not None: + return int(e.text) + + @property + def credchange_recommended_days(self): + """Days until password update should be recommended""" + e = self._xpath('/KeePassFile/Meta/MasterKeyChangeRec', first=True) + if e is not None: + return int(e.text) + + @credchange_required_days.setter + def credchange_required_days(self, days): + """Set credentials required expiry days + + Args: + days (int): days from password change until expiry + """ + + path = '/KeePassFile/Meta/MasterKeyChangeForce' + item = self._xpath(path, first=True) + item.text = str(days) + + @credchange_recommended_days.setter + def credchange_recommended_days(self, days): + """Set credentials recommended expiry days + + Args: + days (int): days from password change until warning + """ + + path = '/KeePassFile/Meta/MasterKeyChangeRec' + item = self._xpath(path, first=True) + item.text = str(days) + + @property + def credchange_date(self): + e = self._xpath('/KeePassFile/Meta/MasterKeyChanged', first=True) + if e is not None: + return self._decode_time(e.text) + + @credchange_date.setter + def credchange_date(self, date): + time = self._xpath('/KeePassFile/Meta/MasterKeyChanged', first=True) + time.text = self._encode_time(date) + + @property + 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()) + return (now_date - change_date).days > self.credchange_required_days + + @property + 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()) + 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): + """Convert datetime to base64 or plaintext string""" + + if self.version >= (4, 0): + diff_seconds = int( + ( + self._datetime_to_utc(value) - + datetime( + year=1, + month=1, + day=1, + tzinfo=tz.gettz('UTC') + ) + ).total_seconds() + ) + return base64.b64encode( + struct.pack('<Q', diff_seconds) + ).decode('utf-8') + else: + return self._datetime_to_utc(value).isoformat() + + def _decode_time(self, text): + """Convert base64 time or plaintext time to datetime""" + + if self.version >= (4, 0): + # decode KDBX4 date from b64 format + try: + return ( + datetime(year=1, month=1, day=1, tzinfo=tz.gettz('UTC')) + + timedelta( + seconds=struct.unpack('<Q', base64.b64decode(text))[0] + ) + ) + except BinasciiError: + return parser.parse( + text, + tzinfos={'UTC': tz.gettz('UTC')} + ) + else: + return parser.parse( + text, + tzinfos={'UTC': tz.gettz('UTC')} + ) def create_database( filename, password=None, keyfile=None, transformed_key=None diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pykeepass-4.0.1/pykeepass/version.py new/pykeepass-4.0.2/pykeepass/version.py --- old/pykeepass-4.0.1/pykeepass/version.py 2021-05-22 09:07:02.000000000 +0200 +++ new/pykeepass-4.0.2/pykeepass/version.py 2022-05-22 01:06:31.000000000 +0200 @@ -1,3 +1,3 @@ -__version__ = "4.0.1" +__version__ = "4.0.2" __all__= ["__version__"] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pykeepass-4.0.1/requirements-rtd.txt new/pykeepass-4.0.2/requirements-rtd.txt --- old/pykeepass-4.0.1/requirements-rtd.txt 2021-05-22 09:07:02.000000000 +0200 +++ new/pykeepass-4.0.2/requirements-rtd.txt 2022-05-22 01:06:31.000000000 +0200 @@ -1,7 +1,7 @@ -lxml==4.6.2 -pycryptodomex==3.10.1 -construct==2.10.54 -argon2-cffi==20.1.0 -python-dateutil==2.8.1 +lxml==4.8.0 +pycryptodomex==3.14.1 +construct==2.10.68 +argon2-cffi==21.3.0 +python-dateutil==2.8.2 future==0.18.2 Sphinx>=3.2.1 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pykeepass-4.0.1/requirements.txt new/pykeepass-4.0.2/requirements.txt --- old/pykeepass-4.0.1/requirements.txt 2021-05-22 09:07:02.000000000 +0200 +++ new/pykeepass-4.0.2/requirements.txt 2022-05-22 01:06:31.000000000 +0200 @@ -1,6 +1,6 @@ -lxml==4.6.2 -pycryptodomex==3.10.1 -construct==2.10.54 -argon2-cffi==20.1.0 -python-dateutil==2.8.1 +lxml==4.8.0 +pycryptodomex==3.14.1 +construct==2.10.68 +argon2-cffi==21.3.0 +python-dateutil==2.8.2 future==0.18.2 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pykeepass-4.0.1/setup.py new/pykeepass-4.0.2/setup.py --- old/pykeepass-4.0.1/setup.py 2021-05-22 09:07:02.000000000 +0200 +++ new/pykeepass-4.0.2/setup.py 2022-05-22 01:06:31.000000000 +0200 @@ -17,11 +17,11 @@ author="Philipp Schmitt", author_email="phil...@schmitt.co", url="https://github.com/libkeepass/pykeepass", - packages=find_packages(), + packages=find_packages(include=['pykeepass', 'pykeepass.*']), install_requires=[ "python-dateutil", # FIXME python2 - last version to support python2 - "construct==2.10.54", + "construct==2.10.68", "argon2_cffi", "pycryptodomex>=3.6.2", "lxml", Binary files old/pykeepass-4.0.1/tests/test4_argon2id.kdbx and new/pykeepass-4.0.2/tests/test4_argon2id.kdbx differ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pykeepass-4.0.1/tests/tests.py new/pykeepass-4.0.2/tests/tests.py --- old/pykeepass-4.0.1/tests/tests.py 2021-05-22 09:07:02.000000000 +0200 +++ new/pykeepass-4.0.2/tests/tests.py 2022-05-22 01:06:31.000000000 +0200 @@ -41,16 +41,17 @@ class KDBX3Tests(unittest.TestCase): - database = 'test3.kdbx' + database = os.path.join(base_dir, 'test3.kdbx') password = 'password' - keyfile = 'test3.key' + keyfile = os.path.join(base_dir, 'test3.key') + + database_tmp = os.path.join(base_dir, 'test3_tmp.kdbx') + keyfile_tmp = os.path.join(base_dir, 'test3_tmp.key') # get some things ready before testing def setUp(self): - shutil.copy( - os.path.join(base_dir, self.database), - os.path.join(base_dir, 'change_creds.kdbx') - ) + shutil.copy(self.database, self.database_tmp) + shutil.copy(self.keyfile, self.keyfile_tmp) self.kp = PyKeePass( os.path.join(base_dir, self.database), password=self.password, @@ -58,16 +59,23 @@ ) # for tests which modify the database, use this self.kp_tmp = PyKeePass( - os.path.join(base_dir, 'change_creds.kdbx'), + os.path.join(base_dir, self.database_tmp), password=self.password, - keyfile=os.path.join(base_dir, self.keyfile) + keyfile=os.path.join(base_dir, self.keyfile_tmp) ) + def tearDown(self): + os.remove(self.keyfile_tmp) + os.remove(self.database_tmp) + class KDBX4Tests(KDBX3Tests): - database = 'test4.kdbx' + database = os.path.join(base_dir, 'test4.kdbx') password = 'password' - keyfile = 'test4.key' + keyfile = os.path.join(base_dir, 'test4.key') + + database_tmp = os.path.join(base_dir, 'test4_tmp.kdbx') + keyfile_tmp = os.path.join(base_dir, 'test4_tmp.key') class EntryFindTests3(KDBX3Tests): @@ -230,6 +238,7 @@ self.assertEqual(results.notes, unique_str + 'notes') self.assertEqual(results.tags, [unique_str + 'tags']) self.assertTrue(results.uuid != 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) @@ -638,7 +647,7 @@ for item in hist: self.assertTrue(item.is_a_history_entry) self.assertEqual(item.group, entry.group) - self.assertTrue(str(item).startswith('[History of:')) + self.assertTrue(str(item).startswith('HistoryEntry:')) # here history items are expected res2 = self.kp.find_entries(title=prefix + 'title', history=True) @@ -698,7 +707,6 @@ for item in hist: self.assertTrue(item.is_a_history_entry) self.assertEqual(item.group, entry.group) - self.assertTrue(str(item).startswith('[History of:')) res2 = self.kp.find_entries(title=changed + 'title', history=True) self.assertEqual(len(res2), 6) @@ -706,6 +714,16 @@ if entry not in res1: self.assertTrue(entry.is_a_history_entry) + # try deleting a history entry + h = e1.history[0] + self.assertIn(h, e1.history) + e1.delete_history(h) + self.assertNotIn(h, e1.history) + + # delete all history + self.assertTrue(len(e1.history) > 0) + e1.delete_history(all=True) + self.assertTrue(len(e1.history) == 0) class GroupTests3(KDBX3Tests): @@ -729,39 +747,26 @@ class AttachmentTests3(KDBX3Tests): # get some things ready before testing - def setUp(self): - shutil.copy( - os.path.join(base_dir, self.database), - os.path.join(base_dir, 'test_attachment.kdbx') - ) - self.open() - - def open(self): - self.kp = PyKeePass( - os.path.join(base_dir, 'test_attachment.kdbx'), - password=self.password, - keyfile=os.path.join(base_dir, self.keyfile) - ) def test_create_delete_binary(self): with self.assertRaises(BinaryError): - self.kp.delete_binary(999) + self.kp_tmp.delete_binary(999) with self.assertRaises(BinaryError): - e = self.kp.entries[0] + e = self.kp_tmp.entries[0] e.add_attachment(filename='foo.txt', id=123) e.attachments[0].binary - binary_id = self.kp.add_binary(b'Ronald McDonald Trump') - self.kp.save() - self.open() - self.assertEqual(self.kp.binaries[binary_id], b'Ronald McDonald Trump') - self.assertEqual(len(self.kp.attachments), 1) - - num_attach = len(self.kp.binaries) - self.kp.delete_binary(binary_id) - self.kp.save() - self.open() - self.assertEqual(len(self.kp.binaries), num_attach - 1) + binary_id = self.kp_tmp.add_binary(b'Ronald McDonald Trump') + self.kp_tmp.save() + self.kp_tmp.reload() + self.assertEqual(self.kp_tmp.binaries[binary_id], b'Ronald McDonald Trump') + self.assertEqual(len(self.kp_tmp.attachments), 1) + + num_attach = len(self.kp_tmp.binaries) + self.kp_tmp.delete_binary(binary_id) + self.kp_tmp.save() + self.kp_tmp.reload() + self.assertEqual(len(self.kp_tmp.binaries), num_attach - 1) def test_attachment_reference_decrement(self): e = self.kp.entries[0] @@ -784,9 +789,6 @@ self.assertEqual(a.id, binary_id) self.assertEqual(a.filename, 'test.txt') - def tearDown(self): - os.remove(os.path.join(base_dir, 'test_attachment.kdbx')) - class PyKeePassTests3(KDBX3Tests): @@ -809,9 +811,46 @@ first_line = f.readline() self.assertEqual(first_line, '<?xml version=\'1.0\' encoding=\'utf-8\' standalone=\'yes\'?>\n') - def tearDown(self): - os.remove(os.path.join(base_dir, 'change_creds.kdbx')) - + def test_credchange(self): + """ + - test rec/req boolean (expired, no expired, days=-1) + - test get/set days + - test cred set timer reset + """ + + required_days = 5 + recommended_days = 5 + unexpired_date = datetime.now() - timedelta(days=1) + expired_date = datetime.now() - timedelta(days=10) + + self.kp.credchange_required_days = required_days + self.kp.credchange_recommended_days = recommended_days + + # test not expired + self.kp.credchange_date = unexpired_date + self.assertFalse(self.kp.credchange_required) + self.assertFalse(self.kp.credchange_recommended) + + # test expired + self.kp.credchange_date = expired_date + self.assertTrue(self.kp.credchange_required) + self.assertTrue(self.kp.credchange_recommended) + + # test expiry disabled + self.kp.credchange_required_days = -1 + self.kp.credchange_recommended_days = -1 + self.assertFalse(self.kp.credchange_required) + self.assertFalse(self.kp.credchange_recommended) + + # test credential update + self.kp.credchange_required_days = required_days + self.kp.credchange_recommended_days = recommended_days + self.kp.credchange_date = expired_date + self.assertTrue(self.kp.credchange_required) + self.assertTrue(self.kp.credchange_recommended) + self.kp.keyfile = 'foo' + self.assertFalse(self.kp.credchange_required) + self.assertFalse(self.kp.credchange_recommended) class BugRegressionTests3(KDBX3Tests): def test_issue129(self): @@ -851,6 +890,35 @@ e = self.kp_tmp.find_entries(title='protect_test', first=True) self.assertEqual(e.password, 'pass') + def test_issue223(self): + # issue 223 - database is clobbered when kp.save() fails + # even if exception is caught + + # test clobbering with file on disk + # change keyfile so database save fails + self.kp_tmp.keyfile = 'foo' + with self.assertRaises(Exception): + self.kp_tmp.save() + # try to open database + self.kp_tmp.keyfile = self.keyfile_tmp + PyKeePass(self.database_tmp, self.password, self.keyfile_tmp) + # reset test database + self.setUp() + + # test clobbering with buffer + stream = BytesIO() + self.kp_tmp.save(stream) + stream.seek(0) + + # change keyfile so database save fails + self.kp_tmp.keyfile = 'foo' + self.kp_tmp.password = ('invalid', 'type') + with self.assertRaises(Exception): + self.kp_tmp.save(stream) + stream.seek(0) + # try to open database + self.kp_tmp.keyfile = self.keyfile_tmp + PyKeePass(stream, self.password, self.keyfile_tmp) class EntryFindTests4(KDBX4Tests, EntryFindTests3): @@ -904,7 +972,10 @@ 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 stream, - os.path.join(base_dir, 'test4_aes_uncompressed.kdbx') # KDBX v4 AES uncompressed + 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 ] filenames_out = [ os.path.join(base_dir, 'test3.kdbx.out'), @@ -918,8 +989,9 @@ os.path.join(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'), - os.path.join(base_dir, 'test4_chacha20_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'), ] passwords = [ 'password', @@ -935,6 +1007,7 @@ 'password', 'password', 'password', + 'password', ] transformed_keys = [ None, @@ -950,6 +1023,7 @@ None, None, None, + None, ] keyfiles = [ 'test3.key', @@ -965,6 +1039,7 @@ None, None, None, + None, ] encryption_algorithms = [ 'aes256', @@ -980,6 +1055,7 @@ 'aes256', 'twofish', 'chacha20', + 'aes256', ] kdf_algorithms = [ 'aeskdf', @@ -995,6 +1071,7 @@ 'argon2', 'argon2', 'argon2', + 'argon2id', ] versions = [ (3, 1), @@ -1010,6 +1087,7 @@ (4, 0), (4, 0), (4, 0), + (4, 0), ] for (filename_in, filename_out, password, transformed_key, @@ -1078,3 +1156,4 @@ if __name__ == '__main__': unittest.main() +