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 <[email protected]>
+
+- 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="[email protected]",
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()
+