1414from cryptography import x509
1515from cryptography .hazmat .primitives import serialization
1616from packaging .utils import parse_sdist_filename , parse_wheel_filename
17+ from pyasn1 .codec .der .decoder import decode as der_decode
18+ from pyasn1 .type .char import UTF8String
1719from pydantic import Base64Encoder , BaseModel , ConfigDict , EncodedBytes , Field , field_validator
1820from pydantic .alias_generators import to_snake
1921from pydantic_core import ValidationError
2325from sigstore .dsse import Error as DsseError
2426from sigstore .models import Bundle , LogEntry
2527from sigstore .sign import ExpiredCertificate , ExpiredIdentity
28+ from sigstore .verify import Verifier , policy
2629from sigstore_protobuf_specs .io .intoto import Envelope as _Envelope
2730from sigstore_protobuf_specs .io .intoto import Signature as _Signature
2831
29- if TYPE_CHECKING :
30- from pathlib import Path # pragma: no cover
32+ if TYPE_CHECKING : # pragma: no cover
33+ from pathlib import Path
3134
32- from sigstore . sign import Signer # pragma: no cover
33- from sigstore .verify import Verifier # pragma: no cover
34- from sigstore .verify .policy import VerificationPolicy # pragma: no cover
35+ from cryptography . x509 import Certificate
36+ from sigstore .sign import Signer
37+ from sigstore .verify .policy import VerificationPolicy
3538
3639
3740class Base64EncoderSansNewline (Base64Encoder ):
@@ -180,14 +183,36 @@ def sign(cls, signer: Signer, dist: Distribution) -> Attestation:
180183
181184 def verify (
182185 self ,
183- verifier : Verifier ,
184- policy : VerificationPolicy ,
186+ identity : VerificationPolicy | Publisher ,
185187 dist : Distribution ,
188+ * ,
189+ staging : bool = False ,
186190 ) -> tuple [str , dict [str , Any ] | None ]:
187191 """Verify against an existing Python distribution.
188192
193+ The `identity` can be an object confirming to
194+ `sigstore.policy.VerificationPolicy` or a `Publisher`, which will be
195+ transformed into an appropriate verification policy.
196+
197+ By default, Sigstore's production verifier will be used. The
198+ `staging` parameter can be toggled to enable the staging verifier
199+ instead.
200+
189201 On failure, raises an appropriate subclass of `AttestationError`.
190202 """
203+ # NOTE: Can't do `isinstance` with `Publisher` since it's
204+ # a `_GenericAlias`; instead we punch through to the inner
205+ # `_Publisher` union.
206+ if isinstance (identity , _Publisher ):
207+ policy = identity ._as_policy () # noqa: SLF001
208+ else :
209+ policy = identity
210+
211+ if staging :
212+ verifier = Verifier .staging ()
213+ else :
214+ verifier = Verifier .production ()
215+
191216 bundle = self .to_bundle ()
192217 try :
193218 type_ , payload = verifier .verify_dsse (bundle , policy )
@@ -364,6 +389,82 @@ class _PublisherBase(BaseModel):
364389 kind : str
365390 claims : dict [str , Any ] | None = None
366391
392+ def _as_policy (self ) -> VerificationPolicy :
393+ """Return an appropriate `sigstore.policy.VerificationPolicy` for this publisher."""
394+ raise NotImplementedError # pragma: no cover
395+
396+
397+ class _GitHubTrustedPublisherPolicy :
398+ """A custom sigstore-python policy for verifying against a GitHub-based Trusted Publisher."""
399+
400+ def __init__ (self , repository : str , workflow : str ) -> None :
401+ self ._repository = repository
402+ self ._workflow = workflow
403+ # This policy must also satisfy some baseline underlying policies:
404+ # the issuer must be GitHub Actions, and the repo must be the one
405+ # we expect.
406+ self ._subpolicy = policy .AllOf (
407+ [
408+ policy .OIDCIssuerV2 ("https://token.actions.githubusercontent.com" ),
409+ policy .OIDCSourceRepositoryURI (f"https://github.com/{ self ._repository } " ),
410+ ]
411+ )
412+
413+ @classmethod
414+ def _der_decode_utf8string (cls , der : bytes ) -> str :
415+ """Decode a DER-encoded UTF8String."""
416+ return der_decode (der , UTF8String )[0 ].decode () # type: ignore[no-any-return]
417+
418+ def verify (self , cert : Certificate ) -> None :
419+ """Verify the certificate against the Trusted Publisher identity."""
420+ self ._subpolicy .verify (cert )
421+
422+ # This process has a few annoying steps, since a Trusted Publisher
423+ # isn't aware of the commit or ref it runs on, while Sigstore's
424+ # leaf certificate claims (like GitHub Actions' OIDC claims) only
425+ # ever encode the workflow filename (which we need to check) next
426+ # to the ref/sha (which we can't check).
427+ #
428+ # To get around this, we:
429+ # (1) extract the `Build Config URI` extension;
430+ # (2) extract the `Source Repository Digest` and
431+ # `Source Repository Ref` extensions;
432+ # (3) build the *expected* URI with the user-controlled
433+ # Trusted Publisher identity *with* (2)
434+ # (4) compare (1) with (3)
435+
436+ # (1) Extract the build config URI, which looks like this:
437+ # https://github.com/OWNER/REPO/.github/workflows/WORKFLOW@REF
438+ # where OWNER/REPO and WORKFLOW are controlled by the TP identity,
439+ # and REF is controlled by the certificate's own claims.
440+ build_config_uri = cert .extensions .get_extension_for_oid (policy ._OIDC_BUILD_CONFIG_URI_OID ) # noqa: SLF001
441+ raw_build_config_uri = self ._der_decode_utf8string (build_config_uri .value .public_bytes ())
442+
443+ # (2) Extract the source repo digest and ref.
444+ source_repo_digest = cert .extensions .get_extension_for_oid (
445+ policy ._OIDC_SOURCE_REPOSITORY_DIGEST_OID # noqa: SLF001
446+ )
447+ sha = self ._der_decode_utf8string (source_repo_digest .value .public_bytes ())
448+
449+ source_repo_ref = cert .extensions .get_extension_for_oid (
450+ policy ._OIDC_SOURCE_REPOSITORY_REF_OID # noqa: SLF001
451+ )
452+ ref = self ._der_decode_utf8string (source_repo_ref .value .public_bytes ())
453+
454+ # (3)-(4): Build the expected URIs and compare them
455+ for suffix in [sha , ref ]:
456+ expected = (
457+ f"https://github.com/{ self ._repository } /.github/workflows/{ self ._workflow } @{ suffix } "
458+ )
459+ if raw_build_config_uri == expected :
460+ return
461+
462+ # If none of the expected URIs matched, the policy fails.
463+ raise sigstore .errors .VerificationError (
464+ f"Certificate's Build Config URI ({ build_config_uri } ) does not match expected "
465+ f"Trusted Publisher ({ self ._workflow } @ { self ._repository } )"
466+ )
467+
367468
368469class GitHubPublisher (_PublisherBase ):
369470 """A GitHub-based Trusted Publisher."""
@@ -388,6 +489,9 @@ class GitHubPublisher(_PublisherBase):
388489 action was performed from.
389490 """
390491
492+ def _as_policy (self ) -> VerificationPolicy :
493+ return _GitHubTrustedPublisherPolicy (self .repository , self .workflow )
494+
391495
392496class GitLabPublisher (_PublisherBase ):
393497 """A GitLab-based Trusted Publisher."""
@@ -406,8 +510,29 @@ class GitLabPublisher(_PublisherBase):
406510 The optional environment that the publishing action was performed from.
407511 """
408512
513+ def _as_policy (self ) -> VerificationPolicy :
514+ policies : list [VerificationPolicy ] = [
515+ policy .OIDCIssuerV2 ("https://gitlab.com" ),
516+ policy .OIDCSourceRepositoryURI (f"https://gitlab.com/{ self .repository } " ),
517+ ]
518+
519+ if not self .claims :
520+ raise VerificationError ("refusing to build a policy without claims" )
521+
522+ if ref := self .claims .get ("ref" ):
523+ policies .append (
524+ policy .OIDCBuildConfigURI (
525+ f"https://gitlab.com/{ self .repository } //.gitlab-ci.yml@{ ref } "
526+ )
527+ )
528+ else :
529+ raise VerificationError ("refusing to build a policy without a ref claim" )
530+
531+ return policy .AllOf (policies )
532+
409533
410- Publisher = Annotated [GitHubPublisher | GitLabPublisher , Field (discriminator = "kind" )]
534+ _Publisher = GitHubPublisher | GitLabPublisher
535+ Publisher = Annotated [_Publisher , Field (discriminator = "kind" )]
411536
412537
413538class AttestationBundle (BaseModel ):
0 commit comments