Skip to content

Commit

Permalink
Handle PUT requests for pushing image signatures
Browse files Browse the repository at this point in the history
closes #502
  • Loading branch information
lubosmj authored and ipanova committed Jan 27, 2022
1 parent ee7c1d1 commit 4e8f515
Show file tree
Hide file tree
Showing 5 changed files with 173 additions and 2 deletions.
2 changes: 2 additions & 0 deletions CHANGES/502.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Added support for pushing image signatures to the Pulp Registry. The signatures can be pushed by
utilizing the extensions API.
39 changes: 39 additions & 0 deletions docs/workflows/sign-images.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://github.com/containers/image/blob/main/docs/containers-registries.d.5.md>`_
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/<namespace>/<name>/signatures/sha256:<manifest-digest>

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/<namespace>/<name>/signatures/sha256:<manifest-digest> < signature.json

The JSON payload has the same structure as described in the `container signature specs <https://github.com/containers/image/blob/main/docs/containers-signature.5.md>`_::

{
"schemaVersion": 2,
"type": "atomic",
"name": "sha256:4028782c08eae4a8c9a28bf661c0a8d1c2fc8e19dbaae2b018b21011197e1484@cddeb7006d914716e2728000746a0b23",
"content": "<cryptographic_signature>"
}

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/<namespace>/<name>
116 changes: 115 additions & 1 deletion pulp_container/app/registry_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"])

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()
15 changes: 14 additions & 1 deletion pulp_container/app/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]+)+)?$")

Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions pulp_container/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit 4e8f515

Please sign in to comment.