Skip to content

Commit b172958

Browse files
remove Verifier param from verify() API (#62)
Co-authored-by: Facundo Tuesca <facundo.tuesca@trailofbits.com>
1 parent a68278c commit b172958

File tree

4 files changed

+243
-64
lines changed

4 files changed

+243
-64
lines changed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ classifiers = [
1212
dependencies = [
1313
"cryptography",
1414
"packaging",
15+
"pyasn1 ~= 0.6",
1516
"pydantic",
16-
"sigstore~=3.3",
17+
"sigstore~=3.4",
1718
"sigstore-protobuf-specs",
1819
]
1920
requires-python = ">=3.11"

src/pypi_attestations/_cli.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from pydantic import ValidationError
1212
from sigstore.oidc import IdentityError, IdentityToken, Issuer
1313
from sigstore.sign import SigningContext
14-
from sigstore.verify import Verifier, policy
14+
from sigstore.verify import policy
1515

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

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

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

293292
try:
294-
attestation.verify(verifier, pol, dist)
293+
attestation.verify(pol, dist, staging=args.staging)
295294
except VerificationError as verification_error:
296295
_die(f"Verification failed for {input}: {verification_error}")
297296

src/pypi_attestations/_impl.py

Lines changed: 133 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
from cryptography import x509
1515
from cryptography.hazmat.primitives import serialization
1616
from 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
1719
from pydantic import Base64Encoder, BaseModel, ConfigDict, EncodedBytes, Field, field_validator
1820
from pydantic.alias_generators import to_snake
1921
from pydantic_core import ValidationError
@@ -23,15 +25,16 @@
2325
from sigstore.dsse import Error as DsseError
2426
from sigstore.models import Bundle, LogEntry
2527
from sigstore.sign import ExpiredCertificate, ExpiredIdentity
28+
from sigstore.verify import Verifier, policy
2629
from sigstore_protobuf_specs.io.intoto import Envelope as _Envelope
2730
from 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

3740
class 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

368469
class 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

392496
class 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

413538
class AttestationBundle(BaseModel):

0 commit comments

Comments
 (0)