From 0aa467beeb6437b50f6907f41b02360ee6070851 Mon Sep 17 00:00:00 2001 From: Pedro Brochado Date: Mon, 5 Feb 2024 18:48:39 -0300 Subject: [PATCH] Add support for signing packages on upload What this does: - Create RpmPackageSigningService - Create RpmTool utility - Add fields to Repository model - Add branch on Package upload to sign the package with the associated SigningService and fingerprint. - Add docs - Use presence of SigningService at Repo as signing trigger Closes #2986 --- CHANGES/2986.feature | 1 + MANIFEST.in | 1 + .../0062_rpmpackagesigningservice_and_more.py | 47 ++++ pulp_rpm/app/models/__init__.py | 1 + pulp_rpm/app/models/content.py | 72 ++++++ pulp_rpm/app/models/repository.py | 33 +-- pulp_rpm/app/serializers/package.py | 41 +++- pulp_rpm/app/serializers/repository.py | 29 ++- pulp_rpm/app/shared_utils.py | 113 ++++++++- pulp_rpm/app/tasks/__init__.py | 1 + pulp_rpm/app/tasks/signing.py | 42 ++++ pulp_rpm/app/viewsets/package.py | 61 ++++- .../functional/api/test_package_signing.py | 111 +++++++++ pulp_rpm/tests/functional/conftest.py | 220 +++++++++++++++++- pulp_rpm/tests/sample-rpm-0-0.x86_64.rpm | Bin 0 -> 6069 bytes pulp_rpm/tests/unit/test_rpm_tool.py | 85 +++++++ requirements.txt | 1 + .../admin/guides/add-signing-services.md | 79 +++++++ staging_docs/user/guides/06-sign-packages.md | 50 ++++ 19 files changed, 952 insertions(+), 36 deletions(-) create mode 100644 CHANGES/2986.feature create mode 100644 pulp_rpm/app/migrations/0062_rpmpackagesigningservice_and_more.py create mode 100644 pulp_rpm/app/models/content.py create mode 100644 pulp_rpm/app/tasks/signing.py create mode 100644 pulp_rpm/tests/functional/api/test_package_signing.py create mode 100644 pulp_rpm/tests/sample-rpm-0-0.x86_64.rpm create mode 100644 pulp_rpm/tests/unit/test_rpm_tool.py create mode 100644 staging_docs/admin/guides/add-signing-services.md create mode 100644 staging_docs/user/guides/06-sign-packages.md diff --git a/CHANGES/2986.feature b/CHANGES/2986.feature new file mode 100644 index 000000000..bdc14d442 --- /dev/null +++ b/CHANGES/2986.feature @@ -0,0 +1 @@ +Added (tech preview) support for signing RPM packages when uploading to a Repository. diff --git a/MANIFEST.in b/MANIFEST.in index 7d107750b..4c58ef5f4 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,6 +6,7 @@ include functest_requirements.txt include LICENSE include pulp_rpm/app/schema/* include pulp_rpm/tests/functional/sign-metadata.sh +include pulp_rpm/tests/sample-rpm-0-0.x86_64.rpm include pyproject.toml include requirements.txt include test_requirements.txt diff --git a/pulp_rpm/app/migrations/0062_rpmpackagesigningservice_and_more.py b/pulp_rpm/app/migrations/0062_rpmpackagesigningservice_and_more.py new file mode 100644 index 000000000..03d28ef35 --- /dev/null +++ b/pulp_rpm/app/migrations/0062_rpmpackagesigningservice_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 4.2.10 on 2024-04-25 16:39 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("rpm", "0061_fix_modulemd_defaults_digest"), + ] + + operations = [ + migrations.CreateModel( + name="RpmPackageSigningService", + 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="rpmrepository", + name="package_signing_fingerprint", + field=models.TextField(max_length=40, null=True), + ), + migrations.AddField( + model_name="rpmrepository", + name="package_signing_service", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="rpm.rpmpackagesigningservice", + ), + ), + ] diff --git a/pulp_rpm/app/models/__init__.py b/pulp_rpm/app/models/__init__.py index 95b2e5f4b..055f8e1f9 100644 --- a/pulp_rpm/app/models/__init__.py +++ b/pulp_rpm/app/models/__init__.py @@ -5,6 +5,7 @@ UpdateReference, ) from .comps import PackageCategory, PackageEnvironment, PackageGroup, PackageLangpacks # noqa +from .content import RpmPackageSigningService # noqa from .custom_metadata import RepoMetadataFile # noqa from .distribution import Addon, Checksum, DistributionTree, Image, Variant # noqa from .modulemd import Modulemd, ModulemdDefaults, ModulemdObsolete # noqa diff --git a/pulp_rpm/app/models/content.py b/pulp_rpm/app/models/content.py new file mode 100644 index 000000000..366de3a6c --- /dev/null +++ b/pulp_rpm/app/models/content.py @@ -0,0 +1,72 @@ +import tempfile +from pathlib import Path + +from django.conf import settings +from pulpcore.plugin.exceptions import PulpException +from pulpcore.plugin.models import SigningService +from typing import Optional + +from pulp_rpm.app.shared_utils import RpmTool + + +class RpmPackageSigningService(SigningService): + """ + A model used for signing RPM 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_figerprint. + + 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 a Rpm Package signature. + + Specifically, it validates that self.signing_script can sign an rpm package with + the sample key self.pubkey and that the self.sign() method returns: + + ```json + {"rpm_package": ""} + ``` + + See [RpmTool.verify_signature][] for the signature verificaton method used. + """ + with tempfile.TemporaryDirectory(dir=settings.WORKING_DIRECTORY) as temp_directory_name: + # get and sign sample rpm + temp_file = RpmTool.get_empty_rpm(temp_directory_name) + return_value = self.sign(temp_file, pubkey_fingerprint=self.pubkey_fingerprint) + try: + return_value["rpm_package"] + except KeyError: + raise PulpException(f"Malformed output from signing script: {return_value}") + + # verify with rpm tool + rpm_tool = RpmTool(root=Path(temp_directory_name)) + rpm_tool.import_pubkey_string(self.public_key) + rpm_tool.verify_signature(temp_file) diff --git a/pulp_rpm/app/models/repository.py b/pulp_rpm/app/models/repository.py index bb2d08e20..e2dc4c7b3 100644 --- a/pulp_rpm/app/models/repository.py +++ b/pulp_rpm/app/models/repository.py @@ -1,29 +1,28 @@ -import re import os +import re import textwrap - from gettext import gettext as _ from logging import getLogger from aiohttp.web_response import Response -from django.core.exceptions import ObjectDoesNotExist from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist from django.db import models from pulpcore.plugin.download import DownloaderFactory from pulpcore.plugin.models import ( - AutoAddObjPermsMixin, Artifact, AsciiArmoredDetachedSigningService, + AutoAddObjPermsMixin, Content, ContentArtifact, + Distribution, + Publication, Remote, RemoteArtifact, Repository, RepositoryContent, RepositoryVersion, - Publication, PublishedMetadata, - Distribution, ) from pulpcore.plugin.repo_version_utils import ( remove_duplicates, @@ -32,22 +31,22 @@ ) from pulp_rpm.app.constants import CHECKSUM_CHOICES, COMPRESSION_CHOICES +from pulp_rpm.app.downloaders import RpmDownloader, RpmFileDownloader, UlnDownloader +from pulp_rpm.app.exceptions import DistributionTreeConflict from pulp_rpm.app.models import ( DistributionTree, + Modulemd, + ModulemdDefaults, + ModulemdObsolete, Package, PackageCategory, - PackageGroup, PackageEnvironment, + PackageGroup, PackageLangpacks, RepoMetadataFile, - Modulemd, - ModulemdDefaults, - ModulemdObsolete, + RpmPackageSigningService, UpdateRecord, ) - -from pulp_rpm.app.downloaders import RpmDownloader, RpmFileDownloader, UlnDownloader -from pulp_rpm.app.exceptions import DistributionTreeConflict from pulp_rpm.app.shared_utils import urlpath_sanitize log = getLogger(__name__) @@ -202,6 +201,10 @@ class RpmRepository(Repository, AutoAddObjPermsMixin): The name of a checksum type to use for metadata when generating metadata. package_checksum_type (String): The name of a default checksum type to use for packages when generating metadata. + package_signing_service (RpmPackageSigningService): + Signing service to be used on package signing operations related to this repository. + package_signing_fingerprint (String): + The V4 fingerprint (160 bits) to be used by @package_signing_service. repo_config (JSON): repo configuration that will be served by distribution compression_type(pulp_rpm.app.constants.COMPRESSION_TYPES): Compression type to use for metadata files. @@ -226,6 +229,10 @@ class RpmRepository(Repository, AutoAddObjPermsMixin): metadata_signing_service = models.ForeignKey( AsciiArmoredDetachedSigningService, on_delete=models.SET_NULL, null=True ) + package_signing_service = models.ForeignKey( + RpmPackageSigningService, on_delete=models.SET_NULL, null=True + ) + package_signing_fingerprint = models.TextField(null=True, max_length=40) original_checksum_types = models.JSONField(default=dict) last_sync_details = models.JSONField(default=dict) retain_package_versions = models.PositiveIntegerField(default=0) diff --git a/pulp_rpm/app/serializers/package.py b/pulp_rpm/app/serializers/package.py index 010985258..819fd2078 100644 --- a/pulp_rpm/app/serializers/package.py +++ b/pulp_rpm/app/serializers/package.py @@ -1,20 +1,17 @@ import logging import traceback - from gettext import gettext as _ -from rest_framework import serializers -from rest_framework.exceptions import NotAcceptable - from pulpcore.plugin.serializers import ( ContentChecksumSerializer, SingleArtifactContentUploadSerializer, ) from pulpcore.plugin.util import get_domain_pk +from rest_framework import serializers +from rest_framework.exceptions import NotAcceptable from pulp_rpm.app.models import Package -from pulp_rpm.app.shared_utils import read_crpackage_from_artifact, format_nvra - +from pulp_rpm.app.shared_utils import format_nvra, read_crpackage_from_artifact log = logging.getLogger(__name__) @@ -230,6 +227,7 @@ class PackageSerializer(SingleArtifactContentUploadSerializer, ContentChecksumSe def __init__(self, *args, **kwargs): """Initializer for RpmPackageSerializer.""" + super().__init__(*args, **kwargs) if "relative_path" in self.fields: self.fields["relative_path"].required = False @@ -322,6 +320,37 @@ class Meta: ) model = Package + 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 serializers.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"): + raise serializers.ValidationError( + _("To sign a package on upload, a file must be provided.") + ) + + return validated_data + class MinimalPackageSerializer(PackageSerializer): """ diff --git a/pulp_rpm/app/serializers/repository.py b/pulp_rpm/app/serializers/repository.py index 436091793..682cf8132 100644 --- a/pulp_rpm/app/serializers/repository.py +++ b/pulp_rpm/app/serializers/repository.py @@ -26,7 +26,14 @@ SKIP_TYPES, SYNC_POLICY_CHOICES, ) -from pulp_rpm.app.models import RpmDistribution, RpmPublication, RpmRemote, RpmRepository, UlnRemote +from pulp_rpm.app.models import ( + RpmDistribution, + RpmPackageSigningService, + RpmPublication, + RpmRemote, + RpmRepository, + UlnRemote, +) from pulp_rpm.app.schema import COPY_CONFIG_SCHEMA from urllib.parse import urlparse @@ -52,6 +59,24 @@ class RpmRepositorySerializer(RepositorySerializer): required=False, allow_null=True, ) + package_signing_service = RelatedField( + help_text="A reference to an associated package signing service.", + view_name="signing-services-detail", + queryset=RpmPackageSigningService.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="", + ) retain_package_versions = serializers.IntegerField( help_text=_( "The number of versions of each package to keep in the repository; " @@ -220,6 +245,8 @@ class Meta: fields = RepositorySerializer.Meta.fields + ( "autopublish", "metadata_signing_service", + "package_signing_service", + "package_signing_fingerprint", "retain_package_versions", "checksum_type", "metadata_checksum_type", diff --git a/pulp_rpm/app/shared_utils.py b/pulp_rpm/app/shared_utils.py index 3d1105c25..dd0ca6e22 100644 --- a/pulp_rpm/app/shared_utils.py +++ b/pulp_rpm/app/shared_utils.py @@ -1,10 +1,15 @@ -import createrepo_c as cr -import tempfile import shutil +import subprocess +import tempfile +import typing as t from hashlib import sha256 +from pathlib import Path +import createrepo_c as cr from django.conf import settings from django.utils.dateparse import parse_datetime +from importlib_resources import files +from pulpcore.plugin.exceptions import InvalidSignatureError def format_nevra(name=None, epoch=0, version=None, release=None, arch=None): @@ -158,3 +163,107 @@ def parse_time(value): int | datetime | None: formatted time value """ return int(value) if value.isdigit() else parse_datetime(value) + + +def _get_datapkg_sample_rpm_copy(basedir: str): + sample_rpm = files("pulp_rpm").joinpath("tests/sample-rpm-0-0.x86_64.rpm") + copy_rpm = shutil.copy(sample_rpm, basedir) + return Path(copy_rpm) + + +class RpmTool: + """ + A wrapper utility for rpm cli tool. + + Args: + root: Alternative root directory passed to `rpm --root` + """ + + INVALID_SIGNATURE_ERROR_MSG = "Signature is invalid or pubkey is unreachable" + UNKNOWN_ERROR_MSG = "Some unknown error occurred" + UNSIGNED_ERROR_MSG = "The package is not signed" + + def __init__(self, root: t.Optional[Path] = None): + completed_process = subprocess.run( + ["which", "rpmsign"], stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + if completed_process.returncode != 0: + raise RuntimeError("Rpm cli tool is not installed on your system.") + + self.opts = ["--root", str(root.absolute())] if root else [] + + @staticmethod + def get_empty_rpm(basedir: str) -> Path: + """ + Get an empty rpm package. + + Args: + basedir: The dir where the rpm will be placed. + """ + return _get_datapkg_sample_rpm_copy(basedir) + + def import_pubkey_file(self, pubkey_file: str): + """ + Import public_key file (ascii-armored) into the rpm-tool. + + Args: + import_pubkey: The public key file in ascii-armored format. + """ + cmd = ("rpm", *self.opts, "--import", pubkey_file) + completed_process = subprocess.run(cmd, capture_output=True) + if completed_process.returncode != 0: + raise RuntimeError( + f"Could not import public key into rpm-tool:\n{completed_process.stderr.decode()}" + ) + + def import_pubkey_string(self, pubkey_content: str): + """ + Import public_key string (ascii-armored) into the rpm-tool. + + Parameters: + import_pubkey: The public key string in ascii-armored format. + """ + with tempfile.NamedTemporaryFile() as pubkey_file: + pubkey_file.write(pubkey_content.encode()) + pubkey_file.flush() + self.import_pubkey_file(pubkey_file.name) + + def verify_signature(self, rpm_package_file: Path, raises=True): + """ + Verify that an Rpm Package is signed by some of the imported pubkey. + + Parameters: + rpm_package_file: Path object to rpm package + + Returns: + True (if has valid) + + Raises: + InvalidSignature (for invalid/unsigned package) + + Notes: + This is based on the command: `rpm --checksig camel-0.1-1.noarch.rpm` + Which have the following scenarios/outputs: + + - unsigned: + * returncode: 0 + * output: "camel-0.1-1.noarch.rpm: digests OK" + - signed, but rpm doesnt have pubkey imported: + * returncode: 1 + * output: "camel-0.1-1.noarch.rpm: digests SIGNATURES NOT OK" + - signed and rpm can validate: + * returncode: 0 + * output: "camel-0.1-1.noarch.rpm: digests signatures OK" + """ + cmd = ("rpm", *self.opts, "--checksig", str(rpm_package_file.resolve())) + completed_process = subprocess.run(cmd, capture_output=True) + stdout = completed_process.stdout.decode() + stderr = completed_process.stderr.decode() + output = f"\nstdout: {stdout}\nstderr: {stderr}" + if completed_process.returncode != 0: + if "SIGNATURES NOT OK" in stdout: + raise InvalidSignatureError(f"{RpmTool.INVALID_SIGNATURE_ERROR_MSG}: {output}") + raise TypeError(f"{RpmTool.UNKNOWN_ERROR_MSG}: {output}") + elif "signatures" not in output: + raise InvalidSignatureError(f"{RpmTool.UNSIGNED_ERROR_MSG}: {output}") + return True diff --git a/pulp_rpm/app/tasks/__init__.py b/pulp_rpm/app/tasks/__init__.py index 9a7407322..1b1537163 100644 --- a/pulp_rpm/app/tasks/__init__.py +++ b/pulp_rpm/app/tasks/__init__.py @@ -1,4 +1,5 @@ from .publishing import publish # noqa from .synchronizing import synchronize # noqa +from .signing import sign_and_create # noqa from .copy import copy_content # noqa from .comps import upload_comps # noqa diff --git a/pulp_rpm/app/tasks/signing.py b/pulp_rpm/app/tasks/signing.py new file mode 100644 index 000000000..807cbfbf0 --- /dev/null +++ b/pulp_rpm/app/tasks/signing.py @@ -0,0 +1,42 @@ +from tempfile import NamedTemporaryFile + +from pulpcore.plugin.models import Artifact, CreatedResource, PulpTemporaryFile +from pulpcore.plugin.tasking import general_create +from pulpcore.plugin.util import get_url + +from pulp_rpm.app.models.content import RpmPackageSigningService + + +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 = RpmPackageSigningService.objects.get(pk=signing_service_pk) + uploaded_package = PulpTemporaryFile.objects.get(pk=temporary_file_pk) + with NamedTemporaryFile(mode="wb", dir=".", delete=False) as final_package: + with uploaded_package.file.open() as fd: + final_package.write(fd.read()) + final_package.flush() + + package_signing_service.sign(final_package.name, pubkey_fingerprint=signing_fingerprint) + artifact = Artifact.init_and_validate(final_package.name) + 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 + general_create(app_label, serializer_name, data=data, context=context, *args, **kwargs) diff --git a/pulp_rpm/app/viewsets/package.py b/pulp_rpm/app/viewsets/package.py index f467e716a..0a70ab380 100644 --- a/pulp_rpm/app/viewsets/package.py +++ b/pulp_rpm/app/viewsets/package.py @@ -1,17 +1,17 @@ from django_filters import CharFilter - +from drf_spectacular.utils import extend_schema +from pulpcore.plugin.models import PulpTemporaryFile +from pulpcore.plugin.serializers import AsyncOperationResponseSerializer +from pulpcore.plugin.tasking import dispatch from pulpcore.plugin.viewsets import ( ContentFilter, + OperationPostponedResponse, SingleArtifactContentUploadViewSet, ) -from pulp_rpm.app.models import ( - Package, -) -from pulp_rpm.app.serializers import ( - MinimalPackageSerializer, - PackageSerializer, -) +from pulp_rpm.app import tasks as rpm_tasks +from pulp_rpm.app.models import Package +from pulp_rpm.app.serializers import MinimalPackageSerializer, PackageSerializer class PackageFilter(ContentFilter): @@ -71,3 +71,48 @@ class PackageViewSet(SingleArtifactContentUploadViewSet): ], "queryset_scoping": {"function": "scope_queryset"}, } + + @extend_schema( + description="Trigger an asynchronous task to create an RPM 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 + request.data.pop("file") + validated_data = serializer.validated_data + temp_uploaded_file = validated_data["file"] + signing_service_pk = validated_data["repository"].package_signing_service.pk + signing_fingerprint = validated_data["repository"].package_signing_fingerprint + + # dispatch signing task + pulp_temp_file = PulpTemporaryFile(file=temp_uploaded_file.temporary_file_path()) + 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( + rpm_tasks.signing.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) diff --git a/pulp_rpm/tests/functional/api/test_package_signing.py b/pulp_rpm/tests/functional/api/test_package_signing.py new file mode 100644 index 000000000..670b0e71d --- /dev/null +++ b/pulp_rpm/tests/functional/api/test_package_signing.py @@ -0,0 +1,111 @@ +from dataclasses import dataclass +from pathlib import Path + +import pytest +import requests +from pulpcore.exceptions.validation import InvalidSignatureError + +from pulp_rpm.app.shared_utils import RpmTool +from pulp_rpm.tests.functional.constants import RPM_PACKAGE_FILENAME, RPM_UNSIGNED_URL +from pulp_rpm.tests.functional.utils import get_package_repo_path + + +def get_fixture(path: Path, url: str) -> Path: + path.write_bytes(requests.get(url).content) + return path + + +@pytest.mark.parallel +def test_register_rpm_package_signing_service(rpm_package_signing_service): + """ + Register a sample rpmsign-based signing service and validate it works. + """ + service = rpm_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 ( + 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, + pulpcore_bindings, + monitor_task, + gen_object_with_cleanup, + download_content_unit, + signing_gpg_extra, + rpm_package_signing_service, + rpm_package_api, + rpm_repository_factory, + rpm_publication_factory, + rpm_package_factory, + rpm_distribution_factory, +): + """ + Sign an Rpm Package with the Package Upload endpoint. + + This ensures different + """ + # Setup RPM tool and package to upload + gpg_a, gpg_b = signing_gpg_extra + fingerprint_set = set([gpg_a.fingerprint, gpg_b.fingerprint]) + assert len(fingerprint_set) == 2 + + rpm_tool = RpmTool(tmp_path) + rpm_tool.import_pubkey_string(gpg_a.pubkey) + rpm_tool.import_pubkey_string(gpg_b.pubkey) + + file_to_upload = tmp_path / RPM_PACKAGE_FILENAME + file_to_upload.write_bytes(requests.get(RPM_UNSIGNED_URL).content) + with pytest.raises(InvalidSignatureError, match="The package is not signed: .*"): + rpm_tool.verify_signature(file_to_upload) + + # Upload Package to Repository + # The same file is uploaded, but signed with different keys each time + for fingerprint in fingerprint_set: + repository = rpm_repository_factory( + package_signing_service=rpm_package_signing_service.pulp_href, + package_signing_fingerprint=fingerprint, + ) + upload_response = rpm_package_api.create( + file=str(file_to_upload.absolute()), + repository=repository.pulp_href, + ) + package_href = monitor_task(upload_response.task).created_resources[2] + pkg_location_href = rpm_package_api.read(package_href).location_href + + # Verify that the final served package is signed + publication = rpm_publication_factory(repository=repository.pulp_href) + distribution = rpm_distribution_factory(publication=publication.pulp_href) + downloaded_package = tmp_path / "package.rpm" + downloaded_package.write_bytes( + download_content_unit(distribution.base_path, get_package_repo_path(pkg_location_href)) + ) + assert rpm_tool.verify_signature(downloaded_package) diff --git a/pulp_rpm/tests/functional/conftest.py b/pulp_rpm/tests/functional/conftest.py index 5b69b173f..286098d00 100644 --- a/pulp_rpm/tests/functional/conftest.py +++ b/pulp_rpm/tests/functional/conftest.py @@ -1,34 +1,36 @@ import hashlib import json +import subprocess import uuid +from dataclasses import dataclass from tempfile import NamedTemporaryFile +import gnupg import pytest import requests - from pulpcore.client.pulp_rpm import ( AcsRpmApi, ContentAdvisoriesApi, ContentDistributionTreesApi, + ContentModulemdDefaultsApi, + ContentModulemdObsoletesApi, + ContentModulemdsApi, ContentPackagecategoriesApi, ContentPackagegroupsApi, ContentPackagelangpacksApi, ContentPackagesApi, - ContentModulemdsApi, - ContentModulemdDefaultsApi, - ContentModulemdObsoletesApi, RemotesUlnApi, - RpmCopyApi, RpmCompsApi, + RpmCopyApi, RpmRepositorySyncURL, ) from pulp_rpm.tests.functional.constants import ( BASE_TEST_JSON, RPM_KICKSTART_FIXTURE_URL, - RPM_SIGNED_URL, RPM_MODULAR_FIXTURE_URL, RPM_SIGNED_FIXTURE_URL, + RPM_SIGNED_URL, ) from pulp_rpm.tests.functional.utils import init_signed_repo_configuration @@ -346,3 +348,209 @@ def _cleanup_domains( assert content_api_client.list(pulp_domain=domain.name).count == 0 return _cleanup_domains + + +# package signing + +SIGNING_SCRIPT_STRING = r"""#!/usr/bin/env bash +# Rpm configuration: +# GPG_HOME: gpg home directory +# GPG_NAME: gpg user identity +# GPG_BIN: gpg binary path + +FILE_PATH=$1 +GPG_HOME=HOMEDIRHERE +GPG_BIN=/usr/bin/gpg + +# user id can be specified by a fingerprint: +# see https://www.gnupg.org/documentation/manuals/gnupg/Specify-a-User-ID.html +GPG_NAME="${PULP_SIGNING_KEY_FINGERPRINT}" + +# Sign the package +rpm \ + --define "_signature gpg" \ + --define "_gpg_path ${GPG_HOME}" \ + --define "_gpg_name ${GPG_NAME}" \ + --define "_gpgbin ${GPG_BIN}" \ + --addsign "${FILE_PATH}" 1> /dev/null + +# Check the exit status +STATUS=$? +if [[ ${STATUS} -eq 0 ]]; then + echo {\"rpm_package\": \"${FILE_PATH}\"} +else + exit ${STATUS} +fi +""" + + +@pytest.fixture(scope="session") +def signing_script_path(signing_script_temp_dir, signing_gpg_homedir_path): + signing_script_file = signing_script_temp_dir / "sign-rpm-package.sh" + signing_script_file.write_text( + 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_rpm_package_signing_service(signing_script_path, signing_gpg_metadata): + """ + Runs the test signing script manually, locally, and returns the signature file produced. + """ + + def _sign_with_rpm_package_signing_service(filename): + env = {"PULP_SIGNING_KEY_FINGERPRINT": signing_gpg_metadata[1]} + cmd = (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_rpm_package_signing_service + + +@dataclass +class GPGMetadata: + public_key: str + fingerprint: str + keyid: str + + +@pytest.fixture(scope="session") +def signing_gpg_metadata2(signing_gpg_homedir_path) -> tuple[gnupg.GPG, list[GPGMetadata]]: + """ + A fixture that returns a GPG instance and related metadata (i.e., fingerprint, keyid). + """ + PRIVATE_KEY_URLS = ( + "https://raw.githubusercontent.com/pulp/pulp-fixtures/master/common/GPG-PRIVATE-KEY-fixture-signing", # noqa: E501 + "https://raw.githubusercontent.com/pulp/pulp-fixtures/master/common/GPG-PRIVATE-KEY-pulp-qe", # noqa: E501 + ) + + gpg = gnupg.GPG(gnupghome=signing_gpg_homedir_path) + keys = [] + for privatekey_url in PRIVATE_KEY_URLS: + response_private = requests.get(privatekey_url) + response_private.raise_for_status() + + gpg.import_keys(response_private.content) + key_info = gpg.list_keys()[-1] + gpg_md = GPGMetadata( + fingerprint=key_info["fingerprint"], + keyid=key_info["keyid"], + public_key=gpg.export_keys(key_info["keyid"]), + ) + gpg.trust_keys(gpg_md.fingerprint, "TRUST_ULTIMATE") + keys.append(gpg_md) + + return (gpg, keys) + + +@pytest.fixture(scope="session") +def signing_gpg_metadata(signing_gpg_homedir_path): + """ + A fixture that returns a GPG instance and related metadata (i.e., fingerprint, keyid). + """ + PRIVATE_KEY_URL = "https://raw.githubusercontent.com/pulp/pulp-fixtures/master/common/GPG-PRIVATE-KEY-fixture-signing" # noqa: E501 + + response_private = requests.get(PRIVATE_KEY_URL) + response_private.raise_for_status() + + gpg = gnupg.GPG(gnupghome=signing_gpg_homedir_path) + gpg.import_keys(response_private.content) + + fingerprint = gpg.list_keys()[0]["fingerprint"] + keyid = gpg.list_keys()[0]["keyid"] + + gpg.trust_keys(fingerprint, "TRUST_ULTIMATE") + + return gpg, fingerprint, keyid + + +@pytest.fixture(scope="session") +def pulp_trusted_public_key(signing_gpg_metadata): + """Fixture to extract the ascii armored trusted public test key.""" + gpg, _, keyid = signing_gpg_metadata + return gpg.export_keys([keyid]) + + +@pytest.fixture(scope="session") +def pulp_trusted_public_key_fingerprint(signing_gpg_metadata): + """Fixture to extract the ascii armored trusted public test keys fingerprint.""" + return signing_gpg_metadata[1] + + +@pytest.fixture(scope="session") +def _rpm_package_signing_service_name( + bindings_cfg, + 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(signing_script_path), + fingerprint, + "--class", + "rpm:RpmPackageSigningService", + "--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", + "rpm:RpmPackageSigningService", + ) + subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + +@pytest.fixture +def rpm_package_signing_service(_rpm_package_signing_service_name, signing_service_api_client): + return signing_service_api_client.list(name=_rpm_package_signing_service_name).results[0] diff --git a/pulp_rpm/tests/sample-rpm-0-0.x86_64.rpm b/pulp_rpm/tests/sample-rpm-0-0.x86_64.rpm new file mode 100644 index 0000000000000000000000000000000000000000..60123d4f32d282d721988e60fd65f292df9ec706 GIT binary patch literal 6069 zcmeHLOKcoP5bgDD5<`SA0*Q~Xv+jW-!%z5jgt5CMA9PfQ=RJQ`p-{;9Jn**jgK!klUGWjm2+cqZ|g&2=8?k?RExi<~g99gn#tvrX66Z9NDr*J3u) zbtBY6hxtOtz%?x?9X*f^hla52$@jlCwR4~7AAJ>d&R@9r)&e+R8HVbQT7g=DT7g=D zT7g=DT7g=DT7g=DT7g=DT7g=D|GNUinaJ+$?wcS4%rpquH#cd+Oyd%`o&tlpM)Mm$ zToZzggW-d751f5V7uO#L#P#8bH?c2r zsraPgk>W=b$N3cGHy<+N*g`WTMAITJ_@eY|g{n=*pDES|R&cB{fmv(Fj>tI=MpD(=wML zj$#>-Bu)q0WZQE#9k#DN&i}Ns5E{$^_qq4IPC*WJ*z{1CyFr|r1}RA#OwDKgypUxn z!^Jp}CwO0$(?rG4vk-S>S>5uTX@)nXhOZf#F+B9>j*R0-6OFE#Z5kz2*5QLHgV%^x6e!Jt7p165@W^c; z^D53#AYJ$`q{xbpc7>qjR$Pgmgg_qWvQ1C(B!SN~qXG_`@HnMgMV5AH!c#~+EsG*6 zI#E{ip-U;npops-@aHpheRFa3)U(TrXExVX*H6tKo1qJLcskZ-<-+FD%JR|qjg8Hx z*Vc|f&gzM^HpOqT?92|zVip9S1#M>w8TNRkfcAJ1%2b9_2m?-4R$QE=c>xzfR9QjG z3OD*M>hLtp0yr3!Y16*L+6dJkl^wWcvWNv#=jBc+dQdSNlys18#VOoI9waE0YAq68 z_VzXa>Ow$7w{y@j>&F$13b-&d&*HR_1vK09Cz40E2Z_kzjJyoDf*8KK$#kRx9o}x* zO$U4TU{FQWBb@2N4H*-r$4tjJc;x%G2t4K)-0@t)2}8>cOw;oX-?Cll%E)$^AL)k9 zU=SP*tBsphs58?HSR}dWMXqZ|&oy0U1cBx1Ffk4+5$d+(!IlH&7^Y=O&(xt!V7uJJ z-;&V}`tC0N4w8wf^Rw4UZ?ZwgjvhSx#jl_L(7!bC_kqUO{ljGGx$@fXyI+1fb7+h- JpSVFD`v;8}on-(3 literal 0 HcmV?d00001 diff --git a/pulp_rpm/tests/unit/test_rpm_tool.py b/pulp_rpm/tests/unit/test_rpm_tool.py new file mode 100644 index 000000000..7f85e79e1 --- /dev/null +++ b/pulp_rpm/tests/unit/test_rpm_tool.py @@ -0,0 +1,85 @@ +import socket +from pathlib import Path + +import pytest +import requests +from pulpcore.plugin.exceptions import InvalidSignatureError + +from pulp_rpm.app.shared_utils import RpmTool +from pulp_rpm.tests.functional.constants import PUBLIC_GPG_KEY_URL, RPM_SIGNED_URL, RPM_UNSIGNED_URL + + +def connection_guard(*args, **kwargs): + raise Exception("I told you not to use the Internet!") + + +def get_fixture(tmp_path: Path, url: str): + """Utility to get unsigned package""" + result = requests.get(url) + file = tmp_path / result.url.split("/")[-1] + file.write_bytes(result.content) + return file + + +def test_can_get_empty_rpm(tmp_path, monkeypatch): + """ + Can get a valid rpm without hitting the internet. + + This rpm can be used in production by the SigningService.validate() method, which + is used to validate a provided signing script knows how to sign an rpm blob. + """ + # Should't hit the internet + # https://stackoverflow.com/a/18601897 + monkeypatch.setattr(socket, "socket", connection_guard) + rpm_pkg = RpmTool.get_empty_rpm(tmp_path) + assert rpm_pkg.exists() + + +def test_verify_signature_is_valid(tmp_path): + """Can verify that a package is unsigned""" + pkg_file = get_fixture(tmp_path, RPM_SIGNED_URL) + pubkey = get_fixture(tmp_path, PUBLIC_GPG_KEY_URL) + + rpm_tool = RpmTool(tmp_path) + rpm_tool.import_pubkey_file(pubkey) + rpm_tool.verify_signature(pkg_file) + + +def test_verify_package_is_unsigned(tmp_path): + """Can verify that a package is unsigned""" + pkg_file = get_fixture(tmp_path, RPM_UNSIGNED_URL) + + rpm_tool = RpmTool(tmp_path) + with pytest.raises(InvalidSignatureError, match=RpmTool.UNSIGNED_ERROR_MSG): + rpm_tool.verify_signature(pkg_file) + + +def test_verify_signature_is_invalid(tmp_path): + """Can verify that a package's signature is invalid. + That means that no pubkeys in the rpm db can validate the package. + """ + pkg_file = get_fixture(tmp_path, RPM_SIGNED_URL) + + rpm_tool = RpmTool(tmp_path) + with pytest.raises(InvalidSignatureError, match=RpmTool.INVALID_SIGNATURE_ERROR_MSG): + rpm_tool.verify_signature(pkg_file) + + +def test_alternative_root_works(tmp_path): + """Can use alternative "rpm --root" option for isolated operations.""" + pkg_file = get_fixture(tmp_path, RPM_SIGNED_URL) + pubkey = get_fixture(tmp_path, PUBLIC_GPG_KEY_URL) + + # 1. First instance imports pubkey using a custom root + root_1 = tmp_path / "root_1" + rpm_tool_1 = RpmTool(root=root_1) + rpm_tool_1.import_pubkey_file(str(pubkey)) + rpm_tool_1.verify_signature(pkg_file) + + # 2. Second instance uses a different root: + # Because it doesnt have access to the other db (in root_1), + # it raises for not being able to find the pubkey to verify the pkg + root_2 = tmp_path / "root_2" + rpm_tool_2 = RpmTool(root=root_2) + with pytest.raises(InvalidSignatureError, match=RpmTool.INVALID_SIGNATURE_ERROR_MSG): + rpm_tool_2.verify_signature(pkg_file) diff --git a/requirements.txt b/requirements.txt index 57f1fdedd..6168db448 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ productmd~=1.33.0 pulpcore>=3.44.1,<3.55 solv~=0.7.21 aiohttp_xmlrpc~=1.5.0 +importlib-resources~=6.4.0 diff --git a/staging_docs/admin/guides/add-signing-services.md b/staging_docs/admin/guides/add-signing-services.md new file mode 100644 index 000000000..3ba7dd543 --- /dev/null +++ b/staging_docs/admin/guides/add-signing-services.md @@ -0,0 +1,79 @@ +# Register Signing Services + +Create a `SigningService` for signing RPM metadata (`repomd.xml`) or RPM Packages. + +## Metadata Signing + +RPM metadata signing uses detached signature, which is already provided by pulpcore. +To register such a service, follow the general instructions [in pulpcore](site:pulpcore/docs/admin/guides/sign-metadata/). + +## Package Signing + +!!! tip "New in 3.26.0 (Tech Preview)" + +Package signing is not detached as metadata signing, so it uses a different type of `SigningService`. +Nevertheless, the process of registering is very similar. + +### Pre-Requisites + +- Get familiar with the general SigningService registration [here](site:pulpcore/docs/admin/guides/sign-metadata/). + +### Instructions + +1. Create a signing script capable of signing an RPM Package. + - The script receives a file path as its first argument. + - The script should return a json-formatted output. No signature is required, since its embedded. + ```json + {"file": "filename"} + ``` +1. Register it with `pulpcore-manager add-signing-service`. + - The `--class` should be `rpm:RpmPackageSigningService`. + - The key provided here serves only for validating the script. + The signing fingerprint is provided dynamically, as [on upload signing](site:pulp_rpm/docs/user/guides/06-sign-packages/#on-upload). +1. Retrieve the signing service for usage. + +### Example + +Write a signing script. +The following example is roughly what we use for testing. + +```bash title="package-signing-script.sh" +#!/usr/bin/env bash + +# Input provided to the script +FILE_PATH=$1 +FINGERPRINT="${PULP_SIGNING_KEY_FINGERPRINT}" + +# Specific signing logic +GPG_HOME=${HOME}/.gnupg +GPG_BIN=/usr/bin/gpg +rpm \ + --define "_signature gpg" \ + --define "_gpg_path ${GPG_HOME}" \ + --define "_gpg_name ${FINGERPRINT}" \ + --define "_gpgbin ${GPG_BIN}" \ + --addsign "${FILE_PATH}" 1> /dev/null + +# Output +STATUS=$? +if [[ ${STATUS} -eq 0 ]]; then + echo {\"rpm_package\": \"${FILE_PATH}\"} +else + exit ${STATUS} +fi +``` + +Register the signing service and retrieve information about it. + +```bash +pulpcore-manager add-signing-service \ + "SimpleRpmSigningService" \ + ${SCRIPT_ABS_FILENAME} \ + ${KEYID} \ + --class "rpm:RpmPackageSigningService" + +pulp signing-service show --name "SimpleRpmSigningService" +``` + + + diff --git a/staging_docs/user/guides/06-sign-packages.md b/staging_docs/user/guides/06-sign-packages.md new file mode 100644 index 000000000..8ef980bd7 --- /dev/null +++ b/staging_docs/user/guides/06-sign-packages.md @@ -0,0 +1,50 @@ +# Sign RPM Packages + +Sign an RPM Package using a registered RPM signing service. + +Currently, only on-upload signing is supported. + +## On Upload + +!!! tip "New in 3.26.0 (Tech Preview)" + +Sign an RPM Package when uploading it to a Repository. + +### Pre-requisites + +- Have an `RpmPackageSigningService` registered + (see [here](site:pulp_rpm/docs/admin/guides/add-package-signing-service/#package-signing)). +- Have the V4 fingerprint of the key you want to use. The key should be accessible by the SigningService you are using. + +### Instructions + +1. Configure a Repository to enable signing. + - Both `package_signing_service` and `package_signing_fingerprint` must be set. + - If they are set, any package upload to the Repository will be signed by the service. +2. Upload a Package to this Repository. + +### Example + +```bash +# Create a Repository w/ required params +http POST $API_ROOT/repositories/rpm/rpm \ + name="MyRepo" \ + package_signing_service=$SIGNING_SERVICE_HREF \ + package_signing_fingerprint=$SIGNING_FINGERPRINT + +# Upload a package +pulp rpm content upload \ + --repository ${REPOSITORY} \ + --file ${FILE} +``` + +### Known Limitations + +**Traffic overhead**: The signing of a package should happen inside of a Pulp worker. + [By design](site:pulpcore/docs/dev/learn/triage-needed!/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. + +For extra context, see discussion [here](https://github.com/pulp/pulp_rpm/issues/2986).