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..8a002e97c --- /dev/null +++ b/docs/user/guides/sign_packages.md @@ -0,0 +1,52 @@ +# Sign Debian Packages + +Sign a Debian package using a registered package signing service. + +Currently, only signing on upload and when modifying a repo's content are 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/0034_package_signing.py b/pulp_deb/app/migrations/0034_package_signing.py new file mode 100644 index 000000000..e4951af21 --- /dev/null +++ b/pulp_deb/app/migrations/0034_package_signing.py @@ -0,0 +1,51 @@ +# 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', '0033_aptalternatecontentsource'), + ] + + 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/migrations/0035_add_deb_package_signing_result.py b/pulp_deb/app/migrations/0035_add_deb_package_signing_result.py new file mode 100644 index 000000000..df64123d2 --- /dev/null +++ b/pulp_deb/app/migrations/0035_add_deb_package_signing_result.py @@ -0,0 +1,32 @@ +# 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', '0034_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/__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 d851b0e97..231f10ecb 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,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 8cf13e1dd..c0dc7331e 100644 --- a/pulp_deb/app/models/signing_service.py +++ b/pulp_deb/app/models/signing_service.py @@ -1,8 +1,39 @@ import os -import gnupg +import shutil +import subprocess import tempfile +from importlib.resources import files +from pathlib import Path +from typing import Optional + +import gnupg +from django.db import models +from pulpcore.plugin.models import BaseModel, Content, SigningService + + +class UnsignedPackage(Exception): + """Raised when a deb package is unsigned and has no _gpgorigin signature.""" + -from pulpcore.plugin.models import SigningService +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): + # 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 +101,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 +157,128 @@ 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}") + + 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._check_deb_signature( + deb_package_path, self.pubkey_fingerprint, temp_directory_name, gpg + ) + + @staticmethod + def _check_deb_signature( + deb_package_path: str, fingerprint: str, temp_directory_name: str, gpg: gnupg.GPG + ): + """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) + 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 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( + f"GPG Verification of the signed package {deb_package_path} failed!" + ) + 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/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 79a9f9c7c..3f55e7c6b 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. @@ -86,23 +95,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 @@ -110,13 +156,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 @@ -130,7 +180,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( @@ -139,6 +189,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/__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 new file mode 100644 index 000000000..d02bf832f --- /dev/null +++ b/pulp_deb/app/tasks/signing.py @@ -0,0 +1,168 @@ +from pathlib import Path +from tempfile import NamedTemporaryFile + +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, + DebPackageSigningResult, + FingerprintMismatch, + UnsignedPackage, +) +from pulp_deb.app.models import AptRepository, Package, PackageReleaseComponent + + +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_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, + 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) + + artifact = _sign_file(final_package, package_signing_service, signing_fingerprint) + 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) + + +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, 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 ba2908e32..3247d6f6c 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 sign_and_create 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( + 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/app/viewsets/repository.py b/pulp_deb/app/viewsets/repository.py index 64aea054d..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): @@ -345,9 +360,9 @@ def _process_config(self, config): number=entry["dest_base_version"] ).pk except RepositoryVersion.DoesNotExist: - message = _( - "Version {version} does not exist for repository " "'{repo}'." - ).format(version=entry["dest_base_version"], repo=dest_repo.name) + message = _("Version {version} does not exist for repository '{repo}'.").format( + version=entry["dest_base_version"], repo=dest_repo.name + ) raise DRFValidationError(detail=message) if entry.get("content") is not None: 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..93eef6a4e --- /dev/null +++ b/pulp_deb/tests/functional/api/test_package_signing.py @@ -0,0 +1,425 @@ +from dataclasses import dataclass +import hashlib +import shutil +import uuid + +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 + + +@pytest.mark.parallel +def test_register_deb_package_signing_service(deb_package_signing_service): + """ + Register a sample deb 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.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, + 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 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 + + 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, 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._check_deb_signature( + 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._check_deb_signature( + 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 + """ + 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._check_deb_signature( + 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._check_deb_signature( + str(downloaded_package), fingerprint, str(tmp_path), gpg + ) + + +@pytest.mark.parallel +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] + + +@pytest.mark.parallel +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( + 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.""" + 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 782b4224c..ab37bd89e 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,10 @@ 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, @@ -24,6 +28,7 @@ DebAptPublication, DebAptAlternateContentSource, DebCopyApi, + DebPackageReleaseComponent, DebRelease, DebReleaseArchitecture, DebReleaseComponent, @@ -209,6 +214,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.""" @@ -686,3 +712,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 f88683c09..025951846 100644 --- a/pulp_deb/tests/functional/constants.py +++ b/pulp_deb/tests/functional/constants.py @@ -446,3 +446,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 +"""