diff --git a/CHANGES/1300.feature b/CHANGES/1300.feature new file mode 100644 index 000000000..00b609829 --- /dev/null +++ b/CHANGES/1300.feature @@ -0,0 +1 @@ +Added (tech preview) support for signing Debian packages when uploading to a Repository. \ No newline at end of file diff --git a/pulp_deb/app/migrations/0032_package_signing.py b/pulp_deb/app/migrations/0032_package_signing.py new file mode 100644 index 000000000..9303a2dcf --- /dev/null +++ b/pulp_deb/app/migrations/0032_package_signing.py @@ -0,0 +1,52 @@ +# 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 = [ + ('core', '0145_domainize_import_export'), + ('deb', '0001_initial_squashed_0031_add_domains'), + ] + + 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 4280a1e4f..a82687638 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,4 +28,8 @@ from .remote import AptRemote -from .repository import AptRepository, AptRepositoryReleaseServiceOverride +from .repository import ( + AptRepository, + AptRepositoryReleaseServiceOverride, + AptRepositoryReleasePackageSigningFingerprintOverride, +) diff --git a/pulp_deb/app/models/repository.py b/pulp_deb/app/models/repository.py index 2c19628e5..df7ac2a01 100644 --- a/pulp_deb/app/models/repository.py +++ b/pulp_deb/app/models/repository.py @@ -28,6 +28,7 @@ SourceIndex, SourcePackage, SourcePackageReleaseComponent, + AptPackageSigningService, ) import logging @@ -66,13 +67,24 @@ 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 class Meta: default_related_name = "%(app_label)s_%(model_name)s" permissions = [ ("manage_roles_aptrepository", "Can manage roles on APT repositories"), - ("modify_content_aptrepository", "Add content to, or remove content from a repository"), + ( + "modify_content_aptrepository", + "Add content to, or remove content from a repository", + ), ("repair_aptrepository", "Copy an APT repository"), ("sync_aptrepository", "Sync an APT repository"), ("delete_aptrepository_version", "Delete a repository version"), @@ -91,6 +103,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 @@ -125,7 +152,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() @@ -134,6 +163,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 8cf13e1dd..db5e786ae 100644 --- a/pulp_deb/app/models/signing_service.py +++ b/pulp_deb/app/models/signing_service.py @@ -1,8 +1,30 @@ import os +from pathlib import Path +import shutil +import subprocess +from typing import Optional import gnupg import tempfile from pulpcore.plugin.models import SigningService +from importlib_resources import files + + +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): @@ -70,19 +92,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") @@ -138,3 +148,105 @@ 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 + with (temp_dir / "_gpgorigin").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!" + ) \ No newline at end of file diff --git a/pulp_deb/app/serializers/content_serializers.py b/pulp_deb/app/serializers/content_serializers.py index 08cfb68a3..c91955d9f 100644 --- a/pulp_deb/app/serializers/content_serializers.py +++ b/pulp_deb/app/serializers/content_serializers.py @@ -694,6 +694,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 99b100b55..7ba9955d5 100644 --- a/pulp_deb/app/serializers/repository_serializers.py +++ b/pulp_deb/app/serializers/repository_serializers.py @@ -11,7 +11,9 @@ from pulpcore.plugin.util import get_url, get_domain from pulp_deb.app.models import ( + AptRepositoryReleasePackageSigningFingerprintOverride, AptRepositoryReleaseServiceOverride, + AptPackageSigningService, AptReleaseSigningService, AptRepository, ) @@ -39,6 +41,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. @@ -76,22 +85,59 @@ 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 + ( "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 @@ -99,13 +145,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 @@ -119,7 +169,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( @@ -128,6 +178,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..37c9b4117 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.app.serializers import AsyncOperationResponseSerializer +from pulpcore.plugin.models import Repository, RepositoryVersion, PulpTemporaryFile 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..c1a17ab19 --- /dev/null +++ b/pulp_deb/tests/functional/api/test_package_signing.py @@ -0,0 +1,247 @@ +from dataclasses import dataclass +import hashlib +import shutil +import uuid +from importlib_resources import files + +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="No such file or directory:.*"): + 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", + ) + # uncommenting this line causes a failure since it overrides the existing package with a version signed with fingerprint_a + # deb_package_factory( + # file=file_to_upload, + # repository=repository.pulp_href, + # distribution="test2", + # ) + + # 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="No such file or directory:.*"): + 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 a0748fc40..491fe18cd 100644 --- a/pulp_deb/tests/functional/conftest.py +++ b/pulp_deb/tests/functional/conftest.py @@ -1,3 +1,4 @@ +import json from urllib.parse import urlsplit from uuid import uuid4 import pytest @@ -7,7 +8,7 @@ import subprocess import uuid -from pulp_deb.tests.functional.constants import DEB_SIGNING_SCRIPT_STRING +from pulp_deb.tests.functional.constants import DEB_SIGNING_SCRIPT_STRING, DEB_PACKAGE_SIGNING_SCRIPT_STRING from pulpcore.client.pulp_deb import ( ContentGenericContentsApi, ContentPackagesApi, @@ -669,3 +670,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 b578c6308..7fd0a6ace 100644 --- a/pulp_deb/tests/functional/constants.py +++ b/pulp_deb/tests/functional/constants.py @@ -427,3 +427,26 @@ def _clean_dict(d): } \ } """ + +DEB_PACKAGE_SIGNING_SCRIPT_STRING = r"""#!/usr/bin/env bash +FILE_PATH=$1 +export GNUPGHOME="HOMEDIRHERE" +GPG_NAME="${PULP_SIGNING_KEY_FINGERPRINT}" + + +# check if fingerprint exists +gpg --fingerprint ${GPG_NAME} 1> /dev/null +if [[ $? -ne 0 ]]; then + echo "GPG key with fingerprint '${GPG_NAME}' not found!" >&2 + exit 2 +fi +# Sign the package +debsigs --sign=origin --default-key=${GPG_NAME} "${FILE_PATH}" 1> /dev/null +# Check the exit status +STATUS=$? +if [[ ${STATUS} -eq 0 ]]; then + echo {\"deb_package\": \"${FILE_PATH}\"} +else + exit ${STATUS} +fi +""" \ No newline at end of file