From 28aad53ec99b919a12b39058b9b16a80b0d63077 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Wed, 5 Jun 2024 18:23:40 -0400 Subject: [PATCH 01/14] WIP: switch to in-toto statements Signed-off-by: William Woodruff --- pyproject.toml | 14 +-- src/pypi_attestation_models/__init__.py | 4 +- src/pypi_attestation_models/_impl.py | 131 +++++++++++++++--------- 3 files changed, 92 insertions(+), 57 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1fcbd65..d682b52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,14 +8,17 @@ dynamic = ["version"] description = "A library to convert between Sigstore Bundles and PEP-740 Attestation objects" readme = "README.md" license = { file = "LICENSE" } -authors = [ - { name = "Trail of Bits", email = "opensource@trailofbits.com" }, -] +authors = [{ name = "Trail of Bits", email = "opensource@trailofbits.com" }] classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: Apache Software License", ] -dependencies = ["cryptography", "pydantic", "sigstore~=3.0.0"] +dependencies = [ + "cryptography", + "pydantic", + "sigstore~=3.0.0", + "sigstore-protobuf-specs", +] requires-python = ">=3.9" [project.optional-dependencies] @@ -34,7 +37,6 @@ lint = [ dev = ["pypi-attestation-models[doc,test,lint]", "twine", "wheel", "build"] - [project.urls] Homepage = "https://pypi.org/project/pypi-attestation-models" Documentation = "https://trailofbits.github.io/pypi-attestation-models/" @@ -72,7 +74,7 @@ line-length = 100 target-version = "py39" [tool.ruff.lint] -select = ["ALL"] +select = ["E", "F", "I", "W", "UP", "ANN", "D", "COM", "ISC", "TCH", "SLF"] # ANN101 and ANN102 are deprecated # D203 and D213 are incompatible with D211 and D212 respectively. # COM812 and ISC001 can cause conflicts when using ruff as a formatter. diff --git a/src/pypi_attestation_models/__init__.py b/src/pypi_attestation_models/__init__.py index 7b57a9e..934d72a 100644 --- a/src/pypi_attestation_models/__init__.py +++ b/src/pypi_attestation_models/__init__.py @@ -4,8 +4,8 @@ from ._impl import ( Attestation, - AttestationPayload, ConversionError, + Envelope, InvalidAttestationError, TransparencyLogEntry, VerificationError, @@ -16,7 +16,7 @@ __all__ = [ "Attestation", - "AttestationPayload", + "Envelope", "ConversionError", "InvalidAttestationError", "TransparencyLogEntry", diff --git a/src/pypi_attestation_models/_impl.py b/src/pypi_attestation_models/_impl.py index 690ef64..913c46d 100644 --- a/src/pypi_attestation_models/_impl.py +++ b/src/pypi_attestation_models/_impl.py @@ -5,19 +5,20 @@ from __future__ import annotations -import binascii -from base64 import b64decode, b64encode from typing import TYPE_CHECKING, Annotated, Any, Literal, NewType -import rfc8785 import sigstore.errors from annotated_types import MinLen # noqa: TCH002 from cryptography import x509 from cryptography.hazmat.primitives import serialization -from pydantic import BaseModel +from pydantic import Base64Bytes, BaseModel from pydantic_core import ValidationError from sigstore._utils import _sha256_streaming +from sigstore.dsse import Envelope as DsseEnvelope +from sigstore.dsse import _DigestSet, _Statement, _StatementBuilder, _Subject from sigstore.models import Bundle, LogEntry +from sigstore_protobuf_specs.io.intoto import Envelope as _Envelope +from sigstore_protobuf_specs.io.intoto import Signature as _Signature if TYPE_CHECKING: from pathlib import Path # pragma: no cover @@ -53,7 +54,7 @@ def __init__(self: VerificationError, msg: str) -> None: class VerificationMaterial(BaseModel): """Cryptographic materials used to verify attestation objects.""" - certificate: str + certificate: Base64Bytes """ The signing certificate, as `base64(DER(cert))`. """ @@ -78,12 +79,28 @@ class Attestation(BaseModel): Cryptographic materials used to verify `message_signature`. """ - message_signature: str + envelope: Envelope """ - The attestation's signature, as `base64(raw-sig)`, where `raw-sig` - is the raw bytes of the signing operation over the attestation payload. + The enveloped attestation statement and signature. """ + @classmethod + def sign(cls, signer: Signer, dist: Path) -> Attestation: + """Create an envelope, with signature, from a distribution file.""" + with dist.open(mode="rb", buffering=0) as io: + # Replace this with `hashlib.file_digest()` once + # our minimum supported Python is >=3.11 + digest = _sha256_streaming(io).hex() + + stmt = ( + _StatementBuilder() + .subjects([_Subject(name=dist.name, digest=_DigestSet(root={"sha256": digest}))]) + .build() + ) + bundle = signer.sign_dsse(stmt) + + return sigstore_to_pypi(bundle) + def verify(self, verifier: Verifier, policy: VerificationPolicy, dist: Path) -> None: """Verify against an existing Python artifact. @@ -92,48 +109,52 @@ def verify(self, verifier: Verifier, policy: VerificationPolicy, dist: Path) -> a Sigstore Bundle. - `VerificationError` if the attestation could not be verified. """ - payload_to_verify = AttestationPayload.from_dist(dist) + with dist.open(mode="rb", buffering=0) as io: + # Replace this with `hashlib.file_digest()` once + # our minimum supported Python is >=3.11 + expected_digest = _sha256_streaming(io).hex() + bundle = pypi_to_sigstore(self) try: - verifier.verify_artifact(bytes(payload_to_verify), bundle, policy) + type_, payload = verifier.verify_dsse(bundle, policy) except sigstore.errors.VerificationError as err: raise VerificationError(str(err)) from err + if type_ != DsseEnvelope._TYPE: # noqa: SLF001 + raise VerificationError(f"expected JSON envelope, got {type_}") -class AttestationPayload(BaseModel): - """Attestation Payload object as defined in PEP 740.""" + try: + statement = _Statement.model_validate_json(payload) + except ValidationError as e: + raise VerificationError(f"invalid statement: {str(e)}") - distribution: str - """ - The file name of the Python package distribution. - """ + if len(statement.subjects) != 1: + raise VerificationError("too many subjects in statement (must be exactly one)") - digest: str - """ - The SHA-256 digest of the distribution's contents, as a hexadecimal string. - """ + subject = statement.subjects[0] + if subject.name != dist.name: + raise VerificationError( + f"subject does not match distribution name: {subject.name} != {dist.name}" + ) - @classmethod - def from_dist(cls, dist: Path) -> AttestationPayload: - """Create an `AttestationPayload` from a distribution file.""" - with dist.open(mode="rb", buffering=0) as io: - # Replace this with `hashlib.file_digest()` once - # our minimum supported Python is >=3.11 - digest = _sha256_streaming(io).hex() + digest = subject.digest.root.get("sha256") + if digest is None or digest != expected_digest: + raise VerificationError("subject does not match distribution digest") - return AttestationPayload( - distribution=dist.name, - digest=digest, - ) - def sign(self, signer: Signer) -> Attestation: - """Create a PEP 740 attestation by signing this payload.""" - sigstore_bundle = signer.sign_artifact(bytes(self)) - return sigstore_to_pypi(sigstore_bundle) +class Envelope(BaseModel): + statement: Base64Bytes + """ + The attestation statement. + + This is represented as opaque bytes on the wire (encoded as base64), + but it MUST be an JSON in-toto v1 Statement. + """ - def __bytes__(self: AttestationPayload) -> bytes: - """Convert to bytes using a canonicalized JSON representation (from RFC8785).""" - return rfc8785.dumps(self.model_dump()) + signature: Base64Bytes + """ + A signature for the above statement, encoded as base64. + """ def sigstore_to_pypi(sigstore_bundle: Bundle) -> Attestation: @@ -142,28 +163,40 @@ def sigstore_to_pypi(sigstore_bundle: Bundle) -> Attestation: encoding=serialization.Encoding.DER ) - signature = sigstore_bundle._inner.message_signature.signature # noqa: SLF001 + envelope = sigstore_bundle._inner.dsse_envelope # noqa: SLF001 + + if len(envelope.signatures) != 1: + raise InvalidAttestationError( + f"expected exactly one signature, got {len(envelope.signatures)}" + ) + return Attestation( version=1, verification_material=VerificationMaterial( - certificate=b64encode(certificate).decode("ascii"), + certificate=certificate, transparency_entries=[TransparencyLogEntry(sigstore_bundle.log_entry._to_dict_rekor())], # noqa: SLF001 ), - message_signature=b64encode(signature).decode("ascii"), + envelope=Envelope(statement=envelope.payload, signature=envelope.signatures[0].sig), ) def pypi_to_sigstore(pypi_attestation: Attestation) -> Bundle: """Convert a PyPI attestation object as defined in PEP 740 into a Sigstore Bundle.""" - try: - certificate_bytes = b64decode(pypi_attestation.verification_material.certificate) - signature_bytes = b64decode(pypi_attestation.message_signature) - except binascii.Error as err: - raise InvalidAttestationError(str(err)) from err + cert_bytes = pypi_attestation.verification_material.certificate + statement = pypi_attestation.envelope.statement + signature = pypi_attestation.envelope.signature + + evp = DsseEnvelope( + _Envelope( + payload=statement, + payload_type=DsseEnvelope._TYPE, # noqa: SLF001 + signatures=[_Signature(sig=signature)], + ) + ) tlog_entry = pypi_attestation.verification_material.transparency_entries[0] try: - certificate = x509.load_der_x509_certificate(certificate_bytes) + certificate = x509.load_der_x509_certificate(cert_bytes) except ValueError as err: raise InvalidAttestationError(str(err)) from err @@ -172,8 +205,8 @@ def pypi_to_sigstore(pypi_attestation: Attestation) -> Bundle: except (ValidationError, sigstore.errors.Error) as err: raise InvalidAttestationError(str(err)) from err - return Bundle.from_parts( + return Bundle._from_parts( # noqa: SLF001 cert=certificate, - sig=signature_bytes, + content=evp, log_entry=log_entry, ) From 2d86fc1274aeade55b9e082f60b68e590278c8f2 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 6 Jun 2024 15:09:42 -0400 Subject: [PATCH 02/14] begin rewriting tests Signed-off-by: William Woodruff --- .github/workflows/tests.yml | 2 + src/pypi_attestation_models/_impl.py | 9 +- ...rfc8785-0.1.2-py3-none-any.whl.attestation | 97 ++--- .../rfc8785-0.1.2-py3-none-any.whl.sigstore | 108 +++--- test/conftest.py | 15 + test/test_impl.py | 331 ++++++++++-------- 6 files changed, 311 insertions(+), 251 deletions(-) create mode 100644 test/conftest.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9bd0005..70ae659 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,6 +16,8 @@ jobs: - "3.11" - "3.12" runs-on: ubuntu-latest + permissions: + id-token: write # unit tests use the ambient OIDC credential steps: - uses: actions/checkout@v4 diff --git a/src/pypi_attestation_models/_impl.py b/src/pypi_attestation_models/_impl.py index 913c46d..2176d4b 100644 --- a/src/pypi_attestation_models/_impl.py +++ b/src/pypi_attestation_models/_impl.py @@ -5,6 +5,7 @@ from __future__ import annotations +import base64 from typing import TYPE_CHECKING, Annotated, Any, Literal, NewType import sigstore.errors @@ -95,6 +96,7 @@ def sign(cls, signer: Signer, dist: Path) -> Attestation: stmt = ( _StatementBuilder() .subjects([_Subject(name=dist.name, digest=_DigestSet(root={"sha256": digest}))]) + .predicate_type("https://docs.pypi.org/attestations/publish/v1") .build() ) bundle = signer.sign_dsse(stmt) @@ -173,10 +175,13 @@ def sigstore_to_pypi(sigstore_bundle: Bundle) -> Attestation: return Attestation( version=1, verification_material=VerificationMaterial( - certificate=certificate, + certificate=base64.b64encode(certificate), transparency_entries=[TransparencyLogEntry(sigstore_bundle.log_entry._to_dict_rekor())], # noqa: SLF001 ), - envelope=Envelope(statement=envelope.payload, signature=envelope.signatures[0].sig), + envelope=Envelope( + statement=base64.b64encode(envelope.payload), + signature=base64.b64encode(envelope.signatures[0].sig), + ), ) diff --git a/test/assets/rfc8785-0.1.2-py3-none-any.whl.attestation b/test/assets/rfc8785-0.1.2-py3-none-any.whl.attestation index 883ce1e..fff256e 100644 --- a/test/assets/rfc8785-0.1.2-py3-none-any.whl.attestation +++ b/test/assets/rfc8785-0.1.2-py3-none-any.whl.attestation @@ -1,48 +1,51 @@ { - "version": 1, - "verification_material": { - "certificate": "MIIC1jCCAlygAwIBAgIUTDMXIHMGbNF+Sm0qoQoj35hY3zkwCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjQwNTAzMTQwNTU0WhcNMjQwNTAzMTQxNTU0WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEqv2vvFAD66IdGSg/+JbB/nYfook1FqpmM773o9MdVZktl1LkUWAU8SzaZhSso/7qVriyH/S8km0HqTVMzuZ+SaOCAXswggF3MA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQU5TQvo55q5OXkVFmYDsR93Neffq0wHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4YZD8wLAYDVR0RAQH/BCIwIIEeZmFjdW5kby50dWVzY2FAdHJhaWxvZmJpdHMuY29tMCkGCisGAQQBg78wAQEEG2h0dHBzOi8vYWNjb3VudHMuZ29vZ2xlLmNvbTArBgorBgEEAYO/MAEIBB0MG2h0dHBzOi8vYWNjb3VudHMuZ29vZ2xlLmNvbTCBiQYKKwYBBAHWeQIEAgR7BHkAdwB1AN09MGrGxxEyYxkeHJlnNwKiSl643jyt/4eKcoAvKe6OAAABjz7Gm3oAAAQDAEYwRAIgGkFpx5q3NzmgBIPywysdADDRRRPM/xsa8Fkfva+chKECIAu5HgO2eKsdc9pohmgn/modVcJ1Q5Muou9d4l1c5fXyMAoGCCqGSM49BAMDA2gAMGUCMHschAnWt88W4cu35dEv0MJ72s3BZsudUQzZ8dtg0xBlF3uwdDoprNfbA2tM5piDlAIxAMBg5Dqx0BV/9Rp/QMLhb+aGqZm7n7E5GkXJpHA8TySZamdyYuRlEiPf4cj7x/ruyw==", - "transparency_entries": [ - { - "logIndex": "90818200", - "logId": { - "keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=" - }, - "kindVersion": { - "kind": "hashedrekord", - "version": "0.0.1" - }, - "integratedTime": "1714745154", - "inclusionPromise": { - "signedEntryTimestamp": "MEUCIQCyiwcUzPS8I8WKRuLGelfRdlmx/AtM6TwMmyvK8utURwIgDKtqe3gaZe8bbVupw3HJm4nzvpYAZHaHmv/cCbJRYlo=" - }, - "inclusionProof": { - "logIndex": "86654769", - "rootHash": "rpJ6l1p+pM8A+nMvMT80o0+qrCozIEfbG6qa0psYB0U=", - "treeSize": "86654770", - "hashes": [ - "9X9+Ewnt1Jv3SVUsUbtRTb8QezxTGW71hfmYyj4urho=", - "XMz5WH5iwOITJcBdVsAUj+h1uxgH8EPXezgGg4lNP5M=", - "LLawJ9A3X4PEGMwFRwVbwhUxkHMJj9wFemKagvJlDFw=", - "7PXuijLdBg+hW8HdjY+IyuFgLXvlTKOKqHRFTMJB4Ko=", - "er0Z148pal4gYGSVfQ/rvzPKTc1EIDd7pYQjHd7RtKk=", - "ZmLzbyYsyxNDvtbH/T00VHzMFhW8WBS9lgPH2rlNFMY=", - "DH+I5x/ZvX3i2Ysc2divZ/6MK1e3ppSmNtUTg+CQwEw=", - "nrCOZ+D41yjNI2zgMlBLN0LHOn5OP4sJYffOuoPgSJg=", - "vQtr96qBr+8s4Up0YK2ibgbVOYyLK4e8zsjHOyMnOM4=", - "4GdrZvm4dTLD8P8KPOQx8Op0UOT7XbRS1WG43uXseJU=", - "fp4aY4dEhrMBmS4ex9s/lm8UPsk42eWg77zd+uJn0F4=", - "2aaLz8XcvX3I3Ihft0W701fVKICZLYtBIxRbPmdkcZ0=", - "sjohk/3DQIfXTgf/5XpwtdF7yNbrf8YykOMHr1CyBYQ=", - "98enzMaC+x5oCMvIZQA5z8vu2apDMCFvE/935NfuPw8=" - ], - "checkpoint": { - "envelope": "rekor.sigstore.dev - 2605736670972794746\n86654770\nrpJ6l1p+pM8A+nMvMT80o0+qrCozIEfbG6qa0psYB0U=\n\n— rekor.sigstore.dev wNI9ajBFAiBXlxCjY0PMu4XUVJa/auC+EwEJws9xXfEbiYRM5uIjpgIhAKRduPCMlSRhNCQeGUifB2nAkKJDGlOJa75mkYYrrVMK\n" - } - }, - "canonicalizedBody": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiJmMTQ2ZmY4NWMxMGZjMTg4ODM0MDVjMWQ5Mzc0NjIzZWI3YzI5ZjRlYTRiYTYyYzZmNWUyYzZmMTc5M2ZiZDEwIn19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FVUNJUUMvd1ZYcTlBeWExQW1JZlgzZmVoSUZTbkN1Q0tzNGhWTks1eGJ3ckVtV1d3SWdHMzhtcmtsOHhmNG1SYWhmYmNIckVTdFZYYjg3enFySVFoT1BUOVJTcFdFPSIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVTXhha05EUVd4NVowRjNTVUpCWjBsVlZFUk5XRWxJVFVkaVRrWXJVMjB3Y1c5UmIyb3pOV2haTTNwcmQwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcFJkMDVVUVhwTlZGRjNUbFJWTUZkb1kwNU5hbEYzVGxSQmVrMVVVWGhPVkZVd1YycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVZ4ZGpKMmRrWkJSRFkyU1dSSFUyY3ZLMHBpUWk5dVdXWnZiMnN4Um5Gd2JVMDNOek1LYnpsTlpGWmFhM1JzTVV4clZWZEJWVGhUZW1GYWFGTnpieTgzY1ZaeWFYbElMMU00YTIwd1NIRlVWazE2ZFZvclUyRlBRMEZZYzNkblowWXpUVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXUkNaMDVXU0ZFMFJVWm5VVlUxVkZGMkNtODFOWEUxVDFoclZrWnRXVVJ6VWprelRtVm1abkV3ZDBoM1dVUldVakJxUWtKbmQwWnZRVlV6T1ZCd2VqRlphMFZhWWpWeFRtcHdTMFpYYVhocE5Ga0tXa1E0ZDB4QldVUldVakJTUVZGSUwwSkRTWGRKU1VWbFdtMUdhbVJYTld0aWVUVXdaRmRXZWxreVJrRmtTRXBvWVZkNGRscHRTbkJrU0UxMVdUSTVkQXBOUTJ0SFEybHpSMEZSVVVKbk56aDNRVkZGUlVjeWFEQmtTRUo2VDJrNGRsbFhUbXBpTTFaMVpFaE5kVm95T1haYU1uaHNURzFPZG1KVVFYSkNaMjl5Q2tKblJVVkJXVTh2VFVGRlNVSkNNRTFITW1nd1pFaENlazlwT0haWlYwNXFZak5XZFdSSVRYVmFNamwyV2pKNGJFeHRUblppVkVOQ2FWRlpTMHQzV1VJS1FrRklWMlZSU1VWQloxSTNRa2hyUVdSM1FqRkJUakE1VFVkeVIzaDRSWGxaZUd0bFNFcHNiazUzUzJsVGJEWTBNMnA1ZEM4MFpVdGpiMEYyUzJVMlR3cEJRVUZDYW5vM1IyMHpiMEZCUVZGRVFVVlpkMUpCU1dkSGEwWndlRFZ4TTA1NmJXZENTVkI1ZDNselpFRkVSRkpTVWxCTkwzaHpZVGhHYTJaMllTdGpDbWhMUlVOSlFYVTFTR2RQTW1WTGMyUmpPWEJ2YUcxbmJpOXRiMlJXWTBveFVUVk5kVzkxT1dRMGJERmpOV1pZZVUxQmIwZERRM0ZIVTAwME9VSkJUVVFLUVRKblFVMUhWVU5OU0hOamFFRnVWM1E0T0ZjMFkzVXpOV1JGZGpCTlNqY3ljek5DV25OMVpGVlJlbG80WkhSbk1IaENiRVl6ZFhka1JHOXdjazVtWWdwQk1uUk5OWEJwUkd4QlNYaEJUVUpuTlVSeGVEQkNWaTg1VW5BdlVVMU1hR0lyWVVkeFdtMDNiamRGTlVkcldFcHdTRUU0VkhsVFdtRnRaSGxaZFZKc0NrVnBVR1kwWTJvM2VDOXlkWGwzUFQwS0xTMHRMUzFGVGtRZ1EwVlNWRWxHU1VOQlZFVXRMUzB0TFFvPSJ9fX19" - } - ] - }, - "message_signature": "MEUCIQC/wVXq9Aya1AmIfX3fehIFSnCuCKs4hVNK5xbwrEmWWwIgG38mrkl8xf4mRahfbcHrEStVXb87zqrIQhOPT9RSpWE=" -} \ No newline at end of file + "version": 1, + "verification_material": { + "certificate": "MIIC0zCCAlmgAwIBAgIUNa1+nVgkOX1xlssDyRyt0DZ6M5UwCgYIKoZIzj0EAwMwNzEVMBMGA1UE\nChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjQwNjA2\nMTgzOTA1WhcNMjQwNjA2MTg0OTA1WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEyrm8stLQ\nwPX/MdVS50NZ4gmXEPEh6kYlvhEo079Yk1lMMmMobwFvINC8Lc02kg+03BMscXbM/OKv3Fl1qH9P\nCKOCAXgwggF0MA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQU\nn98gJQymjI+dFUDEea6CKbQngj4wHwYDVR0jBBgwFoAUcYYwphR8Ym/599b0BRp/X//rb6wwIwYD\nVR0RAQH/BBkwF4EVd2lsbGlhbUB5b3NzYXJpYW4ubmV0MCwGCisGAQQBg78wAQEEHmh0dHBzOi8v\nZ2l0aHViLmNvbS9sb2dpbi9vYXV0aDAuBgorBgEEAYO/MAEIBCAMHmh0dHBzOi8vZ2l0aHViLmNv\nbS9sb2dpbi9vYXV0aDCBiQYKKwYBBAHWeQIEAgR7BHkAdwB1ACswvNxoiMni4dgmKV50H0g5MZYC\n8pwzy15DQP6yrIZ6AAABj+7Y7/YAAAQDAEYwRAIgTWyPyS2CKRm5ZUaTwngfBtrOJozwlIfOOfXH\nyyej0BQCIGCwmYVKhNS7JbUTFeDe90SWNlpwl5YAVDb/2GGFxGNCMAoGCCqGSM49BAMDA2gAMGUC\nMQCxIekmLNdhAS7HVo6CRgqVRht8RiFO6lbyGK4fDuEQOk/MPaBlRhsaUxwejf7jI2kCMCw5AOij\nMvqsXHjZYk7TfRH/079Zy0qEWjD9lurfPiTX9qSQKSiXORvxpk/DQsfTsg==\n", + "transparency_entries": [ + { + "logIndex": "28175749", + "logId": { + "keyId": "0y8wo8MtY5wrdiIFohx7sHeI5oKDpK5vQhGHI6G+pJY=" + }, + "kindVersion": { + "kind": "dsse", + "version": "0.0.1" + }, + "integratedTime": "1717699145", + "inclusionPromise": { + "signedEntryTimestamp": "MEYCIQDx9J86FXVVe/PIoY5jHvlQJ85A6oZ2BiZ6/3ZYe3EeAQIhALl97dZebI/Smm0qQMdVVkbVznthHZCaSClN4djajx3G" + }, + "inclusionProof": { + "logIndex": "28160930", + "rootHash": "zWVcqCxxaF+b1WWfb+xZZlQYK4MdEr81Dd0KzOFu0Ko=", + "treeSize": "28160931", + "hashes": [ + "qDMDpEGtUE3c8CnnlguBb24eYIGo+nv0wGjN2Wdq1V8=", + "r3g45oVhy3zCnIK7lkTsH8Sg1Qdy0kH/CqfaBUE0yok=", + "XAv5fJtrNK1YPZwvB0JIVOOwWiLHk/oWoqzN1xzF9t4=", + "14fYRBMB/6rTWV5Qpei46FU+7rHmaqqLFV/K22kI6sg=", + "KhgfVnUZkrYVk1Je+xSJ3iT5wZMgut38srFhH/iVsWQ=", + "C9LjSdxA96yalX4DOGX/fV0kuhx9LLU1BERodtxE+No=", + "NwfjLTWUBnDymaU+Ca/ykaXOiGNRvIt5/5ZZDzEyTyA=", + "jKHh3ZbaWLoBLn5qZTUpiw9oPlStl/ZSfPmdsHte+AQ=", + "ekhZZrQ/riDDmsvqy3I4gAcbUBcoyoNMChiDAXsTu3Y=", + "oMHAlypWw/lk5Q9JHd9O5UJZ7bdcH6Gzs+zCES7YUKo=", + "Kn3gkyUwY86Ut3fWtexgSLtxteycn2p6k7Kj7qJFEDw=", + "IfPx7HUTjLRrRAy6mhkYP/7aq48i6G+Mk/NQidZPJk8=", + "Edul4W41O3EfxKEEMlX2nW0+GTgCv00nGmcpwhALgVA=", + "rBWB37+HwkTZgDv0rMtGBUoDI0UZqcgDZp48M6CaUlA=" + ], + "checkpoint": { + "envelope": "rekor.sigstage.dev - 8050909264565447525\n28160931\nzWVcqCxxaF+b1WWfb+xZZlQYK4MdEr81Dd0KzOFu0Ko=\n\n— rekor.sigstage.dev 0y8wozBFAiBOHi+eUTSSX6mrNLjQwoKJLum7cpnVpvAb8QwK+DnLngIhAO2170Q0xfbOMwrbF2sM80z1wkYhnlVRidI+/j4/k4JJ\n" + } + }, + "canonicalizedBody": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiZHNzZSIsInNwZWMiOnsiZW52ZWxvcGVIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiZGY1MDk2Njg2NzNkMmY4MjAxOTQ2ZTBmNTliNmFiNzhiZWY0NmYyMTc5NTc5N2EzYjJkMTUyZjc3NmFmYzEyZSJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6IjcyOTM0Yjc1YzgxODk3ZWE4Yjg4NTk0N2ExOWRjODE4ZWUzNjIwYzUwMzJhZmIzYjc4ODc3ZmJjYmI3MjMwYzEifSwic2lnbmF0dXJlcyI6W3sic2lnbmF0dXJlIjoiTUVVQ0lBdmtSSEZ1K24yenMvNGorVjNjTTIyRFZaSTF6cUs0TmpmbHphdEVRTWZnQWlFQW82VjNaN3RpaE9Ha1lpeXNGMTh4dFpWcWVPdDNyZHdWVmI3Nm1XcDhETWM9IiwidmVyaWZpZXIiOiJMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VNd2VrTkRRV3h0WjBGM1NVSkJaMGxWVG1FeEsyNVdaMnRQV0RGNGJITnpSSGxTZVhRd1JGbzJUVFZWZDBObldVbExiMXBKZW1vd1JVRjNUWGNLVG5wRlZrMUNUVWRCTVZWRlEyaE5UV015Ykc1ak0xSjJZMjFWZFZwSFZqSk5ValIzU0VGWlJGWlJVVVJGZUZaNllWZGtlbVJIT1hsYVV6RndZbTVTYkFwamJURnNXa2RzYUdSSFZYZElhR05PVFdwUmQwNXFRVEpOVkdkNlQxUkJNVmRvWTA1TmFsRjNUbXBCTWsxVVp6QlBWRUV4VjJwQlFVMUdhM2RGZDFsSUNrdHZXa2w2YWpCRFFWRlpTVXR2V2tsNmFqQkVRVkZqUkZGblFVVjVjbTA0YzNSTVVYZFFXQzlOWkZaVE5UQk9XalJuYlZoRlVFVm9ObXRaYkhab1JXOEtNRGM1V1dzeGJFMU5iVTF2WW5kR2RrbE9RemhNWXpBeWEyY3JNRE5DVFhOaldHSk5MMDlMZGpOR2JERnhTRGxRUTB0UFEwRllaM2RuWjBZd1RVRTBSd3BCTVZWa1JIZEZRaTkzVVVWQmQwbElaMFJCVkVKblRsWklVMVZGUkVSQlMwSm5aM0pDWjBWR1FsRmpSRUY2UVdSQ1owNVdTRkUwUlVablVWVnVPVGhuQ2twUmVXMXFTU3RrUmxWRVJXVmhOa05MWWxGdVoybzBkMGgzV1VSV1VqQnFRa0puZDBadlFWVmpXVmwzY0doU09GbHRMelU1T1dJd1FsSndMMWd2TDNJS1lqWjNkMGwzV1VSV1VqQlNRVkZJTDBKQ2EzZEdORVZXWkRKc2MySkhiR2hpVlVJMVlqTk9lbGxZU25CWlZ6UjFZbTFXTUUxRGQwZERhWE5IUVZGUlFncG5OemgzUVZGRlJVaHRhREJrU0VKNlQyazRkbG95YkRCaFNGWnBURzFPZG1KVE9YTmlNbVJ3WW1rNWRsbFlWakJoUkVGMVFtZHZja0puUlVWQldVOHZDazFCUlVsQ1EwRk5TRzFvTUdSSVFucFBhVGgyV2pKc01HRklWbWxNYlU1MllsTTVjMkl5WkhCaWFUbDJXVmhXTUdGRVEwSnBVVmxMUzNkWlFrSkJTRmNLWlZGSlJVRm5VamRDU0d0QlpIZENNVUZEYzNkMlRuaHZhVTF1YVRSa1oyMUxWalV3U0RCbk5VMWFXVU00Y0hkNmVURTFSRkZRTm5seVNWbzJRVUZCUWdwcUt6ZFpOeTlaUVVGQlVVUkJSVmwzVWtGSloxUlhlVkI1VXpKRFMxSnROVnBWWVZSM2JtZG1RblJ5VDBwdmVuZHNTV1pQVDJaWVNIbDVaV293UWxGRENrbEhRM2R0V1ZaTGFFNVROMHBpVlZSR1pVUmxPVEJUVjA1c2NIZHNOVmxCVmtSaUx6SkhSMFo0UjA1RFRVRnZSME5EY1VkVFRUUTVRa0ZOUkVFeVowRUtUVWRWUTAxUlEzaEpaV3R0VEU1a2FFRlROMGhXYnpaRFVtZHhWbEpvZERoU2FVWlBObXhpZVVkTE5HWkVkVVZSVDJzdlRWQmhRbXhTYUhOaFZYaDNaUXBxWmpkcVNUSnJRMDFEZHpWQlQybHFUWFp4YzFoSWFscFphemRVWmxKSUx6QTNPVnA1TUhGRlYycEVPV3gxY21aUWFWUllPWEZUVVV0VGFWaFBVblo0Q25CckwwUlJjMlpVYzJjOVBRb3RMUzB0TFVWT1JDQkRSVkpVU1VaSlEwRlVSUzB0TFMwdENnPT0ifV19fQ==" + } + ] + }, + "envelope": { + "statement": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJu\nYW1lIjoicmZjODc4NS0wLjEuMi1weTMtbm9uZS1hbnkud2hsIiwiZGlnZXN0Ijp7InNoYTI1NiI6\nImM0ZTkyZTllY2M4MjhiZWYyYWE3ZGJhMWRlOGFjOTgzNTExZjc1MzJhMGRmMTFjNzcwZDM5MDk5\nYTI1Y2YyMDEifX1dLCJwcmVkaWNhdGVUeXBlIjoiaHR0cHM6Ly9kb2NzLnB5cGkub3JnL2F0dGVz\ndGF0aW9ucy9wdWJsaXNoL3YxIiwicHJlZGljYXRlIjpudWxsfQ==\n", + "signature": "MEUCIAvkRHFu+n2zs/4j+V3cM22DVZI1zqK4NjflzatEQMfgAiEAo6V3Z7tihOGkYiysF18xtZVq\neOt3rdwVVb76mWp8DMc=\n" + } +} diff --git a/test/assets/rfc8785-0.1.2-py3-none-any.whl.sigstore b/test/assets/rfc8785-0.1.2-py3-none-any.whl.sigstore index 9072423..7ed3d12 100644 --- a/test/assets/rfc8785-0.1.2-py3-none-any.whl.sigstore +++ b/test/assets/rfc8785-0.1.2-py3-none-any.whl.sigstore @@ -1,56 +1,58 @@ { - "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", - "verificationMaterial": { - "certificate": { - "rawBytes": "MIIC1jCCAlygAwIBAgIUTDMXIHMGbNF+Sm0qoQoj35hY3zkwCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjQwNTAzMTQwNTU0WhcNMjQwNTAzMTQxNTU0WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEqv2vvFAD66IdGSg/+JbB/nYfook1FqpmM773o9MdVZktl1LkUWAU8SzaZhSso/7qVriyH/S8km0HqTVMzuZ+SaOCAXswggF3MA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQU5TQvo55q5OXkVFmYDsR93Neffq0wHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4YZD8wLAYDVR0RAQH/BCIwIIEeZmFjdW5kby50dWVzY2FAdHJhaWxvZmJpdHMuY29tMCkGCisGAQQBg78wAQEEG2h0dHBzOi8vYWNjb3VudHMuZ29vZ2xlLmNvbTArBgorBgEEAYO/MAEIBB0MG2h0dHBzOi8vYWNjb3VudHMuZ29vZ2xlLmNvbTCBiQYKKwYBBAHWeQIEAgR7BHkAdwB1AN09MGrGxxEyYxkeHJlnNwKiSl643jyt/4eKcoAvKe6OAAABjz7Gm3oAAAQDAEYwRAIgGkFpx5q3NzmgBIPywysdADDRRRPM/xsa8Fkfva+chKECIAu5HgO2eKsdc9pohmgn/modVcJ1Q5Muou9d4l1c5fXyMAoGCCqGSM49BAMDA2gAMGUCMHschAnWt88W4cu35dEv0MJ72s3BZsudUQzZ8dtg0xBlF3uwdDoprNfbA2tM5piDlAIxAMBg5Dqx0BV/9Rp/QMLhb+aGqZm7n7E5GkXJpHA8TySZamdyYuRlEiPf4cj7x/ruyw==" - }, - "tlogEntries": [ - { - "logIndex": "90818200", - "logId": { - "keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=" - }, - "kindVersion": { - "kind": "hashedrekord", - "version": "0.0.1" - }, - "integratedTime": "1714745154", - "inclusionPromise": { - "signedEntryTimestamp": "MEUCIQCyiwcUzPS8I8WKRuLGelfRdlmx/AtM6TwMmyvK8utURwIgDKtqe3gaZe8bbVupw3HJm4nzvpYAZHaHmv/cCbJRYlo=" - }, - "inclusionProof": { - "logIndex": "86654769", - "rootHash": "rpJ6l1p+pM8A+nMvMT80o0+qrCozIEfbG6qa0psYB0U=", - "treeSize": "86654770", - "hashes": [ - "9X9+Ewnt1Jv3SVUsUbtRTb8QezxTGW71hfmYyj4urho=", - "XMz5WH5iwOITJcBdVsAUj+h1uxgH8EPXezgGg4lNP5M=", - "LLawJ9A3X4PEGMwFRwVbwhUxkHMJj9wFemKagvJlDFw=", - "7PXuijLdBg+hW8HdjY+IyuFgLXvlTKOKqHRFTMJB4Ko=", - "er0Z148pal4gYGSVfQ/rvzPKTc1EIDd7pYQjHd7RtKk=", - "ZmLzbyYsyxNDvtbH/T00VHzMFhW8WBS9lgPH2rlNFMY=", - "DH+I5x/ZvX3i2Ysc2divZ/6MK1e3ppSmNtUTg+CQwEw=", - "nrCOZ+D41yjNI2zgMlBLN0LHOn5OP4sJYffOuoPgSJg=", - "vQtr96qBr+8s4Up0YK2ibgbVOYyLK4e8zsjHOyMnOM4=", - "4GdrZvm4dTLD8P8KPOQx8Op0UOT7XbRS1WG43uXseJU=", - "fp4aY4dEhrMBmS4ex9s/lm8UPsk42eWg77zd+uJn0F4=", - "2aaLz8XcvX3I3Ihft0W701fVKICZLYtBIxRbPmdkcZ0=", - "sjohk/3DQIfXTgf/5XpwtdF7yNbrf8YykOMHr1CyBYQ=", - "98enzMaC+x5oCMvIZQA5z8vu2apDMCFvE/935NfuPw8=" - ], - "checkpoint": { - "envelope": "rekor.sigstore.dev - 2605736670972794746\n86654770\nrpJ6l1p+pM8A+nMvMT80o0+qrCozIEfbG6qa0psYB0U=\n\n\u2014 rekor.sigstore.dev wNI9ajBFAiBXlxCjY0PMu4XUVJa/auC+EwEJws9xXfEbiYRM5uIjpgIhAKRduPCMlSRhNCQeGUifB2nAkKJDGlOJa75mkYYrrVMK\n" - } + "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "verificationMaterial": { + "certificate": { + "rawBytes": "MIIC0zCCAlmgAwIBAgIUNa1+nVgkOX1xlssDyRyt0DZ6M5UwCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjQwNjA2MTgzOTA1WhcNMjQwNjA2MTg0OTA1WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEyrm8stLQwPX/MdVS50NZ4gmXEPEh6kYlvhEo079Yk1lMMmMobwFvINC8Lc02kg+03BMscXbM/OKv3Fl1qH9PCKOCAXgwggF0MA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUn98gJQymjI+dFUDEea6CKbQngj4wHwYDVR0jBBgwFoAUcYYwphR8Ym/599b0BRp/X//rb6wwIwYDVR0RAQH/BBkwF4EVd2lsbGlhbUB5b3NzYXJpYW4ubmV0MCwGCisGAQQBg78wAQEEHmh0dHBzOi8vZ2l0aHViLmNvbS9sb2dpbi9vYXV0aDAuBgorBgEEAYO/MAEIBCAMHmh0dHBzOi8vZ2l0aHViLmNvbS9sb2dpbi9vYXV0aDCBiQYKKwYBBAHWeQIEAgR7BHkAdwB1ACswvNxoiMni4dgmKV50H0g5MZYC8pwzy15DQP6yrIZ6AAABj+7Y7/YAAAQDAEYwRAIgTWyPyS2CKRm5ZUaTwngfBtrOJozwlIfOOfXHyyej0BQCIGCwmYVKhNS7JbUTFeDe90SWNlpwl5YAVDb/2GGFxGNCMAoGCCqGSM49BAMDA2gAMGUCMQCxIekmLNdhAS7HVo6CRgqVRht8RiFO6lbyGK4fDuEQOk/MPaBlRhsaUxwejf7jI2kCMCw5AOijMvqsXHjZYk7TfRH/079Zy0qEWjD9lurfPiTX9qSQKSiXORvxpk/DQsfTsg==" }, - "canonicalizedBody": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiJmMTQ2ZmY4NWMxMGZjMTg4ODM0MDVjMWQ5Mzc0NjIzZWI3YzI5ZjRlYTRiYTYyYzZmNWUyYzZmMTc5M2ZiZDEwIn19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FVUNJUUMvd1ZYcTlBeWExQW1JZlgzZmVoSUZTbkN1Q0tzNGhWTks1eGJ3ckVtV1d3SWdHMzhtcmtsOHhmNG1SYWhmYmNIckVTdFZYYjg3enFySVFoT1BUOVJTcFdFPSIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVTXhha05EUVd4NVowRjNTVUpCWjBsVlZFUk5XRWxJVFVkaVRrWXJVMjB3Y1c5UmIyb3pOV2haTTNwcmQwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcFJkMDVVUVhwTlZGRjNUbFJWTUZkb1kwNU5hbEYzVGxSQmVrMVVVWGhPVkZVd1YycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVZ4ZGpKMmRrWkJSRFkyU1dSSFUyY3ZLMHBpUWk5dVdXWnZiMnN4Um5Gd2JVMDNOek1LYnpsTlpGWmFhM1JzTVV4clZWZEJWVGhUZW1GYWFGTnpieTgzY1ZaeWFYbElMMU00YTIwd1NIRlVWazE2ZFZvclUyRlBRMEZZYzNkblowWXpUVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXUkNaMDVXU0ZFMFJVWm5VVlUxVkZGMkNtODFOWEUxVDFoclZrWnRXVVJ6VWprelRtVm1abkV3ZDBoM1dVUldVakJxUWtKbmQwWnZRVlV6T1ZCd2VqRlphMFZhWWpWeFRtcHdTMFpYYVhocE5Ga0tXa1E0ZDB4QldVUldVakJTUVZGSUwwSkRTWGRKU1VWbFdtMUdhbVJYTld0aWVUVXdaRmRXZWxreVJrRmtTRXBvWVZkNGRscHRTbkJrU0UxMVdUSTVkQXBOUTJ0SFEybHpSMEZSVVVKbk56aDNRVkZGUlVjeWFEQmtTRUo2VDJrNGRsbFhUbXBpTTFaMVpFaE5kVm95T1haYU1uaHNURzFPZG1KVVFYSkNaMjl5Q2tKblJVVkJXVTh2VFVGRlNVSkNNRTFITW1nd1pFaENlazlwT0haWlYwNXFZak5XZFdSSVRYVmFNamwyV2pKNGJFeHRUblppVkVOQ2FWRlpTMHQzV1VJS1FrRklWMlZSU1VWQloxSTNRa2hyUVdSM1FqRkJUakE1VFVkeVIzaDRSWGxaZUd0bFNFcHNiazUzUzJsVGJEWTBNMnA1ZEM4MFpVdGpiMEYyUzJVMlR3cEJRVUZDYW5vM1IyMHpiMEZCUVZGRVFVVlpkMUpCU1dkSGEwWndlRFZ4TTA1NmJXZENTVkI1ZDNselpFRkVSRkpTVWxCTkwzaHpZVGhHYTJaMllTdGpDbWhMUlVOSlFYVTFTR2RQTW1WTGMyUmpPWEJ2YUcxbmJpOXRiMlJXWTBveFVUVk5kVzkxT1dRMGJERmpOV1pZZVUxQmIwZERRM0ZIVTAwME9VSkJUVVFLUVRKblFVMUhWVU5OU0hOamFFRnVWM1E0T0ZjMFkzVXpOV1JGZGpCTlNqY3ljek5DV25OMVpGVlJlbG80WkhSbk1IaENiRVl6ZFhka1JHOXdjazVtWWdwQk1uUk5OWEJwUkd4QlNYaEJUVUpuTlVSeGVEQkNWaTg1VW5BdlVVMU1hR0lyWVVkeFdtMDNiamRGTlVkcldFcHdTRUU0VkhsVFdtRnRaSGxaZFZKc0NrVnBVR1kwWTJvM2VDOXlkWGwzUFQwS0xTMHRMUzFGVGtRZ1EwVlNWRWxHU1VOQlZFVXRMUzB0TFFvPSJ9fX19" - } - ] - }, - "messageSignature": { - "messageDigest": { - "algorithm": "SHA2_256", - "digest": "8Ub/hcEPwYiDQFwdk3RiPrfCn06kumLG9eLG8Xk/vRA=" + "tlogEntries": [ + { + "logIndex": "28175749", + "logId": { + "keyId": "0y8wo8MtY5wrdiIFohx7sHeI5oKDpK5vQhGHI6G+pJY=" + }, + "kindVersion": { + "kind": "dsse", + "version": "0.0.1" + }, + "integratedTime": "1717699145", + "inclusionPromise": { + "signedEntryTimestamp": "MEYCIQDx9J86FXVVe/PIoY5jHvlQJ85A6oZ2BiZ6/3ZYe3EeAQIhALl97dZebI/Smm0qQMdVVkbVznthHZCaSClN4djajx3G" + }, + "inclusionProof": { + "logIndex": "28160930", + "rootHash": "zWVcqCxxaF+b1WWfb+xZZlQYK4MdEr81Dd0KzOFu0Ko=", + "treeSize": "28160931", + "hashes": [ + "qDMDpEGtUE3c8CnnlguBb24eYIGo+nv0wGjN2Wdq1V8=", + "r3g45oVhy3zCnIK7lkTsH8Sg1Qdy0kH/CqfaBUE0yok=", + "XAv5fJtrNK1YPZwvB0JIVOOwWiLHk/oWoqzN1xzF9t4=", + "14fYRBMB/6rTWV5Qpei46FU+7rHmaqqLFV/K22kI6sg=", + "KhgfVnUZkrYVk1Je+xSJ3iT5wZMgut38srFhH/iVsWQ=", + "C9LjSdxA96yalX4DOGX/fV0kuhx9LLU1BERodtxE+No=", + "NwfjLTWUBnDymaU+Ca/ykaXOiGNRvIt5/5ZZDzEyTyA=", + "jKHh3ZbaWLoBLn5qZTUpiw9oPlStl/ZSfPmdsHte+AQ=", + "ekhZZrQ/riDDmsvqy3I4gAcbUBcoyoNMChiDAXsTu3Y=", + "oMHAlypWw/lk5Q9JHd9O5UJZ7bdcH6Gzs+zCES7YUKo=", + "Kn3gkyUwY86Ut3fWtexgSLtxteycn2p6k7Kj7qJFEDw=", + "IfPx7HUTjLRrRAy6mhkYP/7aq48i6G+Mk/NQidZPJk8=", + "Edul4W41O3EfxKEEMlX2nW0+GTgCv00nGmcpwhALgVA=", + "rBWB37+HwkTZgDv0rMtGBUoDI0UZqcgDZp48M6CaUlA=" + ], + "checkpoint": { + "envelope": "rekor.sigstage.dev - 8050909264565447525\n28160931\nzWVcqCxxaF+b1WWfb+xZZlQYK4MdEr81Dd0KzOFu0Ko=\n\n\u2014 rekor.sigstage.dev 0y8wozBFAiBOHi+eUTSSX6mrNLjQwoKJLum7cpnVpvAb8QwK+DnLngIhAO2170Q0xfbOMwrbF2sM80z1wkYhnlVRidI+/j4/k4JJ\n" + } + }, + "canonicalizedBody": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiZHNzZSIsInNwZWMiOnsiZW52ZWxvcGVIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiZGY1MDk2Njg2NzNkMmY4MjAxOTQ2ZTBmNTliNmFiNzhiZWY0NmYyMTc5NTc5N2EzYjJkMTUyZjc3NmFmYzEyZSJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6IjcyOTM0Yjc1YzgxODk3ZWE4Yjg4NTk0N2ExOWRjODE4ZWUzNjIwYzUwMzJhZmIzYjc4ODc3ZmJjYmI3MjMwYzEifSwic2lnbmF0dXJlcyI6W3sic2lnbmF0dXJlIjoiTUVVQ0lBdmtSSEZ1K24yenMvNGorVjNjTTIyRFZaSTF6cUs0TmpmbHphdEVRTWZnQWlFQW82VjNaN3RpaE9Ha1lpeXNGMTh4dFpWcWVPdDNyZHdWVmI3Nm1XcDhETWM9IiwidmVyaWZpZXIiOiJMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VNd2VrTkRRV3h0WjBGM1NVSkJaMGxWVG1FeEsyNVdaMnRQV0RGNGJITnpSSGxTZVhRd1JGbzJUVFZWZDBObldVbExiMXBKZW1vd1JVRjNUWGNLVG5wRlZrMUNUVWRCTVZWRlEyaE5UV015Ykc1ak0xSjJZMjFWZFZwSFZqSk5ValIzU0VGWlJGWlJVVVJGZUZaNllWZGtlbVJIT1hsYVV6RndZbTVTYkFwamJURnNXa2RzYUdSSFZYZElhR05PVFdwUmQwNXFRVEpOVkdkNlQxUkJNVmRvWTA1TmFsRjNUbXBCTWsxVVp6QlBWRUV4VjJwQlFVMUdhM2RGZDFsSUNrdHZXa2w2YWpCRFFWRlpTVXR2V2tsNmFqQkVRVkZqUkZGblFVVjVjbTA0YzNSTVVYZFFXQzlOWkZaVE5UQk9XalJuYlZoRlVFVm9ObXRaYkhab1JXOEtNRGM1V1dzeGJFMU5iVTF2WW5kR2RrbE9RemhNWXpBeWEyY3JNRE5DVFhOaldHSk5MMDlMZGpOR2JERnhTRGxRUTB0UFEwRllaM2RuWjBZd1RVRTBSd3BCTVZWa1JIZEZRaTkzVVVWQmQwbElaMFJCVkVKblRsWklVMVZGUkVSQlMwSm5aM0pDWjBWR1FsRmpSRUY2UVdSQ1owNVdTRkUwUlVablVWVnVPVGhuQ2twUmVXMXFTU3RrUmxWRVJXVmhOa05MWWxGdVoybzBkMGgzV1VSV1VqQnFRa0puZDBadlFWVmpXVmwzY0doU09GbHRMelU1T1dJd1FsSndMMWd2TDNJS1lqWjNkMGwzV1VSV1VqQlNRVkZJTDBKQ2EzZEdORVZXWkRKc2MySkhiR2hpVlVJMVlqTk9lbGxZU25CWlZ6UjFZbTFXTUUxRGQwZERhWE5IUVZGUlFncG5OemgzUVZGRlJVaHRhREJrU0VKNlQyazRkbG95YkRCaFNGWnBURzFPZG1KVE9YTmlNbVJ3WW1rNWRsbFlWakJoUkVGMVFtZHZja0puUlVWQldVOHZDazFCUlVsQ1EwRk5TRzFvTUdSSVFucFBhVGgyV2pKc01HRklWbWxNYlU1MllsTTVjMkl5WkhCaWFUbDJXVmhXTUdGRVEwSnBVVmxMUzNkWlFrSkJTRmNLWlZGSlJVRm5VamRDU0d0QlpIZENNVUZEYzNkMlRuaHZhVTF1YVRSa1oyMUxWalV3U0RCbk5VMWFXVU00Y0hkNmVURTFSRkZRTm5seVNWbzJRVUZCUWdwcUt6ZFpOeTlaUVVGQlVVUkJSVmwzVWtGSloxUlhlVkI1VXpKRFMxSnROVnBWWVZSM2JtZG1RblJ5VDBwdmVuZHNTV1pQVDJaWVNIbDVaV293UWxGRENrbEhRM2R0V1ZaTGFFNVROMHBpVlZSR1pVUmxPVEJUVjA1c2NIZHNOVmxCVmtSaUx6SkhSMFo0UjA1RFRVRnZSME5EY1VkVFRUUTVRa0ZOUkVFeVowRUtUVWRWUTAxUlEzaEpaV3R0VEU1a2FFRlROMGhXYnpaRFVtZHhWbEpvZERoU2FVWlBObXhpZVVkTE5HWkVkVVZSVDJzdlRWQmhRbXhTYUhOaFZYaDNaUXBxWmpkcVNUSnJRMDFEZHpWQlQybHFUWFp4YzFoSWFscFphemRVWmxKSUx6QTNPVnA1TUhGRlYycEVPV3gxY21aUWFWUllPWEZUVVV0VGFWaFBVblo0Q25CckwwUlJjMlpVYzJjOVBRb3RMUzB0TFVWT1JDQkRSVkpVU1VaSlEwRlVSUzB0TFMwdENnPT0ifV19fQ==" + } + ] }, - "signature": "MEUCIQC/wVXq9Aya1AmIfX3fehIFSnCuCKs4hVNK5xbwrEmWWwIgG38mrkl8xf4mRahfbcHrEStVXb87zqrIQhOPT9RSpWE=" - } -} \ No newline at end of file + "dsseEnvelope": { + "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoicmZjODc4NS0wLjEuMi1weTMtbm9uZS1hbnkud2hsIiwiZGlnZXN0Ijp7InNoYTI1NiI6ImM0ZTkyZTllY2M4MjhiZWYyYWE3ZGJhMWRlOGFjOTgzNTExZjc1MzJhMGRmMTFjNzcwZDM5MDk5YTI1Y2YyMDEifX1dLCJwcmVkaWNhdGVUeXBlIjoiaHR0cHM6Ly9kb2NzLnB5cGkub3JnL2F0dGVzdGF0aW9ucy9wdWJsaXNoL3YxIiwicHJlZGljYXRlIjpudWxsfQ==", + "payloadType": "application/vnd.in-toto+json", + "signatures": [ + { + "sig": "MEUCIAvkRHFu+n2zs/4j+V3cM22DVZI1zqK4NjflzatEQMfgAiEAo6V3Z7tihOGkYiysF18xtZVqeOt3rdwVVb76mWp8DMc=" + } + ] + } +} diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..eb1192c --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,15 @@ +import os + +import pytest +from sigstore import oidc + + +@pytest.fixture(scope="session") +def id_token() -> oidc.IdentityToken: + if "CI" in os.environ: + token = oidc.detect_credential() + if token is None: + pytest.fail("misconfigured CI: no ambient OIDC credential") + return oidc.IdentityToken(token) + else: + return oidc.Issuer.staging().identity_token() diff --git a/test/test_impl.py b/test/test_impl.py index 5fc34bc..192e727 100644 --- a/test/test_impl.py +++ b/test/test_impl.py @@ -1,163 +1,196 @@ """Internal implementation tests.""" -import hashlib -import json +import os from pathlib import Path -import pretend import pypi_attestation_models._impl as impl import pytest -from sigstore.models import Bundle +from sigstore.oidc import IdentityToken +from sigstore.sign import SigningContext from sigstore.verify import Verifier, policy +ONLINE_TESTS = "CI" in os.environ or "TEST_INTERACTIVE" in os.environ + +online = pytest.mark.skipif(not ONLINE_TESTS, reason="online tests not enabled") + artifact_path = Path(__file__).parent / "assets" / "rfc8785-0.1.2-py3-none-any.whl" bundle_path = Path(__file__).parent / "assets" / "rfc8785-0.1.2-py3-none-any.whl.sigstore" attestation_path = Path(__file__).parent / "assets" / "rfc8785-0.1.2-py3-none-any.whl.attestation" -class TestSigningAndVerifying: - def test_payload_sign(self) -> None: - # Call sign on a new AttestationPayload, but mock the underlying call to - # sigstore.Signer.sign() to check that it's called with the correct argument. - sigstore_bundle = Bundle.from_json(bundle_path.read_bytes()) - payload = impl.AttestationPayload.from_dist(artifact_path) - signer = pretend.stub(sign_artifact=pretend.call_recorder(lambda _input: sigstore_bundle)) - payload.sign(signer) - - # Sigstore sign operation should have been called with the AttestationPayload - # corresponding to the Python artifact - assert signer.sign_artifact.calls == [pretend.call(bytes(payload))] - - def test_attestation_verify(self) -> None: - # Call verify on an existing attestation, but mock the underlying call to - # sigstore.Verifier.verify() to check that it's called with the correct arguments. - attestation = impl.Attestation.model_validate_json(attestation_path.read_bytes()) - verifier = pretend.stub( - verify_artifact=pretend.call_recorder(lambda _input, _bundle, _policy: None) - ) - policy_stub = pretend.stub() - attestation.verify(verifier, policy_stub, artifact_path) - - # This is the input `verify_artifact` should have been called with - # (the bytes of the `AttestationPayload`, not the artifact itself) - expected_payload = bytes(impl.AttestationPayload.from_dist(artifact_path)) - - # This is the bundle `verify_artifact` should have been caled with - # (the Sigstore Bundle corresponding to the input Attestation) - expected_bundle = impl.pypi_to_sigstore(attestation) - - assert len(verifier.verify_artifact.calls) == 1 - verify_args = verifier.verify_artifact.calls[0].args - assert verify_args[0] == expected_payload - assert verify_args[1].to_json() == expected_bundle.to_json() - assert verify_args[2] == policy_stub - - def test_actual_verify(self) -> None: - # Test the actual online verification using a pre-generated attestation - verifier = Verifier.production() - identity_policy = policy.Identity( - identity="facundo.tuesca@trailofbits.com", issuer="https://accounts.google.com" - ) - - attestation = impl.Attestation.model_validate_json(attestation_path.read_bytes()) - attestation.verify(verifier, identity_policy, artifact_path) - - with pytest.raises(impl.VerificationError): - # Pass a file that is not the Python artifact this attestation is for - attestation.verify(verifier, identity_policy, bundle_path) - - -class TestModelConversions: - def test_sigstore_to_pypi(self) -> None: - # Load an existing Sigstore bundle, convert it to a PyPI attestation, - # and check that the result is what we expect. - with bundle_path.open("rb") as f: - sigstore_bundle = Bundle.from_json(f.read()) - attestation = impl.sigstore_to_pypi(sigstore_bundle) - with attestation_path.open("rb") as expected_file: - assert json.loads(attestation.model_dump_json()) == json.load(expected_file) - - def test_pypi_to_sigstore(self) -> None: - # Load an existing PyPI attestation, convert it to a Sigstore bundle, - # and check that the result matches the original Sigstore bundle used - # to generate the attestation - with attestation_path.open("rb") as f: - attestation = impl.Attestation.model_validate_json(f.read()) +class TestAttestation: + @online + def test_roundtrip(self, id_token: IdentityToken) -> None: + sign_ctx = SigningContext.staging() + verifier = Verifier.staging() + + with sign_ctx.signer(id_token) as signer: + attestation = impl.Attestation.sign(signer, artifact_path) + + attestation.verify(verifier, policy.UnsafeNoOp(), artifact_path) + + # converting to a bundle and verifying as a bundle also works + bundle = impl.pypi_to_sigstore(attestation) + verifier.verify_dsse(bundle, policy.UnsafeNoOp()) + + # converting back also works + roundtripped_attestation = impl.sigstore_to_pypi(bundle) + roundtripped_attestation.verify(verifier, policy.UnsafeNoOp(), artifact_path) + + def test_verify(self) -> None: + verifier = Verifier.staging() + + attestation = impl.Attestation.model_validate_json(attestation_path.read_text()) + attestation.verify(verifier, policy.UnsafeNoOp(), artifact_path) + + # convert the attestation to a bundle and verify it that way too bundle = impl.pypi_to_sigstore(attestation) - with bundle_path.open("rb") as original_bundle_file: - original_bundle = Bundle.from_json(original_bundle_file.read()) - - # Sigstore Bundle -> PyPI attestation is a lossy operation, so when we go backwards - # the resulting Bundle will have fewer fields than the original Bundle. - assert bundle._inner.media_type == original_bundle._inner.media_type # noqa: SLF001 - assert bundle._inner.verification_material == original_bundle._inner.verification_material # noqa: SLF001 - assert ( - bundle._inner.message_signature.signature # noqa: SLF001 - == original_bundle._inner.message_signature.signature # noqa: SLF001 - ) - assert bundle.log_entry == original_bundle.log_entry - assert bundle.signing_certificate == original_bundle.signing_certificate - - def test_pypi_to_sigstore_invalid_certificate_base64(self) -> None: - with attestation_path.open("rb") as f: - attestation = impl.Attestation.model_validate_json(f.read()) - attestation.verification_material.certificate = "invalid base64 @@@@ string" - with pytest.raises(impl.InvalidAttestationError): - impl.pypi_to_sigstore(attestation) - - def test_pypi_to_sigstore_invalid_certificate(self) -> None: - with attestation_path.open("rb") as f: - attestation = impl.Attestation.model_validate_json(f.read()) - new_cert = attestation.verification_material.certificate.replace("M", "x") - attestation.verification_material.certificate = new_cert - with pytest.raises(impl.InvalidAttestationError): - impl.pypi_to_sigstore(attestation) - - def test_pypi_to_sigstore_invalid_log_entry(self) -> None: - with attestation_path.open("rb") as f: - attestation = impl.Attestation.model_validate_json(f.read()) - new_log_entry = attestation.verification_material.transparency_entries[0] - del new_log_entry["inclusionProof"] - attestation.verification_material.transparency_entries = [new_log_entry] - with pytest.raises(impl.InvalidAttestationError): - impl.pypi_to_sigstore(attestation) - - def test_verification_roundtrip(self) -> None: - # Load an existing Sigstore bundle, check that verification passes, - # convert it to a PyPI attestation and then back again to a Sigstore - # bundle, and check that verification still passes. - with bundle_path.open("rb") as f: - sigstore_bundle = Bundle.from_json(f.read()) - - verifier = Verifier.production() - with artifact_path.open("rb") as f: - payload = impl.AttestationPayload.from_dist(artifact_path) - verifier.verify_artifact( - bytes(payload), - sigstore_bundle, - policy.Identity( - identity="facundo.tuesca@trailofbits.com", issuer="https://accounts.google.com" - ), - ) - - attestation = impl.sigstore_to_pypi(sigstore_bundle) - roundtrip_bundle = impl.pypi_to_sigstore(attestation) - with artifact_path.open("rb") as f: - verifier.verify_artifact( - bytes(payload), - roundtrip_bundle, - policy.Identity( - identity="facundo.tuesca@trailofbits.com", issuer="https://accounts.google.com" - ), - ) - - def test_attestation_payload(self) -> None: - payload = impl.AttestationPayload.from_dist(artifact_path) - - assert payload.digest == hashlib.sha256(artifact_path.read_bytes()).hexdigest() - assert payload.distribution == artifact_path.name - - expected = f'{{"digest":"{payload.digest}","distribution":"{payload.distribution}"}}' - - assert bytes(payload) == bytes(expected, "utf-8") - assert json.loads(bytes(payload)) == json.loads(expected) + verifier.verify_dsse(bundle, policy.UnsafeNoOp()) + + +# class TestSigningAndVerifying: +# def test_payload_sign(self) -> None: +# # Call sign on a new AttestationPayload, but mock the underlying call to +# # sigstore.Signer.sign() to check that it's called with the correct argument. +# sigstore_bundle = Bundle.from_json(bundle_path.read_bytes()) +# payload = impl.AttestationPayload.from_dist(artifact_path) +# signer = pretend.stub(sign_artifact=pretend.call_recorder(lambda _input: sigstore_bundle)) +# payload.sign(signer) + +# # Sigstore sign operation should have been called with the AttestationPayload +# # corresponding to the Python artifact +# assert signer.sign_artifact.calls == [pretend.call(bytes(payload))] + +# def test_attestation_verify(self) -> None: +# # Call verify on an existing attestation, but mock the underlying call to +# # sigstore.Verifier.verify() to check that it's called with the correct arguments. +# attestation = impl.Attestation.model_validate_json(attestation_path.read_bytes()) +# verifier = pretend.stub( +# verify_artifact=pretend.call_recorder(lambda _input, _bundle, _policy: None) +# ) +# policy_stub = pretend.stub() +# attestation.verify(verifier, policy_stub, artifact_path) + +# # This is the input `verify_artifact` should have been called with +# # (the bytes of the `AttestationPayload`, not the artifact itself) +# expected_payload = bytes(impl.AttestationPayload.from_dist(artifact_path)) + +# # This is the bundle `verify_artifact` should have been caled with +# # (the Sigstore Bundle corresponding to the input Attestation) +# expected_bundle = impl.pypi_to_sigstore(attestation) + +# assert len(verifier.verify_artifact.calls) == 1 +# verify_args = verifier.verify_artifact.calls[0].args +# assert verify_args[0] == expected_payload +# assert verify_args[1].to_json() == expected_bundle.to_json() +# assert verify_args[2] == policy_stub + +# def test_actual_verify(self) -> None: +# # Test the actual online verification using a pre-generated attestation +# verifier = Verifier.production() +# identity_policy = policy.Identity( +# identity="facundo.tuesca@trailofbits.com", issuer="https://accounts.google.com" +# ) + +# attestation = impl.Attestation.model_validate_json(attestation_path.read_bytes()) +# attestation.verify(verifier, identity_policy, artifact_path) + +# with pytest.raises(impl.VerificationError): +# # Pass a file that is not the Python artifact this attestation is for +# attestation.verify(verifier, identity_policy, bundle_path) + + +# class TestModelConversions: +# def test_sigstore_to_pypi(self) -> None: +# # Load an existing Sigstore bundle, convert it to a PyPI attestation, +# # and check that the result is what we expect. +# with bundle_path.open("rb") as f: +# sigstore_bundle = Bundle.from_json(f.read()) +# attestation = impl.sigstore_to_pypi(sigstore_bundle) +# with attestation_path.open("rb") as expected_file: +# assert json.loads(attestation.model_dump_json()) == json.load(expected_file) + +# def test_pypi_to_sigstore(self) -> None: +# # Load an existing PyPI attestation, convert it to a Sigstore bundle, +# # and check that the result matches the original Sigstore bundle used +# # to generate the attestation +# with attestation_path.open("rb") as f: +# attestation = impl.Attestation.model_validate_json(f.read()) +# bundle = impl.pypi_to_sigstore(attestation) +# with bundle_path.open("rb") as original_bundle_file: +# original_bundle = Bundle.from_json(original_bundle_file.read()) + +# # Sigstore Bundle -> PyPI attestation is a lossy operation, so when we go backwards +# # the resulting Bundle will have fewer fields than the original Bundle. +# assert bundle._inner.media_type == original_bundle._inner.media_type # noqa: SLF001 +# assert bundle._inner.verification_material == original_bundle._inner.verification_material # noqa: SLF001 +# assert ( +# bundle._inner.message_signature.signature # noqa: SLF001 +# == original_bundle._inner.message_signature.signature # noqa: SLF001 +# ) +# assert bundle.log_entry == original_bundle.log_entry +# assert bundle.signing_certificate == original_bundle.signing_certificate + +# def test_pypi_to_sigstore_invalid_certificate_base64(self) -> None: +# with attestation_path.open("rb") as f: +# attestation = impl.Attestation.model_validate_json(f.read()) +# attestation.verification_material.certificate = "invalid base64 @@@@ string" +# with pytest.raises(impl.InvalidAttestationError): +# impl.pypi_to_sigstore(attestation) + +# def test_pypi_to_sigstore_invalid_certificate(self) -> None: +# with attestation_path.open("rb") as f: +# attestation = impl.Attestation.model_validate_json(f.read()) +# new_cert = attestation.verification_material.certificate.replace("M", "x") +# attestation.verification_material.certificate = new_cert +# with pytest.raises(impl.InvalidAttestationError): +# impl.pypi_to_sigstore(attestation) + +# def test_pypi_to_sigstore_invalid_log_entry(self) -> None: +# with attestation_path.open("rb") as f: +# attestation = impl.Attestation.model_validate_json(f.read()) +# new_log_entry = attestation.verification_material.transparency_entries[0] +# del new_log_entry["inclusionProof"] +# attestation.verification_material.transparency_entries = [new_log_entry] +# with pytest.raises(impl.InvalidAttestationError): +# impl.pypi_to_sigstore(attestation) + +# def test_verification_roundtrip(self) -> None: +# # Load an existing Sigstore bundle, check that verification passes, +# # convert it to a PyPI attestation and then back again to a Sigstore +# # bundle, and check that verification still passes. +# with bundle_path.open("rb") as f: +# sigstore_bundle = Bundle.from_json(f.read()) + +# verifier = Verifier.production() +# with artifact_path.open("rb") as f: +# payload = impl.AttestationPayload.from_dist(artifact_path) +# verifier.verify_artifact( +# bytes(payload), +# sigstore_bundle, +# policy.Identity( +# identity="facundo.tuesca@trailofbits.com", issuer="https://accounts.google.com" +# ), +# ) + +# attestation = impl.sigstore_to_pypi(sigstore_bundle) +# roundtrip_bundle = impl.pypi_to_sigstore(attestation) +# with artifact_path.open("rb") as f: +# verifier.verify_artifact( +# bytes(payload), +# roundtrip_bundle, +# policy.Identity( +# identity="facundo.tuesca@trailofbits.com", issuer="https://accounts.google.com" +# ), +# ) + +# def test_attestation_payload(self) -> None: +# payload = impl.AttestationPayload.from_dist(artifact_path) + +# assert payload.digest == hashlib.sha256(artifact_path.read_bytes()).hexdigest() +# assert payload.distribution == artifact_path.name + +# expected = f'{{"digest":"{payload.digest}","distribution":"{payload.distribution}"}}' + +# assert bytes(payload) == bytes(expected, "utf-8") +# assert json.loads(bytes(payload)) == json.loads(expected) From 31008bac386d0a85802481baa7c2408558c882d6 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 6 Jun 2024 15:10:52 -0400 Subject: [PATCH 03/14] test_impl: delete-o-rama Signed-off-by: William Woodruff --- test/test_impl.py | 148 ---------------------------------------------- 1 file changed, 148 deletions(-) diff --git a/test/test_impl.py b/test/test_impl.py index 192e727..0944648 100644 --- a/test/test_impl.py +++ b/test/test_impl.py @@ -46,151 +46,3 @@ def test_verify(self) -> None: # convert the attestation to a bundle and verify it that way too bundle = impl.pypi_to_sigstore(attestation) verifier.verify_dsse(bundle, policy.UnsafeNoOp()) - - -# class TestSigningAndVerifying: -# def test_payload_sign(self) -> None: -# # Call sign on a new AttestationPayload, but mock the underlying call to -# # sigstore.Signer.sign() to check that it's called with the correct argument. -# sigstore_bundle = Bundle.from_json(bundle_path.read_bytes()) -# payload = impl.AttestationPayload.from_dist(artifact_path) -# signer = pretend.stub(sign_artifact=pretend.call_recorder(lambda _input: sigstore_bundle)) -# payload.sign(signer) - -# # Sigstore sign operation should have been called with the AttestationPayload -# # corresponding to the Python artifact -# assert signer.sign_artifact.calls == [pretend.call(bytes(payload))] - -# def test_attestation_verify(self) -> None: -# # Call verify on an existing attestation, but mock the underlying call to -# # sigstore.Verifier.verify() to check that it's called with the correct arguments. -# attestation = impl.Attestation.model_validate_json(attestation_path.read_bytes()) -# verifier = pretend.stub( -# verify_artifact=pretend.call_recorder(lambda _input, _bundle, _policy: None) -# ) -# policy_stub = pretend.stub() -# attestation.verify(verifier, policy_stub, artifact_path) - -# # This is the input `verify_artifact` should have been called with -# # (the bytes of the `AttestationPayload`, not the artifact itself) -# expected_payload = bytes(impl.AttestationPayload.from_dist(artifact_path)) - -# # This is the bundle `verify_artifact` should have been caled with -# # (the Sigstore Bundle corresponding to the input Attestation) -# expected_bundle = impl.pypi_to_sigstore(attestation) - -# assert len(verifier.verify_artifact.calls) == 1 -# verify_args = verifier.verify_artifact.calls[0].args -# assert verify_args[0] == expected_payload -# assert verify_args[1].to_json() == expected_bundle.to_json() -# assert verify_args[2] == policy_stub - -# def test_actual_verify(self) -> None: -# # Test the actual online verification using a pre-generated attestation -# verifier = Verifier.production() -# identity_policy = policy.Identity( -# identity="facundo.tuesca@trailofbits.com", issuer="https://accounts.google.com" -# ) - -# attestation = impl.Attestation.model_validate_json(attestation_path.read_bytes()) -# attestation.verify(verifier, identity_policy, artifact_path) - -# with pytest.raises(impl.VerificationError): -# # Pass a file that is not the Python artifact this attestation is for -# attestation.verify(verifier, identity_policy, bundle_path) - - -# class TestModelConversions: -# def test_sigstore_to_pypi(self) -> None: -# # Load an existing Sigstore bundle, convert it to a PyPI attestation, -# # and check that the result is what we expect. -# with bundle_path.open("rb") as f: -# sigstore_bundle = Bundle.from_json(f.read()) -# attestation = impl.sigstore_to_pypi(sigstore_bundle) -# with attestation_path.open("rb") as expected_file: -# assert json.loads(attestation.model_dump_json()) == json.load(expected_file) - -# def test_pypi_to_sigstore(self) -> None: -# # Load an existing PyPI attestation, convert it to a Sigstore bundle, -# # and check that the result matches the original Sigstore bundle used -# # to generate the attestation -# with attestation_path.open("rb") as f: -# attestation = impl.Attestation.model_validate_json(f.read()) -# bundle = impl.pypi_to_sigstore(attestation) -# with bundle_path.open("rb") as original_bundle_file: -# original_bundle = Bundle.from_json(original_bundle_file.read()) - -# # Sigstore Bundle -> PyPI attestation is a lossy operation, so when we go backwards -# # the resulting Bundle will have fewer fields than the original Bundle. -# assert bundle._inner.media_type == original_bundle._inner.media_type # noqa: SLF001 -# assert bundle._inner.verification_material == original_bundle._inner.verification_material # noqa: SLF001 -# assert ( -# bundle._inner.message_signature.signature # noqa: SLF001 -# == original_bundle._inner.message_signature.signature # noqa: SLF001 -# ) -# assert bundle.log_entry == original_bundle.log_entry -# assert bundle.signing_certificate == original_bundle.signing_certificate - -# def test_pypi_to_sigstore_invalid_certificate_base64(self) -> None: -# with attestation_path.open("rb") as f: -# attestation = impl.Attestation.model_validate_json(f.read()) -# attestation.verification_material.certificate = "invalid base64 @@@@ string" -# with pytest.raises(impl.InvalidAttestationError): -# impl.pypi_to_sigstore(attestation) - -# def test_pypi_to_sigstore_invalid_certificate(self) -> None: -# with attestation_path.open("rb") as f: -# attestation = impl.Attestation.model_validate_json(f.read()) -# new_cert = attestation.verification_material.certificate.replace("M", "x") -# attestation.verification_material.certificate = new_cert -# with pytest.raises(impl.InvalidAttestationError): -# impl.pypi_to_sigstore(attestation) - -# def test_pypi_to_sigstore_invalid_log_entry(self) -> None: -# with attestation_path.open("rb") as f: -# attestation = impl.Attestation.model_validate_json(f.read()) -# new_log_entry = attestation.verification_material.transparency_entries[0] -# del new_log_entry["inclusionProof"] -# attestation.verification_material.transparency_entries = [new_log_entry] -# with pytest.raises(impl.InvalidAttestationError): -# impl.pypi_to_sigstore(attestation) - -# def test_verification_roundtrip(self) -> None: -# # Load an existing Sigstore bundle, check that verification passes, -# # convert it to a PyPI attestation and then back again to a Sigstore -# # bundle, and check that verification still passes. -# with bundle_path.open("rb") as f: -# sigstore_bundle = Bundle.from_json(f.read()) - -# verifier = Verifier.production() -# with artifact_path.open("rb") as f: -# payload = impl.AttestationPayload.from_dist(artifact_path) -# verifier.verify_artifact( -# bytes(payload), -# sigstore_bundle, -# policy.Identity( -# identity="facundo.tuesca@trailofbits.com", issuer="https://accounts.google.com" -# ), -# ) - -# attestation = impl.sigstore_to_pypi(sigstore_bundle) -# roundtrip_bundle = impl.pypi_to_sigstore(attestation) -# with artifact_path.open("rb") as f: -# verifier.verify_artifact( -# bytes(payload), -# roundtrip_bundle, -# policy.Identity( -# identity="facundo.tuesca@trailofbits.com", issuer="https://accounts.google.com" -# ), -# ) - -# def test_attestation_payload(self) -> None: -# payload = impl.AttestationPayload.from_dist(artifact_path) - -# assert payload.digest == hashlib.sha256(artifact_path.read_bytes()).hexdigest() -# assert payload.distribution == artifact_path.name - -# expected = f'{{"digest":"{payload.digest}","distribution":"{payload.distribution}"}}' - -# assert bytes(payload) == bytes(expected, "utf-8") -# assert json.loads(bytes(payload)) == json.loads(expected) From cc571930b406d4e77214e35cb07b6952df016b2e Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 6 Jun 2024 15:11:53 -0400 Subject: [PATCH 04/14] docstring Signed-off-by: William Woodruff --- src/pypi_attestation_models/_impl.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pypi_attestation_models/_impl.py b/src/pypi_attestation_models/_impl.py index 2176d4b..86630e3 100644 --- a/src/pypi_attestation_models/_impl.py +++ b/src/pypi_attestation_models/_impl.py @@ -145,6 +145,8 @@ def verify(self, verifier: Verifier, policy: VerificationPolicy, dist: Path) -> class Envelope(BaseModel): + """The attestation envelope, containing the attested-for payload and its signature.""" + statement: Base64Bytes """ The attestation statement. From 15e3abd94df008d3a4c4587248dab4ea2efccec6 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 6 Jun 2024 15:19:59 -0400 Subject: [PATCH 05/14] simplify errors Signed-off-by: William Woodruff --- src/pypi_attestation_models/__init__.py | 4 ++-- src/pypi_attestation_models/_impl.py | 27 +++++++++---------------- 2 files changed, 11 insertions(+), 20 deletions(-) diff --git a/src/pypi_attestation_models/__init__.py b/src/pypi_attestation_models/__init__.py index 934d72a..06e9f71 100644 --- a/src/pypi_attestation_models/__init__.py +++ b/src/pypi_attestation_models/__init__.py @@ -4,9 +4,9 @@ from ._impl import ( Attestation, + AttestationError, ConversionError, Envelope, - InvalidAttestationError, TransparencyLogEntry, VerificationError, VerificationMaterial, @@ -16,9 +16,9 @@ __all__ = [ "Attestation", + "AttestationError", "Envelope", "ConversionError", - "InvalidAttestationError", "TransparencyLogEntry", "VerificationError", "VerificationMaterial", diff --git a/src/pypi_attestation_models/_impl.py b/src/pypi_attestation_models/_impl.py index 86630e3..5fa9908 100644 --- a/src/pypi_attestation_models/_impl.py +++ b/src/pypi_attestation_models/_impl.py @@ -29,19 +29,15 @@ from sigstore.verify.policy import VerificationPolicy # pragma: no cover -class ConversionError(ValueError): - """The base error for all errors during conversion.""" - +class AttestationError(ValueError): + """Base error for all APIs.""" -class InvalidAttestationError(ConversionError): - """The PyPI Attestation given as input is not valid.""" - def __init__(self: InvalidAttestationError, msg: str) -> None: - """Initialize an `InvalidAttestationError`.""" - super().__init__(f"Could not convert input Attestation: {msg}") +class ConversionError(AttestationError): + """The base error for all errors during conversion.""" -class VerificationError(ValueError): +class VerificationError(AttestationError): """The PyPI Attestation failed verification.""" def __init__(self: VerificationError, msg: str) -> None: @@ -106,10 +102,7 @@ def sign(cls, signer: Signer, dist: Path) -> Attestation: def verify(self, verifier: Verifier, policy: VerificationPolicy, dist: Path) -> None: """Verify against an existing Python artifact. - On failure, raises: - - `InvalidAttestationError` if the attestation could not be converted to - a Sigstore Bundle. - - `VerificationError` if the attestation could not be verified. + On failure, raises an appropriate subclass of `AttestationError`. """ with dist.open(mode="rb", buffering=0) as io: # Replace this with `hashlib.file_digest()` once @@ -170,9 +163,7 @@ def sigstore_to_pypi(sigstore_bundle: Bundle) -> Attestation: envelope = sigstore_bundle._inner.dsse_envelope # noqa: SLF001 if len(envelope.signatures) != 1: - raise InvalidAttestationError( - f"expected exactly one signature, got {len(envelope.signatures)}" - ) + raise ConversionError(f"expected exactly one signature, got {len(envelope.signatures)}") return Attestation( version=1, @@ -205,12 +196,12 @@ def pypi_to_sigstore(pypi_attestation: Attestation) -> Bundle: try: certificate = x509.load_der_x509_certificate(cert_bytes) except ValueError as err: - raise InvalidAttestationError(str(err)) from err + raise ConversionError(str(err)) from err try: log_entry = LogEntry._from_dict_rekor(tlog_entry) # noqa: SLF001 except (ValidationError, sigstore.errors.Error) as err: - raise InvalidAttestationError(str(err)) from err + raise ConversionError(str(err)) from err return Bundle._from_parts( # noqa: SLF001 cert=certificate, From d6f78309da0ccbcb36d2e3ef365aacef30e82295 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 6 Jun 2024 15:28:35 -0400 Subject: [PATCH 06/14] test_impl: more coverage Signed-off-by: William Woodruff --- test/test_impl.py | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/test/test_impl.py b/test/test_impl.py index 0944648..12585bb 100644 --- a/test/test_impl.py +++ b/test/test_impl.py @@ -39,10 +39,42 @@ def test_roundtrip(self, id_token: IdentityToken) -> None: def test_verify(self) -> None: verifier = Verifier.staging() + # Our checked-in asset has this identity. + pol = policy.Identity( + identity="william@yossarian.net", issuer="https://github.com/login/oauth" + ) attestation = impl.Attestation.model_validate_json(attestation_path.read_text()) - attestation.verify(verifier, policy.UnsafeNoOp(), artifact_path) + attestation.verify(verifier, pol, artifact_path) # convert the attestation to a bundle and verify it that way too bundle = impl.pypi_to_sigstore(attestation) verifier.verify_dsse(bundle, policy.UnsafeNoOp()) + + def test_verify_digest_mismatch(self, tmp_path: Path) -> None: + verifier = Verifier.staging() + # Our checked-in asset has this identity. + pol = policy.Identity( + identity="william@yossarian.net", issuer="https://github.com/login/oauth" + ) + + attestation = impl.Attestation.model_validate_json(attestation_path.read_text()) + + modified_artifact_path = tmp_path / artifact_path.name + modified_artifact_path.write_bytes(b"nothing") + + # attestation has the correct filename, but a mismatching digest. + with pytest.raises( + impl.VerificationError, match="subject does not match distribution digest" + ): + attestation.verify(verifier, pol, modified_artifact_path) + + def test_verify_policy_mismatch(self) -> None: + verifier = Verifier.staging() + # Wrong identity. + pol = policy.Identity(identity="fake@example.com", issuer="https://github.com/login/oauth") + + attestation = impl.Attestation.model_validate_json(attestation_path.read_text()) + + with pytest.raises(impl.VerificationError, match=r"Certificate's SANs do not match"): + attestation.verify(verifier, pol, artifact_path) From 40c46f86de363263f52fa00f167d2c70a61247ec Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 6 Jun 2024 15:52:10 -0400 Subject: [PATCH 07/14] test_impl: more cov Signed-off-by: William Woodruff --- test/test_impl.py | 70 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/test/test_impl.py b/test/test_impl.py index 12585bb..25531ae 100644 --- a/test/test_impl.py +++ b/test/test_impl.py @@ -3,8 +3,10 @@ import os from pathlib import Path +import pretend import pypi_attestation_models._impl as impl import pytest +from sigstore.dsse import _DigestSet, _StatementBuilder, _Subject from sigstore.oidc import IdentityToken from sigstore.sign import SigningContext from sigstore.verify import Verifier, policy @@ -69,6 +71,24 @@ def test_verify_digest_mismatch(self, tmp_path: Path) -> None: ): attestation.verify(verifier, pol, modified_artifact_path) + def test_verify_filename_mismatch(self, tmp_path: Path) -> None: + verifier = Verifier.staging() + # Our checked-in asset has this identity. + pol = policy.Identity( + identity="william@yossarian.net", issuer="https://github.com/login/oauth" + ) + + attestation = impl.Attestation.model_validate_json(attestation_path.read_text()) + + modified_artifact_path = tmp_path / "wrong_name-0.1.2-py3-none-any.whl" + modified_artifact_path.write_bytes(artifact_path.read_bytes()) + + # attestation has the correct digest, but a mismatching filename. + with pytest.raises( + impl.VerificationError, match="subject does not match distribution name" + ): + attestation.verify(verifier, pol, modified_artifact_path) + def test_verify_policy_mismatch(self) -> None: verifier = Verifier.staging() # Wrong identity. @@ -78,3 +98,53 @@ def test_verify_policy_mismatch(self) -> None: with pytest.raises(impl.VerificationError, match=r"Certificate's SANs do not match"): attestation.verify(verifier, pol, artifact_path) + + def test_verify_wrong_envelope(self) -> None: + verifier = pretend.stub( + verify_dsse=pretend.call_recorder(lambda bundle, policy: ("fake-type", None)) + ) + pol = pretend.stub() + + attestation = impl.Attestation.model_validate_json(attestation_path.read_text()) + + with pytest.raises(impl.VerificationError, match="expected JSON envelope, got fake-type"): + attestation.verify(verifier, pol, artifact_path) + + def test_verify_bad_payload(self) -> None: + verifier = pretend.stub( + verify_dsse=pretend.call_recorder( + lambda bundle, policy: ("application/vnd.in-toto+json", b"invalid json") + ) + ) + pol = pretend.stub() + + attestation = impl.Attestation.model_validate_json(attestation_path.read_text()) + + with pytest.raises(impl.VerificationError, match="invalid statement"): + attestation.verify(verifier, pol, artifact_path) + + def test_verify_too_many_subjects(self) -> None: + statement = ( + _StatementBuilder() # noqa: SLF001 + .subjects( + [ + _Subject(name="foo", digest=_DigestSet(root={"sha256": "abcd"})), + _Subject(name="bar", digest=_DigestSet(root={"sha256": "1234"})), + ] + ) + .predicate_type("foo") + .build() + ._inner.model_dump_json() + ) + + verifier = pretend.stub( + verify_dsse=pretend.call_recorder( + lambda bundle, policy: ("application/vnd.in-toto+json", statement.encode()) + ) + ) + pol = pretend.stub() + + attestation = impl.Attestation.model_validate_json(attestation_path.read_text()) + + with pytest.raises(impl.VerificationError, match="too many subjects in statement"): + attestation.verify(verifier, pol, artifact_path) From 84b55224a5104db74980390f65cffe29e0bd3596 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 6 Jun 2024 15:59:59 -0400 Subject: [PATCH 08/14] more cov Signed-off-by: William Woodruff --- src/pypi_attestation_models/_impl.py | 4 ++-- test/test_impl.py | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/pypi_attestation_models/_impl.py b/src/pypi_attestation_models/_impl.py index 5fa9908..8efba4a 100644 --- a/src/pypi_attestation_models/_impl.py +++ b/src/pypi_attestation_models/_impl.py @@ -196,12 +196,12 @@ def pypi_to_sigstore(pypi_attestation: Attestation) -> Bundle: try: certificate = x509.load_der_x509_certificate(cert_bytes) except ValueError as err: - raise ConversionError(str(err)) from err + raise ConversionError("invalid X.509 certificate") from err try: log_entry = LogEntry._from_dict_rekor(tlog_entry) # noqa: SLF001 except (ValidationError, sigstore.errors.Error) as err: - raise ConversionError(str(err)) from err + raise ConversionError("invalid transparency log entry") from err return Bundle._from_parts( # noqa: SLF001 cert=certificate, diff --git a/test/test_impl.py b/test/test_impl.py index 25531ae..db3e48e 100644 --- a/test/test_impl.py +++ b/test/test_impl.py @@ -7,6 +7,7 @@ import pypi_attestation_models._impl as impl import pytest from sigstore.dsse import _DigestSet, _StatementBuilder, _Subject +from sigstore.models import Bundle from sigstore.oidc import IdentityToken from sigstore.sign import SigningContext from sigstore.verify import Verifier, policy @@ -148,3 +149,27 @@ def test_verify_too_many_subjects(self) -> None: with pytest.raises(impl.VerificationError, match="too many subjects in statement"): attestation.verify(verifier, pol, artifact_path) + + +def test_sigstore_to_pypi_missing_signatures() -> None: + bundle = Bundle.from_json(bundle_path.read_bytes()) + bundle._inner.dsse_envelope.signatures = [] # noqa: SLF001 + + with pytest.raises(impl.ConversionError, match="expected exactly one signature, got 0"): + impl.sigstore_to_pypi(bundle) + + +def test_pypi_to_sigstore_invalid_cert() -> None: + attestation = impl.Attestation.model_validate_json(attestation_path.read_bytes()) + attestation.verification_material.certificate = b"foo" + + with pytest.raises(impl.ConversionError, match="invalid X.509 certificate"): + impl.pypi_to_sigstore(attestation) + + +def test_pypi_to_sigstore_invalid_tlog_entry() -> None: + attestation = impl.Attestation.model_validate_json(attestation_path.read_bytes()) + attestation.verification_material.transparency_entries[0].clear() + + with pytest.raises(impl.ConversionError, match="invalid transparency log entry"): + impl.pypi_to_sigstore(attestation) From da5427b1ebcc5f1b117f961c36dae02c9a3b059a Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 6 Jun 2024 17:37:09 -0400 Subject: [PATCH 09/14] add TODO Signed-off-by: William Woodruff --- src/pypi_attestation_models/_impl.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pypi_attestation_models/_impl.py b/src/pypi_attestation_models/_impl.py index 8efba4a..8ab712f 100644 --- a/src/pypi_attestation_models/_impl.py +++ b/src/pypi_attestation_models/_impl.py @@ -127,6 +127,7 @@ def verify(self, verifier: Verifier, policy: VerificationPolicy, dist: Path) -> raise VerificationError("too many subjects in statement (must be exactly one)") subject = statement.subjects[0] + # TODO: This is too brittle: we need to check with `parse_{sdist,wheel}_filename`. if subject.name != dist.name: raise VerificationError( f"subject does not match distribution name: {subject.name} != {dist.name}" From d6c69f510d11901ed3155234ef3b2d759bacb630 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Mon, 10 Jun 2024 11:40:46 -0400 Subject: [PATCH 10/14] ultranormalized dist filenames Signed-off-by: William Woodruff --- pyproject.toml | 6 +- src/pypi_attestation_models/_impl.py | 69 ++++++++++++++++++++-- test/test_impl.py | 86 ++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d682b52..c62e1bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ classifiers = [ ] dependencies = [ "cryptography", + "packaging", "pydantic", "sigstore~=3.0.0", "sigstore-protobuf-specs", @@ -84,8 +85,9 @@ ignore = ["ANN101", "ANN102", "D203", "D213", "COM812", "ISC001"] [tool.ruff.lint.per-file-ignores] "test/**/*.py" = [ - "D", # no docstrings in tests - "S101", # asserts are expected in tests + "D", # no docstrings in tests + "S101", # asserts are expected in tests + "SLF001", # private APIs are expected in tests ] [tool.interrogate] diff --git a/src/pypi_attestation_models/_impl.py b/src/pypi_attestation_models/_impl.py index 8ab712f..ca707b6 100644 --- a/src/pypi_attestation_models/_impl.py +++ b/src/pypi_attestation_models/_impl.py @@ -6,12 +6,14 @@ from __future__ import annotations import base64 +from re import L from typing import TYPE_CHECKING, Annotated, Any, Literal, NewType import sigstore.errors from annotated_types import MinLen # noqa: TCH002 from cryptography import x509 from cryptography.hazmat.primitives import serialization +from packaging.utils import parse_sdist_filename, parse_wheel_filename from pydantic import Base64Bytes, BaseModel from pydantic_core import ValidationError from sigstore._utils import _sha256_streaming @@ -91,7 +93,14 @@ def sign(cls, signer: Signer, dist: Path) -> Attestation: stmt = ( _StatementBuilder() - .subjects([_Subject(name=dist.name, digest=_DigestSet(root={"sha256": digest}))]) + .subjects( + [ + _Subject( + name=_ultranormalize_dist_filename(dist.name), + digest=_DigestSet(root={"sha256": digest}), + ) + ] + ) .predicate_type("https://docs.pypi.org/attestations/publish/v1") .build() ) @@ -127,10 +136,10 @@ def verify(self, verifier: Verifier, policy: VerificationPolicy, dist: Path) -> raise VerificationError("too many subjects in statement (must be exactly one)") subject = statement.subjects[0] - # TODO: This is too brittle: we need to check with `parse_{sdist,wheel}_filename`. - if subject.name != dist.name: + normalized = _ultranormalize_dist_filename(dist.name) + if subject.name != _ultranormalize_dist_filename(dist.name): raise VerificationError( - f"subject does not match distribution name: {subject.name} != {dist.name}" + f"subject does not match distribution name: {subject.name} != {normalized}" ) digest = subject.digest.root.get("sha256") @@ -209,3 +218,55 @@ def pypi_to_sigstore(pypi_attestation: Attestation) -> Bundle: content=evp, log_entry=log_entry, ) + + +def _ultranormalize_dist_filename(dist: str) -> str: + """Return an "ultranormalized" form of the given distribution filename. + + This form is equivalent to the normalized form for sdist and wheel + filenames, with the additional stipulation that compressed tag sets, + if present, are also sorted alphanumerically. + + Raises `ValueError` on any invalid distribution filename. + """ + # NOTE: .whl and .tar.gz are assumed lowercase, since `packaging` + # already rejects non-lowercase variants. + if dist.endswith(".whl"): + # `parse_wheel_filename` raises a supertype of ValueError on failure. + name, ver, build, tags = parse_wheel_filename(dist) + + # The name has been normalized to replace runs of `[.-_]+` with `-`, + # which then needs to be replaced with `_` for the wheel. + name = name.replace("-", "_") + + # `parse_wheel_filename` normalizes the name and version for us, + # so all we need to do is re-compress the tag set in a canonical + # order. + # NOTE(ww): This is written in a not very efficient manner, since + # I wasn't feeling smart. + impls, abis, platforms = set(), set(), set() + for tag in tags: + impls.add(tag.interpreter) + abis.add(tag.abi) + platforms.add(tag.platform) + + impl_tag = ".".join(sorted(impls)) + abi_tag = ".".join(sorted(abis)) + platform_tag = ".".join(sorted(platforms)) + + if build: + parts = "-".join( + [name, str(ver), f"{build[0]}{build[1]}", impl_tag, abi_tag, platform_tag] + ) + else: + parts = "-".join([name, str(ver), impl_tag, abi_tag, platform_tag]) + + return f"{parts}.whl" + + elif dist.endswith(".tar.gz"): + # `parse_sdist_filename` raises a supertype of ValueError on failure. + name, ver = parse_sdist_filename(dist) + name = name.replace("-", "_") + return f"{name}-{ver}.tar.gz" + else: + raise ValueError(f"unknown distribution format: {dist}") diff --git a/test/test_impl.py b/test/test_impl.py index db3e48e..c1d004e 100644 --- a/test/test_impl.py +++ b/test/test_impl.py @@ -173,3 +173,89 @@ def test_pypi_to_sigstore_invalid_tlog_entry() -> None: with pytest.raises(impl.ConversionError, match="invalid transparency log entry"): impl.pypi_to_sigstore(attestation) + + +class TestPackaging: + """Behavioral backstops for our dependency on `packaging`.""" + + def test_exception_types(self) -> None: + from packaging.utils import InvalidSdistFilename, InvalidWheelFilename + + assert issubclass(InvalidSdistFilename, ValueError) + assert issubclass(InvalidWheelFilename, ValueError) + + +@pytest.mark.parametrize( + ("input", "normalized"), + [ + # wheel: fully normalized, no changes + ("foo-1.0-py3-none-any.whl", "foo-1.0-py3-none-any.whl"), + # wheel: dist name is not case normalized + ("Foo-1.0-py3-none-any.whl", "foo-1.0-py3-none-any.whl"), + ("FOO-1.0-py3-none-any.whl", "foo-1.0-py3-none-any.whl"), + ("FoO-1.0-py3-none-any.whl", "foo-1.0-py3-none-any.whl"), + # wheel: dist name contains alternate separators + ("foo.bar-1.0-py3-none-any.whl", "foo_bar-1.0-py3-none-any.whl"), + ("foo_bar-1.0-py3-none-any.whl", "foo_bar-1.0-py3-none-any.whl"), + # wheel: dist version is not normalized + ("foo-1.0beta1-py3-none-any.whl", "foo-1.0b1-py3-none-any.whl"), + ("foo-1.0beta.1-py3-none-any.whl", "foo-1.0b1-py3-none-any.whl"), + ("foo-01.0beta.1-py3-none-any.whl", "foo-1.0b1-py3-none-any.whl"), + # wheel: build tag works as expected + ("foo-1.0-1whatever-py3-none-any.whl", "foo-1.0-1whatever-py3-none-any.whl"), + # wheel: compressed tag sets are sorted, even when conflicting or nonsense + ("foo-1.0-py3.py2-none-any.whl", "foo-1.0-py2.py3-none-any.whl"), + ("foo-1.0-py3.py2-none.abi3.cp37-any.whl", "foo-1.0-py2.py3-abi3.cp37.none-any.whl"), + ( + "foo-1.0-py3.py2-none.abi3.cp37-linux_x86_64.any.whl", + "foo-1.0-py2.py3-abi3.cp37.none-any.linux_x86_64.whl", + ), + # sdist: fully normalized, no changes + ("foo-1.0.tar.gz", "foo-1.0.tar.gz"), + # sdist: dist name is not case normalized + ("Foo-1.0.tar.gz", "foo-1.0.tar.gz"), + ("FOO-1.0.tar.gz", "foo-1.0.tar.gz"), + ("FoO-1.0.tar.gz", "foo-1.0.tar.gz"), + # sdist: dist name contains alternate separators, including + # `-` despite being forbidden by PEP 625 + ("foo-bar-1.0.tar.gz", "foo_bar-1.0.tar.gz"), + ("foo-bar-baz-1.0.tar.gz", "foo_bar_baz-1.0.tar.gz"), + ("foo--bar-1.0.tar.gz", "foo_bar-1.0.tar.gz"), + ("foo.bar-1.0.tar.gz", "foo_bar-1.0.tar.gz"), + ("foo..bar-1.0.tar.gz", "foo_bar-1.0.tar.gz"), + ("foo.bar.baz-1.0.tar.gz", "foo_bar_baz-1.0.tar.gz"), + # sdist: dist version is not normalized + ("foo-1.0beta1.tar.gz", "foo-1.0b1.tar.gz"), + ("foo-01.0beta1.tar.gz", "foo-1.0b1.tar.gz"), + ], +) +def test_ultranormalize_dist_filename(input: str, normalized: str) -> None: + # normalization works as expected + assert impl._ultranormalize_dist_filename(input) == normalized + + # normalization is a fixpoint, and normalized names are valid dist names + assert impl._ultranormalize_dist_filename(normalized) == normalized + + +@pytest.mark.parametrize( + "input", + [ + # completely invalid + "foo", + # suffixes must be lowercase + "foo-1.0.TAR.GZ", + "foo-1.0-py3-none-any.WHL", + # wheel: invalid separator in dist name + "foo-bar-1.0-py3-none-any.whl", + "foo__bar-1.0-py3-none-any.whl", + # wheel: invalid version + "foo-charmander-py3-none-any.whl", + "foo-1charmander-py3-none-any.whl", + # sdist: invalid version + "foo-charmander.tar.gz", + "foo-1charmander.tar.gz", + ], +) +def test_ultranormalize_dist_filename_invalid(input: str) -> None: + with pytest.raises(ValueError): + impl._ultranormalize_dist_filename(input) From 67399f5ab392f42357abd198fb2d4d1d7546ca7a Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Mon, 10 Jun 2024 11:42:24 -0400 Subject: [PATCH 11/14] lintage Signed-off-by: William Woodruff --- src/pypi_attestation_models/_impl.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pypi_attestation_models/_impl.py b/src/pypi_attestation_models/_impl.py index ca707b6..34fd918 100644 --- a/src/pypi_attestation_models/_impl.py +++ b/src/pypi_attestation_models/_impl.py @@ -6,7 +6,6 @@ from __future__ import annotations import base64 -from re import L from typing import TYPE_CHECKING, Annotated, Any, Literal, NewType import sigstore.errors From 72024f0ac63e102d4f53b5a3528f3c062f1b6b78 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Mon, 10 Jun 2024 11:58:31 -0400 Subject: [PATCH 12/14] test_impl: ensure dupe tag sets are handled Signed-off-by: William Woodruff --- test/test_impl.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/test_impl.py b/test/test_impl.py index c1d004e..eaaa9c3 100644 --- a/test/test_impl.py +++ b/test/test_impl.py @@ -210,6 +210,9 @@ def test_exception_types(self) -> None: "foo-1.0-py3.py2-none.abi3.cp37-linux_x86_64.any.whl", "foo-1.0-py2.py3-abi3.cp37.none-any.linux_x86_64.whl", ), + # wheel: verbose compressed tag sets are re-compressed + ("foo-1.0-py3.py2.py3-none-any.whl", "foo-1.0-py2.py3-none-any.whl"), + ("foo-1.0-py3-none.none.none-any.whl", "foo-1.0-py3-none-any.whl"), # sdist: fully normalized, no changes ("foo-1.0.tar.gz", "foo-1.0.tar.gz"), # sdist: dist name is not case normalized From d3b17af284797efe13f96115389835fbfbb57f07 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Mon, 10 Jun 2024 12:07:11 -0400 Subject: [PATCH 13/14] src, test: improve name handling Signed-off-by: William Woodruff --- src/pypi_attestation_models/_impl.py | 15 ++++++-- test/test_impl.py | 52 ++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/pypi_attestation_models/_impl.py b/src/pypi_attestation_models/_impl.py index 34fd918..3758f01 100644 --- a/src/pypi_attestation_models/_impl.py +++ b/src/pypi_attestation_models/_impl.py @@ -133,12 +133,21 @@ def verify(self, verifier: Verifier, policy: VerificationPolicy, dist: Path) -> if len(statement.subjects) != 1: raise VerificationError("too many subjects in statement (must be exactly one)") - subject = statement.subjects[0] + + if not subject.name: + raise VerificationError("invalid subject: missing name") + + try: + # We always ultranormalize when signing, but other signers may not. + subject_name = _ultranormalize_dist_filename(subject.name) + except ValueError as e: + raise VerificationError(f"invalid subject: {str(e)}") + normalized = _ultranormalize_dist_filename(dist.name) - if subject.name != _ultranormalize_dist_filename(dist.name): + if subject_name != normalized: raise VerificationError( - f"subject does not match distribution name: {subject.name} != {normalized}" + f"subject does not match distribution name: {subject_name} != {normalized}" ) digest = subject.digest.root.get("sha256") diff --git a/test/test_impl.py b/test/test_impl.py index eaaa9c3..ec0d22d 100644 --- a/test/test_impl.py +++ b/test/test_impl.py @@ -150,6 +150,58 @@ def test_verify_too_many_subjects(self) -> None: with pytest.raises(impl.VerificationError, match="too many subjects in statement"): attestation.verify(verifier, pol, artifact_path) + def test_verify_subject_missing_name(self) -> None: + statement = ( + _StatementBuilder() # noqa: SLF001 + .subjects( + [ + _Subject(name=None, digest=_DigestSet(root={"sha256": "abcd"})), + ] + ) + .predicate_type("foo") + .build() + ._inner.model_dump_json() + ) + + verifier = pretend.stub( + verify_dsse=pretend.call_recorder( + lambda bundle, policy: ("application/vnd.in-toto+json", statement.encode()) + ) + ) + pol = pretend.stub() + + attestation = impl.Attestation.model_validate_json(attestation_path.read_text()) + + with pytest.raises(impl.VerificationError, match="invalid subject: missing name"): + attestation.verify(verifier, pol, artifact_path) + + def test_verify_subject_invalid_name(self) -> None: + statement = ( + _StatementBuilder() # noqa: SLF001 + .subjects( + [ + _Subject( + name="foo-bar-invalid-wheel.whl", digest=_DigestSet(root={"sha256": "abcd"}) + ), + ] + ) + .predicate_type("foo") + .build() + ._inner.model_dump_json() + ) + + verifier = pretend.stub( + verify_dsse=pretend.call_recorder( + lambda bundle, policy: ("application/vnd.in-toto+json", statement.encode()) + ) + ) + pol = pretend.stub() + + attestation = impl.Attestation.model_validate_json(attestation_path.read_text()) + + with pytest.raises(impl.VerificationError, match="invalid subject: Invalid wheel filename"): + attestation.verify(verifier, pol, artifact_path) + def test_sigstore_to_pypi_missing_signatures() -> None: bundle = Bundle.from_json(bundle_path.read_bytes()) From 1b1d1b09fc0f7692fafacab15c8f890ef8813a7a Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Mon, 10 Jun 2024 16:13:25 -0400 Subject: [PATCH 14/14] test: github attestation test Signed-off-by: William Woodruff --- .../pypi_attestation_models-0.0.4a2.tar.gz | Bin 0 -> 8380 bytes ...attestation_models-0.0.4a2.tar.gz.sigstore | 62 ++++++++++++++++++ test/test_impl.py | 29 +++++++- 3 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 test/assets/pypi_attestation_models-0.0.4a2.tar.gz create mode 100644 test/assets/pypi_attestation_models-0.0.4a2.tar.gz.sigstore diff --git a/test/assets/pypi_attestation_models-0.0.4a2.tar.gz b/test/assets/pypi_attestation_models-0.0.4a2.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..10c21f931268c3f1535b0803fb51207dcb2c9ec2 GIT binary patch literal 8380 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 None: roundtripped_attestation = impl.sigstore_to_pypi(bundle) roundtripped_attestation.verify(verifier, policy.UnsafeNoOp(), artifact_path) + def test_verify_github_attested(self) -> None: + verifier = Verifier.production() + pol = policy.AllOf( + [ + policy.OIDCSourceRepositoryURI( + "https://github.com/trailofbits/pypi-attestation-models" + ), + policy.OIDCIssuerV2("https://token.actions.githubusercontent.com"), + ] + ) + + bundle = Bundle.from_json(gh_signed_bundle_path.read_bytes()) + attestation = impl.sigstore_to_pypi(bundle) + + attestation.verify(verifier, pol, gh_signed_artifact_path) + def test_verify(self) -> None: verifier = Verifier.staging() # Our checked-in asset has this identity.