diff --git a/CHANGES/502.feature b/CHANGES/502.feature new file mode 100644 index 000000000..4cec50009 --- /dev/null +++ b/CHANGES/502.feature @@ -0,0 +1,2 @@ +Added support for pushing image signatures to the Pulp Registry. The signatures can be pushed by +utilizing the extensions API. diff --git a/docs/workflows/sign-images.rst b/docs/workflows/sign-images.rst index a4a6133a1..edd7f9401 100644 --- a/docs/workflows/sign-images.rst +++ b/docs/workflows/sign-images.rst @@ -298,3 +298,42 @@ It is possible to specify a single manifest identified by tag or a list of manif by proviging ``tags_list`` option to the call. Note that ``manifest lists`` are not signed, instead all the image manifests that manifest lists contain, are signed. + +Managing signatures via the Extensions API +========================================== + +This API exposes an endpoint for reading and writing image signatures. Users should configure the +sigstore section in the `registries.d file `_ +accordingly to benefit from the API. + +Reading image signatures +------------------------ + +To read existing signatures, issue the following GET request:: + + $ http GET http://localhost:24817/extensions/v2///signatures/sha256: + +Signatures are retrieved by container clients automatically if the policy requires so. The policy is +defined in the file ``/etc/containers/policy.json``. + +Writing image signatures +------------------------ + +To add a new signature to an image, execute the following PUT request:: + + $ http PUT http://localhost:24817/extensions/v2///signatures/sha256: < signature.json + +The JSON payload has the same structure as described in the `container signature specs `_:: + + { + "schemaVersion": 2, + "type": "atomic", + "name": "sha256:4028782c08eae4a8c9a28bf661c0a8d1c2fc8e19dbaae2b018b21011197e1484@cddeb7006d914716e2728000746a0b23", + "content": "" + } + +This step can be also done via podman or skopeo. After configuring a GPG keyring, it is possible to +issue the following command to push a tagged image altogether with its signature to the Pulp +Registry:: + + $ podman push --tls-verify=false --sign-by username@email.com localhost:24817// diff --git a/pulp_container/app/registry_api.py b/pulp_container/app/registry_api.py index 87aaa2fe3..cf9b0d1f0 100644 --- a/pulp_container/app/registry_api.py +++ b/pulp_container/app/registry_api.py @@ -4,6 +4,8 @@ . _Plugin Writer's Guide: http://docs.pulpproject.org/plugins/plugin-writer/index.html """ +import base64 +import binascii import json import logging import hashlib @@ -59,7 +61,13 @@ RegistryPermission, TokenPermission, ) -from pulp_container.constants import EMPTY_BLOB, SIGNATURE_HEADER +from pulp_container.app.utils import extract_data_from_signature +from pulp_container.constants import ( + EMPTY_BLOB, + SIGNATURE_HEADER, + SIGNATURE_PAYLOAD_MAX_SIZE, + SIGNATURE_TYPE, +) FakeView = namedtuple("FakeView", ["action", "get_object"]) @@ -169,6 +177,24 @@ def __init__(self, digest): ) +class ManifestSignatureInvalid(ParseError): + """An exception to render an HTTP 400 response with the code 'SIGNATURE_INVALID'.""" + + def __init__(self, digest): + """Initialize the exception with the digest of a signed manifest.""" + super().__init__( + detail={ + "errors": [ + { + "code": "SIGNATURE_INVALID", + "message": "signature invalid", + "detail": {"manifest_digest": digest}, + } + ] + } + ) + + class ContentRenderer(BaseRenderer): """ Rendered class for rendering Manifest and Blob responses. @@ -234,6 +260,22 @@ def __init__(self, manifest, path, request, status=200): super().__init__(headers=headers, status=status, content_type=manifest.media_type) +class ManifestSignatureResponse(Response): + """ + An HTTP response class after creating an image signature. + """ + + def __init__(self, signature, path, status=201): + """Initialize the headers with the path to the repository and corresponding digests.""" + headers = { + "Location": "/extensions/v2/{path}/signatures/{digest}".format( + path=path, digest=signature.signed_manifest.digest + ), + "Content-Length": 0, + } + super().__init__(headers=headers, status=status) + + class BlobResponse(Response): """ An HTTP response class for returning Blobs. @@ -1009,3 +1051,75 @@ def get_response_data(signatures): } data.append(signature) return {"signatures": data} + + def put(self, request, path, pk): + """Create a new signature from the received data.""" + _, repository = self.get_dr_push(request, path) + + try: + manifest = models.Manifest.objects.get( + digest=pk, pk__in=repository.latest_version().content + ) + except models.Manifest.DoesNotExist: + raise ManifestNotFound(reference=pk) + + signature_payload = request.META["wsgi.input"].read(SIGNATURE_PAYLOAD_MAX_SIZE) + try: + signature_dict = json.loads(signature_payload) + except json.decoder.JSONDecodeError: + raise ManifestSignatureInvalid(digest=pk) + + serializer = serializers.ManifestSignaturePutSerializer(data=signature_dict) + serializer.is_valid(raise_exception=True) + + try: + signature_raw = base64.b64decode(signature_dict["content"]) + except binascii.Error: + raise ManifestSignatureInvalid(digest=pk) + + signature_json = extract_data_from_signature(signature_raw, manifest.digest) + if signature_json is None: + raise ManifestSignatureInvalid(digest=pk) + + sig_digest = hashlib.sha256(signature_raw).hexdigest() + signature = models.ManifestSignature( + name=f"{manifest.digest}@{sig_digest[:32]}", + digest=f"sha256:{sig_digest}", + type=SIGNATURE_TYPE.ATOMIC_SHORT, + key_id=signature_json["signing_key_id"], + timestamp=signature_json["signature_timestamp"], + creator=signature_json["optional"].get("creator"), + data=signature_dict["content"], + signed_manifest=manifest, + ) + try: + signature.save() + except IntegrityError: + signature = models.ManifestSignature.objects.get(digest=signature.digest) + signature.touch() + + dispatched_task = dispatch( + add_and_remove, + exclusive_resources=[repository], + kwargs={ + "repository_pk": str(repository.pk), + "add_content_units": [str(signature.pk)], + "remove_content_units": [], + }, + ) + + # wait a small amount of time until a new repository version + # with the new signature is created + for dummy in range(3): + time.sleep(1) + task = Task.objects.get(pk=dispatched_task.pk) + if task.state == "completed": + task.delete() + return ManifestSignatureResponse(signature, path) + elif task.state in ["waiting", "running"]: + continue + else: + error = task.error + task.delete() + raise Exception(str(error)) + raise Throttled() diff --git a/pulp_container/app/serializers.py b/pulp_container/app/serializers.py index 81bcde40d..a1ef711b7 100644 --- a/pulp_container/app/serializers.py +++ b/pulp_container/app/serializers.py @@ -30,8 +30,10 @@ validate_unknown_fields, ) -from . import models +from pulp_container.app import models +from pulp_container.constants import SIGNATURE_TYPE +VALID_SIGNATURE_NAME_REGEX = r"^sha256:[0-9a-f]{64}@[0-9a-f]{32}$" VALID_TAG_REGEX = r"^[A-Za-z0-9][A-Za-z0-9._-]*$" VALID_BASE_PATH_REGEX_COMPILED = re.compile(r"^[a-z0-9]+(?:(?:(?:[._]|__|[-]*)[a-z0-9]+)+)?$") @@ -160,6 +162,17 @@ class Meta: model = models.ManifestSignature +class ManifestSignaturePutSerializer(serializers.Serializer): + """ + A serializer for image signatures provided in a PUT request. + """ + + name = serializers.RegexField(regex=VALID_SIGNATURE_NAME_REGEX) + schemaVersion = serializers.IntegerField(max_value=2, min_value=2) + type = serializers.ChoiceField([SIGNATURE_TYPE.ATOMIC_SHORT]) + content = serializers.CharField() + + class RegistryPathField(serializers.CharField): """ Serializer Field for the registry_path field of the ContainerDistribution. diff --git a/pulp_container/constants.py b/pulp_container/constants.py index 498db001c..b03a276cb 100644 --- a/pulp_container/constants.py +++ b/pulp_container/constants.py @@ -35,3 +35,6 @@ SIGNATURE_SOURCE = SimpleNamespace(SIGSTORE="sigstore", API_EXTENSION="API extension") SIGNATURE_HEADER = "X-Registry-Supports-Signatures" + +MEGABYTE = 1_000_000 +SIGNATURE_PAYLOAD_MAX_SIZE = 4 * MEGABYTE