From f77550ffce7cb8b28ebf9c105e547dca8493a16f Mon Sep 17 00:00:00 2001 From: Gerrod Ubben Date: Fri, 5 Dec 2025 03:52:38 -0500 Subject: [PATCH] Add attestation upload support fixes: #984 --- CHANGES/984.feature | 1 + MANIFEST.in | 1 + docs/user/guides/attestation.md | 91 +++++++++++ docs/user/learn/tech-preview.md | 1 + pulp_python/app/models.py | 2 +- pulp_python/app/provenance.py | 71 +++++++++ pulp_python/app/pypi/serializers.py | 15 ++ pulp_python/app/pypi/views.py | 26 +++- pulp_python/app/serializers.py | 93 ++++++++++-- pulp_python/app/tasks/upload.py | 73 +++++++-- .../tests/functional/api/test_attestations.py | 141 ++++++++++++++++++ ...helf-reader-0.1.tar.gz.publish.attestation | 1 + ...r-0.1-py2-none-any.whl.publish.attestation | 1 + 13 files changed, 485 insertions(+), 32 deletions(-) create mode 100644 CHANGES/984.feature create mode 100644 docs/user/guides/attestation.md create mode 100644 pulp_python/app/provenance.py create mode 100644 pulp_python/tests/functional/assets/shelf-reader-0.1.tar.gz.publish.attestation create mode 100644 pulp_python/tests/functional/assets/shelf_reader-0.1-py2-none-any.whl.publish.attestation diff --git a/CHANGES/984.feature b/CHANGES/984.feature new file mode 100644 index 00000000..c4ca74bd --- /dev/null +++ b/CHANGES/984.feature @@ -0,0 +1 @@ +Added attestations field to package upload that will create a PEP 740 Provenance object for that content. diff --git a/MANIFEST.in b/MANIFEST.in index 82301a4a..58b7fb00 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -9,4 +9,5 @@ include functest_requirements.txt include test_requirements.txt include unittest_requirements.txt include pulp_python/app/webserver_snippets/* +include pulp_python/tests/functional/assets/* exclude releasing.md diff --git a/docs/user/guides/attestation.md b/docs/user/guides/attestation.md new file mode 100644 index 00000000..d15360a9 --- /dev/null +++ b/docs/user/guides/attestation.md @@ -0,0 +1,91 @@ +# Attestation Hosting (PEP 740) + +Pulp Python has support for uploading attestations as originally specified in [PEP 740](https://peps.python.org/pep-0740/). +Attestations are stored in Pulp as Provenance Content that can be added/synced/removed from python +repositories. The provenance objects will be available through the Simple API and served by the +[Integrity API matching PyPI's implementation](https://docs.pypi.org/api/integrity/). + +## Uploading Attestations + +Attestations can be uploaded to Pulp with its package as a JSON list under the field `attestations`. + +```bash +att=$(jq '[.]' twine-6.2.0.tar.gz.publish.attestation) +# multiple attestation files can be combined using --slurp and '.', jq --slurp '.' att1 att2 ... +http POST $PULP_API/pulp/api/v3/content/python/packages/ \ + repository="$PYTHON_REPO_HREF" \ + relative_path=twine-6.2.0.tar.gz \ + artifact=$PACKAGE_ARTIFACT_PRN \ + attestations:="$att" +``` + +The uploaded attestations can be found in the created Provenance object attached to the content in +the task report. + +```json +// Task output abbreviated +{ + "pulp_href": "/pulp/api/v3/tasks/019af033-c8e8-7a02-a583-0fac5e39e54b/", + "state": "completed", + "name": "pulpcore.app.tasks.base.general_create", + "created_resources": [ + "/pulp/api/v3/content/python/provenance/019aeb59-34bb-7ae4-ab95-4f8a62199be9/", + "/pulp/api/v3/content/python/packages/019aeb59-34b1-7c73-a746-aea2cc3fbd85/" + ], + "result": { + "prn": "prn:python.pythonpackagecontent:019aeb59-34b1-7c73-a746-aea2cc3fbd85", + "name": "twine", + "sha256": "418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8",, + "version": "6.2.0", + "artifact": "/pulp/api/v3/artifacts/019aeb59-33c3-7877-9787-22c34eb6c15b/", + "filename": "twine-6.2.0.tar.gz", + "pulp_href": "/pulp/api/v3/content/python/packages/019aeb59-34b1-7c73-a746-aea2cc3fbd85/", + // PRN of newly created Provenance object + "provenance": "prn:python.packageprovenance:019aeb59-34bb-7ae4-ab95-4f8a62199be9", + } +} +``` + +You can also use twine to upload your packages. Twine will find the attestations in files ending with +`.attestation` and attach them to the same filename during the upload. Pulp will then add the new +package and provenance object to the backing repository of the distribution. + +```bash +pulp python distribution create --name foo --base-path foo --repository foo +pypi-attestations sign dist/twine-6.2.0.tar.gz dist/twine-6.2.0-py3-none-any.whl +twine upload --repository-url $PULP_API/pypi/foo/simple/ --attestations dist/* +``` + +## Interacting with Provenance Content + +Provenance content can be directly uploaded to Pulp through its content endpoint. + +```bash +http POST $PULP_API/pulp/api/v3/content/python/provenance/ --form \ + file@twine.provenance \ + package="$PACKAGE_PRN" \ + repository="$REPO_PRN" +``` + +Provenance objects are artifactless content, their data is stored in a json field and are unique by +their sha256 digest. In a repository a provenance object is unique by their associated package, i.e +a package can only have one provenance in the repository at a time. Provenance objects can't be +modified after upload as content is immutable, but a new one can be uploaded to replace the existing +one. Since provenance objects are content they can be added, removed, and synced into repositories. +To sync provenance objects from an upstream repository set the `provenance` field on the remote. + +```bash +http PATCH $PULP_API/$FOO_REMOTE_HREF provenance=true +pulp python repository sync --repository foo --remote foo +``` + +## Downloading Provenance objects + +A package's provenance objects are exposed through its Simple page and downloaded from the Integrity +API. The attestations can then be verified using tools like `sigstore` or `pypi-attestations`. + +```bash +http $PULP_API/pypi/foo/simple/twine/ "Accept:application/vnd.pypi.simple.v1+json" | jq -r ".files[].provenance" + +http $PULP_API/pypi/foo/integrity/twine/6.2.0/twine-6.2.0.tar.gz/ +``` diff --git a/docs/user/learn/tech-preview.md b/docs/user/learn/tech-preview.md index a7c05015..32ed6d8e 100644 --- a/docs/user/learn/tech-preview.md +++ b/docs/user/learn/tech-preview.md @@ -10,3 +10,4 @@ The following features are currently being released as part of a tech preview - Create pull-through caches of remote sources. - Pulp Domain Support - RBAC support +- PEP 740 attestations upload and provenance syncing/serving. diff --git a/pulp_python/app/models.py b/pulp_python/app/models.py index b11b8a32..3ea252e3 100644 --- a/pulp_python/app/models.py +++ b/pulp_python/app/models.py @@ -21,8 +21,8 @@ ) from pulpcore.plugin.responses import ArtifactResponse -from pypi_attestations import Provenance from pathlib import PurePath +from .provenance import Provenance from .utils import ( artifact_to_python_content_data, canonicalize_name, diff --git a/pulp_python/app/provenance.py b/pulp_python/app/provenance.py new file mode 100644 index 00000000..ad46dfc0 --- /dev/null +++ b/pulp_python/app/provenance.py @@ -0,0 +1,71 @@ +from typing import Annotated, Literal, Union, get_args + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_snake +from pypi_attestations import ( + Attestation, + Distribution, + Publisher, +) + + +class _PermissivePolicy: + """A permissive verification policy that always succeeds.""" + + def verify(self, cert): + """Succeed regardless of the publisher's identity.""" + pass + + +class AnyPublisher(BaseModel): + """A fallback publisher for any kind not matching other publisher types.""" + + model_config = ConfigDict(alias_generator=to_snake, extra="allow") + + kind: str + + def _as_policy(self): + """Return a permissive policy that always succeed.""" + return _PermissivePolicy() + + +# Get the underlying Union type of the original Publisher +# Publisher is Annotated[Union[...], Field(discriminator="kind")] +_OriginalPublisherTypes = get_args(Publisher.__origin__) +# Add AnyPublisher to the list of original publisher types +_ExtendedPublisherTypes = (*_OriginalPublisherTypes, AnyPublisher) +_ExtendedPublisherUnion = Union[_ExtendedPublisherTypes] +# Create a new type that fallbacks to AnyPublisher +ExtendedPublisher = Annotated[_ExtendedPublisherUnion, Field(union_mode="left_to_right")] + + +class AttestationBundle(BaseModel): + """ + AttestationBundle object as defined in PEP740. + + PyPI only accepts attestations from TrustedPublishers (GitHub, GitLab, Google), but we will + accept from any user. + """ + + publisher: ExtendedPublisher + attestations: list[Attestation] + + +class Provenance(BaseModel): + """Provenance object as defined in PEP740.""" + + version: Literal[1] = 1 + attestation_bundles: list[AttestationBundle] + + +def verify_provenance(filename, sha256, provenance, offline=False): + """Verify the provenance object is valid for the package.""" + dist = Distribution(name=filename, digest=sha256) + for bundle in provenance.attestation_bundles: + publisher = bundle.publisher + policy = publisher._as_policy() + for attestation in bundle.attestations: + sig_bundle = attestation.to_bundle() + checkpoint = sig_bundle.log_entry._inner.inclusion_proof.checkpoint + staging = "sigstage.dev" in checkpoint.envelope + attestation.verify(policy, dist, staging=staging, offline=offline) diff --git a/pulp_python/app/pypi/serializers.py b/pulp_python/app/pypi/serializers.py index 1c9e91f0..5c910d5b 100644 --- a/pulp_python/app/pypi/serializers.py +++ b/pulp_python/app/pypi/serializers.py @@ -2,6 +2,8 @@ from gettext import gettext as _ from rest_framework import serializers +from pydantic import TypeAdapter, ValidationError +from pulp_python.app.provenance import Attestation from pulp_python.app.utils import DIST_EXTENSIONS, SUPPORTED_METADATA_VERSIONS from pulpcore.plugin.models import Artifact from pulpcore.plugin.util import get_domain @@ -70,6 +72,11 @@ class PackageUploadSerializer(serializers.Serializer): required=False, choices=SUPPORTED_METADATA_VERSIONS, ) + attestations = serializers.JSONField( + required=False, + help_text=_("A JSON list containing attestations for the package."), + write_only=True, + ) def validate(self, data): """Validates the request.""" @@ -98,6 +105,14 @@ def validate(self, data): } ) + if attestations := data.get("attestations"): + try: + attestations = TypeAdapter(list[Attestation]).validate_python(attestations) + except ValidationError as e: + raise serializers.ValidationError( + {"attestations": _("The uploaded attestations are not valid: {}".format(e))} + ) + sha256 = data.get("sha256_digest") digests = {"sha256": sha256} if sha256 else None artifact = Artifact.init_and_validate(file, expected_digests=digests) diff --git a/pulp_python/app/pypi/views.py b/pulp_python/app/pypi/views.py index 73faea37..4bbbb8a6 100644 --- a/pulp_python/app/pypi/views.py +++ b/pulp_python/app/pypi/views.py @@ -181,12 +181,15 @@ def upload(self, request, path): serializer = PackageUploadSerializer(data=request.data) serializer.is_valid(raise_exception=True) artifact, filename = serializer.validated_data["content"] + attestations = serializer.validated_data.get("attestations", None) repo_content = self.get_content(self.get_repository_version(self.distribution)) if repo_content.filter(filename=filename).exists(): return HttpResponseBadRequest(reason=f"Package {filename} already exists in index") if settings.PYTHON_GROUP_UPLOADS: - return self.upload_package_group(repo, artifact, filename, request.session) + return self.upload_package_group( + repo, artifact, filename, attestations, request.session + ) result = dispatch( tasks.upload, @@ -194,17 +197,20 @@ def upload(self, request, path): kwargs={ "artifact_sha256": artifact.sha256, "filename": filename, + "attestations": attestations, "repository_pk": str(repo.pk), }, ) return OperationPostponedResponse(result, request) - def upload_package_group(self, repo, artifact, filename, session): + def upload_package_group(self, repo, artifact, filename, attestations, session): """Steps 4 & 5, spawns tasks to add packages to index.""" start_time = datetime.now(tz=timezone.utc) + timedelta(seconds=5) task = "updated" if not session.get("start"): - task = self.create_group_upload_task(session, repo, artifact, filename, start_time) + task = self.create_group_upload_task( + session, repo, artifact, filename, attestations, start_time + ) else: sq = Session.objects.select_for_update(nowait=True).filter(pk=session.session_key) try: @@ -212,7 +218,7 @@ def upload_package_group(self, repo, artifact, filename, session): sq.first() current_start = datetime.fromisoformat(session["start"]) if current_start >= datetime.now(tz=timezone.utc): - session["artifacts"].append((str(artifact.sha256), filename)) + session["artifacts"].append((str(artifact.sha256), filename, attestations)) session["start"] = str(start_time) session.modified = False session.save() @@ -220,14 +226,18 @@ def upload_package_group(self, repo, artifact, filename, session): raise DatabaseError except DatabaseError: session.cycle_key() - task = self.create_group_upload_task(session, repo, artifact, filename, start_time) + task = self.create_group_upload_task( + session, repo, artifact, filename, attestations, start_time + ) data = {"session": session.session_key, "task": task, "task_start_time": start_time} return Response(data=data) - def create_group_upload_task(self, cur_session, repository, artifact, filename, start_time): + def create_group_upload_task( + self, cur_session, repository, artifact, filename, attestations, start_time + ): """Creates the actual task that adds the packages to the index.""" cur_session["start"] = str(start_time) - cur_session["artifacts"] = [(str(artifact.sha256), filename)] + cur_session["artifacts"] = [(str(artifact.sha256), filename, attestations)] cur_session.modified = False cur_session.save() task = dispatch( @@ -536,7 +546,7 @@ def retrieve(self, request, path, package, version, filename): name__normalize=package, version=version, filename=filename ).first() if package_content: - provenance = PackageProvenance.objects.filter(package=package_content).first() + provenance = self.get_provenances(repo_ver).filter(package=package_content).first() if provenance: return Response(data=provenance.provenance) return HttpResponseNotFound(f"{package} {version} {filename} provenance does not exist.") diff --git a/pulp_python/app/serializers.py b/pulp_python/app/serializers.py index a59e9932..091a27a1 100644 --- a/pulp_python/app/serializers.py +++ b/pulp_python/app/serializers.py @@ -5,14 +5,21 @@ from django.db.utils import IntegrityError from packaging.requirements import Requirement from rest_framework import serializers -from pydantic import ValidationError -from pypi_attestations import Distribution, Provenance, VerificationError +from pypi_attestations import AttestationError +from pydantic import TypeAdapter, ValidationError from pulpcore.plugin import models as core_models from pulpcore.plugin import serializers as core_serializers -from pulpcore.plugin.util import get_domain +from pulpcore.plugin.util import get_domain, get_prn, get_current_authenticated_user from pulp_python.app import models as python_models +from pulp_python.app.provenance import ( + Attestation, + Provenance, + verify_provenance, + AttestationBundle, + AnyPublisher, +) from pulp_python.app.utils import ( DIST_EXTENSIONS, artifact_to_python_content_data, @@ -296,6 +303,46 @@ class PythonPackageContentSerializer(core_serializers.SingleArtifactContentUploa allow_null=True, help_text=_("The SHA256 digest of the package's METADATA file."), ) + # PEP740 Attestations/Provenance + attestations = serializers.JSONField( + required=False, + help_text=_("A JSON list containing attestations for the package."), + write_only=True, + ) + provenance = serializers.SerializerMethodField( + read_only=True, help_text=_("The created provenance object on upload.") + ) + + def get_provenance(self, obj): + """Get the provenance for the package.""" + if provenance := getattr(obj, "provenance", None): + return get_prn(provenance) + return None + + def validate_attestations(self, value): + """Validate the attestations, turn into Attestation objects.""" + try: + if isinstance(value, str): + attestations = TypeAdapter(list[Attestation]).validate_json(value) + else: + attestations = TypeAdapter(list[Attestation]).validate_python(value) + except ValidationError as e: + raise serializers.ValidationError(_("Invalid attestations: {}".format(e))) + return attestations + + def handle_attestations(self, filename, sha256, attestations, offline=False): + """Handle converting attestations to a Provenance object.""" + user = get_current_authenticated_user() + publisher = AnyPublisher(kind="Pulp User", prn=get_prn(user)) + att_bundle = AttestationBundle(publisher=publisher, attestations=attestations) + provenance = Provenance(attestation_bundles=[att_bundle]) + try: + verify_provenance(filename, sha256, provenance, offline=offline) + except AttestationError as e: + raise serializers.ValidationError( + {"attestations": _("Attestations failed verification: {}".format(e))} + ) + return provenance.model_dump(mode="json") def deferred_validate(self, data): """ @@ -336,6 +383,8 @@ def deferred_validate(self, data): ) data.update(_data) + if attestations := data.pop("attestations", None): + data["provenance"] = self.handle_attestations(filename, data["sha256"], attestations) return data @@ -345,6 +394,29 @@ def retrieve(self, validated_data): ) return content.first() + def create(self, validated_data): + """Create new PythonPackageContent object.""" + repository = validated_data.pop("repository", None) + provenance = validated_data.pop("provenance", None) + content = super().create(validated_data) + if provenance: + prov_sha256 = python_models.PackageProvenance.calculate_sha256(provenance) + prov_model, _ = python_models.PackageProvenance.objects.get_or_create( + sha256=prov_sha256, + _pulp_domain=get_domain(), + defaults={"package": content, "provenance": provenance}, + ) + if core_models.Task.current(): + core_models.CreatedResource.objects.create(content_object=prov_model) + setattr(content, "provenance", prov_model) + if repository: + repository.cast() + content_to_add = [content.pk, content.provenance.pk] if provenance else [content.pk] + content_to_add = core_models.Content.objects.filter(pk__in=content_to_add) + with repository.new_version() as new_version: + new_version.add_content(content_to_add) + return content + class Meta: fields = core_serializers.SingleArtifactContentUploadSerializer.Meta.fields + ( "author", @@ -381,6 +453,8 @@ class Meta: "size", "sha256", "metadata_sha256", + "attestations", + "provenance", ) model = python_models.PythonPackageContent @@ -436,6 +510,10 @@ def validate(self, data): data.update(parse_project_metadata(vars(metadata))) # Overwrite filename from metadata data["filename"] = filename + if attestations := data.pop("attestations", None): + data["provenance"] = self.handle_attestations( + filename, data["sha256"], attestations, offline=True + ) return data class Meta(PythonPackageContentSerializer.Meta): @@ -497,14 +575,9 @@ def deferred_validate(self, data): _("The uploaded provenance is not valid: {}".format(e)) ) if data.pop("verify"): - dist = Distribution(name=data["package"].filename, digest=data["package"].sha256) try: - for attestation_bundle in provenance.attestation_bundles: - publisher = attestation_bundle.publisher - policy = publisher._as_policy() - for attestation in attestation_bundle.attestations: - attestation.verify(policy, dist) - except VerificationError as e: + verify_provenance(data["package"].filename, data["package"].sha256, provenance) + except AttestationError as e: raise serializers.ValidationError(_("Provenance verification failed: {}".format(e))) return data diff --git a/pulp_python/app/tasks/upload.py b/pulp_python/app/tasks/upload.py index 01fb4ec2..dcd7aa72 100644 --- a/pulp_python/app/tasks/upload.py +++ b/pulp_python/app/tasks/upload.py @@ -3,26 +3,38 @@ from datetime import datetime, timezone from django.db import transaction from django.contrib.sessions.models import Session -from pulpcore.plugin.models import Artifact, CreatedResource, ContentArtifact -from pulpcore.plugin.util import get_domain +from pydantic import TypeAdapter +from pulpcore.plugin.models import Artifact, CreatedResource, Content, ContentArtifact +from pulpcore.plugin.util import get_domain, get_current_authenticated_user, get_prn -from pulp_python.app.models import PythonPackageContent, PythonRepository +from pulp_python.app.models import PythonPackageContent, PythonRepository, PackageProvenance +from pulp_python.app.provenance import ( + Attestation, + AttestationBundle, + AnyPublisher, + Provenance, + verify_provenance, +) from pulp_python.app.utils import artifact_to_python_content_data -def upload(artifact_sha256, filename, repository_pk=None): +def upload(artifact_sha256, filename, attestations=None, repository_pk=None): """ Uploads a Python Package to Pulp Args: artifact_sha256: the sha256 of the artifact in Pulp to create a package from filename: the full filename of the package to create + attestations: optional list of attestations to create a provenance from repository_pk: the optional pk of the repository to add the content to """ domain = get_domain() pre_check = PythonPackageContent.objects.filter(sha256=artifact_sha256, _pulp_domain=domain) - content_to_add = pre_check or create_content(artifact_sha256, filename, domain) - content_to_add.get().touch() + content_to_add = [pre_check.first() or create_content(artifact_sha256, filename, domain)] + if attestations: + content_to_add += [create_provenance(content_to_add[0], attestations, domain)] + content_to_add = Content.objects.filter(pk__in=[c.pk for c in content_to_add]) + content_to_add.touch() if repository_pk: repository = PythonRepository.objects.get(pk=repository_pk) with repository.new_version() as new_version: @@ -45,13 +57,16 @@ def upload_group(session_pk, repository_pk=None): now = datetime.now(tz=timezone.utc) start_time = datetime.fromisoformat(session_data["start"]) if now >= start_time: - content_to_add = PythonPackageContent.objects.none() - for artifact_sha256, filename in session_data["artifacts"]: + content_to_add = Content.objects.none() + for artifact_sha256, filename, attestations in session_data["artifacts"]: pre_check = PythonPackageContent.objects.filter( sha256=artifact_sha256, _pulp_domain=domain - ) - content = pre_check or create_content(artifact_sha256, filename, domain) - content.get().touch() + ).first() + content = [pre_check or create_content(artifact_sha256, filename, domain)] + if attestations: + content += [create_provenance(content[0], attestations, domain)] + content = Content.objects.filter(pk__in=[c.pk for c in content]) + content.touch() content_to_add |= content if repository_pk: @@ -73,7 +88,7 @@ def create_content(artifact_sha256, filename, domain): filename: file name domain: the pulp_domain to perform this task in Returns: - queryset of the new created content + the newly created PythonPackageContent """ artifact = Artifact.objects.get(sha256=artifact_sha256, pulp_domain=domain) data = artifact_to_python_content_data(filename, artifact, domain) @@ -88,4 +103,36 @@ def create(): resource = CreatedResource(content_object=new_content) resource.save() - return PythonPackageContent.objects.filter(pk=new_content.pk) + return new_content + + +def create_provenance(package, attestations, domain): + """ + Creates PackageProvenance from attestations. + + Args: + package: the package to create the provenance for + attestations: the attestations to create the provenance from + domain: the pulp_domain to perform this task in + Returns: + the newly created PackageProvenance + """ + attestations = TypeAdapter(list[Attestation]).validate_python(attestations) + + user = get_current_authenticated_user() + publisher = AnyPublisher(kind="Pulp User", prn=get_prn(user)) + att_bundle = AttestationBundle(publisher=publisher, attestations=attestations) + provenance = Provenance(attestation_bundles=[att_bundle]) + verify_provenance(package.filename, package.sha256, provenance) + provenance_json = provenance.model_dump(mode="json") + + prov_sha256 = PackageProvenance.calculate_sha256(provenance_json) + prov_model, _ = PackageProvenance.objects.get_or_create( + sha256=prov_sha256, + _pulp_domain=domain, + defaults={"package": package, "provenance": provenance_json}, + ) + resource = CreatedResource(content_object=prov_model) + resource.save() + + return prov_model diff --git a/pulp_python/tests/functional/api/test_attestations.py b/pulp_python/tests/functional/api/test_attestations.py index 9200c8e6..2fb652f2 100644 --- a/pulp_python/tests/functional/api/test_attestations.py +++ b/pulp_python/tests/functional/api/test_attestations.py @@ -1,5 +1,10 @@ import pytest +import json import requests +import shutil +import subprocess +from pathlib import Path +from urllib.parse import urljoin from pypi_simple import PyPISimple @@ -19,6 +24,14 @@ def twine_package(): raise ValueError("Twine package not found") +def get_attestations(provenance_url): + """Get the attestations from the provenance url.""" + r = requests.get(provenance_url) + assert r.status_code == 200 + prov = r.json() + return prov["attestation_bundles"][0]["attestations"] + + @pytest.mark.parallel def test_crd_provenance(python_bindings, twine_package, python_content_factory, monitor_task): """ @@ -99,3 +112,131 @@ def test_integrity_api( r = requests.get(url) assert r.status_code == 200 assert r.json() == provenance.provenance + + +@pytest.mark.parallel +def test_attestation_upload(python_bindings, twine_package, monitor_task): + """Check that attestations can be uploaded along with a package.""" + attestations = get_attestations(twine_package.provenance_url) + body = { + "relative_path": twine_package.filename, + "file_url": twine_package.url, + "attestations": json.dumps(attestations), + } + task = python_bindings.ContentPackagesApi.create(**body).task + response = monitor_task(task) + + assert len(response.created_resources) == 2 + prov = python_bindings.ContentProvenanceApi.read(response.created_resources[1]) + assert prov.package == response.created_resources[0] + att_bundle = prov.provenance["attestation_bundles"][0] + assert att_bundle["attestations"] == attestations + assert att_bundle["publisher"]["kind"] == "Pulp User" + + +@pytest.mark.parallel +def test_attestation_sync_upload(python_bindings, twine_package, download_python_file): + """Check that attestations can be uploaded along with a package.""" + attestations = get_attestations(twine_package.provenance_url) + body = { + "file": download_python_file(twine_package.filename, twine_package.url), + "attestations": json.dumps(attestations), + } + content = python_bindings.ContentPackagesApi.upload(**body) + + assert content.provenance is not None + provs = python_bindings.ContentProvenanceApi.list(prn__in=[content.provenance]) + assert len(provs.results) == 1 + prov = provs.results[0] + assert prov.package == content.pulp_href + att_bundle = prov.provenance["attestation_bundles"][0] + assert att_bundle["attestations"] == attestations + assert att_bundle["publisher"]["kind"] == "Pulp User" + + +def test_attestation_twine_upload( + pulpcore_bindings, + python_content_summary, + python_empty_repo_distro, + python_package_dist_directory, + monitor_task, +): + """Tests that packages with attestations can be properly uploaded through Twine.""" + repo, distro = python_empty_repo_distro() + url = urljoin(distro.base_url, "legacy/") + dist_dir, _, _ = python_package_dist_directory + + # Copy attestation files from test assets to dist_dir + assets_dir = Path(__file__).parent.parent / "assets" + attestation_files = [ + "shelf-reader-0.1.tar.gz.publish.attestation", + "shelf_reader-0.1-py2-none-any.whl.publish.attestation", + ] + for attestation_file in attestation_files: + src = assets_dir / attestation_file + dst = dist_dir / attestation_file + shutil.copy2(src, dst) + + username, password = "admin", "password" + subprocess.run( + ( + "twine", + "upload", + "--attestations", + "--repository-url", + url, + dist_dir / "*", + "-u", + username, + "-p", + password, + ), + capture_output=True, + check=True, + ) + tasks = pulpcore_bindings.TasksApi.list(reserved_resources=repo.pulp_href).results + for task in reversed(tasks): + t = monitor_task(task.pulp_href) + repo_ver_href = t.created_resources[0] + + assert repo_ver_href.endswith("versions/2/") + summary = python_content_summary(repository_version=repo_ver_href) + assert summary.present["python.python"]["count"] == 2 + assert summary.present["python.provenance"]["count"] == 2 + + +@pytest.mark.parallel +def test_bad_attestation_upload(python_bindings, twine_package, monitor_task): + """Check that bad attestations are rejected.""" + attestations = get_attestations(twine_package.provenance_url) + attestation = attestations[0] + attestation["version"] = 2 # Only version 1 is supported + body = { + "relative_path": twine_package.filename, + "file_url": twine_package.url, + "attestations": json.dumps(attestations), + } + with pytest.raises(python_bindings.ApiException) as e: + python_bindings.ContentPackagesApi.create(**body) + assert e.value.status == 400 + assert "Invalid attestations" in e.value.body + + attestation["version"] = 1 + del attestation["envelope"] + body["attestations"] = json.dumps(attestations) + with pytest.raises(python_bindings.ApiException) as e: + python_bindings.ContentPackagesApi.create(**body) + assert e.value.status == 400 + assert "Invalid attestations" in e.value.body + + # Upload valid but wrong attestation + prov_url = twine_package.provenance_url.replace( + "twine-6.2.0.tar.gz", "twine-6.2.0-py3-none-any.whl" + ) + attestations = get_attestations(prov_url) + body["attestations"] = json.dumps(attestations) + task = python_bindings.ContentPackagesApi.create(**body).task + with pytest.raises(PulpTaskError) as e: + monitor_task(task) + assert e.value.task.state == "failed" + assert "Attestations failed verification" in e.value.task.error["description"] diff --git a/pulp_python/tests/functional/assets/shelf-reader-0.1.tar.gz.publish.attestation b/pulp_python/tests/functional/assets/shelf-reader-0.1.tar.gz.publish.attestation new file mode 100644 index 00000000..151bd27d --- /dev/null +++ b/pulp_python/tests/functional/assets/shelf-reader-0.1.tar.gz.publish.attestation @@ -0,0 +1 @@ +{"version":1,"verification_material":{"certificate":"MIIIMzCCB7igAwIBAgIUKrdo9OfFJy6MuUewIeE/ILWw/tswCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjUxMjA0MjAzMjM5WhcNMjUxMjA0MjA0MjM5WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEleubASHneG8xQmJ/HacfPp63RpBRb71lTUW3uqc/dxFttAkIu11LtR5Y1aRa7h/uFP6e9B/3WAV11xiu29vxRqOCBtcwggbTMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUcDHutpRx316lThqpRMpz2kw+/f4wHwYDVR0jBBgwFoAUcYYwphR8Ym/599b0BRp/X//rb6wwgaUGA1UdEQEB/wSBmjCBl4aBlGh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbi8uZ2l0aHViL3dvcmtmbG93cy9leHRyZW1lbHktZGFuZ2Vyb3VzLW9pZGMtYmVhY29uLnltbEByZWZzL2hlYWRzL21haW4wOQYKKwYBBAGDvzABAQQraHR0cHM6Ly90b2tlbi5hY3Rpb25zLmdpdGh1YnVzZXJjb250ZW50LmNvbTAfBgorBgEEAYO/MAECBBF3b3JrZmxvd19kaXNwYXRjaDA2BgorBgEEAYO/MAEDBCg3OTU4NmMxZGVhZDhiODhmYmZhOWE3ODgyZGExMTZkZWNmNjc0YTQyMC0GCisGAQQBg78wAQQEH0V4dHJlbWVseSBkYW5nZXJvdXMgT0lEQyBiZWFjb24wSQYKKwYBBAGDvzABBQQ7c2lnc3RvcmUtY29uZm9ybWFuY2UvZXh0cmVtZWx5LWRhbmdlcm91cy1wdWJsaWMtb2lkYy1iZWFjb24wHQYKKwYBBAGDvzABBgQPcmVmcy9oZWFkcy9tYWluMDsGCisGAQQBg78wAQgELQwraHR0cHM6Ly90b2tlbi5hY3Rpb25zLmdpdGh1YnVzZXJjb250ZW50LmNvbTCBpgYKKwYBBAGDvzABCQSBlwyBlGh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbi8uZ2l0aHViL3dvcmtmbG93cy9leHRyZW1lbHktZGFuZ2Vyb3VzLW9pZGMtYmVhY29uLnltbEByZWZzL2hlYWRzL21haW4wOAYKKwYBBAGDvzABCgQqDCg3OTU4NmMxZGVhZDhiODhmYmZhOWE3ODgyZGExMTZkZWNmNjc0YTQyMB0GCisGAQQBg78wAQsEDwwNZ2l0aHViLWhvc3RlZDBeBgorBgEEAYO/MAEMBFAMTmh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbjA4BgorBgEEAYO/MAENBCoMKDc5NTg2YzFkZWFkOGI4OGZiZmE5YTc4ODJkYTExNmRlY2Y2NzRhNDIwHwYKKwYBBAGDvzABDgQRDA9yZWZzL2hlYWRzL21haW4wGQYKKwYBBAGDvzABDwQLDAk2MzI1OTY4OTcwNwYKKwYBBAGDvzABEAQpDCdodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUtY29uZm9ybWFuY2UwGQYKKwYBBAGDvzABEQQLDAkxMzE4MDQ1NjMwgaYGCisGAQQBg78wARIEgZcMgZRodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUtY29uZm9ybWFuY2UvZXh0cmVtZWx5LWRhbmdlcm91cy1wdWJsaWMtb2lkYy1iZWFjb24vLmdpdGh1Yi93b3JrZmxvd3MvZXh0cmVtZWx5LWRhbmdlcm91cy1vaWRjLWJlYWNvbi55bWxAcmVmcy9oZWFkcy9tYWluMDgGCisGAQQBg78wARMEKgwoNzk1ODZjMWRlYWQ4Yjg4ZmJmYTlhNzg4MmRhMTE2ZGVjZjY3NGE0MjAhBgorBgEEAYO/MAEUBBMMEXdvcmtmbG93X2Rpc3BhdGNoMIGCBgorBgEEAYO/MAEVBHQMcmh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbi9hY3Rpb25zL3J1bnMvMTk5NDMwMzAxOTkvYXR0ZW1wdHMvMTAWBgorBgEEAYO/MAEWBAgMBnB1YmxpYzCBigYKKwYBBAHWeQIEAgR8BHoAeAB2ACswvNxoiMni4dgmKV50H0g5MZYC8pwzy15DQP6yrIZ6AAABmusRH1wAAAQDAEcwRQIgMjrB10EwMTKAWmjTMab2AX1YMg1lWJp7xV3yhBx7GyYCIQC5zr44ZzUSPnJ3Gtm6k8M1CBoIzDYWzwBIoRAdh5NE3TAKBggqhkjOPQQDAwNpADBmAjEAgyfhSQno+h3UugP0A6V0A8b++q1IV4hYm4dVfi6skZKkEYMFbs0ocMWqWlJg8PaFAjEAt7+HDiWOfa3MxLPnIdzeEPz4kbgXZqKQDMHY5m1ZG976bnJBwttMAbAEXhELfyHl","transparency_entries":[{"logIndex":"51125016","logId":{"keyId":"0y8wo8MtY5wrdiIFohx7sHeI5oKDpK5vQhGHI6G+pJY="},"kindVersion":{"kind":"dsse","version":"0.0.1"},"integratedTime":"1764880360","inclusionPromise":{"signedEntryTimestamp":"MEUCIQC48Y/s6Rv+IoXoftvGOhM3sccchkODD/YqwIKJgwJAiAIgWkva5KEaCl3P78T21oElOTbq9cOxurz//9N9giFPDWw="},"inclusionProof":{"logIndex":"19442604","rootHash":"EdmMTYTkzJL0XRtufY9DMHS2axO0Vg6jO4eVM/Cetd4=","treeSize":"19442605","hashes":["k5UsnMaUY0kOQlFg0Za5deoskYSOUTFQ2Jzl3upcbX0=","F8+CGCoMNj9Jb1Csuk4bxbAWW35iktydoLKPPLk6Hb8=","P6IFMwnZlp99wiunjusnNW+K5V06iOEtzfoD9MtHCSU=","V4//f/ISXnSeJjUckmcLLzwS38XjXwMjrMAw6B3hKfg=","baLnSFGPpXZDhE38YKkGPW9P35RXQTaZlgDJ2i/u5xA=","YPKEUUZB3AVufkBQm5r7wLJeJMECETlxWoHo3+sKnHE=","VEcYk6UKGgyx1fCjyU6cfCPViC8OZFBjvHz8fco4eGM=","tf738kZ3YKyYiskKppTF5etATmFZgyY6H1ktyC0leCA=","gXQKVsGeO2E9oDbviE7+SuHFG5g7UH35dYGHc9JSz8s=","6kC2DcsZfB5F/TmenKHUzDr2UH6XK//Ch6hC8PZWJ5c=","JA9P8Q8v916uA/xomSrsiXeVBiyxxQAD5Mer9MBIYzA=","BrjYsYvo65dTDWKO8aY0s+9WaWHlVdZuOEzsBHLHe8U="],"checkpoint":{"envelope":"rekor.sigstage.dev - 8202293616175992157\n19442605\nEdmMTYTkzJL0XRtufY9DMHS2axO0Vg6jO4eVM/Cetd4=\n\n— rekor.sigstage.dev 0y8wozBFAiAPcLZnrw7rn8sFhxqGjp5q58EtMUibZptS8W1Mk6CcAAIhAJ6c+pCBCCe4+K1+zxuUsofR9Y56J5pTBvqvfGceJI/l\n"}},"canonicalizedBody":"eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiZHNzZSIsInNwZWMiOnsiZW52ZWxvcGVIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiYzJkZjczOTEzMzgyZmVkM2MzNTU5ODRjOTdiN2Y2NTc5YTYxZmVmZDAwOWMxYmM3M2M2ODE0ODk3ZjMxNDY3MyJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6IjRkMjdjMTZjMTI3ZDM4ZGE2NDU3MGVmMmY3Mjc2MjIwOWVjMzlkMmU0NjhiYjQ0YTU5NjE1MjhlOTMyNDNjNWQifSwic2lnbmF0dXJlcyI6W3sic2lnbmF0dXJlIjoiTUVZQ0lRQ2hSUGN1N2w5QXBIbTAxNlE3bnpob3pxaHkvYXVBV1NiWWc5MUpxQzluYlFJaEFOdGtFV01hV2RHS0dUZHVNZW4yQ081eDhPY1N0UStyazlnWVFWeDk1YU9VIiwidmVyaWZpZXIiOiJMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VsTmVrTkRRamRwWjBGM1NVSkJaMGxWUzNKa2J6bFBaa1pLZVRaTmRWVmxkMGxsUlM5SlRGZDNMM1J6ZDBObldVbExiMXBKZW1vd1JVRjNUWGNLVG5wRlZrMUNUVWRCTVZWRlEyaE5UV015Ykc1ak0xSjJZMjFWZFZwSFZqSk5ValIzU0VGWlJGWlJVVVJGZUZaNllWZGtlbVJIT1hsYVV6RndZbTVTYkFwamJURnNXa2RzYUdSSFZYZElhR05PVFdwVmVFMXFRVEJOYWtGNlRXcE5OVmRvWTA1TmFsVjRUV3BCTUUxcVFUQk5hazAxVjJwQlFVMUdhM2RGZDFsSUNrdHZXa2w2YWpCRFFWRlpTVXR2V2tsNmFqQkVRVkZqUkZGblFVVnNaWFZpUVZOSWJtVkhPSGhSYlVvdlNHRmpabEJ3TmpOU2NFSlNZamN4YkZSVlZ6TUtkWEZqTDJSNFJuUjBRV3RKZFRFeFRIUlNOVmt4WVZKaE4yZ3ZkVVpRTm1VNVFpOHpWMEZXTVRGNGFYVXlPWFo0VW5GUFEwSjBZM2RuWjJKVVRVRTBSd3BCTVZWa1JIZEZRaTkzVVVWQmQwbElaMFJCVkVKblRsWklVMVZGUkVSQlMwSm5aM0pDWjBWR1FsRmpSRUY2UVdSQ1owNVdTRkUwUlVablVWVmpSRWgxQ25Sd1VuZ3pNVFpzVkdoeGNGSk5jSG95YTNjckwyWTBkMGgzV1VSV1VqQnFRa0puZDBadlFWVmpXVmwzY0doU09GbHRMelU1T1dJd1FsSndMMWd2TDNJS1lqWjNkMmRoVlVkQk1WVmtSVkZGUWk5M1UwSnRha05DYkRSaFFteEhhREJrU0VKNlQyazRkbG95YkRCaFNGWnBURzFPZG1KVE9YcGhWMlI2WkVjNWVRcGFVekZxWWpJMWJXSXpTblJaVnpWcVdsTTViR1ZJVW5sYVZ6RnNZa2hyZEZwSFJuVmFNbFo1WWpOV2VreFlRakZaYlhod1dYa3hkbUZYVW1wTVYwcHNDbGxYVG5aaWFUaDFXakpzTUdGSVZtbE1NMlIyWTIxMGJXSkhPVE5qZVRsc1pVaFNlVnBYTVd4aVNHdDBXa2RHZFZveVZubGlNMVo2VEZjNWNGcEhUWFFLV1cxV2FGa3lPWFZNYm14MFlrVkNlVnBYV25wTU1taHNXVmRTZWt3eU1XaGhWelIzVDFGWlMwdDNXVUpDUVVkRWRucEJRa0ZSVVhKaFNGSXdZMGhOTmdwTWVUa3dZakowYkdKcE5XaFpNMUp3WWpJMWVreHRaSEJrUjJneFdXNVdlbHBZU21waU1qVXdXbGMxTUV4dFRuWmlWRUZtUW1kdmNrSm5SVVZCV1U4dkNrMUJSVU5DUWtZellqTktjbHB0ZUhaa01UbHJZVmhPZDFsWVVtcGhSRUV5UW1kdmNrSm5SVVZCV1U4dlRVRkZSRUpEWnpOUFZGVTBUbTFOZUZwSFZtZ0tXa1JvYVU5RWFHMVpiVnBvVDFkRk0wOUVaM2xhUjBWNFRWUmFhMXBYVG0xT2FtTXdXVlJSZVUxRE1FZERhWE5IUVZGUlFtYzNPSGRCVVZGRlNEQldOQXBrU0Vwc1lsZFdjMlZUUW10WlZ6VnVXbGhLZG1SWVRXZFVNR3hGVVhsQ2FWcFhSbXBpTWpSM1UxRlpTMHQzV1VKQ1FVZEVkbnBCUWtKUlVUZGpNbXh1Q21NelVuWmpiVlYwV1RJNWRWcHRPWGxpVjBaMVdUSlZkbHBZYURCamJWWjBXbGQ0TlV4WFVtaGliV1JzWTIwNU1XTjVNWGRrVjBwellWZE5kR0l5YkdzS1dYa3hhVnBYUm1waU1qUjNTRkZaUzB0M1dVSkNRVWRFZG5wQlFrSm5VVkJqYlZadFkzazViMXBYUm10amVUbDBXVmRzZFUxRWMwZERhWE5IUVZGUlFncG5OemgzUVZGblJVeFJkM0poU0ZJd1kwaE5Oa3g1T1RCaU1uUnNZbWsxYUZrelVuQmlNalY2VEcxa2NHUkhhREZaYmxaNldsaEthbUl5TlRCYVZ6VXdDa3h0VG5aaVZFTkNjR2RaUzB0M1dVSkNRVWRFZG5wQlFrTlJVMEpzZDNsQ2JFZG9NR1JJUW5wUGFUaDJXakpzTUdGSVZtbE1iVTUyWWxNNWVtRlhaSG9LWkVjNWVWcFRNV3BpTWpWdFlqTktkRmxYTldwYVV6bHNaVWhTZVZwWE1XeGlTR3QwV2tkR2RWb3lWbmxpTTFaNlRGaENNVmx0ZUhCWmVURjJZVmRTYWdwTVYwcHNXVmRPZG1KcE9IVmFNbXd3WVVoV2FVd3paSFpqYlhSdFlrYzVNMk41T1d4bFNGSjVXbGN4YkdKSWEzUmFSMFoxV2pKV2VXSXpWbnBNVnpsd0NscEhUWFJaYlZab1dUSTVkVXh1YkhSaVJVSjVXbGRhZWt3eWFHeFpWMUo2VERJeGFHRlhOSGRQUVZsTFMzZFpRa0pCUjBSMmVrRkNRMmRSY1VSRFp6TUtUMVJWTkU1dFRYaGFSMVpvV2tSb2FVOUVhRzFaYlZwb1QxZEZNMDlFWjNsYVIwVjRUVlJhYTFwWFRtMU9hbU13V1ZSUmVVMUNNRWREYVhOSFFWRlJRZ3BuTnpoM1FWRnpSVVIzZDA1YU1td3dZVWhXYVV4WGFIWmpNMUpzV2tSQ1pVSm5iM0pDWjBWRlFWbFBMMDFCUlUxQ1JrRk5WRzFvTUdSSVFucFBhVGgyQ2xveWJEQmhTRlpwVEcxT2RtSlRPWHBoVjJSNlpFYzVlVnBUTVdwaU1qVnRZak5LZEZsWE5XcGFVemxzWlVoU2VWcFhNV3hpU0d0MFdrZEdkVm95Vm5rS1lqTldla3hZUWpGWmJYaHdXWGt4ZG1GWFVtcE1WMHBzV1ZkT2RtSnFRVFJDWjI5eVFtZEZSVUZaVHk5TlFVVk9Ra052VFV0RVl6Vk9WR2N5V1hwR2F3cGFWMFpyVDBkSk5FOUhXbWxhYlVVMVdWUmpORTlFU210WlZFVjRUbTFTYkZreVdUSk9lbEpvVGtSSmQwaDNXVXRMZDFsQ1FrRkhSSFo2UVVKRVoxRlNDa1JCT1hsYVYxcDZUREpvYkZsWFVucE1NakZvWVZjMGQwZFJXVXRMZDFsQ1FrRkhSSFo2UVVKRWQxRk1SRUZyTWsxNlNURlBWRmswVDFSamQwNTNXVXNLUzNkWlFrSkJSMFIyZWtGQ1JVRlJjRVJEWkc5a1NGSjNZM3B2ZGt3eVpIQmtSMmd4V1drMWFtSXlNSFpqTW14dVl6TlNkbU50VlhSWk1qbDFXbTA1ZVFwaVYwWjFXVEpWZDBkUldVdExkMWxDUWtGSFJIWjZRVUpGVVZGTVJFRnJlRTE2UlRSTlJGRXhUbXBOZDJkaFdVZERhWE5IUVZGUlFtYzNPSGRCVWtsRkNtZGFZMDFuV2xKdlpFaFNkMk42YjNaTU1tUndaRWRvTVZscE5XcGlNakIyWXpKc2JtTXpVblpqYlZWMFdUSTVkVnB0T1hsaVYwWjFXVEpWZGxwWWFEQUtZMjFXZEZwWGVEVk1WMUpvWW0xa2JHTnRPVEZqZVRGM1pGZEtjMkZYVFhSaU1teHJXWGt4YVZwWFJtcGlNalIyVEcxa2NHUkhhREZaYVRrellqTktjZ3BhYlhoMlpETk5kbHBZYURCamJWWjBXbGQ0TlV4WFVtaGliV1JzWTIwNU1XTjVNWFpoVjFKcVRGZEtiRmxYVG5aaWFUVTFZbGQ0UVdOdFZtMWplVGx2Q2xwWFJtdGplVGwwV1Zkc2RVMUVaMGREYVhOSFFWRlJRbWMzT0hkQlVrMUZTMmQzYjA1NmF6RlBSRnBxVFZkU2JGbFhVVFJaYW1jMFdtMUtiVmxVYkdnS1RucG5ORTF0VW1oTlZFVXlXa2RXYWxwcVdUTk9SMFV3VFdwQmFFSm5iM0pDWjBWRlFWbFBMMDFCUlZWQ1FrMU5SVmhrZG1OdGRHMWlSemt6V0RKU2NBcGpNMEpvWkVkT2IwMUpSME5DWjI5eVFtZEZSVUZaVHk5TlFVVldRa2hSVFdOdGFEQmtTRUo2VDJrNGRsb3liREJoU0ZacFRHMU9kbUpUT1hwaFYyUjZDbVJIT1hsYVV6RnFZakkxYldJelNuUlpWelZxV2xNNWJHVklVbmxhVnpGc1lraHJkRnBIUm5WYU1sWjVZak5XZWt4WVFqRlpiWGh3V1hreGRtRlhVbW9LVEZkS2JGbFhUblppYVRsb1dUTlNjR0l5TlhwTU0wb3hZbTVOZGsxVWF6Vk9SRTEzVFhwQmVFOVVhM1paV0ZJd1dsY3hkMlJJVFhaTlZFRlhRbWR2Y2dwQ1owVkZRVmxQTDAxQlJWZENRV2ROUW01Q01WbHRlSEJaZWtOQ2FXZFpTMHQzV1VKQ1FVaFhaVkZKUlVGblVqaENTRzlCWlVGQ01rRkRjM2QyVG5odkNtbE5ibWswWkdkdFMxWTFNRWd3WnpWTldsbERPSEIzZW5reE5VUlJVRFo1Y2tsYU5rRkJRVUp0ZFhOU1NERjNRVUZCVVVSQlJXTjNVbEZKWjAxcWNrSUtNVEJGZDAxVVMwRlhiV3BVVFdGaU1rRllNVmxOWnpGc1YwcHdOM2hXTTNsb1FuZzNSM2xaUTBsUlF6VjZjalEwV25wVlUxQnVTak5IZEcwMmF6aE5NUXBEUW05SmVrUlpWM3AzUWtsdlVrRmthRFZPUlROVVFVdENaMmR4YUd0cVQxQlJVVVJCZDA1d1FVUkNiVUZxUlVGbmVXWm9VMUZ1Ynl0b00xVjFaMUF3Q2tFMlZqQkJPR0lySzNFeFNWWTBhRmx0TkdSV1ptazJjMnRhUzJ0RldVMUdZbk13YjJOTlYzRlhiRXBuT0ZCaFJrRnFSVUYwTnl0SVJHbFhUMlpoTTAwS2VFeFFia2xrZW1WRlVIbzBhMkpuV0ZweFMxRkVUVWhaTlcweFdrYzVOelppYmtwQ2QzUjBUVUZpUVVWWWFFVk1abmxJYkFvdExTMHRMVVZPUkNCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2c9PSJ9XX19"}]},"envelope":{"statement":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoic2hlbGYtcmVhZGVyLTAuMS50YXIuZ3oiLCJkaWdlc3QiOnsic2hhMjU2IjoiMDRjZmQ4YmI0Zjg0M2UzNWQ1MWJmZGVmMjAzNTEwOWJkZWE4MzFiNTVhNTdjM2U2YTE1NGQxNGJlMTE2Mzk4YyJ9fV0sInByZWRpY2F0ZVR5cGUiOiJodHRwczovL2RvY3MucHlwaS5vcmcvYXR0ZXN0YXRpb25zL3B1Ymxpc2gvdjEiLCJwcmVkaWNhdGUiOm51bGx9","signature":"MEYCIQChRPcu7l9ApHm016Q7nzhozqhy/auAWSbYg91JqC9nbQIhANtkEWMaWdGKGTduMen2CO5x8OcStQ+rk9gYQVx95aOU"}} \ No newline at end of file diff --git a/pulp_python/tests/functional/assets/shelf_reader-0.1-py2-none-any.whl.publish.attestation b/pulp_python/tests/functional/assets/shelf_reader-0.1-py2-none-any.whl.publish.attestation new file mode 100644 index 00000000..43f37bd5 --- /dev/null +++ b/pulp_python/tests/functional/assets/shelf_reader-0.1-py2-none-any.whl.publish.attestation @@ -0,0 +1 @@ +{"version":1,"verification_material":{"certificate":"MIIIMzCCB7igAwIBAgIUKrdo9OfFJy6MuUewIeE/ILWw/tswCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjUxMjA0MjAzMjM5WhcNMjUxMjA0MjA0MjM5WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEleubASHneG8xQmJ/HacfPp63RpBRb71lTUW3uqc/dxFttAkIu11LtR5Y1aRa7h/uFP6e9B/3WAV11xiu29vxRqOCBtcwggbTMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUcDHutpRx316lThqpRMpz2kw+/f4wHwYDVR0jBBgwFoAUcYYwphR8Ym/599b0BRp/X//rb6wwgaUGA1UdEQEB/wSBmjCBl4aBlGh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbi8uZ2l0aHViL3dvcmtmbG93cy9leHRyZW1lbHktZGFuZ2Vyb3VzLW9pZGMtYmVhY29uLnltbEByZWZzL2hlYWRzL21haW4wOQYKKwYBBAGDvzABAQQraHR0cHM6Ly90b2tlbi5hY3Rpb25zLmdpdGh1YnVzZXJjb250ZW50LmNvbTAfBgorBgEEAYO/MAECBBF3b3JrZmxvd19kaXNwYXRjaDA2BgorBgEEAYO/MAEDBCg3OTU4NmMxZGVhZDhiODhmYmZhOWE3ODgyZGExMTZkZWNmNjc0YTQyMC0GCisGAQQBg78wAQQEH0V4dHJlbWVseSBkYW5nZXJvdXMgT0lEQyBiZWFjb24wSQYKKwYBBAGDvzABBQQ7c2lnc3RvcmUtY29uZm9ybWFuY2UvZXh0cmVtZWx5LWRhbmdlcm91cy1wdWJsaWMtb2lkYy1iZWFjb24wHQYKKwYBBAGDvzABBgQPcmVmcy9oZWFkcy9tYWluMDsGCisGAQQBg78wAQgELQwraHR0cHM6Ly90b2tlbi5hY3Rpb25zLmdpdGh1YnVzZXJjb250ZW50LmNvbTCBpgYKKwYBBAGDvzABCQSBlwyBlGh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbi8uZ2l0aHViL3dvcmtmbG93cy9leHRyZW1lbHktZGFuZ2Vyb3VzLW9pZGMtYmVhY29uLnltbEByZWZzL2hlYWRzL21haW4wOAYKKwYBBAGDvzABCgQqDCg3OTU4NmMxZGVhZDhiODhmYmZhOWE3ODgyZGExMTZkZWNmNjc0YTQyMB0GCisGAQQBg78wAQsEDwwNZ2l0aHViLWhvc3RlZDBeBgorBgEEAYO/MAEMBFAMTmh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbjA4BgorBgEEAYO/MAENBCoMKDc5NTg2YzFkZWFkOGI4OGZiZmE5YTc4ODJkYTExNmRlY2Y2NzRhNDIwHwYKKwYBBAGDvzABDgQRDA9yZWZzL2hlYWRzL21haW4wGQYKKwYBBAGDvzABDwQLDAk2MzI1OTY4OTcwNwYKKwYBBAGDvzABEAQpDCdodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUtY29uZm9ybWFuY2UwGQYKKwYBBAGDvzABEQQLDAkxMzE4MDQ1NjMwgaYGCisGAQQBg78wARIEgZcMgZRodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUtY29uZm9ybWFuY2UvZXh0cmVtZWx5LWRhbmdlcm91cy1wdWJsaWMtb2lkYy1iZWFjb24vLmdpdGh1Yi93b3JrZmxvd3MvZXh0cmVtZWx5LWRhbmdlcm91cy1vaWRjLWJlYWNvbi55bWxAcmVmcy9oZWFkcy9tYWluMDgGCisGAQQBg78wARMEKgwoNzk1ODZjMWRlYWQ4Yjg4ZmJmYTlhNzg4MmRhMTE2ZGVjZjY3NGE0MjAhBgorBgEEAYO/MAEUBBMMEXdvcmtmbG93X2Rpc3BhdGNoMIGCBgorBgEEAYO/MAEVBHQMcmh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbi9hY3Rpb25zL3J1bnMvMTk5NDMwMzAxOTkvYXR0ZW1wdHMvMTAWBgorBgEEAYO/MAEWBAgMBnB1YmxpYzCBigYKKwYBBAHWeQIEAgR8BHoAeAB2ACswvNxoiMni4dgmKV50H0g5MZYC8pwzy15DQP6yrIZ6AAABmusRH1wAAAQDAEcwRQIgMjrB10EwMTKAWmjTMab2AX1YMg1lWJp7xV3yhBx7GyYCIQC5zr44ZzUSPnJ3Gtm6k8M1CBoIzDYWzwBIoRAdh5NE3TAKBggqhkjOPQQDAwNpADBmAjEAgyfhSQno+h3UugP0A6V0A8b++q1IV4hYm4dVfi6skZKkEYMFbs0ocMWqWlJg8PaFAjEAt7+HDiWOfa3MxLPnIdzeEPz4kbgXZqKQDMHY5m1ZG976bnJBwttMAbAEXhELfyHl","transparency_entries":[{"logIndex":"51125015","logId":{"keyId":"0y8wo8MtY5wrdiIFohx7sHeI5oKDpK5vQhGHI6G+pJY="},"kindVersion":{"kind":"dsse","version":"0.0.1"},"integratedTime":"1764880359","inclusionPromise":{"signedEntryTimestamp":"MEUCIQCiibbwwQHoLxvH9C1/P8Lve6IUoBU/6Rcs1ShGqGoyqQIgBngTOdjZiPEaBMzkNTGRDkGKDAO8FVlClua3dB+VczU="},"inclusionProof":{"logIndex":"19442603","rootHash":"WveYvRUsyPpMgj5BTpatQytFwmxXNIAtg3r3T0Erwzc=","treeSize":"19442604","hashes":["u9GSDHFTk49/HwIcxCnZ46wrLt8BhbE4ghHA15/zujs=","4+9oEq/UTGK6dNePTKcKp7D/uIuzi/1MCP3/QyYOtWg=","F8+CGCoMNj9Jb1Csuk4bxbAWW35iktydoLKPPLk6Hb8=","P6IFMwnZlp99wiunjusnNW+K5V06iOEtzfoD9MtHCSU=","V4//f/ISXnSeJjUckmcLLzwS38XjXwMjrMAw6B3hKfg=","baLnSFGPpXZDhE38YKkGPW9P35RXQTaZlgDJ2i/u5xA=","YPKEUUZB3AVufkBQm5r7wLJeJMECETlxWoHo3+sKnHE=","VEcYk6UKGgyx1fCjyU6cfCPViC8OZFBjvHz8fco4eGM=","tf738kZ3YKyYiskKppTF5etATmFZgyY6H1ktyC0leCA=","gXQKVsGeO2E9oDbviE7+SuHFG5g7UH35dYGHc9JSz8s=","6kC2DcsZfB5F/TmenKHUzDr2UH6XK//Ch6hC8PZWJ5c=","JA9P8Q8v916uA/xomSrsiXeVBiyxxQAD5Mer9MBIYzA=","BrjYsYvo65dTDWKO8aY0s+9WaWHlVdZuOEzsBHLHe8U="],"checkpoint":{"envelope":"rekor.sigstage.dev - 8202293616175992157\n19442604\nWveYvRUsyPpMgj5BTpatQytFwmxXNIAtg3r3T0Erwzc=\n\n— rekor.sigstage.dev 0y8wozBEAiBLrKviMibkIK4/qPxYL29tvR5IlL8BTQKpjRpzoORIEAIgaXw4XKDTCbaHka5VBOwwH6PuT70TIC2vxO6ukgQrVqI=\n"}},"canonicalizedBody":"eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiZHNzZSIsInNwZWMiOnsiZW52ZWxvcGVIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiNjNhNzJhYjFkM2FhNWY5ZjlhMzUzMTQzNjE4NTBiZTRkMTg3NGI0OWZhODUwYmExYTY4OWM4MDIzY2UwYWFhZiJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6ImJjNWM2OTczMDRhYTU0MmE0YTZlMjkyYmExYzg4OWRhMWVhZWE0NmJkYTlhN2JlMmI4MmY1NjIzNTUzMTNlN2UifSwic2lnbmF0dXJlcyI6W3sic2lnbmF0dXJlIjoiTUVRQ0lEekoyZWZRUy9xSG5Bd1B4KzVnV2NOSlhHNmtIbndJMmpvMXlycmxzUlhNQWlBNDg2QzBoK3pGOEY3aUNJbjAyTGJzSFpwSGdHZnhDWFkraGU2QU1aUU5IQT09IiwidmVyaWZpZXIiOiJMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VsTmVrTkRRamRwWjBGM1NVSkJaMGxWUzNKa2J6bFBaa1pLZVRaTmRWVmxkMGxsUlM5SlRGZDNMM1J6ZDBObldVbExiMXBKZW1vd1JVRjNUWGNLVG5wRlZrMUNUVWRCTVZWRlEyaE5UV015Ykc1ak0xSjJZMjFWZFZwSFZqSk5ValIzU0VGWlJGWlJVVVJGZUZaNllWZGtlbVJIT1hsYVV6RndZbTVTYkFwamJURnNXa2RzYUdSSFZYZElhR05PVFdwVmVFMXFRVEJOYWtGNlRXcE5OVmRvWTA1TmFsVjRUV3BCTUUxcVFUQk5hazAxVjJwQlFVMUdhM2RGZDFsSUNrdHZXa2w2YWpCRFFWRlpTVXR2V2tsNmFqQkVRVkZqUkZGblFVVnNaWFZpUVZOSWJtVkhPSGhSYlVvdlNHRmpabEJ3TmpOU2NFSlNZamN4YkZSVlZ6TUtkWEZqTDJSNFJuUjBRV3RKZFRFeFRIUlNOVmt4WVZKaE4yZ3ZkVVpRTm1VNVFpOHpWMEZXTVRGNGFYVXlPWFo0VW5GUFEwSjBZM2RuWjJKVVRVRTBSd3BCTVZWa1JIZEZRaTkzVVVWQmQwbElaMFJCVkVKblRsWklVMVZGUkVSQlMwSm5aM0pDWjBWR1FsRmpSRUY2UVdSQ1owNVdTRkUwUlVablVWVmpSRWgxQ25Sd1VuZ3pNVFpzVkdoeGNGSk5jSG95YTNjckwyWTBkMGgzV1VSV1VqQnFRa0puZDBadlFWVmpXVmwzY0doU09GbHRMelU1T1dJd1FsSndMMWd2TDNJS1lqWjNkMmRoVlVkQk1WVmtSVkZGUWk5M1UwSnRha05DYkRSaFFteEhhREJrU0VKNlQyazRkbG95YkRCaFNGWnBURzFPZG1KVE9YcGhWMlI2WkVjNWVRcGFVekZxWWpJMWJXSXpTblJaVnpWcVdsTTViR1ZJVW5sYVZ6RnNZa2hyZEZwSFJuVmFNbFo1WWpOV2VreFlRakZaYlhod1dYa3hkbUZYVW1wTVYwcHNDbGxYVG5aaWFUaDFXakpzTUdGSVZtbE1NMlIyWTIxMGJXSkhPVE5qZVRsc1pVaFNlVnBYTVd4aVNHdDBXa2RHZFZveVZubGlNMVo2VEZjNWNGcEhUWFFLV1cxV2FGa3lPWFZNYm14MFlrVkNlVnBYV25wTU1taHNXVmRTZWt3eU1XaGhWelIzVDFGWlMwdDNXVUpDUVVkRWRucEJRa0ZSVVhKaFNGSXdZMGhOTmdwTWVUa3dZakowYkdKcE5XaFpNMUp3WWpJMWVreHRaSEJrUjJneFdXNVdlbHBZU21waU1qVXdXbGMxTUV4dFRuWmlWRUZtUW1kdmNrSm5SVVZCV1U4dkNrMUJSVU5DUWtZellqTktjbHB0ZUhaa01UbHJZVmhPZDFsWVVtcGhSRUV5UW1kdmNrSm5SVVZCV1U4dlRVRkZSRUpEWnpOUFZGVTBUbTFOZUZwSFZtZ0tXa1JvYVU5RWFHMVpiVnBvVDFkRk0wOUVaM2xhUjBWNFRWUmFhMXBYVG0xT2FtTXdXVlJSZVUxRE1FZERhWE5IUVZGUlFtYzNPSGRCVVZGRlNEQldOQXBrU0Vwc1lsZFdjMlZUUW10WlZ6VnVXbGhLZG1SWVRXZFVNR3hGVVhsQ2FWcFhSbXBpTWpSM1UxRlpTMHQzV1VKQ1FVZEVkbnBCUWtKUlVUZGpNbXh1Q21NelVuWmpiVlYwV1RJNWRWcHRPWGxpVjBaMVdUSlZkbHBZYURCamJWWjBXbGQ0TlV4WFVtaGliV1JzWTIwNU1XTjVNWGRrVjBwellWZE5kR0l5YkdzS1dYa3hhVnBYUm1waU1qUjNTRkZaUzB0M1dVSkNRVWRFZG5wQlFrSm5VVkJqYlZadFkzazViMXBYUm10amVUbDBXVmRzZFUxRWMwZERhWE5IUVZGUlFncG5OemgzUVZGblJVeFJkM0poU0ZJd1kwaE5Oa3g1T1RCaU1uUnNZbWsxYUZrelVuQmlNalY2VEcxa2NHUkhhREZaYmxaNldsaEthbUl5TlRCYVZ6VXdDa3h0VG5aaVZFTkNjR2RaUzB0M1dVSkNRVWRFZG5wQlFrTlJVMEpzZDNsQ2JFZG9NR1JJUW5wUGFUaDJXakpzTUdGSVZtbE1iVTUyWWxNNWVtRlhaSG9LWkVjNWVWcFRNV3BpTWpWdFlqTktkRmxYTldwYVV6bHNaVWhTZVZwWE1XeGlTR3QwV2tkR2RWb3lWbmxpTTFaNlRGaENNVmx0ZUhCWmVURjJZVmRTYWdwTVYwcHNXVmRPZG1KcE9IVmFNbXd3WVVoV2FVd3paSFpqYlhSdFlrYzVNMk41T1d4bFNGSjVXbGN4YkdKSWEzUmFSMFoxV2pKV2VXSXpWbnBNVnpsd0NscEhUWFJaYlZab1dUSTVkVXh1YkhSaVJVSjVXbGRhZWt3eWFHeFpWMUo2VERJeGFHRlhOSGRQUVZsTFMzZFpRa0pCUjBSMmVrRkNRMmRSY1VSRFp6TUtUMVJWTkU1dFRYaGFSMVpvV2tSb2FVOUVhRzFaYlZwb1QxZEZNMDlFWjNsYVIwVjRUVlJhYTFwWFRtMU9hbU13V1ZSUmVVMUNNRWREYVhOSFFWRlJRZ3BuTnpoM1FWRnpSVVIzZDA1YU1td3dZVWhXYVV4WGFIWmpNMUpzV2tSQ1pVSm5iM0pDWjBWRlFWbFBMMDFCUlUxQ1JrRk5WRzFvTUdSSVFucFBhVGgyQ2xveWJEQmhTRlpwVEcxT2RtSlRPWHBoVjJSNlpFYzVlVnBUTVdwaU1qVnRZak5LZEZsWE5XcGFVemxzWlVoU2VWcFhNV3hpU0d0MFdrZEdkVm95Vm5rS1lqTldla3hZUWpGWmJYaHdXWGt4ZG1GWFVtcE1WMHBzV1ZkT2RtSnFRVFJDWjI5eVFtZEZSVUZaVHk5TlFVVk9Ra052VFV0RVl6Vk9WR2N5V1hwR2F3cGFWMFpyVDBkSk5FOUhXbWxhYlVVMVdWUmpORTlFU210WlZFVjRUbTFTYkZreVdUSk9lbEpvVGtSSmQwaDNXVXRMZDFsQ1FrRkhSSFo2UVVKRVoxRlNDa1JCT1hsYVYxcDZUREpvYkZsWFVucE1NakZvWVZjMGQwZFJXVXRMZDFsQ1FrRkhSSFo2UVVKRWQxRk1SRUZyTWsxNlNURlBWRmswVDFSamQwNTNXVXNLUzNkWlFrSkJSMFIyZWtGQ1JVRlJjRVJEWkc5a1NGSjNZM3B2ZGt3eVpIQmtSMmd4V1drMWFtSXlNSFpqTW14dVl6TlNkbU50VlhSWk1qbDFXbTA1ZVFwaVYwWjFXVEpWZDBkUldVdExkMWxDUWtGSFJIWjZRVUpGVVZGTVJFRnJlRTE2UlRSTlJGRXhUbXBOZDJkaFdVZERhWE5IUVZGUlFtYzNPSGRCVWtsRkNtZGFZMDFuV2xKdlpFaFNkMk42YjNaTU1tUndaRWRvTVZscE5XcGlNakIyWXpKc2JtTXpVblpqYlZWMFdUSTVkVnB0T1hsaVYwWjFXVEpWZGxwWWFEQUtZMjFXZEZwWGVEVk1WMUpvWW0xa2JHTnRPVEZqZVRGM1pGZEtjMkZYVFhSaU1teHJXWGt4YVZwWFJtcGlNalIyVEcxa2NHUkhhREZaYVRrellqTktjZ3BhYlhoMlpETk5kbHBZYURCamJWWjBXbGQ0TlV4WFVtaGliV1JzWTIwNU1XTjVNWFpoVjFKcVRGZEtiRmxYVG5aaWFUVTFZbGQ0UVdOdFZtMWplVGx2Q2xwWFJtdGplVGwwV1Zkc2RVMUVaMGREYVhOSFFWRlJRbWMzT0hkQlVrMUZTMmQzYjA1NmF6RlBSRnBxVFZkU2JGbFhVVFJaYW1jMFdtMUtiVmxVYkdnS1RucG5ORTF0VW1oTlZFVXlXa2RXYWxwcVdUTk9SMFV3VFdwQmFFSm5iM0pDWjBWRlFWbFBMMDFCUlZWQ1FrMU5SVmhrZG1OdGRHMWlSemt6V0RKU2NBcGpNMEpvWkVkT2IwMUpSME5DWjI5eVFtZEZSVUZaVHk5TlFVVldRa2hSVFdOdGFEQmtTRUo2VDJrNGRsb3liREJoU0ZacFRHMU9kbUpUT1hwaFYyUjZDbVJIT1hsYVV6RnFZakkxYldJelNuUlpWelZxV2xNNWJHVklVbmxhVnpGc1lraHJkRnBIUm5WYU1sWjVZak5XZWt4WVFqRlpiWGh3V1hreGRtRlhVbW9LVEZkS2JGbFhUblppYVRsb1dUTlNjR0l5TlhwTU0wb3hZbTVOZGsxVWF6Vk9SRTEzVFhwQmVFOVVhM1paV0ZJd1dsY3hkMlJJVFhaTlZFRlhRbWR2Y2dwQ1owVkZRVmxQTDAxQlJWZENRV2ROUW01Q01WbHRlSEJaZWtOQ2FXZFpTMHQzV1VKQ1FVaFhaVkZKUlVGblVqaENTRzlCWlVGQ01rRkRjM2QyVG5odkNtbE5ibWswWkdkdFMxWTFNRWd3WnpWTldsbERPSEIzZW5reE5VUlJVRFo1Y2tsYU5rRkJRVUp0ZFhOU1NERjNRVUZCVVVSQlJXTjNVbEZKWjAxcWNrSUtNVEJGZDAxVVMwRlhiV3BVVFdGaU1rRllNVmxOWnpGc1YwcHdOM2hXTTNsb1FuZzNSM2xaUTBsUlF6VjZjalEwV25wVlUxQnVTak5IZEcwMmF6aE5NUXBEUW05SmVrUlpWM3AzUWtsdlVrRmthRFZPUlROVVFVdENaMmR4YUd0cVQxQlJVVVJCZDA1d1FVUkNiVUZxUlVGbmVXWm9VMUZ1Ynl0b00xVjFaMUF3Q2tFMlZqQkJPR0lySzNFeFNWWTBhRmx0TkdSV1ptazJjMnRhUzJ0RldVMUdZbk13YjJOTlYzRlhiRXBuT0ZCaFJrRnFSVUYwTnl0SVJHbFhUMlpoTTAwS2VFeFFia2xrZW1WRlVIbzBhMkpuV0ZweFMxRkVUVWhaTlcweFdrYzVOelppYmtwQ2QzUjBUVUZpUVVWWWFFVk1abmxJYkFvdExTMHRMVVZPUkNCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2c9PSJ9XX19"}]},"envelope":{"statement":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoic2hlbGZfcmVhZGVyLTAuMS1weTItbm9uZS1hbnkud2hsIiwiZGlnZXN0Ijp7InNoYTI1NiI6IjJlY2ViMTY0M2MxMGM1ZTRhNjU5NzBiYWY2M2JkZTQzYjc5Y2JkYWM3ZGU4MWRhZTg1M2NlNDdhYjA1MTk3ZTkifX1dLCJwcmVkaWNhdGVUeXBlIjoiaHR0cHM6Ly9kb2NzLnB5cGkub3JnL2F0dGVzdGF0aW9ucy9wdWJsaXNoL3YxIiwicHJlZGljYXRlIjpudWxsfQ==","signature":"MEQCIDzJ2efQS/qHnAwPx+5gWcNJXG6kHnwI2jo1yrrlsRXMAiA486C0h+zF8F7iCIn02LbsHZpHgGfxCXY+he6AMZQNHA=="}} \ No newline at end of file