Skip to content

Add Provenance.verify() #126

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- The `Provenance` class now exposes a `verify()` method that takes a
list of required publishers and verifies that all the attestations
inside it pass verification with those required publishers. Verification
will fail if any of the required publishers is missing from the Provenance.

## [0.0.26]

### Fixed
Expand Down
47 changes: 47 additions & 0 deletions src/pypi_attestations/_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -668,3 +668,50 @@ class Provenance(BaseModel):
"""
One or more attestation "bundles".
"""

def verify(
self,
required_publishers: list[Publisher],
dist: Distribution,
*,
staging: bool = False,
offline: bool = False,
) -> list[tuple[str, Optional[dict[str, Any]]]]:
"""Verify against an existing Python distribution.

The `required_publishers` must be a non-empty list of the publisher
identities that will be used to verify the distribution. All of
these publishers must be present in the Provenance in order for
verification to succeed.

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

If `offline` is `True`, the verifier will not attempt to refresh the
TUF repository.

On failure, raises an appropriate subclass of `AttestationError`.
"""
if not required_publishers:
raise VerificationError("list of required publishers cannot be empty")

provenance_publishers = [bundle.publisher for bundle in self.attestation_bundles]
for required_publisher in required_publishers:
if required_publisher not in provenance_publishers:
raise VerificationError(
f"required publisher not present in provenance: {required_publisher}"
)

# A list to contain all the return values of the individual Attestation.verify() calls
predicates: list[tuple[str, Optional[dict[str, Any]]]] = list()

for bundle in self.attestation_bundles:
if bundle.publisher not in required_publishers:
continue
for attestation in bundle.attestations:
predicates.append(
attestation.verify(bundle.publisher, dist, staging=staging, offline=offline)
)

return predicates
85 changes: 85 additions & 0 deletions test/test_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,91 @@ def test_version(self) -> None:
],
)

def test_verify_empty_required_publishers(self) -> None:
attestation = impl.Attestation.model_validate_json(dist_attestation_path.read_bytes())
provenance = impl.Provenance(
attestation_bundles=[
impl.AttestationBundle(
publisher=impl.GitHubPublisher(repository="foo/bar", workflow="publish.yml"),
attestations=[attestation],
)
]
)
with pytest.raises(
impl.VerificationError,
match=("Verification failed: list of required publishers cannot be empty"),
):
provenance.verify([], dist, offline=True)

def test_verify_missing_required_publisher(self) -> None:
attestation = impl.Attestation.model_validate_json(dist_attestation_path.read_bytes())
bundle = Bundle.from_json(gh_signed_dist_bundle_path.read_bytes())
attestation = impl.Attestation.from_bundle(bundle)
provenance = impl.Provenance(
attestation_bundles=[
impl.AttestationBundle(
publisher=impl.GitHubPublisher(repository="foo/bar", workflow="publish.yml"),
attestations=[attestation],
)
]
)

required_publisher = impl.GitHubPublisher(repository="foo2/bar2", workflow="publish2.yml")

with pytest.raises(
impl.VerificationError,
match=("Verification failed: required publisher not present in provenance"),
):
provenance.verify([required_publisher], dist, offline=True)

def test_verify_ok_required_publisher(self) -> None:
bundle = Bundle.from_json(gh_signed_dist_bundle_path.read_bytes())
attestation = impl.Attestation.from_bundle(bundle)
provenance = impl.Provenance(
attestation_bundles=[
impl.AttestationBundle(
publisher=impl.GitHubPublisher(
repository="trailofbits/pypi-attestation-models", workflow="release.yml"
),
attestations=[attestation],
)
]
)

required_publisher = impl.GitHubPublisher(
repository="trailofbits/pypi-attestation-models", workflow="release.yml"
)

predicates = provenance.verify([required_publisher], gh_signed_dist, offline=True)
assert len(predicates) == 1
assert predicates[0] == ("https://docs.pypi.org/attestations/publish/v1", {})

def test_verify_ok_ignore_publisher_not_in_required_publisher(self) -> None:
bundle = Bundle.from_json(gh_signed_dist_bundle_path.read_bytes())
attestation = impl.Attestation.from_bundle(bundle)
provenance = impl.Provenance(
attestation_bundles=[
impl.AttestationBundle(
publisher=impl.GitHubPublisher(
repository="trailofbits/pypi-attestation-models", workflow="release.yml"
),
attestations=[attestation],
),
impl.AttestationBundle(
publisher=impl.GitHubPublisher(repository="other/repo", workflow="release.yml"),
attestations=[attestation],
),
]
)

required_publisher = impl.GitHubPublisher(
repository="trailofbits/pypi-attestation-models", workflow="release.yml"
)

predicates = provenance.verify([required_publisher], gh_signed_dist, offline=True)
assert len(predicates) == 1
assert predicates[0] == ("https://docs.pypi.org/attestations/publish/v1", {})


class DummyModel(BaseModel):
base64_bytes: Base64Bytes
Expand Down