From ce6592f82d718781f4662d79648aa850d07e5630 Mon Sep 17 00:00:00 2001
From: Daniel Milnes <daniel.milnes@lmax.com>
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"

