From ce6592f82d718781f4662d79648aa850d07e5630 Mon Sep 17 00:00:00 2001 From: Daniel Milnes Date: Apr 27 2026 00:48:24 +0000 Subject: Add support for container image signing using GPG or Sigstore --- diff --git a/Containerfile b/Containerfile index c756a75..69817ff 100644 --- a/Containerfile +++ b/Containerfile @@ -6,7 +6,11 @@ RUN dnf install -y \ patch \ python3-pip \ python3-hatchling \ - skopeo + skopeo \ + gnupg-pkcs11-scd \ + siguldry-pkcs11 \ + gnupg2-scdaemon \ + && dnf clean all RUN mkdir -p /srv/image-uploader/ COPY . /srv/image-uploader/src diff --git a/fedora-image-uploader/fedora_image_uploader/handler.py b/fedora-image-uploader/fedora_image_uploader/handler.py index a34f8b8..186e8ea 100644 --- a/fedora-image-uploader/fedora_image_uploader/handler.py +++ b/fedora-image-uploader/fedora_image_uploader/handler.py @@ -11,16 +11,15 @@ from dataclasses import dataclass from fedfind import exceptions as ff_exceptions from fedfind import helpers as ff_helpers from fedfind import release as ff_release -from fedora_image_uploader_messages import ContainerPublishedV1 from fedora_messaging import config from fedora_messaging import exceptions as fm_exceptions from fedora_messaging import message as fm_message +from fedora_image_uploader_messages import ContainerPublishedV1 from .utils import download_image, fallible_publish, get_milestone _log = logging.getLogger(__name__) - try: from . import aws except ImportError: @@ -36,7 +35,6 @@ try: except ImportError: _log.info("Install the 'gcp' extra for Google Cloud Platform support") - DOCKER_ARCHES = {"amd64": "x86_64", "arm64": "aarch64", "ppc64le": "ppc64le", "s390x": "s390x"} @@ -126,6 +124,8 @@ class Uploader: self.xdg_dir = tempfile.mkdtemp(prefix="image-uploader-xdg") os.environ["XDG_RUNTIME_DIR"] = self.xdg_dir self.registry_login() + + self._prepare_signing() if "azure" in self.conf.keys(): _log.info("Configuration includes 'azure' section; handling Azure images") self.handlers["azure"] = azure.Azure() @@ -255,7 +255,7 @@ class Uploader: ) def publish_container_manifest( - self, registry: dict, repo: str, index: FatManifest, tags: Iterable[str] + self, registry: dict, repo: str, index: FatManifest, tags: Iterable[str] ): """ Create a container manifest for a given set of refs. @@ -274,24 +274,11 @@ class Uploader: index.write_to_dir(image_dir) prefix = registry["credential_prefix"] registry = registry["url"] - try: - cert_dir = os.environ[f"{prefix}CERT_DIR"] - except KeyError: - cert_dir = None + cert_dir = os.environ.get(f"{prefix}CERT_DIR") + # something like "docker://registry.fedoraproject.org/fedora:40" for target in [f"docker://{registry}/{repo}:{tag}" for tag in tags]: - pushargs = [ - "skopeo", - "copy", - ] - if cert_dir: - pushargs.append(f"--dest-cert-dir={cert_dir}") - pushargs += [ - f"oci:{image_dir}", - target, - "--multi-arch=index-only", - ] - _run(pushargs) + self._copy_container(f"oci:{image_dir}", target, cert_dir, ["--multi-arch=index-only"]) def publish_container_manifests(self, ffrel: ff_release.Release): """ @@ -438,26 +425,54 @@ class Uploader: for registry in registries: prefix = registry["credential_prefix"] registry = registry["url"] - try: - cert_dir = os.environ[f"{prefix}CERT_DIR"] - except KeyError: - cert_dir = None - args = [ - "skopeo", - "copy", - ] - if cert_dir: - args.append(f"--dest-cert-dir={cert_dir}") + cert_dir = os.environ.get(f"{prefix}CERT_DIR") + # We only support OCI archives; F40+ .oci.tar.xz images # with type "docker" are xz-compressed OCI archives, # .ociarchive images with type "ociarchive" are non- # compressed OCI archives - args += [ - f"oci-archive:{image_path}", - f"docker://{registry}/{repo}:{tag}-{arch}", - ] - _run(args) + self._copy_container(f"oci-archive:{image_path}", f"docker://{registry}/{repo}:{tag}-{arch}", cert_dir) if repo in self.container_repos: self.container_repos[repo].append(arch) else: self.container_repos[repo] = [arch] + + def _copy_container(self, source: str, dest: str, cert_dir: str | None = None, extra_args: list[str] = []) -> None: + args = [ + "skopeo", + "copy", + source, + dest, + ] + + if cert_dir: + args.append(f"--dest-cert-dir={cert_dir}") + + if self.gpg_key_id: + args.append(f"--sign-by={self.gpg_key_id}") + + if self.sigstore_parameter_file: + args.append(f"--sign-by-sigstore={self.sigstore_parameter_file}") + + args += extra_args + + _run(args) + + def _prepare_signing(self) -> None: + signing_config = self.conf["container"].get("signing", {}) + + self.gpg_key_id = signing_config.get("gpg_key_id") + + if self.gpg_key_id: + # Generate the private key stub files for keys coming from Siguldry, expect to fail if using a local key + _run(["gpg", "--card-status"], failok=True) + + if not self.gpg_key_id in _run(["gpg", "--list-secret-keys", "--keyid-format=long"]).stdout: + _log.error(f"Configured GPG key {self.gpg_key_id} not found in secret keys!") + raise fm_exceptions.Nack() + + self.sigstore_parameter_file = signing_config.get("sigstore_parameter_file") + + if self.sigstore_parameter_file and not os.path.isfile(self.sigstore_parameter_file): + _log.error(f"Configured Sigstore parameter file {self.sigstore_parameter_file} does not exist!") + raise fm_exceptions.Nack() diff --git a/fedora-image-uploader/tests/test_handler.py b/fedora-image-uploader/tests/test_handler.py index b475a19..09cc8a4 100644 --- a/fedora-image-uploader/tests/test_handler.py +++ b/fedora-image-uploader/tests/test_handler.py @@ -2,9 +2,12 @@ import json import logging import os import tempfile +from subprocess import CompletedProcess from unittest import mock +from unittest.mock import MagicMock import pytest + from fedora_image_uploader_messages import ContainerPublishedV1 from fedora_messaging import config, exceptions, message from fedora_messaging import testing as fm_testing @@ -464,3 +467,26 @@ def test_manifest_write_to_dir(): assert os.path.isfile(expected_index_path) with open(expected_index_path) as f: assert f.read() == manifest.image_index() + + +@mock.patch.dict( + config.conf, + { + "consumer_config": { + "container": { + "signing": { + "gpg_key_id": "ABC123", + "sigstore_parameter_file": "/foo.json", + }, + } + } + }, +) +@mock.patch("fedora_image_uploader.handler._run", return_value=CompletedProcess("", 0, "ABC123", "")) +@mock.patch("os.path.isfile", return_value=True) +def test_container_signing_arguments(mock_isfile: MagicMock, mock_subrun: MagicMock) -> None: + uploader = Uploader() + + uploader._copy_container("docker://foo", "docker://bar") + + mock_subrun.assert_called_with(["skopeo", "copy", "docker://foo", "docker://bar", "--sign-by=ABC123", "--sign-by-sigstore=/foo.json"]) diff --git a/fedora-messaging.toml.example b/fedora-messaging.toml.example index 3958c01..6d1c05d 100644 --- a/fedora-messaging.toml.example +++ b/fedora-messaging.toml.example @@ -77,6 +77,10 @@ credential_prefix = "FEDORA_REGISTRY_" url = "quay.io/fedora" credential_prefix = "QUAY_IO_" +[consumer_config.container.signing] +gpg_key_id = "1CB5892C33ADD038FB11B666CEA84AC159D2476C" +sigstore_parameter_file = "/config.json" + [consumer_config.aws] base_region = "us-east-1"