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()
+

Reply via email to