dnf-plugins-core/SOURCES/0023-multisig-A-new-plugin-for-verifying-extraordinary-RP.patch

618 lines
24 KiB
Diff

From 37c4cd7014f4b3c5db64a0a900761e53caf645be Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Petr=20P=C3=ADsa=C5=99?= <ppisar@redhat.com>
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ř <ppisar@redhat.com>
---
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
+ # <https://github.com/rpm-software-management/rpm/issues/3721>
+ 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
+ # <https://github.com/rpm-software-management/rpm/pull/3706>.
+ 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