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):
 

Reply via email to