Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ dependencies = [
"cryptography",
"packaging",
"pydantic",
"sigstore~=3.3",
"sigstore~=3.4",
"sigstore-protobuf-specs",
]
requires-python = ">=3.11"
Expand Down
5 changes: 2 additions & 3 deletions src/pypi_attestations/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from pydantic import ValidationError
from sigstore.oidc import IdentityError, IdentityToken, Issuer
from sigstore.sign import SigningContext
from sigstore.verify import Verifier, policy
from sigstore.verify import policy

from pypi_attestations import Attestation, AttestationError, VerificationError, __version__
from pypi_attestations._impl import Distribution
Expand Down Expand Up @@ -256,7 +256,6 @@ def _inspect(args: argparse.Namespace) -> None:

def _verify(args: argparse.Namespace) -> None:
"""Verify the files passed as argument."""
verifier: Verifier = Verifier.staging() if args.staging else Verifier.production()
pol = policy.Identity(identity=args.identity)

# Validate that both the attestations and files exists
Expand Down Expand Up @@ -291,7 +290,7 @@ def _verify(args: argparse.Namespace) -> None:
_die(f"Invalid Python package distribution: {e}")

try:
attestation.verify(verifier, pol, dist)
attestation.verify(pol, dist, staging=args.staging)
except VerificationError as verification_error:
_die(f"Verification failed for {input}: {verification_error}")

Expand Down
82 changes: 78 additions & 4 deletions src/pypi_attestations/_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,14 @@
from sigstore.dsse import Error as DsseError
from sigstore.models import Bundle, LogEntry
from sigstore.sign import ExpiredCertificate, ExpiredIdentity
from sigstore.verify import Verifier, policy
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

from sigstore.sign import Signer # pragma: no cover
from sigstore.verify import Verifier # pragma: no cover
from sigstore.verify.policy import VerificationPolicy # pragma: no cover


Expand Down Expand Up @@ -180,14 +180,36 @@ def sign(cls, signer: Signer, dist: Distribution) -> Attestation:

def verify(
self,
verifier: Verifier,
policy: VerificationPolicy,
identity: VerificationPolicy | Publisher,
dist: Distribution,
*,
staging: bool = False,
) -> tuple[str, dict[str, Any] | None]:
"""Verify against an existing Python distribution.

The `identity` can be an object confirming to
`sigstore.policy.VerificationPolicy` or a `Publisher`, which will be
transformed into an appropriate verification policy.

By default, Sigstore's production verifier will be used. The
`staging` parameter can be toggled to enable the staging verifier
instead.

On failure, raises an appropriate subclass of `AttestationError`.
"""
# NOTE: Can't do `isinstance` with `Publisher` since it's
# a `_GenericAlias`; instead we punch through to the inner
# `_Publisher` union.
if isinstance(identity, _Publisher):
policy = identity._as_policy() # noqa: SLF001
else:
policy = identity

if staging:
verifier = Verifier.staging()
else:
verifier = Verifier.production()

bundle = self.to_bundle()
try:
type_, payload = verifier.verify_dsse(bundle, policy)
Expand Down Expand Up @@ -364,6 +386,10 @@ class _PublisherBase(BaseModel):
kind: str
claims: dict[str, Any] | None = None

def _as_policy(self) -> VerificationPolicy:
"""Return an appropriate `sigstore.policy.VerificationPolicy` for this publisher."""
raise NotImplementedError # pragma: no cover


class GitHubPublisher(_PublisherBase):
"""A GitHub-based Trusted Publisher."""
Expand All @@ -388,6 +414,33 @@ class GitHubPublisher(_PublisherBase):
action was performed from.
"""

def _as_policy(self) -> VerificationPolicy:
policies: list[VerificationPolicy] = [
policy.OIDCIssuerV2("https://token.actions.githubusercontent.com"),
policy.OIDCSourceRepositoryURI(f"https://github.com/{self.repository}"),
]

if not self.claims:
raise VerificationError("refusing to build a policy without claims")

sha = self.claims.get("sha")
ref = self.claims.get("ref")
if not (sha or ref):
# This should never happen, since we should always _at least_ have
# the `sha` claim.
raise VerificationError("refusing to build a policy without a sha or ref claim")

expected_build_configs: list[VerificationPolicy] = [
policy.OIDCBuildConfigURI(
f"https://github.com/{self.repository}/.github/workflows/{self.workflow}@{claim}"
)
for claim in [ref, sha]
if claim is not None
]
policies.append(policy.AnyOf(expected_build_configs))

return policy.AllOf(policies)


class GitLabPublisher(_PublisherBase):
"""A GitLab-based Trusted Publisher."""
Expand All @@ -406,8 +459,29 @@ class GitLabPublisher(_PublisherBase):
The optional environment that the publishing action was performed from.
"""

def _as_policy(self) -> VerificationPolicy:
policies: list[VerificationPolicy] = [
policy.OIDCIssuerV2("https://gitlab.com"),
policy.OIDCSourceRepositoryURI(f"https://gitlab.com/{self.repository}"),
]

if not self.claims:
raise VerificationError("refusing to build a policy without claims")

if ref := self.claims.get("ref"):
policies.append(
policy.OIDCBuildConfigURI(
f"https://gitlab.com/{self.repository}//.gitlab-ci.yml@{ref}"
)
)
else:
raise VerificationError("refusing to build a policy without a ref claim")

return policy.AllOf(policies)


Publisher = Annotated[GitHubPublisher | GitLabPublisher, Field(discriminator="kind")]
_Publisher = GitHubPublisher | GitLabPublisher
Publisher = Annotated[_Publisher, Field(discriminator="kind")]


class AttestationBundle(BaseModel):
Expand Down
Loading