Skip to content

Commit

Permalink
Use sigstore-python for the data models
Browse files Browse the repository at this point in the history
  • Loading branch information
facutuesca committed Apr 30, 2024
1 parent 0fd2819 commit c6bdbda
Showing 10 changed files with 193 additions and 252 deletions.
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -20,22 +20,22 @@ See the full API documentation [here].

```python
from pathlib import Path
from pypi_attestation_models import sigstore_to_pypi
from sigstore_protobuf_specs.dev.sigstore.bundle.v1 import Bundle
from pypi_attestation_models import pypi_to_sigstore, sigstore_to_pypi, Attestation
from sigstore.models import Bundle

# Sigstore Bundle -> PEP 740 Attestation object
bundle_path = Path("test_package-0.0.1-py3-none-any.whl.sigstore")
with bundle_path.open("rb") as f:
sigstore_bundle = Bundle().from_json(f.read())
sigstore_bundle = Bundle.from_json(f.read())
attestation_object = sigstore_to_pypi(sigstore_bundle)
print(attestation_object.to_json())
print(attestation_object.model_dump_json())


# PEP 740 Attestation object -> Sigstore Bundle
attestation_path = Path("attestation.json")
with attestation_path.open("rb") as f:
attestation = impl.Attestation.from_dict(json.load(f))
bundle = impl.pypi_to_sigstore(attestation)
attestation = Attestation.model_validate_json(f.read())
bundle = pypi_to_sigstore(attestation)
print(bundle.to_json())
```

3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -15,7 +15,8 @@ classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: Apache Software License",
]
dependencies = ["sigstore-protobuf-specs"]
# TODO pin sigstore since we deppend on Bundle._inner, which is an object from the protobuf models and could change
dependencies = ["cryptography", "pydantic", "sigstore @ git+https://github.com/sigstore/sigstore-python.git@7583a787ab808d3780e1fcdae86b8420fde939b8"]
requires-python = ">=3.9"

[project.optional-dependencies]
2 changes: 0 additions & 2 deletions src/pypi_attestation_models/__init__.py
Original file line number Diff line number Diff line change
@@ -6,7 +6,6 @@
Attestation,
ConversionError,
InvalidAttestationError,
InvalidBundleError,
VerificationMaterial,
pypi_to_sigstore,
sigstore_to_pypi,
@@ -16,7 +15,6 @@
"Attestation",
"ConversionError",
"InvalidAttestationError",
"InvalidBundleError",
"VerificationMaterial",
"pypi_to_sigstore",
"sigstore_to_pypi",
127 changes: 28 additions & 99 deletions src/pypi_attestation_models/_impl.py
Original file line number Diff line number Diff line change
@@ -6,30 +6,20 @@
from __future__ import annotations

import binascii
import json
from base64 import b64decode, b64encode
from dataclasses import asdict, dataclass
from typing import Any, Literal
from typing import Annotated, Any, Literal, NewType

import sigstore_protobuf_specs.dev.sigstore.bundle.v1 as sigstore
from sigstore_protobuf_specs.dev.sigstore.common.v1 import MessageSignature, X509Certificate
from sigstore_protobuf_specs.dev.sigstore.rekor.v1 import TransparencyLogEntry

_NO_CERTIFICATES_ERROR_MESSAGE = "No certificates found in Sigstore Bundle"
from annotated_types import MinLen # noqa: TCH002
from cryptography import x509
from cryptography.hazmat.primitives import serialization
from pydantic import BaseModel
from sigstore.models import Bundle, LogEntry


class ConversionError(ValueError):
"""The base error for all errors during conversion."""


class InvalidBundleError(ConversionError):
"""The Sigstore Bundle given as input is not valid."""

def __init__(self: InvalidBundleError, msg: str) -> None:
"""Initialize an `InvalidBundleError`."""
super().__init__(f"Could not convert input Bundle: {msg}")


class InvalidAttestationError(ConversionError):
"""The PyPI Attestation given as input is not valid."""

@@ -38,32 +28,25 @@ def __init__(self: InvalidAttestationError, msg: str) -> None:
super().__init__(f"Could not convert input Attestation: {msg}")


@dataclass
class VerificationMaterial:
TransparencyLogEntry = NewType("TransparencyLogEntry", dict[str, Any])


class VerificationMaterial(BaseModel):
"""Cryptographic materials used to verify attestation objects."""

certificate: str
"""
The signing certificate, as `base64(DER(cert))`.
"""

