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 2026-09-21 12:21:35
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-pykeepass (Old)
and /work/SRC/openSUSE:Factory/.python-pykeepass.new.383539 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-pykeepass"
Mon Sep 21 12:21:35 2026 rev:16 rq:1379302 version:4.2.0
Changes:
--------
--- /work/SRC/openSUSE:Factory/python-pykeepass/python-pykeepass.changes
2025-04-14 12:59:22.538752013 +0200
+++
/work/SRC/openSUSE:Factory/.python-pykeepass.new.383539/python-pykeepass.changes
2026-09-21 12:21:36.947270448 +0200
@@ -1,0 +2,7 @@
+Sun Sep 20 07:50:58 UTC 2026 - Bjørn Lie <[email protected]>
+
+- Update to version 4.2.0:
+ * Fixed [gh#libkeepass/pykeepass219] - seeds not regenerated on
+ save
+
+-------------------------------------------------------------------
Old:
----
pykeepass-4.1.1.post1.tar.gz
New:
----
pykeepass-4.2.0.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-pykeepass.spec ++++++
--- /var/tmp/diff_new_pack.HPc0MS/_old 2026-09-21 12:21:37.489293111 +0200
+++ /var/tmp/diff_new_pack.HPc0MS/_new 2026-09-21 12:21:37.491293195 +0200
@@ -1,7 +1,7 @@
#
# spec file for package python-pykeepass
#
-# Copyright (c) 2025 SUSE LLC
+# Copyright (c) 2026 SUSE LLC and contributors
#
# All modifications and additions to the file contributed by third parties
# remain the property of their copyright owners, unless otherwise agreed
@@ -17,12 +17,12 @@
Name: python-pykeepass
-Version: 4.1.1.post1
+Version: 4.2.0
Release: 0
Summary: Low-level library to interact with keepass databases
License: GPL-3.0-only
URL: https://github.com/libkeepass/pykeepass
-Source:
https://github.com/libkeepass/pykeepass/archive/refs/tags/v%{version}.tar.gz#/pykeepass-%{version}.tar.gz
+Source:
%{url}/archive/refs/tags/v%{version}.tar.gz#/pykeepass-%{version}.tar.gz
BuildRequires: %{python_module devel}
BuildRequires: %{python_module pip}
BuildRequires: %{python_module pyotp}
++++++ pykeepass-4.1.1.post1.tar.gz -> pykeepass-4.2.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/pykeepass-4.1.1.post1/CHANGELOG.rst
new/pykeepass-4.2.0/CHANGELOG.rst
--- old/pykeepass-4.1.1.post1/CHANGELOG.rst 2025-03-06 01:40:39.000000000
+0100
+++ new/pykeepass-4.2.0/CHANGELOG.rst 2026-08-09 23:18:56.000000000 +0200
@@ -1,3 +1,7 @@
+4.2.0 - 2026-08-09
+------------------
+- fixed #219 - seeds not regenerated on save
+
4.1.1 - 2025-03-04
------------------
- fixed #410 - support empty string as password
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/pykeepass-4.1.1.post1/pykeepass/entry.py
new/pykeepass-4.2.0/pykeepass/entry.py
--- old/pykeepass-4.1.1.post1/pykeepass/entry.py 2025-03-06
01:40:39.000000000 +0100
+++ new/pykeepass-4.2.0/pykeepass/entry.py 2026-08-09 23:18:56.000000000
+0200
@@ -39,10 +39,10 @@
expiry_time=expiry_time,
icon=icon
)
- self._element.append(E.String(E.Key('Title'), E.Value(title)))
- self._element.append(E.String(E.Key('UserName'),
E.Value(username)))
+ self._element.append(E.String(E.Key('Title'), E.Value(title or
"")))
+ self._element.append(E.String(E.Key('UserName'), E.Value(username
or "")))
self._element.append(
- E.String(E.Key('Password'), E.Value(password,
Protected="True"))
+ E.String(E.Key('Password'), E.Value(password or "",
Protected="True"))
)
if url:
self._element.append(E.String(E.Key('URL'), E.Value(url)))
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/pykeepass-4.1.1.post1/pykeepass/kdbx_parsing/common.py
new/pykeepass-4.2.0/pykeepass/kdbx_parsing/common.py
--- old/pykeepass-4.1.1.post1/pykeepass/kdbx_parsing/common.py 2025-03-06
01:40:39.000000000 +0100
+++ new/pykeepass-4.2.0/pykeepass/kdbx_parsing/common.py 2026-08-09
23:18:56.000000000 +0200
@@ -23,6 +23,7 @@
Switch,
)
from Cryptodome.Cipher import AES, ChaCha20, Salsa20
+from Cryptodome.Random import get_random_bytes
from Cryptodome.Util import Padding as CryptoPadding
from lxml import etree
@@ -188,6 +189,53 @@
return master_key
+# -------------------- Seed Rotation --------------------
+
+# length of regenerated seeds, per the KDBX specification
+SEED_LENGTH = 32
+# nonce/IV length required by each payload cipher
+IV_LENGTH = {'aes256': 16, 'twofish': 16, 'chacha20': 12}
+
+
+def rotate_seeds(kdbx, rotate_kdf_salt=True):
+ """Replace every random seed and IV in a database with a fresh value.
+
+ Called before saving so that no two writes of a database ever encrypt under
+ the same master key and IV. See issue #219.
+
+ Args:
+ kdbx (`Container`): parsed database to modify in place
+ rotate_kdf_salt (`bool`): whether to also rotate the KDF salt. Pass
+ `False` when reusing a precomputed transformed key, since that key
+ is only valid for the salt it was derived against.
+ """
+
+ header = kdbx.header.value.dynamic_header
+
+ header.master_seed.data = get_random_bytes(SEED_LENGTH)
+ header.encryption_iv.data = get_random_bytes(
+ IV_LENGTH.get(header.cipher_id.data, len(header.encryption_iv.data))
+ )
+
+ if kdbx.header.value.major_version >= 4:
+ # in KDBX4 the protected stream key lives in the inner header
+ if kdbx.body.payload is None:
+ raise ValueError('Database is not decrypted')
+ inner_header = kdbx.body.payload.inner_header
+ inner_header.protected_stream_key.data = get_random_bytes(
+ len(inner_header.protected_stream_key.data)
+ )
+ if rotate_kdf_salt:
+ header.kdf_parameters.data.dict['S'].value =
get_random_bytes(SEED_LENGTH)
+ else:
+ header.protected_stream_key.data = get_random_bytes(
+ len(header.protected_stream_key.data)
+ )
+ header.stream_start_bytes.data = get_random_bytes(SEED_LENGTH)
+ if rotate_kdf_salt:
+ header.transform_seed.data = get_random_bytes(SEED_LENGTH)
+
+
# -------------------- XML Processing --------------------
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/pykeepass-4.1.1.post1/pykeepass/kdbx_parsing/kdbx.py
new/pykeepass-4.2.0/pykeepass/kdbx_parsing/kdbx.py
--- old/pykeepass-4.1.1.post1/pykeepass/kdbx_parsing/kdbx.py 2025-03-06
01:40:39.000000000 +0100
+++ new/pykeepass-4.2.0/pykeepass/kdbx_parsing/kdbx.py 2026-08-09
23:18:56.000000000 +0200
@@ -10,8 +10,22 @@
def check_signature(ctx):
return ctx.sig1 == b'\x03\xd9\xa2\x9a' and ctx.sig2 == b'\x67\xFB\x4B\xB5'
+
+class RawCopyRebuild(RawCopy):
+ """RawCopy that always rebuilds from .value, ignoring cached .data.
+
+ Without this, the header bytes captured at parse time are written back
+ verbatim and any modification to the header (e.g. the seed rotation done by
+ `PyKeePass.save`) is silently dropped."""
+
+ def _build(self, obj, stream, context, path):
+ if 'data' in obj:
+ del obj['data']
+ return super()._build(obj, stream, context, path)
+
+
KDBX = Struct(
- "header" / RawCopy(
+ "header" / RawCopyRebuild(
Struct(
"sig1" / Bytes(4),
"sig2" / Bytes(4),
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/pykeepass-4.1.1.post1/pykeepass/pykeepass.py
new/pykeepass-4.2.0/pykeepass/pykeepass.py
--- old/pykeepass-4.1.1.post1/pykeepass/pykeepass.py 2025-03-06
01:40:39.000000000 +0100
+++ new/pykeepass-4.2.0/pykeepass/pykeepass.py 2026-08-09 23:18:56.000000000
+0200
@@ -1,4 +1,5 @@
import base64
+import io
import logging
import os
import re
@@ -26,6 +27,7 @@
)
from .group import Group
from .kdbx_parsing import KDBX, kdf_uuids
+from .kdbx_parsing.common import rotate_seeds
from .xpath import attachment_xp, entry_xp, group_xp, path_xp
logger = logging.getLogger(__name__)
@@ -143,26 +145,38 @@
def save(self, filename=None, transformed_key=None):
"""Save current database object to disk.
+ Seeds and IVs are rotated on every save, so the bytes written differ
+ each time even when the contents do not. Note `.transformed_key` still
+ reports the key computed when the database was opened; call `.reload()`
+ for the new one.
+
Args:
filename (`str`, optional): path to database or stream object.
If None, the path given when the database was opened is used.
PyKeePass.filename is unchanged.
transformed_key (`bytes`, optional): precomputed transformed
- key.
+ key. The KDF salt is left untouched when this is given, since
+ the key would otherwise no longer match the database.
"""
if not filename:
filename = self.filename
+ rotate_seeds(self.kdbx, rotate_kdf_salt=transformed_key is None)
+
if hasattr(filename, "write"):
+ # buffer stream writes to prevent corruption on failure
+ # (same principle as temp file for disk saves)
+ buffer = io.BytesIO()
KDBX.build_stream(
self.kdbx,
- filename,
+ buffer,
password=self.password,
keyfile=self.keyfile,
transformed_key=transformed_key,
decrypt=True
)
+ filename.write(buffer.getvalue())
else:
# save to temporary file to prevent database clobbering
# see issues 223, 101
@@ -673,7 +687,7 @@
Returns:
- `Group`: newly added group
+ `Entry`: newly added entry
"""
entries = self.find_entries(
@@ -690,22 +704,22 @@
title, destination_group
)
)
- else:
- logger.debug('Creating a new entry')
- entry = Entry(
- title=title,
- username=username,
- password=password,
- notes=notes,
- otp=otp,
- url=url,
- tags=tags,
- expires=True if expiry_time else False,
- expiry_time=expiry_time,
- icon=icon,
- kp=self
- )
- destination_group.append(entry)
+
+ logger.debug('Creating a new entry')
+ entry = Entry(
+ title=title,
+ username=username,
+ password=password,
+ notes=notes,
+ otp=otp,
+ url=url,
+ tags=tags,
+ expires=True if expiry_time else False,
+ expiry_time=expiry_time,
+ icon=icon,
+ kp=self
+ )
+ destination_group.append(entry)
return entry
@@ -1056,7 +1070,7 @@
keepass_instance.password = password
keepass_instance.keyfile = keyfile
- keepass_instance.save(transformed_key)
+ keepass_instance.save(transformed_key=transformed_key)
return keepass_instance
def debug_setup():
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/pykeepass-4.1.1.post1/pykeepass/version.py
new/pykeepass-4.2.0/pykeepass/version.py
--- old/pykeepass-4.1.1.post1/pykeepass/version.py 2025-03-06
01:40:39.000000000 +0100
+++ new/pykeepass-4.2.0/pykeepass/version.py 2026-08-09 23:18:56.000000000
+0200
@@ -1,10 +1,4 @@
__all__= ["__version__"]
-try:
- # Retrieval of metadata version for Python 3.8 and above
- from importlib.metadata import version
- __version__ = version('pykeepass')
-except ImportError:
- # Fallback for older Python versions (< 3.8)
- from pkg_resources import get_distribution
- __version__ = get_distribution('pykeepass').version
\ No newline at end of file
+from importlib.metadata import version
+__version__ = version('pykeepass')
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/pykeepass-4.1.1.post1/pyproject.toml
new/pykeepass-4.2.0/pyproject.toml
--- old/pykeepass-4.1.1.post1/pyproject.toml 2025-03-06 01:40:39.000000000
+0100
+++ new/pykeepass-4.2.0/pyproject.toml 2026-08-09 23:18:56.000000000 +0200
@@ -2,7 +2,7 @@
name = "pykeepass"
# setuptools normalizes semver '-' to '.'
# avoid using hyphens: 1.2.3.post1
-version = "4.1.1.post1"
+version = "4.2.0"
readme = "README.md"
description = "Python library to interact with keepass databases (supports
KDBX3 and KDBX4)"
authors = [
@@ -14,7 +14,7 @@
requires-python = ">=3.7"
dependencies = [
"pyotp>=2.9.0",
- "setuptools; python_version<'3.8'",
+ "importlib-metadata",
"construct>=2.10.53",
"argon2_cffi>=18.1.0",
"pycryptodomex>=3.6.2",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/pykeepass-4.1.1.post1/requirements.txt
new/pykeepass-4.2.0/requirements.txt
--- old/pykeepass-4.1.1.post1/requirements.txt 2025-03-06 01:40:39.000000000
+0100
+++ new/pykeepass-4.2.0/requirements.txt 2026-08-09 23:18:56.000000000
+0200
@@ -2,7 +2,7 @@
argon2-cffi-bindings==21.2.0
cffi==1.17.1
construct==2.10.70
-Jinja2==3.1.5
+Jinja2==3.1.6
lxml==5.3.1
MarkupSafe==3.0.2
pdoc==15.0.1
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/pykeepass-4.1.1.post1/tests/tests.py
new/pykeepass-4.2.0/tests/tests.py
--- old/pykeepass-4.1.1.post1/tests/tests.py 2025-03-06 01:40:39.000000000
+0100
+++ new/pykeepass-4.2.0/tests/tests.py 2026-08-09 23:18:56.000000000 +0200
@@ -511,6 +511,19 @@
self.kp.root_group
)
+ def test_null_values(self):
+ """Set title, username, password to `None` as the docs allow"""
+ entry = Entry(
+ None,
+ None,
+ None,
+ kp=self.kp
+ )
+ self.assertEqual(entry.title, '')
+ self.assertEqual(entry.username, '')
+ self.assertEqual(entry.password, '')
+
+
def test_references(self):
original_entry = self.kp.find_entries(title='foobar_entry', first=True)
original_entry_duplicate = self.kp.find_entries(title='foobar_entry',
first=True)
@@ -1297,17 +1310,40 @@
filenames_in, filenames_out, passwords, transformed_keys,
keyfiles, encryption_algorithms, kdf_algorithms, versions
):
- kp = PyKeePass(
+ old_kp = PyKeePass(
filename_in,
password,
keyfile,
transformed_key=transformed_key
)
- self.assertEqual(kp.encryption_algorithm, encryption_algorithm)
- self.assertEqual(kp.kdf_algorithm, kdf_algorithm)
- self.assertEqual(kp.version, version)
+ self.assertEqual(old_kp.encryption_algorithm, encryption_algorithm)
+ self.assertEqual(old_kp.kdf_algorithm, kdf_algorithm)
+ self.assertEqual(old_kp.version, version)
+
+ # import recursive getattr
+ from operator import attrgetter
+
+ seed_attrs = [
+ 'kdbx.header.value.dynamic_header.master_seed.data',
+ 'kdbx.header.value.dynamic_header.encryption_iv.data',
+ ]
+ # the KDF salt may only be rotated when we are free to rederive the
+ # transformed key from the password/keyfile
+ salt_attrs = []
+ if version == (3, 1):
+ seed_attrs += [
+
'kdbx.header.value.dynamic_header.protected_stream_key.data',
+ 'kdbx.header.value.dynamic_header.stream_start_bytes.data',
+ ]
+
salt_attrs.append('kdbx.header.value.dynamic_header.transform_seed.data')
+ else:
+
seed_attrs.append('kdbx.body.payload.inner_header.protected_stream_key.data')
+ if kdf_algorithm.startswith('argon'):
+
salt_attrs.append('kdbx.header.value.dynamic_header.kdf_parameters.data.dict.S.value')
- kp.save(
+ before = {a: attrgetter(a)(old_kp) for a in seed_attrs +
salt_attrs}
+
+ old_kp.save(
filename_out,
transformed_key=transformed_key
)
@@ -1316,18 +1352,70 @@
# rewind so PyKeePass can read from the same stream
filename_out.seek(0)
- kp = PyKeePass(
+ new_kp = PyKeePass(
filename_out,
password,
keyfile,
transformed_key=transformed_key
)
- for filename in base_dir.glob('*.out'):
- os.remove(filename)
+ for attr in seed_attrs:
+ # seed was rotated by the save...
+ self.assertNotEqual(
+ before[attr], attrgetter(attr)(old_kp),
+ "seed {} not rotated on file {}".format(attr, filename_in)
+ )
+ # ...and the rotated value is what landed on disk
+ self.assertEqual(
+ attrgetter(attr)(old_kp), attrgetter(attr)(new_kp),
+ "in-memory {} does not match saved file {}".format(attr,
filename_in)
+ )
+
+ for attr in salt_attrs:
+ if transformed_key is None:
+ self.assertNotEqual(
+ before[attr], attrgetter(attr)(old_kp),
+ "kdf salt {} not rotated on file {}".format(attr,
filename_in)
+ )
+ else:
+ # a caller supplied transformed key is bound to the salt
+ self.assertEqual(
+ before[attr], attrgetter(attr)(old_kp),
+ "kdf salt {} rotated out from under a supplied "
+ "transformed_key on file {}".format(attr, filename_in)
+ )
+ self.assertEqual(
+ attrgetter(attr)(old_kp), attrgetter(attr)(new_kp)
+ )
+
+
+ # remove old files
+ for filename in os.listdir(base_dir):
+ if filename.endswith('.out'):
+ os.remove(os.path.join(base_dir, filename))
keyfile_fd.close()
+ def test_save_transformed_key_keeps_credentials(self):
+ """saving with a precomputed transformed key must not lock out the
+ password/keyfile the key was derived from"""
+
+ for name, keyfile in [('test3.kdbx', 'test3.key'),
+ ('test4.kdbx', 'test4.key')]:
+ filename_out = base_dir / (name + '.out')
+ shutil.copy(base_dir / name, filename_out)
+
+ kp = PyKeePass(filename_out, 'password', base_dir / keyfile)
+ salt = kp.database_salt
+ kp.save(transformed_key=kp.transformed_key)
+
+ # the salt the supplied key was derived against has to survive
+ self.assertEqual(salt, kp.database_salt, name)
+ # both ways of opening the database still work
+ PyKeePass(filename_out, 'password', base_dir / keyfile)
+ PyKeePass(filename_out, transformed_key=kp.transformed_key)
+
+ os.remove(filename_out)
def test_open_error(self):