Skip to content

Commit

Permalink
do things the annoying way
Browse files Browse the repository at this point in the history
Signed-off-by: William Woodruff <william@trailofbits.com>
  • Loading branch information
woodruffw committed Oct 11, 2024
1 parent 3ef5273 commit 6d4fdf6
Show file tree
Hide file tree
Showing 3 changed files with 70 additions and 45 deletions.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ classifiers = [
dependencies = [
"cryptography",
"packaging",
"pyasn1 ~= 0.6",
"pydantic",
"sigstore~=3.4",
"sigstore-protobuf-specs",
Expand Down
85 changes: 66 additions & 19 deletions src/pypi_attestations/_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
from cryptography import x509
from cryptography.hazmat.primitives import serialization
from packaging.utils import parse_sdist_filename, parse_wheel_filename
from pyasn1.codec.der.decoder import decode as der_decode
from pyasn1.type.char import UTF8String
from pydantic import Base64Encoder, BaseModel, ConfigDict, EncodedBytes, Field, field_validator
from pydantic.alias_generators import to_snake
from pydantic_core import ValidationError
Expand All @@ -30,6 +32,7 @@
if TYPE_CHECKING:
from pathlib import Path # pragma: no cover

from cryptography.x509 import Certificate
from sigstore.sign import Signer # pragma: no cover
from sigstore.verify.policy import VerificationPolicy # pragma: no cover

Expand Down Expand Up @@ -391,6 +394,68 @@ def _as_policy(self) -> VerificationPolicy:
raise NotImplementedError # pragma: no cover


class _GitHubTrustedPublisherPolicy:
"""A custom sigstore-python policy for verifying against a GitHub-based Trusted Publisher."""

def __init__(self, repository: str, workflow: str) -> None:
self._repository = repository
self._workflow = workflow

@classmethod
def _der_decode_utf8string(cls, der: bytes) -> str:
"""Decode a DER-encoded UTF8String."""
return der_decode(der, UTF8String)[0].decode()

def verify(self, cert: Certificate) -> None:
"""Foo."""

# This process has a few annoying steps, since a Trusted Publisher
# isn't aware of the commit or ref it runs on, while Sigstore's
# leaf certificate claims (like GitHub Actions' OIDC claims) only
# ever encode the workflow filename (which we need to check) next
# to the ref/sha (which we can't check).
#
# To get around this, we:
# (1) extract the `Build Config URI` extension;
# (2) extract the `Source Repository Digest` and
# `Source Repository Ref` extensions;
# (3) build the *expected* URI with the user-controlled
# Trusted Publisher identity *with* (2)
# (4) compare (1) with (3)

# (1) Extract the build config URI, which looks like this:
# https://github.com/OWNER/REPO/.github/workflows/WORKFLOW@REF
# where OWNER/REPO and WORKFLOW are controlled by the TP identity,
# and REF is controlled by the certificate's own claims.
build_config_uri = cert.extensions.get_extension_for_oid(policy._OIDC_BUILD_CONFIG_URI_OID) # noqa: SLF001
raw_build_config_uri = self._der_decode_utf8string(build_config_uri.value.value)

# (2) Extract the source repo digest and ref.
source_repo_digest = cert.extensions.get_extension_for_oid(
policy._OIDC_BUILD_SIGNER_DIGEST_OID # noqa: SLF001
)
sha = self._der_decode_utf8string(source_repo_digest.value.value)

source_repo_ref = cert.extensions.get_extension_for_oid(
policy._OIDC_SOURCE_REPOSITORY_REF_OID # noqa: SLF001
)
ref = self._der_decode_utf8string(source_repo_ref.value.value)

# (3)-(4): Build the expected URIs and compare them
for suffix in [sha, ref]:
expected = (
f"https://github.com/{self._repository}/.github/workflows/{self._workflow}@{suffix}"
)
if raw_build_config_uri == expected:
return

# If none of the expected URIs matched, the policy fails.
raise ValidationError(
f"Certificate's Build Config URI ({build_config_uri}) does not match expected "
f"Trusted Publisher ({self._workflow} @ {self._repository})"
)


class GitHubPublisher(_PublisherBase):
"""A GitHub-based Trusted Publisher."""

Expand Down Expand Up @@ -418,27 +483,9 @@ def _as_policy(self) -> VerificationPolicy:
policies: list[VerificationPolicy] = [
policy.OIDCIssuerV2("https://token.actions.githubusercontent.com"),
policy.OIDCSourceRepositoryURI(f"https://github.com/{self.repository}"),
_GitHubTrustedPublisherPolicy(self.repository, self.workflow),
]

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)


Expand Down
29 changes: 3 additions & 26 deletions test/test_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,12 @@ def test_verify_github_attested(self) -> None:
assert predicate_type == "https://docs.pypi.org/attestations/publish/v1"
assert predicate == {}

def test_verify_from_github_publisher(self) -> None:
@pytest.mark.parametrize("claims", (None, {}, {"ref": "refs/tags/v0.0.4a2"}))
def test_verify_from_github_publisher(self, claims: dict | None) -> None:
publisher = impl.GitHubPublisher(
repository="trailofbits/pypi-attestation-models",
workflow="release.yml",
claims={"ref": "refs/tags/v0.0.4a2"},
claims=claims,
)

bundle = Bundle.from_json(gh_signed_dist_bundle_path.read_bytes())
Expand All @@ -146,30 +147,6 @@ def test_verify_from_github_publisher(self) -> None:
assert predicate_type == "https://docs.pypi.org/attestations/publish/v1"
assert predicate == {}

@pytest.mark.parametrize(
"claims",
(
None,
{},
{"something": "other"},
{"ref": None},
{"sha": None},
{"ref": None, "sha": None},
),
)
def test_verify_from_github_publisher_invalid_claims(self, claims: dict | None) -> None:
publisher = impl.GitHubPublisher(
repository="trailofbits/pypi-attestation-models",
workflow="release.yml",
claims=claims,
)

bundle = Bundle.from_json(gh_signed_dist_bundle_path.read_bytes())
attestation = impl.Attestation.from_bundle(bundle)

with pytest.raises(impl.VerificationError, match="refusing to build a policy"):
attestation.verify(publisher, gh_signed_dist)

def test_verify(self) -> None:
# Our checked-in asset has this identity.
pol = policy.Identity(
Expand Down

0 comments on commit 6d4fdf6

Please sign in to comment.