diff --git a/SOURCES/0022-reposync-Avoid-multiple-downloads-of-duplicate-packa.patch b/SOURCES/0022-reposync-Avoid-multiple-downloads-of-duplicate-packa.patch new file mode 100644 index 0000000..c662219 --- /dev/null +++ b/SOURCES/0022-reposync-Avoid-multiple-downloads-of-duplicate-packa.patch @@ -0,0 +1,40 @@ +From 43d07e2b385b069b1f851e5a16f1b7d8bbed1195 Mon Sep 17 00:00:00 2001 +From: Marek Blaha +Date: Mon, 4 Nov 2024 10:35:08 +0100 +Subject: [PATCH] reposync: Avoid multiple downloads of duplicate packages + +Download each package only once if it would have been saved to the same +location. This can occur if the repository metadata contains duplicate +entries for the same package. + +Resolves: https://issues.redhat.com/browse/RHEL-64320 + +Upstream commit: 17e36ed +--- + plugins/reposync.py | 10 +++++++++- + 1 file changed, 9 insertions(+), 1 deletion(-) + +diff --git a/plugins/reposync.py b/plugins/reposync.py +index ab513e7..19f5440 100644 +--- a/plugins/reposync.py ++++ b/plugins/reposync.py +@@ -292,7 +292,15 @@ class RepoSyncCommand(dnf.cli.Command): + query.filterm(arch='src') + elif self.opts.arches: + query.filterm(arch=self.opts.arches) +- return query ++ # skip packages that would have been downloaded to the same location ++ pkglist = [] ++ seen_paths = set() ++ for pkg in query: ++ download_path = self.pkg_download_path(pkg) ++ if download_path not in seen_paths: ++ pkglist.append(pkg) ++ seen_paths.add(download_path) ++ return pkglist + + def download_packages(self, pkglist): + base = self.base +-- +2.48.1 + diff --git a/SOURCES/0023-multisig-A-new-plugin-for-verifying-extraordinary-RP.patch b/SOURCES/0023-multisig-A-new-plugin-for-verifying-extraordinary-RP.patch new file mode 100644 index 0000000..3390be5 --- /dev/null +++ b/SOURCES/0023-multisig-A-new-plugin-for-verifying-extraordinary-RP.patch @@ -0,0 +1,617 @@ +From 37c4cd7014f4b3c5db64a0a900761e53caf645be Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Petr=20P=C3=ADsa=C5=99?= +Date: Tue, 25 Mar 2025 12:34:34 +0100 +Subject: [PATCH] multisig: A new plugin for verifying extraordinary RPM + signatures +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +This plugin executes a dedicated rpmkeys(8) tool of pqrpm for +verifying RPM packages bound into a transaction. + +The dedicated tool is supposed to understand signatures in RPM version +6 format. One feature of that format is that you can have multiple +signatures on a single RPM package. + +This plugin takes public keys from a default key store of pqrpm, which +is separate from the native RPM database. A reason is that the native +database might reject storing OpenPGP keys with an unsupported key +schema. A downside is that "dnf --installroot" places the keyring to +a directory which is ununowned in the installroot. + +XXX: This plugin does not report which keys required for the +verification are missing (NOKEY case in _process_rpm_output()). +Implementing that would require changing too many function interfaces. +Classical single-signature verification does not do that either. + +XXX: The plugin is packaged into a package which requires pqrpm which +will only be available in RHEL 9. Next RHEL version will support the +extraordinary signatures natively. Thus this patch only makes sense in +RHEL 9. + +Signed-off-by: Petr Písař +--- + dnf-plugins-core.spec | 19 ++ + doc/CMakeLists.txt | 5 + + doc/conf.py | 3 + + doc/index.rst | 1 + + doc/multisig.rst | 62 +++++++ + plugins/CMakeLists.txt | 3 + + plugins/multisig.py | 402 +++++++++++++++++++++++++++++++++++++++++ + 7 files changed, 495 insertions(+) + create mode 100644 doc/multisig.rst + create mode 100644 plugins/multisig.py + +diff --git a/dnf-plugins-core.spec b/dnf-plugins-core.spec +index f2d1bc4..cb3b1b8 100644 +--- a/dnf-plugins-core.spec ++++ b/dnf-plugins-core.spec +@@ -301,6 +301,18 @@ Obsoletes: python-dnf-plugins-extras-migrate < %{dnf_plugins_extra} + Migrate Plugin for DNF, Python 2 version. Migrates history, group and yumdb data from yum to dnf. + %endif + ++%if %{with python3} ++%package -n python3-dnf-plugin-multisig ++Summary: Multisig Plugin for DNF ++Requires: pqrpm ++Requires: python3-%{name} = %{version}-%{release} ++Provides: dnf-plugin-multisig = %{version}-%{release} ++ ++%description -n python3-dnf-plugin-multisig ++Multisig Plugin for DNF, Python 3 version. The plugin verifies multiple RPMv6 ++signatures on RPMv4 packages by using an external rpmkeys program. ++%endif ++ + %if %{with python2} + %package -n python2-dnf-plugin-post-transaction-actions + Summary: Post transaction actions Plugin for DNF +@@ -716,6 +728,13 @@ ln -sf %{_mandir}/man1/%{yum_utils_subpackage_name}.1.gz %{buildroot}%{_mandir}/ + %exclude %{_mandir}/man8/dnf-migrate.* + %endif + ++%if %{with python3} ++%files -n python3-dnf-plugin-multisig ++%{python3_sitelib}/dnf-plugins/multisig.* ++%{python3_sitelib}/dnf-plugins/__pycache__/multisig.* ++%{_mandir}/man8/dnf*-multisig.* ++%endif ++ + %if %{with python2} + %files -n python2-dnf-plugin-post-transaction-actions + %config(noreplace) %{_sysconfdir}/dnf/plugins/post-transaction-actions.conf +diff --git a/doc/CMakeLists.txt b/doc/CMakeLists.txt +index 79472a5..297506a 100644 +--- a/doc/CMakeLists.txt ++++ b/doc/CMakeLists.txt +@@ -47,6 +47,11 @@ INSTALL(FILES ${CMAKE_CURRENT_BINARY_DIR}/dnf-migrate.8 + DESTINATION share/man/man8) + endif() + ++if (${PYTHON_VERSION_MAJOR} STREQUAL "3") ++INSTALL(FILES ${CMAKE_CURRENT_BINARY_DIR}/dnf4-multisig.8 ++ DESTINATION share/man/man8) ++endif() ++ + if (${WITHOUT_LOCAL} STREQUAL "0") + INSTALL(FILES ${CMAKE_CURRENT_BINARY_DIR}/dnf-local.8 + DESTINATION share/man/man8) +diff --git a/doc/conf.py b/doc/conf.py +index 327ac07..2845d18 100644 +--- a/doc/conf.py ++++ b/doc/conf.py +@@ -300,6 +300,9 @@ man_pages = [ + if sys.version_info[0] < 3: + man_pages.append(('migrate', 'dnf-migrate', u'DNF migrate Plugin', AUTHORS, 8)) + ++if sys.version_info[0] == 3: ++ man_pages.append(('multisig', 'dnf4-multisig', u'DNF multisig Plugin', AUTHORS, 8)) ++ + # If true, show URL addresses after external links. + #man_show_urls = False + +diff --git a/doc/index.rst b/doc/index.rst +index 251a24e..32984a5 100644 +--- a/doc/index.rst ++++ b/doc/index.rst +@@ -37,6 +37,7 @@ This documents core plugins of DNF: + leaves + local + migrate ++ multisig + modulesync + needs_restarting + post-transaction-actions +diff --git a/doc/multisig.rst b/doc/multisig.rst +new file mode 100644 +index 0000000..21b9436 +--- /dev/null ++++ b/doc/multisig.rst +@@ -0,0 +1,62 @@ ++=================== ++DNF multisig Plugin ++=================== ++ ++----------- ++Description ++----------- ++ ++This plugin verifies extraordinary RPMv6 signatures when installing, ++reinstalling, upgrading, or downgrading packages from a repository. If the ++verification fails, the RPM operation will be aborted. ++ ++The verification is achieved by executing a dedicated rpmkeys(8) tool which is ++supposed to understand package signatures in RPM version 6 format. One feature ++of that format is that you can have multiple signatures on a single RPM ++package. If the package has no RPMv6 signature a signature in version 4 format ++will be verified instead. If there is no signature, the verification will be ++handled as failed. ++ ++The dedicated rpmkeys(8) tool trusts public keys in a key store separate from ++the native RPM database because the native database might reject storing ++OpenPGP keys with an unsupported key schema which is foreseen to be used in ++RPMv6 signatures. ++ ++Public keys missing from the separate key store are attempted to be imported ++from URLs listed in ``gpgkey`` configuration field of a repository the package ++belongs to. Before importing, a user is asked for a confirmation with the ++import, unless DNF was invoked with ``--assumeyes`` or ``--assumeno`` options. ++ ++The key store can be inspected with ``/usr/lib/pqrpm/bin/rpmkeys -D --list`` ++command. A key can be deleted from the key store with ++``/usr/lib/pqrpm/bin/rpmkeys -D --erase KEY_ID`` command. The ``KEY_ID`` is ++the first word in an output of the ``--list`` command. ++ ++Users who do not wish to verify the extraordinary RPMv6 signatures should ++uninstall this plugin. ++ ++------------- ++Configuration ++------------- ++ ++Hard-coded path to the rpmkeys(8) tool is ``/usr/lib/pqrpm/bin/rpmkeys``. ++ ++This plugin respects ``gpgkey`` and ``gpgcheck`` fields in a repository ++configuration. See dnf.conf(5) for more details. ++ ++----- ++Files ++----- ++ ++``/usr/lib/pqrpm/lib/sysimage/rpm`` ++ A location of the key store defined by ``/usr/lib/pqrpm/bin/rpmkeys`` ++ tool. ++ ++-------- ++See Also ++-------- ++ ++* :manpage:`dnf.conf(5)` ++* :manpage:`dnf(8)` ++* :manpage:`rpmkeys(8)` ++ +diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt +index d004e5e..a84a786 100644 +--- a/plugins/CMakeLists.txt ++++ b/plugins/CMakeLists.txt +@@ -14,6 +14,9 @@ endif() + if (${PYTHON_VERSION_MAJOR} STREQUAL "2") + INSTALL (FILES migrate.py DESTINATION ${PYTHON_INSTALL_DIR}/dnf-plugins) + endif() ++if (${PYTHON_VERSION_MAJOR} STREQUAL "3") ++INSTALL (FILES multisig.py DESTINATION ${PYTHON_INSTALL_DIR}/dnf-plugins) ++endif() + INSTALL (FILES needs_restarting.py DESTINATION ${PYTHON_INSTALL_DIR}/dnf-plugins) + INSTALL (FILES post-transaction-actions.py DESTINATION ${PYTHON_INSTALL_DIR}/dnf-plugins) + INSTALL (FILES repoclosure.py DESTINATION ${PYTHON_INSTALL_DIR}/dnf-plugins) +diff --git a/plugins/multisig.py b/plugins/multisig.py +new file mode 100644 +index 0000000..8735a26 +--- /dev/null ++++ b/plugins/multisig.py +@@ -0,0 +1,402 @@ ++from __future__ import print_function, absolute_import, unicode_literals ++import dnf ++import dnf.crypto ++import dnf.dnssec ++import dnf.exceptions ++from dnf.i18n import ucd ++import dnf.rpm.transaction ++import dnf.transaction ++from dnfpluginscore import _, logger ++import os ++import subprocess ++import sys ++ ++class MultiSig(dnf.Plugin): ++ """ ++ This plugin verifies signatures of RPM packages by executing an ++ extraordinary "rpmkeys" tool. That tool can, for example, support multiple ++ RPM v6 signatures, or signature schemata uknown to the ordinary, ++ system-wide rpmkeys tool. ++ ++ This verification is perfmored in addition to the standard verification ++ performed by DNF. ++ """ ++ ++ name = "multisig" ++ ++ def __init__(self, base, cli): ++ super(MultiSig, self).__init__(base, cli) ++ # Path to the rpmkeys executable ++ self.rpmkeys_executable = "/usr/lib/pqrpm/bin/rpmkeys" ++ # List of repositories whose keys we have tried importing so far ++ # during a run of this plugin. ++ self._repo_set_imported_gpg_keys = []; ++ ++ def pre_transaction(self): ++ inbound_packages = [] ++ for ts_item in self.base.transaction: ++ if ts_item.action in dnf.transaction.FORWARD_ACTIONS: ++ inbound_packages.append(ts_item.pkg); ++ self.gpgsigcheck(inbound_packages) ++ ++ def _process_rpm_output(self, data): ++ # No signatures or digests = corrupt package. ++ # There is at least one line for -: and another (empty) entry after the ++ # last newline. ++ if len(data) < 3 or data[0] != b'-:' or data[-1]: ++ return 2 ++ seen_sig, missing_key, not_trusted, not_signed = False, False, False, False ++ for i in data[1:-1]: ++ if b': BAD' in i: ++ return 2 ++ elif i.endswith(b': NOKEY'): ++ missing_key = True ++ elif i.endswith(b': NOTTRUSTED'): ++ not_trusted = True ++ elif i.endswith(b': NOTFOUND'): ++ not_signed = True ++ elif not i.endswith(b': OK'): ++ return 2 ++ if not_trusted: ++ return 3 ++ elif missing_key: ++ return 1 ++ elif not_signed: ++ return 4 ++ # we still check return code, so this is safe ++ return 0 ++ ++ def _verifyPackageUsingRpmkeys(self, package, installroot): ++ # "--define=_pkgverify_level signature" enforces signature checking; ++ # "--define=_pkgverify_flags 0x0" ensures that all signatures are checked. ++ args = (self.rpmkeys_executable, ++ '--checksig', '--root', installroot, '--verbose', ++ '--define=_pkgverify_level signature', '--define=_pkgverify_flags 0x0', ++ '-') ++ env = dict(os.environ) ++ env['LC_ALL'] = 'C' ++ with subprocess.Popen( ++ args=args, ++ executable=self.rpmkeys_executable, ++ env=env, ++ stdout=subprocess.PIPE, ++ cwd='/', ++ stdin=package) as p: ++ data = p.communicate()[0] ++ returncode = p.returncode ++ if type(returncode) is not int: ++ raise AssertionError('Popen set return code to non-int') ++ # rpmkeys can return something other than 0 or 1 in the case of a ++ # fatal error (OOM, abort() called, SIGSEGV, etc) ++ if returncode >= 2 or returncode < 0: ++ return 2 ++ ret = self._process_rpm_output(data.split(b'\n')) ++ if ret: ++ return ret ++ return 2 if returncode else 0 ++ ++ def _checkSig(self, installroot, package): ++ """Takes a transaction set and a package, check it's sigs, ++ return 0 if they are all fine ++ return 1 if the gpg key can't be found ++ return 2 if the header is in someway damaged ++ return 3 if the key is not trusted ++ return 4 if the pkg is not gpg or pgp signed""" ++ ++ fdno = os.open(package, os.O_RDONLY|os.O_NOCTTY|os.O_CLOEXEC) ++ try: ++ value = self._verifyPackageUsingRpmkeys(fdno, installroot) ++ finally: ++ os.close(fdno) ++ return value ++ ++ def _sig_check_pkg(self, po): ++ """Verify the GPG signature of the given package object. ++ ++ :param po: the package object to verify the signature of ++ :return: (result, error_string) ++ where result is:: ++ ++ 0 = GPG signature verifies ok or verification is not required. ++ 1 = GPG verification failed but installation of the right GPG key ++ might help. ++ 2 = Fatal GPG verification error, give up. ++ """ ++ if po._from_cmdline: ++ check = self.base.conf.localpkg_gpgcheck ++ hasgpgkey = 0 ++ else: ++ repo = self.base.repos[po.repoid] ++ check = repo.gpgcheck ++ hasgpgkey = not not repo.gpgkey ++ ++ localfn = os.path.basename(po.localPkg()) ++ if check: ++ logger.debug(_("Multisig: verifying: {}").format(po.localPkg())) ++ sigresult = self._checkSig(self.base.conf.installroot, po.localPkg()) ++ if sigresult == 0: ++ result = 0 ++ msg = _('All signatures for %s successfully verified') % localfn ++ ++ elif sigresult == 1: ++ if hasgpgkey: ++ result = 1 ++ else: ++ result = 2 ++ msg = _('Public key for %s is not installed') % localfn ++ ++ elif sigresult == 2: ++ result = 2 ++ msg = _('Problem opening package %s') % localfn ++ ++ elif sigresult == 3: ++ if hasgpgkey: ++ result = 1 ++ else: ++ result = 2 ++ result = 1 ++ msg = _('Public key for %s is not trusted') % localfn ++ ++ elif sigresult == 4: ++ result = 2 ++ msg = _('Package %s is not signed') % localfn ++ ++ else: ++ result = 0 ++ msg = _('Signature verification for %s is disabled') % localfn ++ ++ logger.debug(_("Multisig: verification result: {} (code={})").format(msg, result)) ++ return result, msg ++ ++ def keyInstalled(self, fingerprint): ++ ''' ++ Return if the GPG key described by the given fingerprint is installed ++ in the multisig keyring. ++ ++ Return values: ++ - True key is installed ++ - False otherwise ++ Trows: If rpmkeys program could not been executed. ++ ++ No effort is made to handle duplicates. ++ ''' ++ # XXX: rpmkeys expects lowercase ++ # ++ logger.debug(_("Multisig: Checking a presence of key={}").format(fingerprint)) ++ args = (self.rpmkeys_executable, ++ '--root', self.base.conf.installroot, ++ '--list', fingerprint.lower()) ++ p = subprocess.run( ++ args=args, ++ executable=self.rpmkeys_executable, ++ cwd='/', ++ stdin=subprocess.DEVNULL, ++ stdout=subprocess.DEVNULL, ++ stderr=subprocess.DEVNULL) ++ return p.returncode == 0 ++ ++ def importKey(self, key): ++ ''' ++ Import given Key object into the multisig keyring. ++ ++ Return values: ++ - True key imported successfully ++ - False otherwise ++ Trows: If rpmkeys program could not been executed. ++ ++ What happens if a key's raw_string contains multiple public key ++ packets, or if the key was already in the keyring is unspecified and ++ it depends on rpmkeys behavior. Current rpmkeys implementation ++ gracefully ignores (or updates?) existing keys. ++ ''' ++ args = (self.rpmkeys_executable, ++ '--root', self.base.conf.installroot, ++ '--import', '-') ++ env = dict(os.environ) ++ env['LC_ALL'] = 'C' ++ with subprocess.Popen( ++ executable=self.rpmkeys_executable, ++ args=args, ++ env=env, ++ cwd='/', ++ # XXX: rpmkeys used to fail reading from a pipe. Fix at ++ # . ++ stdin=subprocess.PIPE, ++ stdout=subprocess.PIPE, ++ stderr=subprocess.PIPE) as p: ++ stdout, stderr = p.communicate(input=key.raw_key) ++ returncode = p.returncode ++ if type(returncode) is not int: ++ raise AssertionError('Popen set return code to non-int') ++ logger.debug(_("Multisig: Key import result: exitcode={}, stdout={}, stderr={}").format( ++ returncode, stdout, stderr)) ++ return returncode == 0 ++ ++ def _get_key_for_package(self, po, askcb=None, fullaskcb=None): ++ """Retrieve a key for a package. If needed, use the given ++ callback to prompt whether the key should be imported. ++ ++ :param po: the package object to retrieve the key of ++ :param askcb: Callback function to use to ask permission to ++ import a key. The arguments *askcb* should take are the ++ package object, the userid of the key, and the keyid ++ :param fullaskcb: Callback function to use to ask permission to ++ import a key. This differs from *askcb* in that it gets ++ passed a dictionary so that we can expand the values passed. ++ :raises: :class:`dnf.exceptions.Error` if there are errors ++ retrieving the keys ++ """ ++ if po._from_cmdline: ++ # raise an exception, because po.repoid is not in self.repos ++ msg = _('Unable to retrieve a key for a commandline package: %s') ++ raise ValueError(msg % po) ++ ++ repo = self.base.repos[po.repoid] ++ key_installed = repo.id in self._repo_set_imported_gpg_keys ++ keyurls = [] if key_installed else repo.gpgkey ++ ++ def _prov_key_data(msg): ++ msg += _('. Failing package is: %s') % (po) + '\n ' ++ msg += _('GPG Keys are configured as: %s') % \ ++ (', '.join(repo.gpgkey)) ++ return msg ++ ++ user_cb_fail = False ++ self._repo_set_imported_gpg_keys.append(repo.id) ++ for keyurl in keyurls: ++ keys = dnf.crypto.retrieve(keyurl, repo) ++ ++ for info in keys: ++ # Check if key is already installed ++ if self.keyInstalled(info.fingerprint): ++ msg = _('GPG key at %s (0x%s) is already installed') ++ logger.info(msg, keyurl, info.short_id) ++ continue ++ ++ # DNS Extension: create a key object, pass it to the verification class ++ # and print its result as an advice to the user. ++ if self.base.conf.gpgkey_dns_verification: ++ dns_input_key = dnf.dnssec.KeyInfo.from_rpm_key_object(info.userid, ++ info.raw_key) ++ dns_result = dnf.dnssec.DNSSECKeyVerification.verify(dns_input_key) ++ logger.info(dnf.dnssec.nice_user_msg(dns_input_key, dns_result)) ++ ++ # Try installing/updating GPG key ++ info.url = keyurl ++ if self.base.conf.gpgkey_dns_verification: ++ dnf.crypto.log_dns_key_import(info, dns_result) ++ else: ++ dnf.crypto.log_key_import(info) ++ rc = False ++ if self.base.conf.assumeno: ++ rc = False ++ elif self.base.conf.assumeyes: ++ # DNS Extension: We assume, that the key is trusted in case it is valid, ++ # its existence is explicitly denied or in case the domain is not signed ++ # and therefore there is no way to know for sure (this is mainly for ++ # backward compatibility) ++ # FAQ: ++ # * What is PROVEN_NONEXISTENCE? ++ # In DNSSEC, your domain does not need to be signed, but this state ++ # (not signed) has to be proven by the upper domain. e.g. when example.com. ++ # is not signed, com. servers have to sign the message, that example.com. ++ # does not have any signing key (KSK to be more precise). ++ if self.base.conf.gpgkey_dns_verification: ++ if dns_result in (dnf.dnssec.Validity.VALID, ++ dnf.dnssec.Validity.PROVEN_NONEXISTENCE): ++ rc = True ++ logger.info(dnf.dnssec.any_msg(_("The key has been approved."))) ++ else: ++ rc = False ++ logger.info(dnf.dnssec.any_msg(_("The key has been rejected."))) ++ else: ++ rc = True ++ ++ # grab the .sig/.asc for the keyurl, if it exists if it ++ # does check the signature on the key if it is signed by ++ # one of our ca-keys for this repo or the global one then ++ # rc = True else ask as normal. ++ ++ elif fullaskcb: ++ rc = fullaskcb({"po": po, "userid": info.userid, ++ "hexkeyid": info.short_id, ++ "keyurl": keyurl, ++ "fingerprint": info.fingerprint, ++ "timestamp": info.timestamp}) ++ elif askcb: ++ rc = askcb(po, info.userid, info.short_id) ++ ++ if not rc: ++ user_cb_fail = True ++ continue ++ ++ # Import the key ++ # XXX: raw_key of second info erroneously contains first and ++ # second key. Probably a bug in key parser. ++ #logger.debug(_("Multisig: Importing a key: {}").format(info.raw_key)) ++ result = self.importKey(info) ++ if result == False: ++ msg = _('Key import failed') ++ raise dnf.exceptions.Error(_prov_key_data(msg)) ++ logger.info(_('Key imported successfully')) ++ key_installed = True ++ ++ if not key_installed and user_cb_fail: ++ raise dnf.exceptions.Error(_("Didn't install any keys")) ++ ++ if not key_installed: ++ msg = _('The GPG keys listed for the "%s" repository are ' ++ 'already installed but they are not correct for this ' ++ 'package.\n' ++ 'Check that the correct key URLs are configured for ' ++ 'this repository.') % repo.name ++ raise dnf.exceptions.Error(_prov_key_data(msg)) ++ ++ # Check if the newly installed keys helped ++ result, errmsg = self._sig_check_pkg(po) ++ if result != 0: ++ if keyurls: ++ msg = _("Import of key(s) didn't help, wrong key(s)?") ++ logger.info(msg) ++ errmsg = ucd(errmsg) ++ raise dnf.exceptions.Error(_prov_key_data(errmsg)) ++ ++ def gpgsigcheck(self, pkgs): ++ """Perform GPG signature verification on the given packages, ++ installing keys if possible. ++ ++ :param pkgs: a list of package objects to verify the GPG ++ signatures of ++ :raises: Will raise :class:`Error` if there's a problem ++ """ ++ error_messages = [] ++ for po in pkgs: ++ result, errmsg = self._sig_check_pkg(po) ++ ++ if result == 0: ++ # Verified ok, or verify not req'd ++ continue ++ ++ elif result == 1: ++ ay = self.base.conf.assumeyes and not self.base.conf.assumeno ++ if (not sys.stdin or not sys.stdin.isatty()) and not ay: ++ raise dnf.exceptions.Error(_('Refusing to automatically import keys when running ' \ ++ 'unattended.\nUse "-y" to override.')) ++ ++ # the callback here expects to be able to take options which ++ # userconfirm really doesn't... so fake it ++ fn = lambda x, y, z: self.base.output.userconfirm() ++ try: ++ self._get_key_for_package(po, fn) ++ except (dnf.exceptions.Error, ValueError) as e: ++ error_messages.append(str(e)) ++ ++ else: ++ # Fatal error ++ error_messages.append(errmsg) ++ ++ if error_messages: ++ for msg in error_messages: ++ logger.critical(msg) ++ raise dnf.exceptions.Error(_("GPG check FAILED")) ++ +-- +2.50.1 + diff --git a/SOURCES/0024-multisig-Do-not-parse-OpenPGP-keys.patch b/SOURCES/0024-multisig-Do-not-parse-OpenPGP-keys.patch new file mode 100644 index 0000000..bb1486a --- /dev/null +++ b/SOURCES/0024-multisig-Do-not-parse-OpenPGP-keys.patch @@ -0,0 +1,242 @@ +From 3fd00a0bf41ac2c9342e0ba1d8550ee1ac5f2604 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Petr=20P=C3=ADsa=C5=99?= +Date: Fri, 12 Sep 2025 18:12:38 +0200 +Subject: [PATCH] multisig: Do not parse OpenPGP keys +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +If a key in OpenPGP version 6 format format was defined for +a repository, the key was always omitted and never imported to pqrpm +key store. As a result, packages signed with that key could not be +verified: + + [...] + Importing GPG key 0xC9D3B13C: + Userid : "rsa4k_user" + Fingerprint: CE77 8FBE 1E8D 4DD2 7691 FB33 CA58 1189 C9D3 B13C + From : /root/repo/rsa4k.cert + Key imported successfully + foo 16 MB/s | 16 kB 00:00 + Import of key(s) didn't help, wrong key(s)? + Public key for foo-1.0-1.noarch.rpm is not installed. Failing package is: foo-1.0-1.noarch + GPG Keys are configured as: file:///root/repo/rsa4k.cert, file:///root/repo/mldsa87.cert + Error: GPG check FAILED + +The cause was that multisig plugin called dnf.crypto.retrieve() to +obtain a list of OpenPGP objects from the gpgkey URLs. That DNF +function uses dnf.crypto.rawkey2infos() to parse the OpenPGP packets +with a gpgme library. But library does not support OpenPGPv6. As +a result, multisig got an empty list of OpenPGP objects and dis not +import any key in OpenPGPv6 format into pqrm key store. + +Considering that the only available OpenPGPv6 parser is rpm-sequoia +library wrapped by pqrpm's librpmio library, and this plugin cannot +load them into its name space not to clash with the system librpmio +library, an ideal fix would have to develop standalone executable on +top of them and parse it output by this plugin. And considering that +best code is no code, + +I removed printing any details about the imported keys from this +plugin. The only details about the key this plugin now prints is URL +or local path to the key file: + + Importing GPG keys from: /root/repos/opgp6/mldsa65.cert + Is this ok [y/N]: y + Key imported successfully + +Because the user ID and key ID were also required for DNSSEC +validation, I had to remove that code. This patch also removes all +code that became unreachable because of this fix. + +Another downside is that now the plugin cannot does not check whether +a key has already been imported, resulting always asking a using for +importing the keys associated with a repository the unverifiable +package comes from. + +Resolve: https://issues.redhat.com/browse/RHEL-114424 +Signed-off-by: Petr Písař +--- + plugins/multisig.py | 115 +++++++++++++++----------------------------- + 1 file changed, 40 insertions(+), 75 deletions(-) + +diff --git a/plugins/multisig.py b/plugins/multisig.py +index 8735a26..f29e41f 100644 +--- a/plugins/multisig.py ++++ b/plugins/multisig.py +@@ -1,16 +1,26 @@ + from __future__ import print_function, absolute_import, unicode_literals + import dnf +-import dnf.crypto +-import dnf.dnssec + import dnf.exceptions + from dnf.i18n import ucd + import dnf.rpm.transaction + import dnf.transaction ++import dnf.util + from dnfpluginscore import _, logger + import os + import subprocess + import sys + ++class MultiSigKey(object): ++ def __init__(self, data, url): ++ self.id_ = None ++ self.fingerprint = None ++ self.timestamp = None ++ self.raw_key = data ++ self.url = url ++ self.userid = None ++ self.short_id = None ++ self.rpm_id = None ++ + class MultiSig(dnf.Plugin): + """ + This plugin verifies signatures of RPM packages by executing an +@@ -168,33 +178,6 @@ class MultiSig(dnf.Plugin): + logger.debug(_("Multisig: verification result: {} (code={})").format(msg, result)) + return result, msg + +- def keyInstalled(self, fingerprint): +- ''' +- Return if the GPG key described by the given fingerprint is installed +- in the multisig keyring. +- +- Return values: +- - True key is installed +- - False otherwise +- Trows: If rpmkeys program could not been executed. +- +- No effort is made to handle duplicates. +- ''' +- # XXX: rpmkeys expects lowercase +- # +- logger.debug(_("Multisig: Checking a presence of key={}").format(fingerprint)) +- args = (self.rpmkeys_executable, +- '--root', self.base.conf.installroot, +- '--list', fingerprint.lower()) +- p = subprocess.run( +- args=args, +- executable=self.rpmkeys_executable, +- cwd='/', +- stdin=subprocess.DEVNULL, +- stdout=subprocess.DEVNULL, +- stderr=subprocess.DEVNULL) +- return p.returncode == 0 +- + def importKey(self, key): + ''' + Import given Key object into the multisig keyring. +@@ -232,6 +215,28 @@ class MultiSig(dnf.Plugin): + returncode, stdout, stderr)) + return returncode == 0 + ++ def retrieve(self, keyurl, repo=None): ++ """Retrieve a content of a key file specified by the URL using ++ repository's proxy configuration. ++ ++ :param keyurl URL of the key file ++ :param repo repository object ++ :returns: list of MultiSigKey objects populated from the key file ++ """ ++ if keyurl.startswith('http:'): ++ logger.warning(_("retrieving repo key for %s unencrypted from %s"), repo.id, keyurl) ++ with dnf.util._urlopen(keyurl, repo=repo) as handle: ++ # This is a place for parsing the key file and populating key ID etc. ++ keyinfos = [MultiSigKey(handle.read(), keyurl)] ++ return keyinfos ++ ++ def log_key_import(self, keyinfo): ++ """Print and log details about keys to be imported. ++ """ ++ msg = (_('Importing GPG keys from: %s') % ++ (keyinfo.url.replace("file://", ""))) ++ logger.critical("%s", msg) ++ + def _get_key_for_package(self, po, askcb=None, fullaskcb=None): + """Retrieve a key for a package. If needed, use the given + callback to prompt whether the key should be imported. +@@ -264,53 +269,17 @@ class MultiSig(dnf.Plugin): + user_cb_fail = False + self._repo_set_imported_gpg_keys.append(repo.id) + for keyurl in keyurls: +- keys = dnf.crypto.retrieve(keyurl, repo) ++ keys = self.retrieve(keyurl, repo) + + for info in keys: +- # Check if key is already installed +- if self.keyInstalled(info.fingerprint): +- msg = _('GPG key at %s (0x%s) is already installed') +- logger.info(msg, keyurl, info.short_id) +- continue +- +- # DNS Extension: create a key object, pass it to the verification class +- # and print its result as an advice to the user. +- if self.base.conf.gpgkey_dns_verification: +- dns_input_key = dnf.dnssec.KeyInfo.from_rpm_key_object(info.userid, +- info.raw_key) +- dns_result = dnf.dnssec.DNSSECKeyVerification.verify(dns_input_key) +- logger.info(dnf.dnssec.nice_user_msg(dns_input_key, dns_result)) +- + # Try installing/updating GPG key + info.url = keyurl +- if self.base.conf.gpgkey_dns_verification: +- dnf.crypto.log_dns_key_import(info, dns_result) +- else: +- dnf.crypto.log_key_import(info) ++ self.log_key_import(info) + rc = False + if self.base.conf.assumeno: + rc = False + elif self.base.conf.assumeyes: +- # DNS Extension: We assume, that the key is trusted in case it is valid, +- # its existence is explicitly denied or in case the domain is not signed +- # and therefore there is no way to know for sure (this is mainly for +- # backward compatibility) +- # FAQ: +- # * What is PROVEN_NONEXISTENCE? +- # In DNSSEC, your domain does not need to be signed, but this state +- # (not signed) has to be proven by the upper domain. e.g. when example.com. +- # is not signed, com. servers have to sign the message, that example.com. +- # does not have any signing key (KSK to be more precise). +- if self.base.conf.gpgkey_dns_verification: +- if dns_result in (dnf.dnssec.Validity.VALID, +- dnf.dnssec.Validity.PROVEN_NONEXISTENCE): +- rc = True +- logger.info(dnf.dnssec.any_msg(_("The key has been approved."))) +- else: +- rc = False +- logger.info(dnf.dnssec.any_msg(_("The key has been rejected."))) +- else: +- rc = True ++ rc = True + + # grab the .sig/.asc for the keyurl, if it exists if it + # does check the signature on the key if it is signed by +@@ -318,11 +287,8 @@ class MultiSig(dnf.Plugin): + # rc = True else ask as normal. + + elif fullaskcb: +- rc = fullaskcb({"po": po, "userid": info.userid, +- "hexkeyid": info.short_id, +- "keyurl": keyurl, +- "fingerprint": info.fingerprint, +- "timestamp": info.timestamp}) ++ rc = fullaskcb({"po": po, ++ "keyurl": keyurl}) + elif askcb: + rc = askcb(po, info.userid, info.short_id) + +@@ -331,8 +297,7 @@ class MultiSig(dnf.Plugin): + continue + + # Import the key +- # XXX: raw_key of second info erroneously contains first and +- # second key. Probably a bug in key parser. ++ # XXX: raw_key contains all keys found in the key file. + #logger.debug(_("Multisig: Importing a key: {}").format(info.raw_key)) + result = self.importKey(info) + if result == False: +-- +2.51.0 + diff --git a/SOURCES/0025-multisig-Rename-dnf4-multisig-8-manual-page-to-dnf-m.patch b/SOURCES/0025-multisig-Rename-dnf4-multisig-8-manual-page-to-dnf-m.patch new file mode 100644 index 0000000..192af35 --- /dev/null +++ b/SOURCES/0025-multisig-Rename-dnf4-multisig-8-manual-page-to-dnf-m.patch @@ -0,0 +1,78 @@ +From e1aebc68eb031f3e91ed39a0b145589f1a4a1734 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Petr=20P=C3=ADsa=C5=99?= +Date: Fri, 3 Oct 2025 12:23:11 +0200 +Subject: [PATCH] multisig: Rename dnf4-multisig(8) manual page to + dnf-multisig(8) +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +To align with all other plugin manual pages. +Create dnf4-multisig(8) symlink for compatibility. + +FILE(CREATE_LINK) is available since cmake 3.14. + +Resolve: https://issues.redhat.com/browse/RHEL-117134 +Signed-off-by: Petr Písař +--- + CMakeLists.txt | 2 +- + dnf-plugins-core.spec | 2 +- + doc/CMakeLists.txt | 4 ++++ + doc/conf.py | 2 +- + 4 files changed, 7 insertions(+), 3 deletions(-) + +diff --git a/CMakeLists.txt b/CMakeLists.txt +index a1eea7b..86225e7 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -1,5 +1,5 @@ + PROJECT (dnf-plugins-core NONE) +-CMAKE_MINIMUM_REQUIRED (VERSION 2.4) ++CMAKE_MINIMUM_REQUIRED (VERSION 3.14) + + if (NOT WITHOUT_LOCAL) + set (WITHOUT_LOCAL "0") +diff --git a/dnf-plugins-core.spec b/dnf-plugins-core.spec +index cb3b1b8..ff6beea 100644 +--- a/dnf-plugins-core.spec ++++ b/dnf-plugins-core.spec +@@ -40,7 +40,7 @@ License: GPLv2+ + URL: https://github.com/rpm-software-management/dnf-plugins-core + Source0: %{url}/archive/%{version}/%{name}-%{version}.tar.gz + BuildArch: noarch +-BuildRequires: cmake ++BuildRequires: cmake >= 3.14 + BuildRequires: gettext + # Documentation + %if %{with python3} +diff --git a/doc/CMakeLists.txt b/doc/CMakeLists.txt +index 297506a..75e74bb 100644 +--- a/doc/CMakeLists.txt ++++ b/doc/CMakeLists.txt +@@ -48,6 +48,10 @@ INSTALL(FILES ${CMAKE_CURRENT_BINARY_DIR}/dnf-migrate.8 + endif() + + if (${PYTHON_VERSION_MAJOR} STREQUAL "3") ++INSTALL(FILES ${CMAKE_CURRENT_BINARY_DIR}/dnf-multisig.8 ++ DESTINATION share/man/man8) ++FILE(CREATE_LINK dnf-multisig.8 ${CMAKE_CURRENT_BINARY_DIR}/dnf4-multisig.8 ++ SYMBOLIC) + INSTALL(FILES ${CMAKE_CURRENT_BINARY_DIR}/dnf4-multisig.8 + DESTINATION share/man/man8) + endif() +diff --git a/doc/conf.py b/doc/conf.py +index 2845d18..225ae5f 100644 +--- a/doc/conf.py ++++ b/doc/conf.py +@@ -301,7 +301,7 @@ if sys.version_info[0] < 3: + man_pages.append(('migrate', 'dnf-migrate', u'DNF migrate Plugin', AUTHORS, 8)) + + if sys.version_info[0] == 3: +- man_pages.append(('multisig', 'dnf4-multisig', u'DNF multisig Plugin', AUTHORS, 8)) ++ man_pages.append(('multisig', 'dnf-multisig', u'DNF multisig Plugin', AUTHORS, 8)) + + # If true, show URL addresses after external links. + #man_show_urls = False +-- +2.51.0 + diff --git a/SPECS/dnf-plugins-core.spec b/SPECS/dnf-plugins-core.spec index b2c4b94..69b2133 100644 --- a/SPECS/dnf-plugins-core.spec +++ b/SPECS/dnf-plugins-core.spec @@ -34,7 +34,7 @@ Name: dnf-plugins-core Version: 4.3.0 -Release: 20%{?dist} +Release: 24%{?dist} Summary: Core Plugins for DNF License: GPLv2+ URL: https://github.com/rpm-software-management/dnf-plugins-core @@ -57,9 +57,13 @@ Patch18: 0018-system-upgrade-change-http-to-https-in-unit-file.patch Patch19: 0019-reposync-Respect-norepopath-with-metadata-path.patch Patch20: 0020-needs-restarting-Get-boot-time-from-systemd-UnitsLoa.patch Patch21: 0021-dnf-copr-enable-on-Asahi-Fedora-Linux-Remix-guesses.patch +Patch22: 0022-reposync-Avoid-multiple-downloads-of-duplicate-packa.patch +Patch23: 0023-multisig-A-new-plugin-for-verifying-extraordinary-RP.patch +Patch24: 0024-multisig-Do-not-parse-OpenPGP-keys.patch +Patch25: 0025-multisig-Rename-dnf4-multisig-8-manual-page-to-dnf-m.patch BuildArch: noarch -BuildRequires: cmake +BuildRequires: cmake >= 3.14 BuildRequires: gettext # Documentation %if %{with python3} @@ -320,6 +324,18 @@ Obsoletes: python-dnf-plugins-extras-migrate < %{dnf_plugins_extra} Migrate Plugin for DNF, Python 2 version. Migrates history, group and yumdb data from yum to dnf. %endif +%if %{with python3} +%package -n python3-dnf-plugin-multisig +Summary: Multisig Plugin for DNF +Requires: pqrpm +Requires: python3-%{name} = %{version}-%{release} +Provides: dnf-plugin-multisig = %{version}-%{release} + +%description -n python3-dnf-plugin-multisig +Multisig Plugin for DNF, Python 3 version. The plugin verifies multiple RPMv6 +signatures on RPMv4 packages by using an external rpmkeys program. +%endif + %if %{with python2} %package -n python2-dnf-plugin-post-transaction-actions Summary: Post transaction actions Plugin for DNF @@ -735,6 +751,13 @@ ln -sf %{_mandir}/man1/%{yum_utils_subpackage_name}.1.gz %{buildroot}%{_mandir}/ %exclude %{_mandir}/man8/dnf-migrate.* %endif +%if %{with python3} +%files -n python3-dnf-plugin-multisig +%{python3_sitelib}/dnf-plugins/multisig.* +%{python3_sitelib}/dnf-plugins/__pycache__/multisig.* +%{_mandir}/man8/dnf*-multisig.* +%endif + %if %{with python2} %files -n python2-dnf-plugin-post-transaction-actions %config(noreplace) %{_sysconfdir}/dnf/plugins/post-transaction-actions.conf @@ -804,6 +827,19 @@ ln -sf %{_mandir}/man1/%{yum_utils_subpackage_name}.1.gz %{buildroot}%{_mandir}/ %endif %changelog +* Fri Oct 03 2025 Petr Pisar - 4.3.0-24 +- Rename dnf4-multisig(8) manual page to dnf-multisig(8) (RHEL-117134) + +* Mon Sep 15 2025 Petr Pisar - 4.3.0-23 +- Fix importing OpenPGPv6 keys (RHEL-114424) + +* Wed Jun 25 2025 Petr Pisar - 4.3.0-22 +- Add multisig plugin (RHEL-100157) + +* Tue Mar 11 2025 Marek Blaha - 4.3.0-21 +- reposync: Avoid multiple downloads of duplicate packages (RHEL-64320) +- Fix bogus changelog entry date + * Mon Dec 16 2024 Jan Kolarik - 4.3.0-20 - Add forgotten changelog @@ -865,7 +901,7 @@ ln -sf %{_mandir}/man1/%{yum_utils_subpackage_name}.1.gz %{buildroot}%{_mandir}/ * Thu Jan 05 2023 Nicola Sella - 4.3.0-3 - Remove requirement of python3-distro -* Wed Dec 03 2022 Nicola Sella - 4.3.0-2 +* Sat Dec 03 2022 Nicola Sella - 4.3.0-2 - Move system-upgrade plugin to core (RhBug:2054235) - offline-upgrade: add support for security filters (RhBug:1939975)