From e759109156a273fc9a34f8d7118cabd27f8f9eea Mon Sep 17 00:00:00 2001 From: adrianabedon Date: Mon, 17 Nov 2025 08:54:55 +0000 Subject: [PATCH 1/8] Add initial support for package signing Assisted By: GPT-5.1-Codex fixes #1300 --- CHANGES/1300.feature | 1 + docs/user/guides/_SUMMARY.md | 1 + docs/user/guides/sign_packages.md | 52 ++++ docs/user/guides/signing_service.md | 73 ++++- .../app/migrations/0035_package_signing.py | 79 ++++++ pulp_deb/app/models/__init__.py | 8 +- pulp_deb/app/models/repository.py | 46 +++- pulp_deb/app/models/signing_service.py | 148 ++++++++++- .../app/serializers/content_serializers.py | 31 +++ .../app/serializers/repository_serializers.py | 93 ++++++- pulp_deb/app/tasks/signing.py | 66 +++++ pulp_deb/app/viewsets/content.py | 67 ++++- .../functional/api/test_package_signing.py | 250 ++++++++++++++++++ pulp_deb/tests/functional/conftest.py | 110 +++++++- pulp_deb/tests/functional/constants.py | 21 ++ 15 files changed, 1019 insertions(+), 27 deletions(-) create mode 100644 CHANGES/1300.feature create mode 100644 docs/user/guides/sign_packages.md create mode 100644 pulp_deb/app/migrations/0035_package_signing.py create mode 100644 pulp_deb/app/tasks/signing.py create mode 100644 pulp_deb/tests/functional/api/test_package_signing.py diff --git a/CHANGES/1300.feature b/CHANGES/1300.feature new file mode 100644 index 000000000..ffc3b329b --- /dev/null +++ b/CHANGES/1300.feature @@ -0,0 +1 @@ +Added (tech preview) support for signing Debian packages when uploading to a Repository. diff --git a/docs/user/guides/_SUMMARY.md b/docs/user/guides/_SUMMARY.md index f22248f42..90f8c579c 100644 --- a/docs/user/guides/_SUMMARY.md +++ b/docs/user/guides/_SUMMARY.md @@ -6,5 +6,6 @@ * [Package Uploads](upload.md) * [Publish Repositories](publish.md) * [Signing Service Creation](signing_service.md) +* [Sign Packages](sign_packages.md) * [Advanced Copy](advanced_copy.md) * [Configuring Checksums](checksums.md) diff --git a/docs/user/guides/sign_packages.md b/docs/user/guides/sign_packages.md new file mode 100644 index 000000000..2d317547c --- /dev/null +++ b/docs/user/guides/sign_packages.md @@ -0,0 +1,52 @@ +# Sign Debian Packages + +Sign a Debian package using a registered APT package signing service. + +Currently, only on-upload signing is supported. + +## On Upload + +!!! tip "New in 3.9.0 (Tech Preview)" + +Sign a Debian package when uploading it to a repository. + +### Prerequisites + +- Have an `AptPackageSigningService` registered + (see the [signing service guide](site:pulp_deb/docs/user/guides/signing_service/)). +- Have the V4 fingerprint of the key you want to use. The key must be accessible by the signing + service you are using (the fingerprint is forwarded via `PULP_SIGNING_KEY_FINGERPRINT`). + +### Instructions + +1. Configure a repository to enable signing. + - Both `package_signing_service` and `package_signing_fingerprint` must be set on the + repository (or provided via the REST API fields with the same names). + - With those fields set, every package upload to the repository will be signed by the service. + - Optionally, set `package_signing_fingerprint_release_overrides` if you need different keys per + dist. +2. Upload a package to this repository. + +### Example + +```bash +# Create or update a repository with signing enabled +http POST $API_ROOT/repositories/deb/apt \ + name="MyDebRepo" \ + package_signing_service=$SIGNING_SERVICE_HREF \ + package_signing_fingerprint=$SIGNING_FINGERPRINT + +# Upload a package +pulp deb content upload \ + --repository ${REPOSITORY} \ + --file ${DEB_FILE} +``` + +### Known Limitations + +**Traffic overhead**: The signing of a package should happen inside of a Pulp worker. + [By design](site:pulpcore/docs/dev/learn/plugin-concepts/#tasks), + Pulp needs to temporarily commit the file to the default backend storage in order to make the Uploaded File available to the tasking system. + This implies in some extra traffic, compared to a scenario where a task could process the file directly. + +**No sign tracking**: We do not track signing information of a package. diff --git a/docs/user/guides/signing_service.md b/docs/user/guides/signing_service.md index c42dd9bf5..8eae2ef64 100644 --- a/docs/user/guides/signing_service.md +++ b/docs/user/guides/signing_service.md @@ -1,8 +1,10 @@ # Signing Service Creation +## Metadata + To sign your APT release files on your `pulp_deb` publications, you will first need to create a signing service of type `AptReleaseSigningService`. -## Prerequisites +### Prerequisites Creating a singing service requires the following: @@ -26,7 +28,7 @@ Creating a singing service requires the following: } ``` -## Example Signing Script +### Example Signing Script The following example signing service script is used as part of the `pulp_deb` test suite: @@ -66,7 +68,7 @@ echo { \ It assumes that both public and secret key for `GPG_KEY_ID="Pulp QE"` is present in the GPG home of the Pulp user and that the secret key is not protecteded by a password. -## Creation Steps +### Creation Steps 1. Add the public key to your pulp users GPG home, for example, if pulp workers are running as the `pulp` user: ```bash @@ -84,3 +86,68 @@ It assumes that both public and secret key for `GPG_KEY_ID="Pulp QE"` is present pulp signing-service show --name=PulpQE | jq -r .pulp_href ``` 5. Start [using the signing service to sign metadata](https://staging-docs.pulpproject.org/pulp_deb/docs/user/guides/publish/#metadata-signing). + + +## Packages + +!!! tip "New in 3.9.0 (Tech Preview)" + +Package signing is available as a tech preview beginning with pulp_deb 3.9.0. Unlike metadata +signing, package signing modifies the `.deb` file directly, so it uses the +`deb:AptPackageSigningService` class. + +### Prerequisites + +- Install `debsigs` and ensure it can access the private key you want to use. +- Familiarize yourself with the general signing instructions in + [pulpcore](site:pulpcore/docs/admin/guides/sign-metadata/). +- Make sure the public key fingerprint you provide matches the key available to `debsigs`. During + package uploads the fingerprint is passed to the script via the + `PULP_SIGNING_KEY_FINGERPRINT` environment variable. + +### Instructions + +1. Create a signing script capable of signing a Debian package with `debsigs`. + - The script receives the package path as its first argument. + - The script must use `PULP_SIGNING_KEY_FINGERPRINT` to select the signing key. + - The script should return JSON describing the signed file: + ```json + {"deb_package": "/absolute/path/to/signed.deb"} + ``` +2. Register the script with `pulpcore-manager add-signing-service`. + - Use `--class "deb:AptPackageSigningService"`. + - The public key fingerprint passed here is only used to validate the script registration. +3. Retrieve the signing service `pulp_href` for later use (for example via + `pulp signing-service show --name `). + +### Example + +The following script illustrates how to sign packages using `debsigs`. It copies the uploaded file +into a working directory (defaulting to `PULP_TEMP_WORKING_DIR` when present), signs it in place, +and emits the JSON payload expected by pulp_deb. + +```bash title="package-signing-script.sh" +#!/usr/bin/env bash +set -euo pipefail + +PACKAGE_PATH=$1 +FINGERPRINT="${PULP_SIGNING_KEY_FINGERPRINT:?PULP_SIGNING_KEY_FINGERPRINT is required}" +WORKDIR="${PULP_TEMP_WORKING_DIR:-$(mktemp -d)}" +SIGNED_PATH="${WORKDIR}/$(basename "${PACKAGE_PATH}")" + +cp "${PACKAGE_PATH}" "${SIGNED_PATH}" +debsigs --sign=origin --default-key "${FINGERPRINT}" "${SIGNED_PATH}" + +echo {"deb_package": "${SIGNED_PATH}"} +``` + +```bash +pulpcore-manager add-signing-service \ + "SimpleDebSigningService" \ + ${SCRIPT_ABS_FILENAME} \ + ${KEYID} \ + --class "deb:AptPackageSigningService" + +pulp signing-service show --name "SimpleDebSigningService" +``` + diff --git a/pulp_deb/app/migrations/0035_package_signing.py b/pulp_deb/app/migrations/0035_package_signing.py new file mode 100644 index 000000000..624a87f84 --- /dev/null +++ b/pulp_deb/app/migrations/0035_package_signing.py @@ -0,0 +1,79 @@ +# Generated by Django 4.2.25 on 2025-10-23 21:43 + +from django.db import migrations, models +import django.db.models.deletion +import django_lifecycle.mixins +import pulpcore.app.models.base + + +class Migration(migrations.Migration): + dependencies = [ + ("deb", "0034_aptpublication_layout"), + ] + + operations = [ + migrations.CreateModel( + name="AptPackageSigningService", + fields=[ + ( + "signingservice_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="core.signingservice", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("core.signingservice",), + ), + migrations.AddField( + model_name="aptrepository", + name="package_signing_fingerprint", + field=models.TextField(max_length=40, null=True), + ), + migrations.AddField( + model_name="aptrepository", + name="package_signing_service", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="deb.aptpackagesigningservice", + ), + ), + migrations.CreateModel( + name="AptRepositoryReleasePackageSigningFingerprintOverride", + fields=[ + ( + "pulp_id", + models.UUIDField( + default=pulpcore.app.models.base.pulp_uuid, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("pulp_created", models.DateTimeField(auto_now_add=True)), + ("pulp_last_updated", models.DateTimeField(auto_now=True, null=True)), + ("package_signing_fingerprint", models.TextField(max_length=40)), + ("release_distribution", models.TextField()), + ( + "repository", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="package_signing_fingerprint_release_overrides", + to="deb.aptrepository", + ), + ), + ], + options={ + "unique_together": {("repository", "release_distribution")}, + }, + bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model), + ), + ] diff --git a/pulp_deb/app/models/__init__.py b/pulp_deb/app/models/__init__.py index c941598e9..f96d78233 100644 --- a/pulp_deb/app/models/__init__.py +++ b/pulp_deb/app/models/__init__.py @@ -9,7 +9,7 @@ SourcePackage, ) -from .signing_service import AptReleaseSigningService +from .signing_service import AptReleaseSigningService, AptPackageSigningService from .content.metadata import ( Release, @@ -28,6 +28,10 @@ from .remote import AptRemote -from .repository import AptRepository, AptRepositoryReleaseServiceOverride +from .repository import ( + AptRepository, + AptRepositoryReleaseServiceOverride, + AptRepositoryReleasePackageSigningFingerprintOverride, +) from .acs import AptAlternateContentSource diff --git a/pulp_deb/app/models/repository.py b/pulp_deb/app/models/repository.py index f2889a34a..967f6cf57 100644 --- a/pulp_deb/app/models/repository.py +++ b/pulp_deb/app/models/repository.py @@ -31,6 +31,7 @@ SourceIndex, SourcePackage, SourcePackageReleaseComponent, + AptPackageSigningService, ) log = logging.getLogger(__name__) @@ -66,7 +67,15 @@ class AptRepository(Repository, AutoAddObjPermsMixin): signing_service = models.ForeignKey( AptReleaseSigningService, on_delete=models.PROTECT, null=True ) + + package_signing_service = models.ForeignKey( + AptPackageSigningService, on_delete=models.SET_NULL, null=True + ) + + package_signing_fingerprint = models.TextField(null=True, max_length=40) + # Implicit signing_service_release_overrides + # Implicit package_signing_fingerprint_release_overrides autopublish = models.BooleanField(default=False) @@ -115,6 +124,21 @@ def release_signing_service(self, release): except AptRepositoryReleaseServiceOverride.DoesNotExist: return self.signing_service + def release_package_signing_fingerprint(self, release): + """ + Return the Package Signing Fingerprint specified in the overrides if there is one for this + release, else return self.package_signing_fingerprint. + """ + if isinstance(release, Release): + release = release.distribution + try: + override = self.package_signing_fingerprint_release_overrides.get( + release_distribution=release + ) + return override.package_signing_fingerprint + except AptRepositoryReleasePackageSigningFingerprintOverride.DoesNotExist: + return self.package_signing_fingerprint + def initialize_new_version(self, new_version): """ Remove old metadata from the repo before performing anything else for the new version. This @@ -149,7 +173,9 @@ class AptRepositoryReleaseServiceOverride(BaseModel): """ repository = models.ForeignKey( - AptRepository, on_delete=models.CASCADE, related_name="signing_service_release_overrides" + AptRepository, + on_delete=models.CASCADE, + related_name="signing_service_release_overrides", ) signing_service = models.ForeignKey(AptReleaseSigningService, on_delete=models.PROTECT) release_distribution = models.TextField() @@ -158,6 +184,24 @@ class Meta: unique_together = (("repository", "release_distribution"),) +class AptRepositoryReleasePackageSigningFingerprintOverride(BaseModel): + """ + Override the signing fingerprint that a single Release will use in this AptRepository for + signing packages. + """ + + repository = models.ForeignKey( + AptRepository, + on_delete=models.CASCADE, + related_name="package_signing_fingerprint_release_overrides", + ) + package_signing_fingerprint = models.TextField(max_length=40) + release_distribution = models.TextField() + + class Meta: + unique_together = (("repository", "release_distribution"),) + + def find_dist_components(package_ids, content_set): """ Given a list of package_ids and a content_set, this function will find all distribution- diff --git a/pulp_deb/app/models/signing_service.py b/pulp_deb/app/models/signing_service.py index e080489b1..a998503c3 100644 --- a/pulp_deb/app/models/signing_service.py +++ b/pulp_deb/app/models/signing_service.py @@ -1,11 +1,33 @@ import os +import shutil +import subprocess import tempfile +from importlib.resources import files +from pathlib import Path +from typing import Optional import gnupg from pulpcore.plugin.models import SigningService +def prepare_gpg(temp_directory_name, public_key, pubkey_fingerprint): + # Prepare GPG: + # gpg = gnupg.GPG(gnupghome=temp_directory_name) + gpg = gnupg.GPG(keyring=str(Path(temp_directory_name) / ".keyring")) + gpg.import_keys(public_key) + imported_keys = gpg.list_keys() + + if len(imported_keys) != 1: + message = "We have imported more than one key! Aborting validation!" + raise RuntimeError(message) + + if imported_keys[0]["fingerprint"] != pubkey_fingerprint: + message = "The signing service fingerprint does not appear to match its public key!" + raise RuntimeError(message) + return gpg + + class AptReleaseSigningService(SigningService): """ A model used for signing Apt repository Release files. @@ -71,19 +93,7 @@ def validate(self): raise RuntimeError(message.format(signature_file, signature_type)) # Prepare GPG: - gpg = gnupg.GPG(gnupghome=temp_directory_name) - gpg.import_keys(self.public_key) - imported_keys = gpg.list_keys() - - if len(imported_keys) != 1: - message = "We have imported more than one key! Aborting validation!" - raise RuntimeError(message) - - if imported_keys[0]["fingerprint"] != self.pubkey_fingerprint: - message = ( - "The signing service fingerprint does not appear to match its public key!" - ) - raise RuntimeError(message) + gpg = prepare_gpg(temp_directory_name, self.public_key, self.pubkey_fingerprint) # Verify InRelease file inline_path = signatures.get("inline") @@ -139,3 +149,115 @@ def validate(self): if verified.pubkey_fingerprint != self.pubkey_fingerprint: message = "'{}' appears to have been signed using the wrong key!" raise RuntimeError(message.format(detached_path)) + + +class AptPackageSigningService(SigningService): + """ + A model used for signing Apt packages. + + The pubkey_fingerprint should be passed explicitly in the sign method. + """ + + def _env_variables(self, env_vars=None): + # Prevent the signing service pubkey to be used for signing a package. + # The pubkey should be provided explicitly. + _env_vars = {"PULP_SIGNING_KEY_FINGERPRINT": None} + if env_vars: + _env_vars.update(env_vars) + return super()._env_variables(_env_vars) + + def sign( + self, + filename: str, + env_vars: Optional[dict] = None, + pubkey_fingerprint: Optional[str] = None, + ): + """ + Sign a package @filename using @pubkey_fingerprint. + + Args: + filename: The absolute path to the package to be signed. + env_vars: (optional) Dict of env_vars to be passed to the signing script. + pubkey_fingerprint: The V4 fingerprint that correlates with the private key to use. + """ + if not pubkey_fingerprint: + raise ValueError("A pubkey_fingerprint must be provided.") + _env_vars = env_vars or {} + _env_vars["PULP_SIGNING_KEY_FINGERPRINT"] = pubkey_fingerprint + return super().sign(filename, _env_vars) + + def validate(self): + """ + Validate a signing service for an Apt package signature. + + Specifically, it validates that self.signing_script can sign an apt package with + the sample key self.pubkey and that the self.sign() method returns: + + ```json + {"apt_package": ""} + ``` + + Recreates the check that "debsig-verify" would be doing because debsig-verify is + complicated to set up correctly, and doing so would add a dependency that is not available + on rpm-based systems. + """ + with tempfile.TemporaryDirectory() as temp_directory_name: + # copy test deb package + sample_deb = shutil.copy( + files("pulp_deb").joinpath("tests/functional/data/packages/frigg_1.0_ppc64.deb"), + temp_directory_name, + ) + return_value = self.sign(sample_deb, pubkey_fingerprint=self.pubkey_fingerprint) + try: + signed_deb = return_value["deb_package"] + except KeyError: + raise Exception(f"Malformed output from signing script: {return_value}") + + # Prepare GPG: + gpg = prepare_gpg(temp_directory_name, self.public_key, self.pubkey_fingerprint) + + self._validate_deb_package( + signed_deb, self.pubkey_fingerprint, temp_directory_name, gpg + ) + + @staticmethod + def _validate_deb_package( + deb_package_path: str, pubkey_fingerprint: str, temp_directory_name: str, gpg: gnupg.GPG + ): + """ + Validate that the deb package at @deb_package_path is correctly signed. + + This is a placeholder for future validation logic if needed. + """ + # unpack the archive + cmd = ["ar", "x", deb_package_path] + res = subprocess.run(cmd, cwd=temp_directory_name, capture_output=True) + if res.returncode != 0: + raise Exception(f"Failed to read package {deb_package_path}. Please check the package.") + + # cat the unpacked archive bits together + temp_dir = Path(temp_directory_name) + with (temp_dir / "combined").open("wb") as combined: + for filename in ("debian-binary", "control.*", "data.*"): + # There will only be one control.tar.gz (or whatever) file, but we have to glob + # and iterate because the compression type can vary. + for x in temp_dir.glob(filename): + with x.open("rb") as f: + shutil.copyfileobj(f, combined) + + # verify combined data with _gpgorigin detached signature + gpgorigin_path = temp_dir / "_gpgorigin" + if not gpgorigin_path.exists(): + raise Exception( + f"_gpgorigin file not found for {deb_package_path}. Package is unsigned." + ) + with gpgorigin_path.open("rb") as gpgorigin: + verified = gpg.verify_file(gpgorigin, str(temp_dir / "combined")) + if not verified.valid: + raise Exception( + f"GPG Verification of the signed package {deb_package_path} failed!" + ) + if verified.pubkey_fingerprint != pubkey_fingerprint: + raise Exception( + f"'{deb_package_path}' appears to have been signed using the wrong key!" + ) diff --git a/pulp_deb/app/serializers/content_serializers.py b/pulp_deb/app/serializers/content_serializers.py index 17f7ab2c4..f92c4cbb1 100644 --- a/pulp_deb/app/serializers/content_serializers.py +++ b/pulp_deb/app/serializers/content_serializers.py @@ -698,6 +698,37 @@ def deferred_validate(self, data): return data + def validate(self, data): + validated_data = super().validate(data) + sign_package = self.context.get("sign_package", None) + # choose branch, if not set externally + if sign_package is None: + sign_package = bool( + validated_data.get("repository") + and validated_data["repository"].package_signing_service + ) + self.context["sign_package"] = sign_package + + # normal branch + if sign_package is False: + return validated_data + + # signing branch + if not validated_data["repository"].package_signing_fingerprint: + raise ValidationError( + _( + "To sign a package on upload, the associated Repository must set both" + "'package_signing_service' and 'package_signing_fingerprint'." + ) + ) + + if not validated_data.get("file") and not validated_data.get("upload"): + raise ValidationError( + _("To sign a package on upload, a file or upload must be provided.") + ) + + return validated_data + class Meta(SinglePackageUploadSerializer.Meta): fields = ( SinglePackageUploadSerializer.Meta.fields diff --git a/pulp_deb/app/serializers/repository_serializers.py b/pulp_deb/app/serializers/repository_serializers.py index eb770fa08..4606eaef5 100644 --- a/pulp_deb/app/serializers/repository_serializers.py +++ b/pulp_deb/app/serializers/repository_serializers.py @@ -16,8 +16,10 @@ from pulpcore.plugin.util import get_domain, get_url from pulp_deb.app.models import ( + AptPackageSigningService, AptReleaseSigningService, AptRepository, + AptRepositoryReleasePackageSigningFingerprintOverride, AptRepositoryReleaseServiceOverride, ) from pulp_deb.app.schema import COPY_CONFIG_SCHEMA @@ -40,6 +42,13 @@ def to_representation(self, overrides): } +class PackageFingerprintOverrideField(serializers.DictField): + child = serializers.CharField(max_length=40) + + def to_representation(self, overrides): + return {x.release_distribution: x.package_signing_fingerprint for x in overrides.all()} + + class AptRepositorySerializer(RepositorySerializer): """ A Serializer for AptRepository. @@ -87,23 +96,60 @@ class AptRepositorySerializer(RepositorySerializer): ), ) + package_signing_fingerprint_release_overrides = PackageFingerprintOverrideField( + default=dict, + required=False, + help_text=_( + "A dictionary of Release distributions and the " + "Package Signing Fingerprints they should use." + "Example: " + '{"bionic": "7FC42CD5F3D8EEC3"}' + ), + ) + + package_signing_service = RelatedField( + help_text="A reference to an associated package signing service.", + view_name="signing-services-detail", + queryset=AptPackageSigningService.objects.all(), + many=False, + required=False, + allow_null=True, + ) + package_signing_fingerprint = serializers.CharField( + help_text=_( + "The pubkey V4 fingerprint (160 bits) to be passed to the package signing service." + "The signing service will use that on signing operations related to this repository." + ), + max_length=40, + required=False, + allow_blank=True, + default="", + ) + class Meta: fields = RepositorySerializer.Meta.fields + ( "autopublish", "publish_upstream_release_fields", "signing_service", "signing_service_release_overrides", + "package_signing_fingerprint_release_overrides", + "package_signing_service", + "package_signing_fingerprint", ) model = AptRepository @transaction.atomic def create(self, validated_data): """Create an AptRepository, special handling for signing_service_release_overrides.""" - overrides = validated_data.pop("signing_service_release_overrides", -1) + service_overrides = validated_data.pop("signing_service_release_overrides", -1) + fingerprint_overrides = validated_data.pop( + "package_signing_fingerprint_release_overrides", -1 + ) repo = super().create(validated_data) try: - self._update_overrides(repo, overrides) + self._update_signing_service_overrides(repo, service_overrides) + self._update_package_signing_fingerprint_overrides(repo, fingerprint_overrides) except DRFValidationError as exc: repo.delete() raise exc @@ -111,13 +157,17 @@ def create(self, validated_data): def update(self, instance, validated_data): """Update an AptRepository, special handling for signing_service_release_overrides.""" - overrides = validated_data.pop("signing_service_release_overrides", -1) + service_overrides = validated_data.pop("signing_service_release_overrides", -1) + fingerprint_overrides = validated_data.pop( + "package_signing_fingerprint_release_overrides", -1 + ) with transaction.atomic(): - self._update_overrides(instance, overrides) + self._update_signing_service_overrides(instance, service_overrides) + self._update_package_signing_fingerprint_overrides(instance, fingerprint_overrides) instance = super().update(instance, validated_data) return instance - def _update_overrides(self, repo, overrides): + def _update_signing_service_overrides(self, repo, overrides): """Update signing_service_release_overrides.""" if overrides == -1: # Sentinel value, no updates @@ -131,7 +181,7 @@ def _update_overrides(self, repo, overrides): elif service: signing_service = AptReleaseSigningService.objects.get(pk=service) if distro in current: # update - current[distro] = signing_service + current[distro].signing_service = signing_service current[distro].save() else: # create AptRepositoryReleaseServiceOverride( @@ -140,6 +190,37 @@ def _update_overrides(self, repo, overrides): release_distribution=distro, ).save() + def _update_package_signing_fingerprint_overrides(self, repo, overrides): + """Update package_signing_fingerprint_release_overrides.""" + if overrides == -1: + # Sentinel value, no updates + return + + current = { + x.release_distribution: x + for x in repo.package_signing_fingerprint_release_overrides.all() + } + # Intentionally only updates items the user specified. + for distro, fingerprint in overrides.items(): + if not fingerprint and distro in current: # the user wants to delete this override + current[distro].delete() + elif fingerprint: + if distro in current: # update + current[distro].package_signing_fingerprint = fingerprint + current[distro].save() + else: # create + AptRepositoryReleasePackageSigningFingerprintOverride( + repository=repo, + package_signing_fingerprint=fingerprint, + release_distribution=distro, + ).save() + + def to_representation(self, instance): + data = super().to_representation(instance) + if "package_signing_fingerprint" in data and data["package_signing_fingerprint"] is None: + data["package_signing_fingerprint"] = "" + return data + class AptRepositorySyncURLSerializer(RepositorySyncURLSerializer): """ diff --git a/pulp_deb/app/tasks/signing.py b/pulp_deb/app/tasks/signing.py new file mode 100644 index 000000000..5d0317739 --- /dev/null +++ b/pulp_deb/app/tasks/signing.py @@ -0,0 +1,66 @@ +from pathlib import Path +from tempfile import NamedTemporaryFile + +from pulpcore.plugin.models import Upload, UploadChunk, Artifact, CreatedResource, PulpTemporaryFile +from pulpcore.plugin.tasking import general_create +from pulpcore.plugin.util import get_url + +from pulp_deb.app.models.signing_service import AptPackageSigningService + + +def _save_file(fileobj, final_package): + with fileobj.file.open() as fd: + final_package.write(fd.read()) + final_package.flush() + + +def _save_upload(uploadobj, final_package): + chunks = UploadChunk.objects.filter(upload=uploadobj).order_by("offset") + for chunk in chunks: + final_package.write(chunk.file.read()) + chunk.file.close() + final_package.flush() + + +def sign_and_create( + app_label, + serializer_name, + signing_service_pk, + signing_fingerprint, + temporary_file_pk, + *args, + **kwargs, +): + data = kwargs.pop("data", None) + context = kwargs.pop("context", {}) + # Get unsigned package file and sign it + package_signing_service = AptPackageSigningService.objects.get(pk=signing_service_pk) + with NamedTemporaryFile(mode="wb", dir=".", delete=False) as final_package: + try: + uploaded_package = PulpTemporaryFile.objects.get(pk=temporary_file_pk) + _save_file(uploaded_package, final_package) + except PulpTemporaryFile.DoesNotExist: + uploaded_package = Upload.objects.get(pk=temporary_file_pk) + _save_upload(uploaded_package, final_package) + + result = package_signing_service.sign( + final_package.name, pubkey_fingerprint=signing_fingerprint + ) + signed_package_path = Path(result["deb_package"]) + if not signed_package_path.exists(): + raise Exception(f"Signing script did not create the signed package: {result}") + artifact = Artifact.init_and_validate(str(signed_package_path)) + artifact.save() + resource = CreatedResource(content_object=artifact) + resource.save() + uploaded_package.delete() + # Create Package content + data["artifact"] = get_url(artifact) + # The Package serializer validation method have two branches: the signing and non-signing. + # Here, the package is already signed, so we need to update the context for a proper validation. + context["sign_package"] = False + # The request data is immutable when there's an upload, so we can't delete the upload out of the + # request data like we do for a file. Instead, we'll delete it here. + if "upload" in data: + del data["upload"] + general_create(app_label, serializer_name, data=data, context=context, *args, **kwargs) diff --git a/pulp_deb/app/viewsets/content.py b/pulp_deb/app/viewsets/content.py index ba2908e32..981f46e4f 100644 --- a/pulp_deb/app/viewsets/content.py +++ b/pulp_deb/app/viewsets/content.py @@ -1,8 +1,10 @@ from gettext import gettext as _ # noqa from django_filters import Filter -from pulpcore.plugin.models import Repository, RepositoryVersion +from pulpcore.plugin.models import Repository, RepositoryVersion, PulpTemporaryFile +from pulpcore.plugin.serializers import AsyncOperationResponseSerializer from pulpcore.plugin.serializers.content import ValidationError +from pulpcore.plugin.tasking import dispatch from pulpcore.plugin.viewsets import ( NAME_FILTER_OPTIONS, ContentFilter, @@ -10,9 +12,16 @@ NamedModelViewSet, NoArtifactContentViewSet, SingleArtifactContentUploadViewSet, + OperationPostponedResponse, ) +from pulp_deb.app.constants import ( + PACKAGE_UPLOAD_DEFAULT_DISTRIBUTION, +) + +from drf_spectacular.utils import extend_schema from pulp_deb.app import models, serializers +from pulp_deb.app.tasks import signing as deb_sign class GenericContentFilter(ContentFilter): @@ -257,6 +266,62 @@ class PackageViewSet(SingleArtifactContentUploadViewSet): "queryset_scoping": {"function": "scope_queryset"}, } + @extend_schema( + description="Trigger an asynchronous task to create an DEB package," + "optionally create new repository version.", + responses={202: AsyncOperationResponseSerializer}, + ) + def create(self, request): + # validation decides if we want to sign and set that in the context space + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + if serializer.context["sign_package"] is False: + return super().create(request) + + # signing case + validated_data = serializer.validated_data + signing_service_pk = validated_data["repository"].package_signing_service.pk + distribution = ( + validated_data.pop("distribution", None) + if "distribution" in validated_data + else PACKAGE_UPLOAD_DEFAULT_DISTRIBUTION + ) + signing_fingerprint = validated_data["repository"].release_package_signing_fingerprint( + distribution + ) + if "file" in validated_data: + request.data.pop("file") + temp_uploaded_file = validated_data["file"] + pulp_temp_file = PulpTemporaryFile(file=temp_uploaded_file.temporary_file_path()) + pulp_temp_file.save() + else: + pulp_temp_file = validated_data["upload"] + + # dispatch signing task + pulp_temp_file.save() + task_args = { + "app_label": self.queryset.model._meta.app_label, + "serializer_name": serializer.__class__.__name__, + "signing_service_pk": signing_service_pk, + "signing_fingerprint": signing_fingerprint, + "temporary_file_pk": pulp_temp_file.pk, + } + task_payload = {k: v for k, v in request.data.items()} + task_exclusive = [ + serializer.validated_data.get("upload"), + serializer.validated_data.get("repository"), + ] + task = dispatch( + deb_sign.sign_and_create, + exclusive_resources=task_exclusive, + args=tuple(task_args.values()), + kwargs={ + "data": task_payload, + "context": self.get_deferred_context(request), + }, + ) + return OperationPostponedResponse(task, request) + class InstallerPackageFilter(ContentFilter): """ diff --git a/pulp_deb/tests/functional/api/test_package_signing.py b/pulp_deb/tests/functional/api/test_package_signing.py new file mode 100644 index 000000000..b1f54c9b1 --- /dev/null +++ b/pulp_deb/tests/functional/api/test_package_signing.py @@ -0,0 +1,250 @@ +from dataclasses import dataclass +import hashlib +import shutil +import uuid + +from pulp_deb.app.models import AptPackageSigningService +import requests +from pulp_deb.tests.functional.utils import get_local_package_absolute_path +import pytest + + +@pytest.mark.parallel +def test_register_rpm_package_signing_service(deb_package_signing_service): + """ + Register a sample rpmsign-based signing service and validate it works. + """ + service = deb_package_signing_service + assert "/api/v3/signing-services/" in service.pulp_href + + +@dataclass +class GPGMetadata: + pubkey: str + fingerprint: str + keyid: str + + +@pytest.fixture +def signing_gpg_extra(signing_gpg_metadata): + """GPG instance with an extra gpg keypair registered.""" + PRIVATE_KEY_PULP_QE = ( + "https://raw.githubusercontent.com/pulp/pulp-fixtures/master/common/GPG-PRIVATE-KEY-pulp-qe" + ) + gpg, fingerprint_a, keyid_a = signing_gpg_metadata + + response_private = requests.get(PRIVATE_KEY_PULP_QE) + response_private.raise_for_status() + import_result = gpg.import_keys(response_private.content) + fingerprint_b = import_result.fingerprints[0] + gpg.trust_keys(fingerprint_b, "TRUST_ULTIMATE") + + pubkey_a = gpg.export_keys(fingerprint_a) + pubkey_b = gpg.export_keys(fingerprint_b) + return ( + gpg, + GPGMetadata(pubkey_a, fingerprint_a, fingerprint_a[-8:]), + GPGMetadata(pubkey_b, fingerprint_b, fingerprint_b[-8:]), + ) + + +@pytest.mark.parallel +def test_sign_package_on_upload( + tmp_path, + download_content_unit, + signing_gpg_extra, + deb_package_signing_service, + deb_package_factory, + deb_repository_factory, + deb_release_factory, + deb_publication_factory, + deb_distribution_factory, +): + """ + Sign an Deb Package with the Package Upload endpoint. + + This ensures different + """ + # Setup RPM tool and package to upload + gpg, gpg_metadata_a, gpg_metadata_b = signing_gpg_extra + fingerprint_set = set([gpg_metadata_a.fingerprint, gpg_metadata_b.fingerprint]) + assert len(fingerprint_set) == 2 + + file_to_upload = shutil.copy( + get_local_package_absolute_path("frigg_1.0_ppc64.deb"), + tmp_path, + ) + with pytest.raises(Exception, match=".*Package is unsigned.*"): + AptPackageSigningService._validate_deb_package( + file_to_upload, gpg_metadata_a.fingerprint, str(tmp_path), gpg + ) + + # Upload Package to Repository + # The same file is uploaded, but signed with different keys each time + for fingerprint in fingerprint_set: + repository = deb_repository_factory( + package_signing_service=deb_package_signing_service.pulp_href, + package_signing_fingerprint=fingerprint, + ) + # create release + deb_package_factory( + file=file_to_upload, + repository=repository.pulp_href, + ) + + # Verify that the final served package is signed + publication = deb_publication_factory(repository) + distribution = deb_distribution_factory(publication=publication) + downloaded_package = tmp_path / "package.deb" + downloaded_package.write_bytes( + download_content_unit(distribution.base_path, "pool/upload/f/frigg/frigg_1.0_ppc64.deb") + ) + AptPackageSigningService._validate_deb_package( + str(downloaded_package), fingerprint, str(tmp_path), gpg + ) + + # Test release override + repository = deb_repository_factory( + package_signing_service=deb_package_signing_service.pulp_href, + package_signing_fingerprint=gpg_metadata_a.fingerprint, + package_signing_fingerprint_release_overrides={"test": gpg_metadata_b.fingerprint}, + ) + + deb_release_factory("test", "test", "test", repository=repository.pulp_href) + deb_release_factory("test2", "test2", "test2", repository=repository.pulp_href) + + deb_package_factory( + file=file_to_upload, + repository=repository.pulp_href, + distribution="test", + ) + + # Verify that the final served package is signed + publication = deb_publication_factory(repository) + distribution = deb_distribution_factory(publication=publication) + downloaded_package = tmp_path / "package.deb" + downloaded_package.write_bytes( + download_content_unit(distribution.base_path, "pool/upload/f/frigg/frigg_1.0_ppc64.deb") + ) + AptPackageSigningService._validate_deb_package( + str(downloaded_package), gpg_metadata_b.fingerprint, str(tmp_path), gpg + ) + + +@pytest.fixture +def pulpcore_chunked_file_factory(tmp_path): + """Returns a function to create chunks from file to be uploaded.""" + + def _create_chunks(upload_path, chunk_size=512): + """Chunks file to be uploaded.""" + chunks = {"chunks": []} + hasher = hashlib.new("sha256") + start = 0 + with open(upload_path, "rb") as f: + data = f.read() + chunks["size"] = len(data) + + while start < len(data): + content = data[start : start + chunk_size] + chunk_file = tmp_path / str(uuid.uuid4()) + hasher.update(content) + chunk_file.write_bytes(content) + content_sha = hashlib.sha256(content).hexdigest() + end = start + len(content) - 1 + chunks["chunks"].append( + (str(chunk_file), f"bytes {start}-{end}/{chunks['size']}", content_sha) + ) + start += len(content) + chunks["digest"] = hasher.hexdigest() + return chunks + + return _create_chunks + + +@pytest.fixture +def pulpcore_upload_chunks( + pulpcore_bindings, + gen_object_with_cleanup, + monitor_task, +): + """Upload file in chunks.""" + + def _upload_chunks(size, chunks, sha256, include_chunk_sha256=False): + """ + Chunks is a list of tuples in the form of (chunk_filename, "bytes-ranges", optional_sha256). + """ + upload = gen_object_with_cleanup(pulpcore_bindings.UploadsApi, {"size": size}) + + for data in chunks: + kwargs = {"file": data[0], "content_range": data[1], "upload_href": upload.pulp_href} + if include_chunk_sha256: + if len(data) != 3: + raise Exception(f"Chunk didn't include its sha256: {data}") + kwargs["sha256"] = data[2] + + pulpcore_bindings.UploadsApi.update(**kwargs) + + return upload + + yield _upload_chunks + + +def test_sign_chunked_package_on_upload( + tmp_path, + download_content_unit, + signing_gpg_extra, + deb_package_signing_service, + deb_package_factory, + deb_repository_factory, + deb_publication_factory, + deb_distribution_factory, + pulpcore_upload_chunks, + pulpcore_chunked_file_factory, +): + """ + Sign an Deb Package with the Package Upload endpoint. + + This ensures different + """ + # Setup RPM tool and package to upload + gpg, gpg_metadata_a, gpg_metadata_b = signing_gpg_extra + fingerprint_set = set([gpg_metadata_a.fingerprint, gpg_metadata_b.fingerprint]) + assert len(fingerprint_set) == 2 + + file_to_upload = shutil.copy( + get_local_package_absolute_path("frigg_1.0_ppc64.deb"), + tmp_path, + ) + with pytest.raises(Exception, match=".*Package is unsigned.*"): + AptPackageSigningService._validate_deb_package( + file_to_upload, gpg_metadata_a.fingerprint, str(tmp_path), gpg + ) + + # Upload Package to Repository + # The same file is uploaded, but signed with different keys each time + for fingerprint in fingerprint_set: + repository = deb_repository_factory( + package_signing_service=deb_package_signing_service.pulp_href, + package_signing_fingerprint=fingerprint, + ) + file_chunks_data = pulpcore_chunked_file_factory(file_to_upload) + size = file_chunks_data["size"] + chunks = file_chunks_data["chunks"] + sha256 = file_chunks_data["digest"] + upload = pulpcore_upload_chunks(size, chunks, sha256, include_chunk_sha256=True) + # create release + deb_package_factory( + upload=upload.pulp_href, + repository=repository.pulp_href, + ) + + # Verify that the final served package is signed + publication = deb_publication_factory(repository) + distribution = deb_distribution_factory(publication=publication) + downloaded_package = tmp_path / "package.deb" + downloaded_package.write_bytes( + download_content_unit(distribution.base_path, "pool/upload/f/frigg/frigg_1.0_ppc64.deb") + ) + AptPackageSigningService._validate_deb_package( + str(downloaded_package), fingerprint, str(tmp_path), gpg + ) diff --git a/pulp_deb/tests/functional/conftest.py b/pulp_deb/tests/functional/conftest.py index 65aabe973..c6b87d84e 100644 --- a/pulp_deb/tests/functional/conftest.py +++ b/pulp_deb/tests/functional/conftest.py @@ -1,3 +1,4 @@ +import json import os import re import stat @@ -32,7 +33,10 @@ PublicationsVerbatimApi, ) -from pulp_deb.tests.functional.constants import DEB_SIGNING_SCRIPT_STRING +from pulp_deb.tests.functional.constants import ( + DEB_PACKAGE_SIGNING_SCRIPT_STRING, + DEB_SIGNING_SCRIPT_STRING, +) from pulp_deb.tests.functional.utils import gen_deb_remote, gen_repo @@ -687,3 +691,107 @@ def _deb_domain_factory(name=None): return gen_object_with_cleanup(pulpcore_bindings.DomainsApi, body) return _deb_domain_factory + + +@pytest.fixture(scope="session") +def package_signing_script_path(signing_script_temp_dir, signing_gpg_homedir_path): + signing_script_file = signing_script_temp_dir / "sign-deb-package.sh" + signing_script_file.write_text( + DEB_PACKAGE_SIGNING_SCRIPT_STRING.replace("HOMEDIRHERE", str(signing_gpg_homedir_path)) + ) + + signing_script_file.chmod(0o755) + + return signing_script_file + + +@pytest.fixture(scope="session") +def signing_script_temp_dir(tmp_path_factory): + return tmp_path_factory.mktemp("sigining_script_dir") + + +@pytest.fixture(scope="session") +def signing_gpg_homedir_path(tmp_path_factory): + return tmp_path_factory.mktemp("gpghome") + + +@pytest.fixture +def sign_with_deb_package_signing_service(package_signing_script_path, signing_gpg_metadata): + """ + Runs the test signing script manually, locally, and returns the signature file produced. + """ + + def _sign_with_deb_package_signing_service(filename): + env = {"PULP_SIGNING_KEY_FINGERPRINT": signing_gpg_metadata[1]} + cmd = (package_signing_script_path, filename) + completed_process = subprocess.run( + cmd, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + if completed_process.returncode != 0: + raise RuntimeError(str(completed_process.stderr)) + + try: + return_value = json.loads(completed_process.stdout) + except json.JSONDecodeError: + raise RuntimeError("The signing script did not return valid JSON!") + + return return_value + + return _sign_with_deb_package_signing_service + + +@pytest.fixture(scope="session") +def _deb_package_signing_service_name( + bindings_cfg, + package_signing_script_path, + signing_gpg_metadata, + signing_gpg_homedir_path, + pytestconfig, +): + service_name = str(uuid.uuid4()) + gpg, fingerprint, keyid = signing_gpg_metadata + + cmd = ( + "pulpcore-manager", + "add-signing-service", + service_name, + str(package_signing_script_path), + fingerprint, + "--class", + "deb:AptPackageSigningService", + "--gnupghome", + str(signing_gpg_homedir_path), + ) + completed_process = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + assert completed_process.returncode == 0 + + yield service_name + + cmd = ( + "pulpcore-manager", + "remove-signing-service", + service_name, + "--class", + "deb:AptPackageSigningService", + ) + subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + +@pytest.fixture +def deb_package_signing_service(_deb_package_signing_service_name, pulpcore_bindings): + return pulpcore_bindings.SigningServicesApi.list( + name=_deb_package_signing_service_name + ).results[0] diff --git a/pulp_deb/tests/functional/constants.py b/pulp_deb/tests/functional/constants.py index 15bc2b092..3d782409d 100644 --- a/pulp_deb/tests/functional/constants.py +++ b/pulp_deb/tests/functional/constants.py @@ -461,3 +461,24 @@ def _clean_dict(d): } \ } """ + +DEB_PACKAGE_SIGNING_SCRIPT_STRING = r"""#!/usr/bin/env bash +export GNUPGHOME="HOMEDIRHERE" +GPG_NAME="${PULP_SIGNING_KEY_FINGERPRINT}" + +# Sign the package without using debsigs so this can run on rpm-based distros +tmpdir=$(mktemp -d) +ctrl=$(ar t "$1" | grep -m1 '^control\.tar\.') +data=$(ar t "$1" | grep -m1 '^data\.tar\.') +ar p "$1" debian-binary "$ctrl" "$data" | \ + gpg --openpgp --detach-sign --default-key "$GPG_NAME" > "$tmpdir/_gpgorigin" +ar r "$1" "$tmpdir/_gpgorigin" >/dev/null + +# Check the exit status +STATUS=$? +if [[ ${STATUS} -eq 0 ]]; then + echo {\"deb_package\": \"$1\"} +else + exit ${STATUS} +fi +""" From 4b6b41f62643cc58864be9d261094db8cd6106bd Mon Sep 17 00:00:00 2001 From: David Davis Date: Mon, 29 Dec 2025 20:07:57 +0000 Subject: [PATCH 2/8] Sign packages when modifying repo content Assisted By: GPT-5.1-Codex fixes #1300 --- docs/user/guides/sign_packages.md | 4 +- .../0036_add_deb_package_signing_result.py | 44 ++++ pulp_deb/app/models/signing_service.py | 56 +++-- pulp_deb/app/tasks/__init__.py | 1 + pulp_deb/app/tasks/signing.py | 129 ++++++++++-- pulp_deb/app/viewsets/content.py | 4 +- pulp_deb/app/viewsets/repository.py | 23 +- .../functional/api/test_package_signing.py | 199 +++++++++++++++++- pulp_deb/tests/functional/conftest.py | 22 ++ pyproject.toml | 2 +- 10 files changed, 436 insertions(+), 48 deletions(-) create mode 100644 pulp_deb/app/migrations/0036_add_deb_package_signing_result.py diff --git a/docs/user/guides/sign_packages.md b/docs/user/guides/sign_packages.md index 2d317547c..8a002e97c 100644 --- a/docs/user/guides/sign_packages.md +++ b/docs/user/guides/sign_packages.md @@ -1,8 +1,8 @@ # Sign Debian Packages -Sign a Debian package using a registered APT package signing service. +Sign a Debian package using a registered package signing service. -Currently, only on-upload signing is supported. +Currently, only signing on upload and when modifying a repo's content are supported. ## On Upload diff --git a/pulp_deb/app/migrations/0036_add_deb_package_signing_result.py b/pulp_deb/app/migrations/0036_add_deb_package_signing_result.py new file mode 100644 index 000000000..91bb01b30 --- /dev/null +++ b/pulp_deb/app/migrations/0036_add_deb_package_signing_result.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.27 on 2025-12-29 19:23 + +from django.db import migrations, models +import django.db.models.deletion +import django_lifecycle.mixins +import pulpcore.app.models.base + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0145_domainize_import_export"), + ("deb", "0035_package_signing"), + ] + + operations = [ + migrations.CreateModel( + name="DebPackageSigningResult", + fields=[ + ( + "pulp_id", + models.UUIDField( + default=pulpcore.app.models.base.pulp_uuid, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("pulp_created", models.DateTimeField(auto_now_add=True)), + ("pulp_last_updated", models.DateTimeField(auto_now=True, null=True)), + ("sha256", models.TextField(max_length=64)), + ("package_signing_fingerprint", models.TextField(max_length=40)), + ( + "result", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="core.content" + ), + ), + ], + options={ + "unique_together": {("sha256", "package_signing_fingerprint")}, + }, + bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model), + ), + ] diff --git a/pulp_deb/app/models/signing_service.py b/pulp_deb/app/models/signing_service.py index a998503c3..89af9803f 100644 --- a/pulp_deb/app/models/signing_service.py +++ b/pulp_deb/app/models/signing_service.py @@ -7,8 +7,21 @@ from typing import Optional import gnupg +from django.db import models -from pulpcore.plugin.models import SigningService +from pulpcore.plugin.models import BaseModel, Content, SigningService + + +class UnsignedPackage(Exception): + """Raised when a deb package is unsigned and has no _gpgorigin signature.""" + + +class InvalidSignature(Exception): + """When GPG verification fails due to the signature (NO_PUBKEY, EXPSIG, etc).""" + + +class FingerprintMismatch(Exception): + """Raised when a deb package is signed with a different key fingerprint.""" def prepare_gpg(temp_directory_name, public_key, pubkey_fingerprint): @@ -213,22 +226,22 @@ def validate(self): except KeyError: raise Exception(f"Malformed output from signing script: {return_value}") - # Prepare GPG: + self.validate_signature(signed_deb) + + def validate_signature(self, deb_package_path: str): + """Validate that the deb package is signed with our pubkey.""" + with tempfile.TemporaryDirectory() as temp_directory_name: gpg = prepare_gpg(temp_directory_name, self.public_key, self.pubkey_fingerprint) - self._validate_deb_package( - signed_deb, self.pubkey_fingerprint, temp_directory_name, gpg + self._check_deb_signature( + deb_package_path, self.pubkey_fingerprint, temp_directory_name, gpg ) @staticmethod - def _validate_deb_package( - deb_package_path: str, pubkey_fingerprint: str, temp_directory_name: str, gpg: gnupg.GPG + def _check_deb_signature( + deb_package_path: str, fingerprint: str, temp_directory_name: str, gpg: gnupg.GPG ): - """ - Validate that the deb package at @deb_package_path is correctly signed. - - This is a placeholder for future validation logic if needed. - """ + """Check the deb package signature matches the provided fingerprint.""" # unpack the archive cmd = ["ar", "x", deb_package_path] res = subprocess.run(cmd, cwd=temp_directory_name, capture_output=True) @@ -248,16 +261,29 @@ def _validate_deb_package( # verify combined data with _gpgorigin detached signature gpgorigin_path = temp_dir / "_gpgorigin" if not gpgorigin_path.exists(): - raise Exception( + raise UnsignedPackage( f"_gpgorigin file not found for {deb_package_path}. Package is unsigned." ) with gpgorigin_path.open("rb") as gpgorigin: verified = gpg.verify_file(gpgorigin, str(temp_dir / "combined")) if not verified.valid: - raise Exception( + raise InvalidSignature( f"GPG Verification of the signed package {deb_package_path} failed!" ) - if verified.pubkey_fingerprint != pubkey_fingerprint: - raise Exception( + if verified.pubkey_fingerprint != fingerprint: + raise FingerprintMismatch( f"'{deb_package_path}' appears to have been signed using the wrong key!" ) + + +class DebPackageSigningResult(BaseModel): + """ + A model used for storing the result of signing a deb package. + """ + + sha256 = models.TextField(max_length=64) + package_signing_fingerprint = models.TextField(max_length=40) + result = models.ForeignKey(Content, on_delete=models.CASCADE) + + class Meta: + unique_together = ("sha256", "package_signing_fingerprint") diff --git a/pulp_deb/app/tasks/__init__.py b/pulp_deb/app/tasks/__init__.py index e18e3af50..660bb1160 100644 --- a/pulp_deb/app/tasks/__init__.py +++ b/pulp_deb/app/tasks/__init__.py @@ -2,3 +2,4 @@ from .publishing import publish, publish_verbatim from .synchronizing import synchronize from .copy import copy_content +from .signing import sign_and_create, signed_add_and_remove diff --git a/pulp_deb/app/tasks/signing.py b/pulp_deb/app/tasks/signing.py index 5d0317739..fa97eb264 100644 --- a/pulp_deb/app/tasks/signing.py +++ b/pulp_deb/app/tasks/signing.py @@ -1,11 +1,25 @@ from pathlib import Path from tempfile import NamedTemporaryFile -from pulpcore.plugin.models import Upload, UploadChunk, Artifact, CreatedResource, PulpTemporaryFile -from pulpcore.plugin.tasking import general_create +from pulpcore.plugin.models import ( + Upload, + UploadChunk, + Artifact, + ContentArtifact, + CreatedResource, + PulpTemporaryFile, +) +from pulpcore.plugin.tasking import add_and_remove, general_create from pulpcore.plugin.util import get_url -from pulp_deb.app.models.signing_service import AptPackageSigningService +from pulp_deb.app.models.signing_service import ( + AptPackageSigningService, + DebPackageSigningResult, + FingerprintMismatch, + InvalidSignature, + UnsignedPackage, +) +from pulp_deb.app.models import AptRepository, Package, PackageReleaseComponent def _save_file(fileobj, final_package): @@ -22,6 +36,18 @@ def _save_upload(uploadobj, final_package): final_package.flush() +def _sign_file(package_file, signing_service, signing_fingerprint): + result = signing_service.sign(package_file.name, pubkey_fingerprint=signing_fingerprint) + signed_package_path = Path(result["deb_package"]) + if not signed_package_path.exists(): + raise Exception(f"Signing script did not create the signed package: {result}") + artifact = Artifact.init_and_validate(str(signed_package_path)) + artifact.save() + resource = CreatedResource(content_object=artifact) + resource.save() + return artifact + + def sign_and_create( app_label, serializer_name, @@ -43,16 +69,7 @@ def sign_and_create( uploaded_package = Upload.objects.get(pk=temporary_file_pk) _save_upload(uploaded_package, final_package) - result = package_signing_service.sign( - final_package.name, pubkey_fingerprint=signing_fingerprint - ) - signed_package_path = Path(result["deb_package"]) - if not signed_package_path.exists(): - raise Exception(f"Signing script did not create the signed package: {result}") - artifact = Artifact.init_and_validate(str(signed_package_path)) - artifact.save() - resource = CreatedResource(content_object=artifact) - resource.save() + artifact = _sign_file(final_package, package_signing_service, signing_fingerprint) uploaded_package.delete() # Create Package content data["artifact"] = get_url(artifact) @@ -64,3 +81,89 @@ def sign_and_create( if "upload" in data: del data["upload"] general_create(app_label, serializer_name, data=data, context=context, *args, **kwargs) + + +def _update_content_units(content_units, old_pk, new_pk): + while str(old_pk) in content_units: + content_units.remove(str(old_pk)) + + if str(new_pk) not in content_units: + content_units.append(str(new_pk)) + + # Repoint PackageReleaseComponents included in this transaction to the new package. + for prc in PackageReleaseComponent.objects.filter(pk__in=content_units, package_id=old_pk): + new_prc, _ = PackageReleaseComponent.objects.get_or_create( + release_component=prc.release_component, + package_id=new_pk, + _pulp_domain=prc._pulp_domain, + ) + + while str(prc.pk) in content_units: + content_units.remove(str(prc.pk)) + + if str(new_prc.pk) not in content_units: + content_units.append(str(new_prc.pk)) + + +def _check_package_signature(repository, package_path): + try: + repository.package_signing_service.validate_signature(package_path) + except (UnsignedPackage, InvalidSignature, FingerprintMismatch): + return False + + return True + + +def signed_add_and_remove( + repository_pk, add_content_units, remove_content_units, base_version_pk=None +): + repo = AptRepository.objects.get(pk=repository_pk) + + if repo.package_signing_service: + # sign each package and replace it in the add_content_units list + for package in Package.objects.filter(pk__in=add_content_units): + content_artifact = package.contentartifact_set.first() + artifact_obj = content_artifact.artifact + package_id = package.pk + + with NamedTemporaryFile(mode="wb", dir=".", delete=False) as final_package: + artifact_file = artifact_obj.file + _save_file(artifact_file, final_package) + + # check if the package is already signed with our fingerprint + if _check_package_signature(repo, final_package.name): + continue + + # check if the package has been signed in the past with our fingerprint + if existing_result := DebPackageSigningResult.objects.filter( + sha256=content_artifact.artifact.sha256, + package_signing_fingerprint=repo.package_signing_fingerprint, + ).first(): + _update_content_units(add_content_units, package_id, existing_result.result.pk) + continue + + # create a new signed version of the package + artifact = _sign_file( + final_package, repo.package_signing_service, repo.package_signing_fingerprint + ) + signed_package = package + signed_package.pk = None + signed_package.pulp_id = None + signed_package.sha256 = artifact.sha256 + signed_package.save() + ContentArtifact.objects.create( + artifact=artifact, + content=signed_package, + relative_path=content_artifact.relative_path, + ) + DebPackageSigningResult.objects.create( + sha256=artifact_obj.sha256, + package_signing_fingerprint=repo.package_signing_fingerprint, + result=signed_package, + ) + + _update_content_units(add_content_units, package_id, signed_package.pk) + resource = CreatedResource(content_object=signed_package) + resource.save() + + return add_and_remove(repository_pk, add_content_units, remove_content_units, base_version_pk) diff --git a/pulp_deb/app/viewsets/content.py b/pulp_deb/app/viewsets/content.py index 981f46e4f..3247d6f6c 100644 --- a/pulp_deb/app/viewsets/content.py +++ b/pulp_deb/app/viewsets/content.py @@ -21,7 +21,7 @@ from drf_spectacular.utils import extend_schema from pulp_deb.app import models, serializers -from pulp_deb.app.tasks import signing as deb_sign +from pulp_deb.app.tasks import sign_and_create class GenericContentFilter(ContentFilter): @@ -312,7 +312,7 @@ def create(self, request): serializer.validated_data.get("repository"), ] task = dispatch( - deb_sign.sign_and_create, + sign_and_create, exclusive_resources=task_exclusive, args=tuple(task_args.values()), kwargs={ diff --git a/pulp_deb/app/viewsets/repository.py b/pulp_deb/app/viewsets/repository.py index abdca5db8..1d7ce99d1 100644 --- a/pulp_deb/app/viewsets/repository.py +++ b/pulp_deb/app/viewsets/repository.py @@ -8,6 +8,7 @@ from pulp_deb.app.models.content.content import Package from pulp_deb.app.models.content.structure_content import PackageReleaseComponent from pulp_deb.app.serializers import AptRepositorySyncURLSerializer +from pulp_deb.app.tasks import signed_add_and_remove from pulpcore.plugin.util import extract_pk, get_url from pulpcore.plugin.actions import ModifyRepositoryActionMixin @@ -15,7 +16,7 @@ AsyncOperationResponseSerializer, RepositoryAddRemoveContentSerializer, ) -from pulpcore.plugin.models import RepositoryVersion +from pulpcore.plugin.models import ContentArtifact, RepositoryVersion from pulpcore.plugin.tasking import dispatch from pulpcore.plugin.viewsets import ( OperationPostponedResponse, @@ -29,6 +30,8 @@ class AptModifyRepositoryActionMixin(ModifyRepositoryActionMixin): + modify_task = signed_add_and_remove + @extend_schema( description="Trigger an asynchronous task to create a new repository version.", summary="Modify Repository Content", @@ -37,13 +40,25 @@ class AptModifyRepositoryActionMixin(ModifyRepositoryActionMixin): @action(detail=True, methods=["post"], serializer_class=RepositoryAddRemoveContentSerializer) def modify(self, request, pk): remove_content_units = request.data.get("remove_content_units", []) - package_hrefs = [href for href in remove_content_units if "/packages/" in href] + remove_package_hrefs = [href for href in remove_content_units if "/packages/" in href] - if package_hrefs: - prc_hrefs = self._get_matching_prc_hrefs(package_hrefs) + if remove_package_hrefs: + prc_hrefs = self._get_matching_prc_hrefs(remove_package_hrefs) remove_content_units.extend(prc_hrefs) request.data["remove_content_units"] = remove_content_units + add_content_units = request.data.get("add_content_units", []) + package_ids = [extract_pk(href) for href in add_content_units if "/packages/" in href] + repository = self.get_object() + if add_content_units and repository.package_signing_service: + ondemand_ca = ContentArtifact.objects.filter( + content_id__in=package_ids, artifact__isnull=True + ) + if ondemand_ca.count() > 0: + raise DRFValidationError( + _("Cannot add on-demand content to repo with set package signing service.") + ) + return super().modify(request, pk) def _get_matching_prc_hrefs(self, package_hrefs): diff --git a/pulp_deb/tests/functional/api/test_package_signing.py b/pulp_deb/tests/functional/api/test_package_signing.py index b1f54c9b1..80465d989 100644 --- a/pulp_deb/tests/functional/api/test_package_signing.py +++ b/pulp_deb/tests/functional/api/test_package_signing.py @@ -3,16 +3,17 @@ import shutil import uuid -from pulp_deb.app.models import AptPackageSigningService +import pytest import requests +from pulpcore.client.pulp_deb.exceptions import ApiException +from pulp_deb.app.models import AptPackageSigningService from pulp_deb.tests.functional.utils import get_local_package_absolute_path -import pytest @pytest.mark.parallel -def test_register_rpm_package_signing_service(deb_package_signing_service): +def test_register_deb_package_signing_service(deb_package_signing_service): """ - Register a sample rpmsign-based signing service and validate it works. + Register a sample deb signing service and validate it works. """ service = deb_package_signing_service assert "/api/v3/signing-services/" in service.pulp_href @@ -48,6 +49,44 @@ def signing_gpg_extra(signing_gpg_metadata): ) +@pytest.fixture +def add_package_to_repo( + deb_modify_repository, + deb_release_component_factory, + deb_package_release_component_factory, + monitor_task, +): + def _add_package_to_repo( + repository, + package, + release_component=None, + prc=None, + ): + if not release_component: + release_component = deb_release_component_factory( + distribution=str(uuid.uuid4()), component="main" + ).pulp_href + if not prc: + prc = deb_package_release_component_factory( + package=package, + release_component=release_component, + ).pulp_href + task = deb_modify_repository( + repository, + { + "add_content_units": [ + package, + release_component, + prc, + ] + }, + ) + monitor_task(task.pulp_href) + return release_component, prc + + return _add_package_to_repo + + @pytest.mark.parallel def test_sign_package_on_upload( tmp_path, @@ -65,7 +104,7 @@ def test_sign_package_on_upload( This ensures different """ - # Setup RPM tool and package to upload + # Setup gpg and package to upload gpg, gpg_metadata_a, gpg_metadata_b = signing_gpg_extra fingerprint_set = set([gpg_metadata_a.fingerprint, gpg_metadata_b.fingerprint]) assert len(fingerprint_set) == 2 @@ -75,7 +114,7 @@ def test_sign_package_on_upload( tmp_path, ) with pytest.raises(Exception, match=".*Package is unsigned.*"): - AptPackageSigningService._validate_deb_package( + AptPackageSigningService._check_deb_signature( file_to_upload, gpg_metadata_a.fingerprint, str(tmp_path), gpg ) @@ -99,7 +138,7 @@ def test_sign_package_on_upload( downloaded_package.write_bytes( download_content_unit(distribution.base_path, "pool/upload/f/frigg/frigg_1.0_ppc64.deb") ) - AptPackageSigningService._validate_deb_package( + AptPackageSigningService._check_deb_signature( str(downloaded_package), fingerprint, str(tmp_path), gpg ) @@ -126,7 +165,7 @@ def test_sign_package_on_upload( downloaded_package.write_bytes( download_content_unit(distribution.base_path, "pool/upload/f/frigg/frigg_1.0_ppc64.deb") ) - AptPackageSigningService._validate_deb_package( + AptPackageSigningService._check_deb_signature( str(downloaded_package), gpg_metadata_b.fingerprint, str(tmp_path), gpg ) @@ -206,7 +245,6 @@ def test_sign_chunked_package_on_upload( This ensures different """ - # Setup RPM tool and package to upload gpg, gpg_metadata_a, gpg_metadata_b = signing_gpg_extra fingerprint_set = set([gpg_metadata_a.fingerprint, gpg_metadata_b.fingerprint]) assert len(fingerprint_set) == 2 @@ -216,7 +254,7 @@ def test_sign_chunked_package_on_upload( tmp_path, ) with pytest.raises(Exception, match=".*Package is unsigned.*"): - AptPackageSigningService._validate_deb_package( + AptPackageSigningService._check_deb_signature( file_to_upload, gpg_metadata_a.fingerprint, str(tmp_path), gpg ) @@ -245,6 +283,145 @@ def test_sign_chunked_package_on_upload( downloaded_package.write_bytes( download_content_unit(distribution.base_path, "pool/upload/f/frigg/frigg_1.0_ppc64.deb") ) - AptPackageSigningService._validate_deb_package( + AptPackageSigningService._check_deb_signature( str(downloaded_package), fingerprint, str(tmp_path), gpg ) + + +def test_signed_repo_modify( + tmp_path, + add_package_to_repo, + download_content_unit, + signing_gpg_metadata, + deb_package_signing_service, + deb_repository_factory, + deb_package_factory, + deb_publication_factory, + deb_distribution_factory, + apt_repository_api, + apt_package_api, +): + """Ensure packages added via modify are signed before distribution.""" + gpg, fingerprint, _ = signing_gpg_metadata + + file_to_upload = shutil.copy( + get_local_package_absolute_path("frigg_1.0_ppc64.deb"), + tmp_path, + ) + with pytest.raises(Exception, match=".*Package is unsigned.*"): + AptPackageSigningService._check_deb_signature( + file_to_upload, fingerprint, str(tmp_path), gpg + ) + + repository = deb_repository_factory( + package_signing_service=deb_package_signing_service.pulp_href, + package_signing_fingerprint=fingerprint, + ) + + created_package = deb_package_factory(file=file_to_upload) + release_component, prc = add_package_to_repo(repository, created_package.pulp_href) + + # Verify that the final served package is signed + publication = deb_publication_factory(repository) + distribution = deb_distribution_factory(publication=publication) + downloaded_package = tmp_path / "package.deb" + downloaded_package.write_bytes( + download_content_unit(distribution.base_path, "pool/main/f/frigg/frigg_1.0_ppc64.deb") + ) + AptPackageSigningService._check_deb_signature( + str(downloaded_package), fingerprint, str(tmp_path), gpg + ) + + repository = apt_repository_api.read(repository.pulp_href) + signed_package_href = ( + apt_package_api.list(repository_version=repository.latest_version_href).results[0].pulp_href + ) + + # attempt to add the package to the repo a second time (should produce same package href) + add_package_to_repo(repository, created_package.pulp_href, release_component, prc) + + repository = apt_repository_api.read(repository.pulp_href) + results = apt_package_api.list(repository_version=repository.latest_version_href).results + + assert [signed_package_href] == [pkg.pulp_href for pkg in results] + + +def test_already_signed_package( + tmp_path, + add_package_to_repo, + signing_gpg_metadata, + deb_package_signing_service, + deb_repository_factory, + deb_package_factory, + apt_repository_api, + apt_package_api, +): + """Don't sign a package if it's already signed with our key.""" + + _, fingerprint, _ = signing_gpg_metadata + + repo_one = deb_repository_factory( + package_signing_service=deb_package_signing_service.pulp_href, + package_signing_fingerprint=fingerprint, + ) + repo_two = deb_repository_factory( + package_signing_service=deb_package_signing_service.pulp_href, + package_signing_fingerprint=fingerprint, + ) + + file_to_upload = shutil.copy( + get_local_package_absolute_path("frigg_1.0_ppc64.deb"), + tmp_path, + ) + created_package = deb_package_factory(file=file_to_upload) + + add_package_to_repo(repo_one, created_package.pulp_href) + + repo_one = apt_repository_api.read(repo_one.pulp_href) + repo_one_packages = apt_package_api.list( + repository_version=repo_one.latest_version_href + ).results + assert len(repo_one_packages) == 1 + signed_package_href = repo_one_packages[0].pulp_href + + add_package_to_repo(repo_two, signed_package_href) + + repo_two = apt_repository_api.read(repo_two.pulp_href) + repo_two_packages = apt_package_api.list( + repository_version=repo_two.latest_version_href + ).results + + # The same signed package should be reused between repositories + assert [r.pulp_href for r in repo_two_packages] == [signed_package_href] + + +def test_signed_repo_rejects_on_demand_content( + monitor_task, + pulpcore_bindings, + add_package_to_repo, + deb_init_and_sync, + deb_package_signing_service, + signing_gpg_metadata, + deb_repository_factory, + apt_package_api, +): + """Ensure modify rejects on-demand content when signing is enabled.""" + monitor_task(pulpcore_bindings.OrphansCleanupApi.cleanup({"orphan_protection_time": 0}).task) + + source_repo, *_ = deb_init_and_sync(remote_args={"policy": "on_demand"}) + _, fingerprint, _ = signing_gpg_metadata + destination_repo = deb_repository_factory( + package_signing_service=deb_package_signing_service.pulp_href, + package_signing_fingerprint=fingerprint, + ) + + packages = apt_package_api.list(repository_version=source_repo.latest_version_href).results + package_href = packages[0].pulp_href + + with pytest.raises(ApiException) as exc: + add_package_to_repo( + destination_repo, + package_href, + ) + + assert "Cannot add on-demand content" in exc.value.body diff --git a/pulp_deb/tests/functional/conftest.py b/pulp_deb/tests/functional/conftest.py index c6b87d84e..42f1161ae 100644 --- a/pulp_deb/tests/functional/conftest.py +++ b/pulp_deb/tests/functional/conftest.py @@ -25,6 +25,7 @@ DebAptAlternateContentSource, DebAptPublication, DebCopyApi, + DebPackageReleaseComponent, DebRelease, DebReleaseArchitecture, DebReleaseComponent, @@ -214,6 +215,27 @@ def _deb_release_component_factory(component, distribution, **kwargs): return _deb_release_component_factory +@pytest.fixture(scope="class") +def deb_package_release_component_factory( + apt_package_release_components_api, gen_object_with_cleanup +): + """Fixture that generates source release comopnent with cleanup.""" + + def _deb_package_release_component_factory(package, release_component, **kwargs): + """Create an APT PackageReleaseComponent. + + :returns: The created SourceReleaseComponent. + """ + package_release_component_object = DebPackageReleaseComponent( + package=package, release_component=release_component, **kwargs + ) + return gen_object_with_cleanup( + apt_package_release_components_api, package_release_component_object + ) + + return _deb_package_release_component_factory + + @pytest.fixture(scope="class") def deb_release_architecture_factory(apt_release_architecture_api, gen_object_with_cleanup): """Fixture that generates deb package with cleanup.""" diff --git a/pyproject.toml b/pyproject.toml index 4ec0d4f7e..0942dcf06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ requires-python = ">=3.11" dependencies = [ # All things django and asyncio are deliberately left to pulpcore # Example transitive requirements: asgiref, asyncio, aiohttp - "pulpcore>=3.85.0,<3.115", + "pulpcore>=3.101.0,<3.115", "python-debian>=0.1.44,<0.2.0", "python-gnupg>=0.5,<0.6", "jsonschema>=4.6,<5.0", From 6f45b6207d70133fc8dc5f5c9902c04ea2a6ce29 Mon Sep 17 00:00:00 2001 From: David Davis Date: Thu, 29 Jan 2026 15:16:15 +0000 Subject: [PATCH 3/8] Add support for release overrides to signed_add_and_remove Add support for release overrides to signed_add_and_remove --- pulp_deb/app/models/repository.py | 23 ++++++++++----- .../app/serializers/repository_serializers.py | 2 +- pulp_deb/app/tasks/signing.py | 29 +++++++++++++++---- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/pulp_deb/app/models/repository.py b/pulp_deb/app/models/repository.py index 967f6cf57..b6a93b5b8 100644 --- a/pulp_deb/app/models/repository.py +++ b/pulp_deb/app/models/repository.py @@ -2,6 +2,7 @@ from gettext import gettext as _ from django.db import models +from django.utils.functional import cached_property from pulpcore.plugin.models import ( AutoAddObjPermsMixin, @@ -16,6 +17,7 @@ from pulpcore.plugin.util import batch_qs, get_domain_pk from pulp_deb.app.models import ( + AptPackageSigningService, AptReleaseSigningService, AptRemote, GenericContent, @@ -31,7 +33,6 @@ SourceIndex, SourcePackage, SourcePackageReleaseComponent, - AptPackageSigningService, ) log = logging.getLogger(__name__) @@ -131,13 +132,21 @@ def release_package_signing_fingerprint(self, release): """ if isinstance(release, Release): release = release.distribution - try: - override = self.package_signing_fingerprint_release_overrides.get( - release_distribution=release - ) - return override.package_signing_fingerprint - except AptRepositoryReleasePackageSigningFingerprintOverride.DoesNotExist: + if not release: return self.package_signing_fingerprint + return self.package_signing_fingerprint_release_overrides_map.get( + release, self.package_signing_fingerprint + ) + + @cached_property + def package_signing_fingerprint_release_overrides_map(self): + """ + Cached mapping of release distributions to package signing fingerprints. + """ + return { + override.release_distribution: override.package_signing_fingerprint + for override in self.package_signing_fingerprint_release_overrides.all() + } def initialize_new_version(self, new_version): """ diff --git a/pulp_deb/app/serializers/repository_serializers.py b/pulp_deb/app/serializers/repository_serializers.py index 4606eaef5..3acc3b270 100644 --- a/pulp_deb/app/serializers/repository_serializers.py +++ b/pulp_deb/app/serializers/repository_serializers.py @@ -43,7 +43,7 @@ def to_representation(self, overrides): class PackageFingerprintOverrideField(serializers.DictField): - child = serializers.CharField(max_length=40) + child = serializers.CharField(max_length=40, allow_blank=True) def to_representation(self, overrides): return {x.release_distribution: x.package_signing_fingerprint for x in overrides.all()} diff --git a/pulp_deb/app/tasks/signing.py b/pulp_deb/app/tasks/signing.py index fa97eb264..18885d548 100644 --- a/pulp_deb/app/tasks/signing.py +++ b/pulp_deb/app/tasks/signing.py @@ -1,6 +1,8 @@ from pathlib import Path from tempfile import NamedTemporaryFile +from django.db.models import Q + from pulpcore.plugin.models import ( Upload, UploadChunk, @@ -21,6 +23,11 @@ ) from pulp_deb.app.models import AptRepository, Package, PackageReleaseComponent +import logging +from gettext import gettext as _ + +log = logging.getLogger(__name__) + def _save_file(fileobj, final_package): with fileobj.file.open() as fd: @@ -37,6 +44,9 @@ def _save_upload(uploadobj, final_package): def _sign_file(package_file, signing_service, signing_fingerprint): + logging.info( + _("Signing package %s with fingerprint %s"), package_file.name, signing_fingerprint + ) result = signing_service.sign(package_file.name, pubkey_fingerprint=signing_fingerprint) signed_package_path = Path(result["deb_package"]) if not signed_package_path.exists(): @@ -120,12 +130,23 @@ def signed_add_and_remove( repo = AptRepository.objects.get(pk=repository_pk) if repo.package_signing_service: + # map packages to releases + prcs = PackageReleaseComponent.objects.filter( + Q(pk__in=add_content_units) | Q(pk__in=repo.content.all()) + ).select_related("package", "release_component") + package_release_map = {prc.package_id: prc.release_component.distribution for prc in prcs} + # sign each package and replace it in the add_content_units list for package in Package.objects.filter(pk__in=add_content_units): content_artifact = package.contentartifact_set.first() artifact_obj = content_artifact.artifact package_id = package.pk + # match the package's release to a fingerprint override if one exists + fingerprint = repo.release_package_signing_fingerprint( + package_release_map.get(package_id) + ) + with NamedTemporaryFile(mode="wb", dir=".", delete=False) as final_package: artifact_file = artifact_obj.file _save_file(artifact_file, final_package) @@ -137,15 +158,13 @@ def signed_add_and_remove( # check if the package has been signed in the past with our fingerprint if existing_result := DebPackageSigningResult.objects.filter( sha256=content_artifact.artifact.sha256, - package_signing_fingerprint=repo.package_signing_fingerprint, + package_signing_fingerprint=fingerprint, ).first(): _update_content_units(add_content_units, package_id, existing_result.result.pk) continue # create a new signed version of the package - artifact = _sign_file( - final_package, repo.package_signing_service, repo.package_signing_fingerprint - ) + artifact = _sign_file(final_package, repo.package_signing_service, fingerprint) signed_package = package signed_package.pk = None signed_package.pulp_id = None @@ -158,7 +177,7 @@ def signed_add_and_remove( ) DebPackageSigningResult.objects.create( sha256=artifact_obj.sha256, - package_signing_fingerprint=repo.package_signing_fingerprint, + package_signing_fingerprint=fingerprint, result=signed_package, ) From deb241c1f2a444132918656cf19e87767dbb3f32 Mon Sep 17 00:00:00 2001 From: David Davis Date: Wed, 18 Feb 2026 15:01:15 +0000 Subject: [PATCH 4/8] Sign packages concurrently when adding to repo Assisted By: Claude Sonnet 4.5 --- pulp_deb/app/settings.py | 1 + pulp_deb/app/tasks/signing.py | 180 ++++++++++++++++++++-------------- 2 files changed, 110 insertions(+), 71 deletions(-) diff --git a/pulp_deb/app/settings.py b/pulp_deb/app/settings.py index 80581f66c..54db5da87 100644 --- a/pulp_deb/app/settings.py +++ b/pulp_deb/app/settings.py @@ -13,3 +13,4 @@ STRUCTURED_EMPTY_REPO_DISTRIBUTION = "default" STRUCTURED_EMPTY_REPO_COMPONENT = "empty" STRUCTURED_EMPTY_REPO_ARCHITECTURES = ["all"] +MAX_PACKAGE_SIGNING_WORKERS = 5 diff --git a/pulp_deb/app/tasks/signing.py b/pulp_deb/app/tasks/signing.py index 18885d548..e0e647b0a 100644 --- a/pulp_deb/app/tasks/signing.py +++ b/pulp_deb/app/tasks/signing.py @@ -1,6 +1,8 @@ +import asyncio from pathlib import Path from tempfile import NamedTemporaryFile +from django.conf import settings from django.db.models import Q from pulpcore.plugin.models import ( @@ -43,12 +45,7 @@ def _save_upload(uploadobj, final_package): final_package.flush() -def _sign_file(package_file, signing_service, signing_fingerprint): - logging.info( - _("Signing package %s with fingerprint %s"), package_file.name, signing_fingerprint - ) - result = signing_service.sign(package_file.name, pubkey_fingerprint=signing_fingerprint) - signed_package_path = Path(result["deb_package"]) +def _create_signed_artifact(signed_package_path, result): if not signed_package_path.exists(): raise Exception(f"Signing script did not create the signed package: {result}") artifact = Artifact.init_and_validate(str(signed_package_path)) @@ -58,6 +55,15 @@ def _sign_file(package_file, signing_service, signing_fingerprint): return artifact +def _sign_file(package_file, signing_service, signing_fingerprint): + logging.info( + _("Signing package %s with fingerprint %s"), package_file.name, signing_fingerprint + ) + result = signing_service.sign(package_file.name, pubkey_fingerprint=signing_fingerprint) + signed_package_path = Path(result["deb_package"]) + return _create_signed_artifact(signed_package_path, result) + + def sign_and_create( app_label, serializer_name, @@ -93,35 +99,66 @@ def sign_and_create( general_create(app_label, serializer_name, data=data, context=context, *args, **kwargs) -def _update_content_units(content_units, old_pk, new_pk): - while str(old_pk) in content_units: - content_units.remove(str(old_pk)) - - if str(new_pk) not in content_units: - content_units.append(str(new_pk)) +def _sign_package(package, signing_service, signing_fingerprint, package_release_map): + """ + Sign a package or reuse an existing signed result. - # Repoint PackageReleaseComponents included in this transaction to the new package. - for prc in PackageReleaseComponent.objects.filter(pk__in=content_units, package_id=old_pk): - new_prc, _ = PackageReleaseComponent.objects.get_or_create( - release_component=prc.release_component, - package_id=new_pk, - _pulp_domain=prc._pulp_domain, - ) + Returns None if already signed with the fingerprint, otherwise a + tuple of (original_package_id, new_package_id, prcs_to_update). + """ + content_artifact = package.contentartifact_set.first() + artifact_obj = content_artifact.artifact + package_id = str(package.pk) - while str(prc.pk) in content_units: - content_units.remove(str(prc.pk)) + with NamedTemporaryFile(mode="wb", dir=".", delete=False) as final_package: + artifact_file = artifact_obj.file + _save_file(artifact_file, final_package) - if str(new_prc.pk) not in content_units: - content_units.append(str(new_prc.pk)) + # check if the package is already signed with our fingerprint + try: + signing_service.validate_signature(final_package.name) + return None + except (UnsignedPackage, InvalidSignature, FingerprintMismatch): + pass + + # Collect PackageReleaseComponents that need to be updated + prcs_to_update = list( + PackageReleaseComponent.objects.filter( + package_id=package_id, _pulp_domain=package._pulp_domain + ) + ) + # check if the package has been signed in the past with our fingerprint + if existing_result := DebPackageSigningResult.objects.filter( + sha256=content_artifact.artifact.sha256, + package_signing_fingerprint=signing_fingerprint, + ).first(): + return (package_id, str(existing_result.result.pk), prcs_to_update) + + # create a new signed version of the package + log.info(f"Signing package {package.name}.") + artifact = _sign_file(final_package, signing_service, signing_fingerprint) + signed_package = package + signed_package.pk = None + signed_package.pulp_id = None + signed_package.sha256 = artifact.sha256 + signed_package.save() + ContentArtifact.objects.create( + artifact=artifact, + content=signed_package, + relative_path=content_artifact.relative_path, + ) + DebPackageSigningResult.objects.create( + sha256=artifact_obj.sha256, + package_signing_fingerprint=signing_fingerprint, + result=signed_package, + ) -def _check_package_signature(repository, package_path): - try: - repository.package_signing_service.validate_signature(package_path) - except (UnsignedPackage, InvalidSignature, FingerprintMismatch): - return False + resource = CreatedResource(content_object=signed_package) + resource.save() + log.info(f"Signed package {package.name}.") - return True + return (package_id, str(signed_package.pk), prcs_to_update) def signed_add_and_remove( @@ -136,53 +173,54 @@ def signed_add_and_remove( ).select_related("package", "release_component") package_release_map = {prc.package_id: prc.release_component.distribution for prc in prcs} - # sign each package and replace it in the add_content_units list + # Prepare package list with their fingerprints + packages = [] for package in Package.objects.filter(pk__in=add_content_units): - content_artifact = package.contentartifact_set.first() - artifact_obj = content_artifact.artifact - package_id = package.pk - # match the package's release to a fingerprint override if one exists fingerprint = repo.release_package_signing_fingerprint( - package_release_map.get(package_id) + package_release_map.get(package.pk) ) - - with NamedTemporaryFile(mode="wb", dir=".", delete=False) as final_package: - artifact_file = artifact_obj.file - _save_file(artifact_file, final_package) - - # check if the package is already signed with our fingerprint - if _check_package_signature(repo, final_package.name): - continue - - # check if the package has been signed in the past with our fingerprint - if existing_result := DebPackageSigningResult.objects.filter( - sha256=content_artifact.artifact.sha256, - package_signing_fingerprint=fingerprint, - ).first(): - _update_content_units(add_content_units, package_id, existing_result.result.pk) - continue - - # create a new signed version of the package - artifact = _sign_file(final_package, repo.package_signing_service, fingerprint) - signed_package = package - signed_package.pk = None - signed_package.pulp_id = None - signed_package.sha256 = artifact.sha256 - signed_package.save() - ContentArtifact.objects.create( - artifact=artifact, - content=signed_package, - relative_path=content_artifact.relative_path, - ) - DebPackageSigningResult.objects.create( - sha256=artifact_obj.sha256, - package_signing_fingerprint=fingerprint, - result=signed_package, + packages.append((package, fingerprint)) + + async def _sign_packages(): + semaphore = asyncio.Semaphore(settings.MAX_PACKAGE_SIGNING_WORKERS) + + async def _bounded_sign(pkg_tuple): + pkg, fingerprint = pkg_tuple + async with semaphore: + return await asyncio.to_thread( + _sign_package, + pkg, + repo.package_signing_service, + fingerprint, + package_release_map, + ) + + return await asyncio.gather(*(_bounded_sign(pkg_tuple) for pkg_tuple in packages)) + + for result in asyncio.run(_sign_packages()): + if not result: + continue + old_id, new_id, prcs_to_update = result + + # Update the add_content_units list with the new package + while old_id in add_content_units: + add_content_units.remove(old_id) + if new_id not in add_content_units: + add_content_units.append(new_id) + + # Repoint PackageReleaseComponents that were collected during signing + for prc in prcs_to_update: + new_prc, _ = PackageReleaseComponent.objects.get_or_create( + release_component=prc.release_component, + package_id=new_id, + _pulp_domain=prc._pulp_domain, ) - _update_content_units(add_content_units, package_id, signed_package.pk) - resource = CreatedResource(content_object=signed_package) - resource.save() + while str(prc.pk) in add_content_units: + add_content_units.remove(str(prc.pk)) + + if str(new_prc.pk) not in add_content_units: + add_content_units.append(str(new_prc.pk)) return add_and_remove(repository_pk, add_content_units, remove_content_units, base_version_pk) From 205bd26bad89b9f27f747a0a35cd8f7df2dfb51e Mon Sep 17 00:00:00 2001 From: David Davis Date: Mon, 6 Apr 2026 17:04:49 +0000 Subject: [PATCH 5/8] Use prefixed fingerprint instead of requiring v4 Switch package_signing_fingerprint fields from raw 40-char hex strings to a prefixed format (e.g. 'v4:' or 'keyid:'). This allows the signing system to distinguish between fingerprint types. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/user/guides/sign_packages.md | 9 ++- docs/user/guides/signing_service.md | 11 +++- .../0037_DATA_fix_signing_fingerprint.py | 30 ++++++++++ pulp_deb/app/models/repository.py | 4 +- pulp_deb/app/models/signing_service.py | 4 +- .../app/serializers/repository_serializers.py | 25 ++++----- pulp_deb/app/tasks/signing.py | 8 ++- .../functional/api/test_package_signing.py | 56 +++++++++++++++++++ pyproject.toml | 2 +- 9 files changed, 123 insertions(+), 26 deletions(-) create mode 100644 pulp_deb/app/migrations/0037_DATA_fix_signing_fingerprint.py diff --git a/docs/user/guides/sign_packages.md b/docs/user/guides/sign_packages.md index 8a002e97c..66a188165 100644 --- a/docs/user/guides/sign_packages.md +++ b/docs/user/guides/sign_packages.md @@ -14,8 +14,10 @@ Sign a Debian package when uploading it to a repository. - Have an `AptPackageSigningService` registered (see the [signing service guide](site:pulp_deb/docs/user/guides/signing_service/)). -- Have the V4 fingerprint of the key you want to use. The key must be accessible by the signing - service you are using (the fingerprint is forwarded via `PULP_SIGNING_KEY_FINGERPRINT`). +- Have the fingerprint of the key you want to use, in prefixed format (e.g. `v4:` + or `keyid:<16-hex-char>`). The key must be accessible by the signing service you are using. + The raw fingerprint is forwarded to the signing script via the `PULP_SIGNING_KEY_FINGERPRINT` + environment variable, and the prefix is forwarded via `PULP_SIGNING_FINGERPRINT_TYPE`. ### Instructions @@ -31,10 +33,11 @@ Sign a Debian package when uploading it to a repository. ```bash # Create or update a repository with signing enabled +# The fingerprint must use the prefixed format, e.g. "v4:7FC42CD5F3D8EEC37FC42CD5F3D8EEC3DEADBEEF" http POST $API_ROOT/repositories/deb/apt \ name="MyDebRepo" \ package_signing_service=$SIGNING_SERVICE_HREF \ - package_signing_fingerprint=$SIGNING_FINGERPRINT + package_signing_fingerprint="v4:$SIGNING_FINGERPRINT" # Upload a package pulp deb content upload \ diff --git a/docs/user/guides/signing_service.md b/docs/user/guides/signing_service.md index 8eae2ef64..f12e11cdc 100644 --- a/docs/user/guides/signing_service.md +++ b/docs/user/guides/signing_service.md @@ -102,14 +102,17 @@ signing, package signing modifies the `.deb` file directly, so it uses the - Familiarize yourself with the general signing instructions in [pulpcore](site:pulpcore/docs/admin/guides/sign-metadata/). - Make sure the public key fingerprint you provide matches the key available to `debsigs`. During - package uploads the fingerprint is passed to the script via the - `PULP_SIGNING_KEY_FINGERPRINT` environment variable. + package uploads the raw fingerprint (without prefix) is passed to the script via the + `PULP_SIGNING_KEY_FINGERPRINT` environment variable, and the fingerprint type prefix (e.g. + `v4`, `keyid`) is passed via `PULP_SIGNING_FINGERPRINT_TYPE`. ### Instructions 1. Create a signing script capable of signing a Debian package with `debsigs`. - The script receives the package path as its first argument. - - The script must use `PULP_SIGNING_KEY_FINGERPRINT` to select the signing key. + - The script must use `PULP_SIGNING_KEY_FINGERPRINT` to select the signing key. The + `PULP_SIGNING_FINGERPRINT_TYPE` environment variable indicates the fingerprint type + (e.g. `v4`, `keyid`). - The script should return JSON describing the signed file: ```json {"deb_package": "/absolute/path/to/signed.deb"} @@ -132,6 +135,8 @@ set -euo pipefail PACKAGE_PATH=$1 FINGERPRINT="${PULP_SIGNING_KEY_FINGERPRINT:?PULP_SIGNING_KEY_FINGERPRINT is required}" +# PULP_SIGNING_FINGERPRINT_TYPE contains the fingerprint type prefix (e.g. "v4", "keyid") +FINGERPRINT_TYPE="${PULP_SIGNING_FINGERPRINT_TYPE:-v4}" WORKDIR="${PULP_TEMP_WORKING_DIR:-$(mktemp -d)}" SIGNED_PATH="${WORKDIR}/$(basename "${PACKAGE_PATH}")" diff --git a/pulp_deb/app/migrations/0037_DATA_fix_signing_fingerprint.py b/pulp_deb/app/migrations/0037_DATA_fix_signing_fingerprint.py new file mode 100644 index 000000000..4c2b11c60 --- /dev/null +++ b/pulp_deb/app/migrations/0037_DATA_fix_signing_fingerprint.py @@ -0,0 +1,30 @@ +from django.db import migrations + + +def replace_empty_fingerprint_with_null(apps, schema_editor): + """Replace empty and bare-prefix package_signing_fingerprint values with NULL.""" + AptRepository = apps.get_model("deb", "AptRepository") + AptRepository.objects.filter(package_signing_fingerprint="").update( + package_signing_fingerprint=None + ) + + +def replace_null_fingerprint_with_empty(apps, schema_editor): + """Replace NULL package_signing_fingerprint values with empty string.""" + AptRepository = apps.get_model("deb", "AptRepository") + AptRepository.objects.filter(package_signing_fingerprint=None).update( + package_signing_fingerprint="" + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("deb", "0036_add_deb_package_signing_result"), + ] + + operations = [ + migrations.RunPython( + replace_empty_fingerprint_with_null, + replace_null_fingerprint_with_empty, + ), + ] diff --git a/pulp_deb/app/models/repository.py b/pulp_deb/app/models/repository.py index b6a93b5b8..a8c5330c2 100644 --- a/pulp_deb/app/models/repository.py +++ b/pulp_deb/app/models/repository.py @@ -73,7 +73,7 @@ class AptRepository(Repository, AutoAddObjPermsMixin): AptPackageSigningService, on_delete=models.SET_NULL, null=True ) - package_signing_fingerprint = models.TextField(null=True, max_length=40) + package_signing_fingerprint = models.TextField(null=True) # Implicit signing_service_release_overrides # Implicit package_signing_fingerprint_release_overrides @@ -204,7 +204,7 @@ class AptRepositoryReleasePackageSigningFingerprintOverride(BaseModel): on_delete=models.CASCADE, related_name="package_signing_fingerprint_release_overrides", ) - package_signing_fingerprint = models.TextField(max_length=40) + package_signing_fingerprint = models.TextField() release_distribution = models.TextField() class Meta: diff --git a/pulp_deb/app/models/signing_service.py b/pulp_deb/app/models/signing_service.py index 89af9803f..064a3ea37 100644 --- a/pulp_deb/app/models/signing_service.py +++ b/pulp_deb/app/models/signing_service.py @@ -191,7 +191,7 @@ def sign( Args: filename: The absolute path to the package to be signed. env_vars: (optional) Dict of env_vars to be passed to the signing script. - pubkey_fingerprint: The V4 fingerprint that correlates with the private key to use. + pubkey_fingerprint: The fingerprint that correlates with the private key to use. """ if not pubkey_fingerprint: raise ValueError("A pubkey_fingerprint must be provided.") @@ -282,7 +282,7 @@ class DebPackageSigningResult(BaseModel): """ sha256 = models.TextField(max_length=64) - package_signing_fingerprint = models.TextField(max_length=40) + package_signing_fingerprint = models.TextField() result = models.ForeignKey(Content, on_delete=models.CASCADE) class Meta: diff --git a/pulp_deb/app/serializers/repository_serializers.py b/pulp_deb/app/serializers/repository_serializers.py index 3acc3b270..a6e4ef730 100644 --- a/pulp_deb/app/serializers/repository_serializers.py +++ b/pulp_deb/app/serializers/repository_serializers.py @@ -8,6 +8,7 @@ from pulpcore.plugin.models import SigningService from pulpcore.plugin.serializers import ( + PgpKeyFingerprintField, RelatedField, RepositorySerializer, RepositorySyncURLSerializer, @@ -43,7 +44,8 @@ def to_representation(self, overrides): class PackageFingerprintOverrideField(serializers.DictField): - child = serializers.CharField(max_length=40, allow_blank=True) + # allow_blank=True so empty strings can be used to delete overrides + child = PgpKeyFingerprintField(allow_blank=True) def to_representation(self, overrides): return {x.release_distribution: x.package_signing_fingerprint for x in overrides.all()} @@ -101,9 +103,9 @@ class AptRepositorySerializer(RepositorySerializer): required=False, help_text=_( "A dictionary of Release distributions and the " - "Package Signing Fingerprints they should use." + "Package Signing Fingerprints they should use. " "Example: " - '{"bionic": "7FC42CD5F3D8EEC3"}' + '{"bionic": "v4:7FC42CD5F3D8EEC37FC42CD5F3D8EEC3DEADBEEF"}' ), ) @@ -115,15 +117,16 @@ class AptRepositorySerializer(RepositorySerializer): required=False, allow_null=True, ) - package_signing_fingerprint = serializers.CharField( + package_signing_fingerprint = PgpKeyFingerprintField( help_text=_( - "The pubkey V4 fingerprint (160 bits) to be passed to the package signing service." + "The pubkey fingerprint to be passed to the package signing service. " + "Format: 'v:' or 'keyid:<16-hex-char>'. " + "Example: 'v4:ABCDEF1234567890ABCDEF1234567890ABCDEF12'. " "The signing service will use that on signing operations related to this repository." ), - max_length=40, required=False, - allow_blank=True, - default="", + allow_null=True, + default=None, ) class Meta: @@ -215,12 +218,6 @@ def _update_package_signing_fingerprint_overrides(self, repo, overrides): release_distribution=distro, ).save() - def to_representation(self, instance): - data = super().to_representation(instance) - if "package_signing_fingerprint" in data and data["package_signing_fingerprint"] is None: - data["package_signing_fingerprint"] = "" - return data - class AptRepositorySyncURLSerializer(RepositorySyncURLSerializer): """ diff --git a/pulp_deb/app/tasks/signing.py b/pulp_deb/app/tasks/signing.py index e0e647b0a..2d6108cde 100644 --- a/pulp_deb/app/tasks/signing.py +++ b/pulp_deb/app/tasks/signing.py @@ -56,10 +56,16 @@ def _create_signed_artifact(signed_package_path, result): def _sign_file(package_file, signing_service, signing_fingerprint): + """Sign a package and return the signed artifact.""" + prefix, raw_fingerprint = signing_fingerprint.split(":", 1) logging.info( _("Signing package %s with fingerprint %s"), package_file.name, signing_fingerprint ) - result = signing_service.sign(package_file.name, pubkey_fingerprint=signing_fingerprint) + result = signing_service.sign( + package_file.name, + env_vars={"PULP_SIGNING_FINGERPRINT_TYPE": prefix}, + pubkey_fingerprint=raw_fingerprint, + ) signed_package_path = Path(result["deb_package"]) return _create_signed_artifact(signed_package_path, result) diff --git a/pulp_deb/tests/functional/api/test_package_signing.py b/pulp_deb/tests/functional/api/test_package_signing.py index 80465d989..a64354489 100644 --- a/pulp_deb/tests/functional/api/test_package_signing.py +++ b/pulp_deb/tests/functional/api/test_package_signing.py @@ -425,3 +425,59 @@ def test_signed_repo_rejects_on_demand_content( ) assert "Cannot add on-demand content" in exc.value.body + + +@pytest.mark.parallel +def test_set_and_unset_signing_service_release_overrides( + signing_gpg_extra, + deb_package_signing_service, + deb_signing_service_factory, + deb_repository_factory, + apt_repository_api, +): + """Ensure signing service release overrides can be set and removed via partial_update.""" + _, gpg_metadata_a, gpg_metadata_b = signing_gpg_extra + + def _prefixed(fp): + return fp if ":" in fp else f"v4:{fp}" + + repository = deb_repository_factory( + package_signing_service=deb_package_signing_service.pulp_href, + package_signing_fingerprint=gpg_metadata_a.fingerprint, + ) + repo = apt_repository_api.read(repository.pulp_href) + assert repo.package_signing_fingerprint_release_overrides == {} + assert repo.signing_service_release_overrides == {} + + # Set a fingerprint override, then remove it with an empty string + apt_repository_api.partial_update( + repository.pulp_href, + {"package_signing_fingerprint_release_overrides": {"bionic": gpg_metadata_b.fingerprint}}, + ) + repo = apt_repository_api.read(repository.pulp_href) + assert repo.package_signing_fingerprint_release_overrides == { + "bionic": _prefixed(gpg_metadata_b.fingerprint), + } + + apt_repository_api.partial_update( + repository.pulp_href, + {"package_signing_fingerprint_release_overrides": {"bionic": ""}}, + ) + repo = apt_repository_api.read(repository.pulp_href) + assert repo.package_signing_fingerprint_release_overrides == {} + + # Set a signing service override, then remove it with null + signing_service = deb_signing_service_factory + apt_repository_api.partial_update( + repository.pulp_href, + {"signing_service_release_overrides": {"jammy": signing_service.pulp_href}}, + ) + repo = apt_repository_api.read(repository.pulp_href) + assert repo.signing_service_release_overrides == {"jammy": signing_service.pulp_href} + + apt_repository_api.partial_update( + repository.pulp_href, + {"signing_service_release_overrides": {"jammy": None}}, + ) + repo = apt_repository_api.read(repository.pulp_href) + assert repo.signing_service_release_overrides == {} diff --git a/pyproject.toml b/pyproject.toml index 0942dcf06..5f0356550 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ requires-python = ">=3.11" dependencies = [ # All things django and asyncio are deliberately left to pulpcore # Example transitive requirements: asgiref, asyncio, aiohttp - "pulpcore>=3.101.0,<3.115", + "pulpcore>=3.107.0,<3.115", "python-debian>=0.1.44,<0.2.0", "python-gnupg>=0.5,<0.6", "jsonschema>=4.6,<5.0", From 571d9d1d9e11f528d21ca6485f6da5d09df63449 Mon Sep 17 00:00:00 2001 From: David Davis Date: Mon, 6 Apr 2026 17:30:50 +0000 Subject: [PATCH 6/8] Add signing_keys field to deb packages Add an ArrayField to BasePackage (Package and InstallerPackage) that records which key fingerprints were used to sign the package. The field is read-only, null by default, and populated with a fingerprint when a package is signed via upload or repository modify. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/user/guides/sign_packages.md | 9 -- docs/user/guides/signing_service.md | 3 + .../0038_signing_fingerprint_prefix.py | 110 ++++++++++++++++++ pulp_deb/app/migrations/0039_signing_keys.py | 27 +++++ pulp_deb/app/models/content/content.py | 3 + .../app/serializers/content_serializers.py | 13 +++ pulp_deb/app/tasks/signing.py | 3 + .../functional/api/test_package_signing.py | 10 +- 8 files changed, 166 insertions(+), 12 deletions(-) create mode 100644 pulp_deb/app/migrations/0038_signing_fingerprint_prefix.py create mode 100644 pulp_deb/app/migrations/0039_signing_keys.py diff --git a/docs/user/guides/sign_packages.md b/docs/user/guides/sign_packages.md index 66a188165..4eb2b8379 100644 --- a/docs/user/guides/sign_packages.md +++ b/docs/user/guides/sign_packages.md @@ -44,12 +44,3 @@ pulp deb content upload \ --repository ${REPOSITORY} \ --file ${DEB_FILE} ``` - -### Known Limitations - -**Traffic overhead**: The signing of a package should happen inside of a Pulp worker. - [By design](site:pulpcore/docs/dev/learn/plugin-concepts/#tasks), - Pulp needs to temporarily commit the file to the default backend storage in order to make the Uploaded File available to the tasking system. - This implies in some extra traffic, compared to a scenario where a task could process the file directly. - -**No sign tracking**: We do not track signing information of a package. diff --git a/docs/user/guides/signing_service.md b/docs/user/guides/signing_service.md index f12e11cdc..35cd2b1da 100644 --- a/docs/user/guides/signing_service.md +++ b/docs/user/guides/signing_service.md @@ -96,6 +96,9 @@ Package signing is available as a tech preview beginning with pulp_deb 3.9.0. Un signing, package signing modifies the `.deb` file directly, so it uses the `deb:AptPackageSigningService` class. +!!! note + Currently only `_gpgorigin` signatures (as produced by `debsigs --sign=origin`) are supported. + ### Prerequisites - Install `debsigs` and ensure it can access the private key you want to use. diff --git a/pulp_deb/app/migrations/0038_signing_fingerprint_prefix.py b/pulp_deb/app/migrations/0038_signing_fingerprint_prefix.py new file mode 100644 index 000000000..bd41ba19d --- /dev/null +++ b/pulp_deb/app/migrations/0038_signing_fingerprint_prefix.py @@ -0,0 +1,110 @@ +from django.db import migrations, models + + +def add_fingerprint_prefix(apps, schema_editor): + """Add 'v4:' prefix to package_signing_fingerprint values that lack a prefix.""" + AptRepository = apps.get_model("deb", "AptRepository") + AptRepository.objects.filter( + package_signing_fingerprint__isnull=False, + ).exclude( + package_signing_fingerprint="", + ).exclude( + package_signing_fingerprint__contains=":", + ).update( + package_signing_fingerprint=models.functions.Concat( + models.Value("v4:"), "package_signing_fingerprint" + ), + ) + + AptRepositoryReleasePackageSigningFingerprintOverride = apps.get_model( + "deb", "AptRepositoryReleasePackageSigningFingerprintOverride" + ) + AptRepositoryReleasePackageSigningFingerprintOverride.objects.exclude( + package_signing_fingerprint="", + ).exclude( + package_signing_fingerprint__contains=":", + ).update( + package_signing_fingerprint=models.functions.Concat( + models.Value("v4:"), "package_signing_fingerprint" + ), + ) + + DebPackageSigningResult = apps.get_model("deb", "DebPackageSigningResult") + DebPackageSigningResult.objects.exclude( + package_signing_fingerprint="", + ).exclude( + package_signing_fingerprint__contains=":", + ).update( + package_signing_fingerprint=models.functions.Concat( + models.Value("v4:"), "package_signing_fingerprint" + ), + ) + + +def remove_fingerprint_prefix(apps, schema_editor): + """Remove type prefix (e.g. 'v4:', 'keyid:') from package_signing_fingerprint values.""" + AptRepository = apps.get_model("deb", "AptRepository") + AptRepository.objects.filter( + package_signing_fingerprint__contains=":", + ).update( + package_signing_fingerprint=models.Func( + "package_signing_fingerprint", + models.Value("^[^:]+:"), + models.Value(""), + function="REGEXP_REPLACE", + output_field=models.TextField(), + ), + ) + + AptRepositoryReleasePackageSigningFingerprintOverride = apps.get_model( + "deb", "AptRepositoryReleasePackageSigningFingerprintOverride" + ) + AptRepositoryReleasePackageSigningFingerprintOverride.objects.filter( + package_signing_fingerprint__contains=":", + ).update( + package_signing_fingerprint=models.Func( + "package_signing_fingerprint", + models.Value("^[^:]+:"), + models.Value(""), + function="REGEXP_REPLACE", + output_field=models.TextField(), + ), + ) + + DebPackageSigningResult = apps.get_model("deb", "DebPackageSigningResult") + DebPackageSigningResult.objects.filter( + package_signing_fingerprint__contains=":", + ).update( + package_signing_fingerprint=models.Func( + "package_signing_fingerprint", + models.Value("^[^:]+:"), + models.Value(""), + function="REGEXP_REPLACE", + output_field=models.TextField(), + ), + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("deb", "0037_DATA_fix_signing_fingerprint"), + ] + + operations = [ + migrations.AlterField( + model_name="aptrepository", + name="package_signing_fingerprint", + field=models.TextField(null=True), + ), + migrations.AlterField( + model_name="aptrepositoryreleasepackagesigningfingerprintoverride", + name="package_signing_fingerprint", + field=models.TextField(), + ), + migrations.AlterField( + model_name="debpackagesigningresult", + name="package_signing_fingerprint", + field=models.TextField(), + ), + migrations.RunPython(add_fingerprint_prefix, remove_fingerprint_prefix), + ] diff --git a/pulp_deb/app/migrations/0039_signing_keys.py b/pulp_deb/app/migrations/0039_signing_keys.py new file mode 100644 index 000000000..409c5a374 --- /dev/null +++ b/pulp_deb/app/migrations/0039_signing_keys.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.12 on 2026-04-06 17:26 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("deb", "0038_signing_fingerprint_prefix"), + ] + + operations = [ + migrations.AddField( + model_name="installerpackage", + name="signing_keys", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.TextField(), default=None, null=True, size=None + ), + ), + migrations.AddField( + model_name="package", + name="signing_keys", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.TextField(), default=None, null=True, size=None + ), + ), + ] diff --git a/pulp_deb/app/models/content/content.py b/pulp_deb/app/models/content/content.py index 6e4ab5aa0..90a65fa99 100644 --- a/pulp_deb/app/models/content/content.py +++ b/pulp_deb/app/models/content/content.py @@ -9,6 +9,7 @@ import os +from django.contrib.postgres.fields import ArrayField from django.db import models from django.db.models import JSONField @@ -69,6 +70,8 @@ class BasePackage(Content): # this digest is transferred to the content as a natural_key sha256 = models.TextField(null=False) + signing_keys = ArrayField(models.TextField(), default=None, null=True) + custom_fields = JSONField(null=True) _pulp_domain = models.ForeignKey("core.Domain", default=get_domain_pk, on_delete=models.PROTECT) diff --git a/pulp_deb/app/serializers/content_serializers.py b/pulp_deb/app/serializers/content_serializers.py index f92c4cbb1..ea3a0cfdc 100644 --- a/pulp_deb/app/serializers/content_serializers.py +++ b/pulp_deb/app/serializers/content_serializers.py @@ -19,6 +19,7 @@ DetailRelatedField, MultipleArtifactContentSerializer, NoArtifactContentSerializer, + PgpKeyFingerprintField, SingleArtifactContentSerializer, SingleArtifactContentUploadSerializer, SingleContentArtifactField, @@ -599,6 +600,13 @@ class BasePackageMixin(Serializer): provides = CharField(read_only=True) replaces = CharField(read_only=True) custom_fields = DictField(child=CharField(), allow_empty=True, required=False) + signing_keys = ListField( + child=PgpKeyFingerprintField(), + help_text=_("List of signing key fingerprints used to sign this package."), + allow_null=True, + required=False, + read_only=True, + ) def __init__(self, *args, **kwargs): """Initializer for BasePackageSerializer.""" @@ -681,6 +689,7 @@ class Meta: "pre_depends", "provides", "replaces", + "signing_keys", ) @@ -696,6 +705,10 @@ def deferred_validate(self, data): if data.get("section") == "debian-installer": raise ValidationError(_("Not a valid Deb Package")) + if signing_key := self.context.get("signing_key"): + # Only _gpgorigin signatures are supported currently, so packages have one signing key. + data["signing_keys"] = [signing_key] + return data def validate(self, data): diff --git a/pulp_deb/app/tasks/signing.py b/pulp_deb/app/tasks/signing.py index 2d6108cde..291ff4b92 100644 --- a/pulp_deb/app/tasks/signing.py +++ b/pulp_deb/app/tasks/signing.py @@ -98,6 +98,7 @@ def sign_and_create( # The Package serializer validation method have two branches: the signing and non-signing. # Here, the package is already signed, so we need to update the context for a proper validation. context["sign_package"] = False + context["signing_key"] = signing_fingerprint # The request data is immutable when there's an upload, so we can't delete the upload out of the # request data like we do for a file. Instead, we'll delete it here. if "upload" in data: @@ -148,6 +149,8 @@ def _sign_package(package, signing_service, signing_fingerprint, package_release signed_package.pk = None signed_package.pulp_id = None signed_package.sha256 = artifact.sha256 + # Only _gpgorigin signatures are supported currently, so packages have one signing key. + signed_package.signing_keys = [signing_fingerprint] signed_package.save() ContentArtifact.objects.create( artifact=artifact, diff --git a/pulp_deb/tests/functional/api/test_package_signing.py b/pulp_deb/tests/functional/api/test_package_signing.py index a64354489..fd14f34b3 100644 --- a/pulp_deb/tests/functional/api/test_package_signing.py +++ b/pulp_deb/tests/functional/api/test_package_signing.py @@ -319,6 +319,7 @@ def test_signed_repo_modify( ) created_package = deb_package_factory(file=file_to_upload) + assert created_package.signing_keys is None release_component, prc = add_package_to_repo(repository, created_package.pulp_href) # Verify that the final served package is signed @@ -333,9 +334,12 @@ def test_signed_repo_modify( ) repository = apt_repository_api.read(repository.pulp_href) - signed_package_href = ( - apt_package_api.list(repository_version=repository.latest_version_href).results[0].pulp_href - ) + signed_package = apt_package_api.list( + repository_version=repository.latest_version_href + ).results[0] + signed_package_href = signed_package.pulp_href + prefixed = fingerprint if ":" in fingerprint else f"v4:{fingerprint}" + assert signed_package.signing_keys == [prefixed] # attempt to add the package to the repo a second time (should produce same package href) add_package_to_repo(repository, created_package.pulp_href, release_component, prc) From 5a61a9b64a988ca6d5c774f775dd5763f9cbebba Mon Sep 17 00:00:00 2001 From: David Davis Date: Wed, 22 Apr 2026 01:14:39 +0000 Subject: [PATCH 7/8] Fixed signature check of already signed packages Previously, we were checking package signatures against the package signing service's key fingerprint to see if they were already signed by the repo's package signing service. Instead the check should be using the repo's package signing fingerprint. For this fix, we extract the signature and use `gpg --list-packets` to find the package's fingerprint, which is compared against the repo fingerprint. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pulp_deb/app/tasks/signing.py | 61 +++++++++++++++---- .../functional/api/test_package_signing.py | 61 +++++++++++++++++++ 2 files changed, 111 insertions(+), 11 deletions(-) diff --git a/pulp_deb/app/tasks/signing.py b/pulp_deb/app/tasks/signing.py index 291ff4b92..36c3ee95d 100644 --- a/pulp_deb/app/tasks/signing.py +++ b/pulp_deb/app/tasks/signing.py @@ -1,4 +1,6 @@ import asyncio +import re +import subprocess from pathlib import Path from tempfile import NamedTemporaryFile @@ -19,9 +21,6 @@ from pulp_deb.app.models.signing_service import ( AptPackageSigningService, DebPackageSigningResult, - FingerprintMismatch, - InvalidSignature, - UnsignedPackage, ) from pulp_deb.app.models import AptRepository, Package, PackageReleaseComponent @@ -55,12 +54,50 @@ def _create_signed_artifact(signed_package_path, result): return artifact +def _verify_package_fingerprint(path, signing_fingerprint): + """Verify if the deb package at path is signed with signing_fingerprint. + + Extracts the key ID from the _gpgorigin member of the .deb archive + and compares it against the provided signing_fingerprint. + """ + ar_proc = subprocess.run( + ["ar", "p", path, "_gpgorigin"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if ar_proc.returncode != 0 or not ar_proc.stdout: + log.info(f"No _gpgorigin found in {path} (unsigned package).") + return False + + gpg_proc = subprocess.run( + ["gpg", "--list-packets", "--verbose"], + input=ar_proc.stdout, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if gpg_proc.returncode != 0: + log.info(f"gpg --list-packets failed for {path}: {gpg_proc.stderr}") + return False + + output = gpg_proc.stdout.decode("utf-8", errors="replace") + raw_fingerprint = signing_fingerprint.split(":", 1)[1] + + # Look for key ID lines in gpg --list-packets output + key_ids = re.findall(r"keyid ([0-9A-Fa-f]+)", output, re.IGNORECASE) + for candidate in key_ids: + if raw_fingerprint.upper().endswith(candidate.upper()): + return True + + log.debug( + f"Fingerprint mismatch for {path}: expected {raw_fingerprint}, found key IDs {key_ids}." + ) + return False + + def _sign_file(package_file, signing_service, signing_fingerprint): """Sign a package and return the signed artifact.""" prefix, raw_fingerprint = signing_fingerprint.split(":", 1) - logging.info( - _("Signing package %s with fingerprint %s"), package_file.name, signing_fingerprint - ) + log.info(_("Signing package %s with fingerprint %s"), package_file.name, signing_fingerprint) result = signing_service.sign( package_file.name, env_vars={"PULP_SIGNING_FINGERPRINT_TYPE": prefix}, @@ -121,12 +158,10 @@ def _sign_package(package, signing_service, signing_fingerprint, package_release artifact_file = artifact_obj.file _save_file(artifact_file, final_package) - # check if the package is already signed with our fingerprint - try: - signing_service.validate_signature(final_package.name) + # check if the package is already signed with the repo's fingerprint + if _verify_package_fingerprint(final_package.name, signing_fingerprint): + log.info(f"Package {package.name} is already signed with {signing_fingerprint}.") return None - except (UnsignedPackage, InvalidSignature, FingerprintMismatch): - pass # Collect PackageReleaseComponents that need to be updated prcs_to_update = list( @@ -140,6 +175,7 @@ def _sign_package(package, signing_service, signing_fingerprint, package_release sha256=content_artifact.artifact.sha256, package_signing_fingerprint=signing_fingerprint, ).first(): + log.info(f"Reusing previously signed package for {package.name}.") return (package_id, str(existing_result.result.pk), prcs_to_update) # create a new signed version of the package @@ -176,6 +212,9 @@ def signed_add_and_remove( repo = AptRepository.objects.get(pk=repository_pk) if repo.package_signing_service: + log.info( + f"Signing packages for repository {repo.name} with {repo.package_signing_service}." + ) # map packages to releases prcs = PackageReleaseComponent.objects.filter( Q(pk__in=add_content_units) | Q(pk__in=repo.content.all()) diff --git a/pulp_deb/tests/functional/api/test_package_signing.py b/pulp_deb/tests/functional/api/test_package_signing.py index fd14f34b3..a63c8570c 100644 --- a/pulp_deb/tests/functional/api/test_package_signing.py +++ b/pulp_deb/tests/functional/api/test_package_signing.py @@ -1,6 +1,7 @@ from dataclasses import dataclass import hashlib import shutil +import subprocess import uuid import pytest @@ -485,3 +486,63 @@ def _prefixed(fp): ) repo = apt_repository_api.read(repository.pulp_href) assert repo.signing_service_release_overrides == {} + + +def test_presigned_package_not_resigned( + tmp_path, + add_package_to_repo, + signing_gpg_extra, + package_signing_script_path, + deb_package_signing_service, + deb_repository_factory, + deb_package_factory, + apt_repository_api, + apt_package_api, +): + """ + Ensure a package already signed with the repo's signing fingerprint is not re-signed, + even when the signing service uses a different key. + """ + gpg, gpg_metadata_a, gpg_metadata_b = signing_gpg_extra + + # Sign a package locally with key B (different from signing service's key A) + file_to_upload = shutil.copy( + get_local_package_absolute_path("frigg_1.0_ppc64.deb"), + tmp_path, + ) + + # Sign the package with key B using the signing script + env = {"PULP_SIGNING_KEY_FINGERPRINT": gpg_metadata_b.fingerprint} + result = subprocess.run( + [str(package_signing_script_path), str(file_to_upload)], + env=env, + capture_output=True, + ) + assert result.returncode == 0, f"Signing failed: {result.stderr}" + + # Verify the package is signed with key B + AptPackageSigningService._check_deb_signature( + str(file_to_upload), gpg_metadata_b.fingerprint, str(tmp_path), gpg + ) + + # Upload the pre-signed package without a signing repository + created_package = deb_package_factory(file=str(file_to_upload)) + original_href = created_package.pulp_href + + # Create a repo with signing service (key A) but package_signing_fingerprint = key B + repository = deb_repository_factory( + package_signing_service=deb_package_signing_service.pulp_href, + package_signing_fingerprint=gpg_metadata_b.fingerprint, + ) + + # Add the pre-signed package to the repo + add_package_to_repo(repository, original_href) + + # Verify the package was NOT re-signed + repository = apt_repository_api.read(repository.pulp_href) + packages = apt_package_api.list(repository_version=repository.latest_version_href).results + + assert len(packages) == 1 + assert packages[0].pulp_href == original_href, ( + "Package was re-signed despite already having the correct signature." + ) From f86caa4420ab9ff62eee71bdcf64d1666f22b7ea Mon Sep 17 00:00:00 2001 From: David Davis Date: Sat, 2 May 2026 17:56:58 +0000 Subject: [PATCH 8/8] Run ruff against code --- pulp_deb/app/tasks/signing.py | 11 +++++------ pulp_deb/tests/functional/api/test_package_signing.py | 4 +++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/pulp_deb/app/tasks/signing.py b/pulp_deb/app/tasks/signing.py index 36c3ee95d..27f237a72 100644 --- a/pulp_deb/app/tasks/signing.py +++ b/pulp_deb/app/tasks/signing.py @@ -1,6 +1,8 @@ import asyncio +import logging import re import subprocess +from gettext import gettext as _ from pathlib import Path from tempfile import NamedTemporaryFile @@ -8,24 +10,21 @@ from django.db.models import Q from pulpcore.plugin.models import ( - Upload, - UploadChunk, Artifact, ContentArtifact, CreatedResource, PulpTemporaryFile, + Upload, + UploadChunk, ) from pulpcore.plugin.tasking import add_and_remove, general_create from pulpcore.plugin.util import get_url +from pulp_deb.app.models import AptRepository, Package, PackageReleaseComponent from pulp_deb.app.models.signing_service import ( AptPackageSigningService, DebPackageSigningResult, ) -from pulp_deb.app.models import AptRepository, Package, PackageReleaseComponent - -import logging -from gettext import gettext as _ log = logging.getLogger(__name__) diff --git a/pulp_deb/tests/functional/api/test_package_signing.py b/pulp_deb/tests/functional/api/test_package_signing.py index a63c8570c..c66d9dd24 100644 --- a/pulp_deb/tests/functional/api/test_package_signing.py +++ b/pulp_deb/tests/functional/api/test_package_signing.py @@ -1,12 +1,14 @@ -from dataclasses import dataclass import hashlib import shutil import subprocess import uuid +from dataclasses import dataclass import pytest import requests + from pulpcore.client.pulp_deb.exceptions import ApiException + from pulp_deb.app.models import AptPackageSigningService from pulp_deb.tests.functional.utils import get_local_package_absolute_path