transparency_entries: list[dict[str, Any]]
transparency_entries: Annotated[list[TransparencyLogEntry], MinLen(1)]
"""
One or more transparency log entries for this attestation's signature
and certificate.
"""

@staticmethod
def from_dict(dict_input: dict[str, Any]) -> VerificationMaterial:
"""Create a VerificationMaterial object from a dict."""
return VerificationMaterial(
certificate=dict_input["certificate"],
transparency_entries=dict_input["transparency_entries"],
)


@dataclass
class Attestation:
class Attestation(BaseModel):
"""Attestation object as defined in PEP 740."""

version: Literal[1]
@@ -82,90 +65,36 @@ class Attestation:
is the raw bytes of the signing operation.
"""

def to_json(self: Attestation) -> str:
"""Serialize the attestation object into JSON."""
return json.dumps(asdict(self))

@staticmethod
def from_dict(dict_input: dict[str, Any]) -> Attestation:
"""Create an Attestation object from a dict."""
return Attestation(
version=dict_input["version"],
verification_material=VerificationMaterial.from_dict(
dict_input["verification_material"],
),
message_signature=dict_input["message_signature"],
)


@dataclass
class Provenance:
"""Provenance object as defined in PEP 740."""

version: Literal[1]
"""
The provenance object's version, which is always 1.
"""

publisher: object | None
"""
An optional open-ended JSON object, specific to the kind of Trusted
Publisher used to publish the file, if one was used.
"""

attestations: list[Attestation]
"""
One or more attestation objects.
"""


def sigstore_to_pypi(sigstore_bundle: sigstore.Bundle) -> Attestation:
"""Convert a Sigstore Bundle into a PyPI attestation object, as defined in PEP 740."""
certificate = sigstore_bundle.verification_material.certificate.raw_bytes
if certificate == b"":
# If there's no single certificate, we check for a leaf certificate in the
# x509_certificate_chain.certificates` field.
certificates = sigstore_bundle.verification_material.x509_certificate_chain.certificates
if not certificates:
raise InvalidBundleError(_NO_CERTIFICATES_ERROR_MESSAGE)
# According to the spec, the first member of the sequence MUST be the leaf certificate
# conveying the signing key
certificate = certificates[0].raw_bytes

certificate = b64encode(certificate).decode("ascii")
tlog_entries = [t.to_dict() for t in sigstore_bundle.verification_material.tlog_entries]
verification_material = VerificationMaterial(
certificate=certificate,
transparency_entries=tlog_entries,
def sigstore_to_pypi(sigstore_bundle: Bundle) -> Attestation:
"""Convert a Sigstore Bundle into a PyPI attestation as defined in PEP 740."""
certificate = sigstore_bundle.signing_certificate.public_bytes(
encoding=serialization.Encoding.DER
)

signature = sigstore_bundle._inner.message_signature.signature # noqa: SLF001
return Attestation(
version=1,
verification_material=verification_material,
message_signature=b64encode(sigstore_bundle.message_signature.signature).decode("ascii"),
verification_material=VerificationMaterial(
certificate=b64encode(certificate).decode("ascii"),
transparency_entries=[sigstore_bundle.log_entry._to_dict_rekor()], # noqa: SLF001
),
message_signature=b64encode(signature).decode("ascii"),
)


def pypi_to_sigstore(pypi_attestation: Attestation) -> sigstore.Bundle:
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

certificate = X509Certificate(raw_bytes=certificate_bytes)
tlog_entries = [
TransparencyLogEntry().from_dict(x)
for x in pypi_attestation.verification_material.transparency_entries
]
tlog_entry = pypi_attestation.verification_material.transparency_entries[0]

verification_material = sigstore.VerificationMaterial(
certificate=certificate,
tlog_entries=tlog_entries,
)
return sigstore.Bundle(
media_type="application/vnd.dev.sigstore.bundle+json;version=0.3",
verification_material=verification_material,
message_signature=MessageSignature(signature=signature_bytes),
return Bundle.from_parts(
cert=x509.load_der_x509_certificate(certificate_bytes),
sig=signature_bytes,
log_entry=LogEntry._from_dict_rekor(tlog_entry), # noqa: SLF001
)
48 changes: 0 additions & 48 deletions test/assets/rfc8785-0.0.2-py3-none-any.whl.json

This file was deleted.

Loading

0 comments on commit c6bdbda

Please sign in to comment.