diff --git a/CHANGELOG.md b/CHANGELOG.md index 82cb846..1156838 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/pypi_attestations/_impl.py b/src/pypi_attestations/_impl.py index 4a9b127..2ddadd0 100644 --- a/src/pypi_attestations/_impl.py +++ b/src/pypi_attestations/_impl.py @@ -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 diff --git a/test/test_impl.py b/test/test_impl.py index 5bd1e9f..a3e9ee0 100644 --- a/test/test_impl.py +++ b/test/test_impl.py @@ -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