diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a773563..933d0b3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,8 +10,8 @@ permissions: {} env: FORCE_COLOR: "1" - PYTHONDEVMODE: "1" # -X dev - PYTHONWARNDEFAULTENCODING: "1" # -X warn_default_encoding + PYTHONDEVMODE: "1" # -X dev + PYTHONWARNDEFAULTENCODING: "1" # -X warn_default_encoding jobs: test: @@ -24,8 +24,6 @@ jobs: - "3.12" - "3.13" runs-on: ubuntu-latest - permissions: - id-token: write # unit tests use the ambient OIDC credential steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: @@ -40,6 +38,10 @@ jobs: - name: test run: make test INSTALL_EXTRA=test + env: + # Use the pubic OIDC beacon for online tests, rather than relying + # on the workflow's own ID token. + EXTREMELY_DANGEROUS_PUBLIC_OIDC_BEACON: 1 test-offline: runs-on: ubuntu-latest diff --git a/pyproject.toml b/pyproject.toml index fd758c4..83fc144 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,9 +10,7 @@ readme = "README.md" license = "Apache-2.0" license-files = ["LICENSE"] authors = [{ name = "Trail of Bits", email = "opensource@trailofbits.com" }] -classifiers = [ - "Programming Language :: Python :: 3", -] +classifiers = ["Programming Language :: Python :: 3"] dependencies = [ "cryptography", "packaging", @@ -20,8 +18,8 @@ dependencies = [ "pydantic >= 2.10.0", "requests", "rfc3986", - "sigstore >= 3.5.3, < 3.7", - "sigstore-protobuf-specs", + "sigstore @ git+https://github.com/sigstore/sigstore-python.git@ww/rm-protobufs", + "sigstore-models", ] requires-python = ">=3.9" @@ -108,10 +106,6 @@ pyupgrade.keep-runtime-typing = true [tool.interrogate] # don't enforce documentation coverage for packaging, testing, the virtual # environment, or the CLI (which is documented separately). -exclude = [ - "env", - "test", - "src/pypi_attestations/__main__.py", -] +exclude = ["env", "test", "src/pypi_attestations/__main__.py"] ignore-semiprivate = true fail-under = 100 diff --git a/src/pypi_attestations/_cli.py b/src/pypi_attestations/_cli.py index 52effea..0c16a87 100644 --- a/src/pypi_attestations/_cli.py +++ b/src/pypi_attestations/_cli.py @@ -21,6 +21,7 @@ ) from pydantic import ValidationError from rfc3986 import exceptions, uri_reference, validators +from sigstore._internal.trust import ClientTrustConfig from sigstore.models import Bundle, InvalidBundle from sigstore.oidc import IdentityError, IdentityToken, Issuer from sigstore.sign import SigningContext @@ -254,8 +255,11 @@ def get_identity_token(args: argparse.Namespace) -> IdentityToken: if oidc_token is not None: return IdentityToken(oidc_token) - # Fallback to interactive OAuth-2 Flow - issuer: Issuer = Issuer.staging() if args.staging else Issuer.production() + if args.staging: + trust_config = ClientTrustConfig.staging() + else: + trust_config = ClientTrustConfig.production() + issuer: Issuer = Issuer(trust_config.signing_config.get_oidc_url()) return issuer.identity_token() @@ -424,7 +428,9 @@ def _sign(args: argparse.Namespace) -> None: except IdentityError as identity_error: _die(f"Failed to detect identity: {identity_error}") - signing_ctx = SigningContext.staging() if args.staging else SigningContext.production() + trust_config = ClientTrustConfig.staging() if args.staging else ClientTrustConfig.production() + + signing_ctx = SigningContext.from_trust_config(trust_config) # Validates that every file we want to sign exist but none of their attestations _validate_files(args.files, should_exist=True) diff --git a/src/pypi_attestations/_impl.py b/src/pypi_attestations/_impl.py index 61ee13a..3388601 100644 --- a/src/pypi_attestations/_impl.py +++ b/src/pypi_attestations/_impl.py @@ -27,12 +27,14 @@ from sigstore._utils import _sha256_streaming from sigstore.dsse import DigestSet, StatementBuilder, Subject, _Statement from sigstore.dsse import Envelope as DsseEnvelope -from sigstore.dsse import Error as DsseError -from sigstore.models import Bundle, LogEntry +from sigstore.errors import Error as SigstoreError +from sigstore.models import Bundle +from sigstore.models import TransparencyLogEntry as _TransparencyLogEntry from sigstore.sign import ExpiredCertificate, ExpiredIdentity from sigstore.verify import Verifier, policy -from sigstore_protobuf_specs.io.intoto import Envelope as _Envelope -from sigstore_protobuf_specs.io.intoto import Signature as _Signature +from sigstore_models.intoto import Envelope as _Envelope +from sigstore_models.intoto import Signature as _Signature +from sigstore_models.rekor.v1 import TransparencyLogEntry as _TransparencyLogEntryInner if TYPE_CHECKING: # pragma: no cover from pathlib import Path @@ -198,7 +200,7 @@ def sign(cls, signer: Signer, dist: Distribution) -> Attestation: .predicate_type(AttestationType.PYPI_PUBLISH_V1) .build() ) - except DsseError as e: + except SigstoreError as e: raise AttestationError(str(e)) try: @@ -327,9 +329,9 @@ def to_bundle(self) -> Bundle: evp = DsseEnvelope( _Envelope( - payload=statement, + payload=base64.b64encode(statement), payload_type=DsseEnvelope._TYPE, # noqa: SLF001 - signatures=[_Signature(sig=signature)], + signatures=[_Signature(sig=base64.b64encode(signature))], ) ) @@ -340,7 +342,8 @@ def to_bundle(self) -> Bundle: raise ConversionError("invalid X.509 certificate") from err try: - log_entry = LogEntry._from_dict_rekor(tlog_entry) # noqa: SLF001 + inner = _TransparencyLogEntryInner.from_dict(tlog_entry) + log_entry = _TransparencyLogEntry(inner) except (ValidationError, sigstore.errors.Error) as err: raise ConversionError("invalid transparency log entry") from err @@ -359,6 +362,9 @@ def from_bundle(cls, sigstore_bundle: Bundle) -> Attestation: envelope = sigstore_bundle._inner.dsse_envelope # noqa: SLF001 + if not envelope: + raise ConversionError("bundle does not contain a DSSE envelope") + if len(envelope.signatures) != 1: raise ConversionError(f"expected exactly one signature, got {len(envelope.signatures)}") @@ -367,7 +373,7 @@ def from_bundle(cls, sigstore_bundle: Bundle) -> Attestation: verification_material=VerificationMaterial( certificate=base64.b64encode(certificate), transparency_entries=[ - sigstore_bundle.log_entry._to_rekor().to_dict() # noqa: SLF001 + sigstore_bundle.log_entry._inner.to_dict() # noqa: SLF001 ], ), envelope=Envelope( diff --git a/test/conftest.py b/test/conftest.py index eb1192c..8029bff 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -6,10 +6,20 @@ @pytest.fixture(scope="session") def id_token() -> oidc.IdentityToken: + if "EXTREMELY_DANGEROUS_PUBLIC_OIDC_BEACON" in os.environ: + import requests + + resp = requests.get( + "https://raw.githubusercontent.com/sigstore-conformance/extremely-dangerous-public-oidc-beacon/refs/heads/current-token/oidc-token.txt" + ) + resp.raise_for_status() + id_token = resp.text.strip() + return oidc.IdentityToken(id_token) + if "CI" in os.environ: token = oidc.detect_credential() if token is None: pytest.fail("misconfigured CI: no ambient OIDC credential") return oidc.IdentityToken(token) - else: - return oidc.Issuer.staging().identity_token() + + pytest.fail("no OIDC token available for tests") diff --git a/test/test_cli.py b/test/test_cli.py index 59e264e..e70b2fb 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -12,7 +12,7 @@ import requests import sigstore.oidc from pretend import raiser, stub -from sigstore.oidc import IdentityError +from sigstore.oidc import IdentityError, IdentityToken import pypi_attestations._cli from pypi_attestations._cli import ( @@ -24,7 +24,7 @@ from pypi_attestations._impl import Attestation, AttestationError, ConversionError, Distribution ONLINE_TESTS = ( - "CI" in os.environ or "TEST_INTERACTIVE" in os.environ + "CI" in os.environ or "EXTREMELY_DANGEROUS_PUBLIC_OIDC_BEACON" in os.environ ) and "TEST_OFFLINE" not in os.environ online = pytest.mark.skipif(not ONLINE_TESTS, reason="online tests not enabled") @@ -75,7 +75,9 @@ def default_sign(_: argparse.Namespace) -> None: @online -def test_get_identity_token(monkeypatch: pytest.MonkeyPatch) -> None: +def test_get_identity_token(id_token: IdentityToken, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sigstore.oidc, "detect_credential", lambda: id_token._raw_token) + # Happy paths identity_token = get_identity_token(argparse.Namespace(staging=True)) assert identity_token.in_validity_period() @@ -92,7 +94,11 @@ def return_invalid_token() -> str: @online -def test_sign_command(tmp_path: Path) -> None: +def test_sign_command( + id_token: IdentityToken, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(sigstore.oidc, "detect_credential", lambda: id_token._raw_token) + # Happy path copied_artifact = tmp_path / artifact_path.name shutil.copy(artifact_path, copied_artifact) @@ -112,7 +118,11 @@ def test_sign_command(tmp_path: Path) -> None: @online -def test_sign_missing_file(caplog: pytest.LogCaptureFixture) -> None: +def test_sign_missing_file( + id_token: IdentityToken, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(sigstore.oidc, "detect_credential", lambda: id_token._raw_token) + # Missing file with pytest.raises(SystemExit): run_main_with_command( @@ -127,7 +137,14 @@ def test_sign_missing_file(caplog: pytest.LogCaptureFixture) -> None: @online -def test_sign_signature_already_exists(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: +def test_sign_signature_already_exists( + id_token: IdentityToken, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(sigstore.oidc, "detect_credential", lambda: id_token._raw_token) + artifact = tmp_path / artifact_path.with_suffix(".copy2.whl").name artifact.touch(exist_ok=False) @@ -168,7 +185,14 @@ def return_invalid_token() -> str: @online -def test_sign_invalid_artifact(caplog: pytest.LogCaptureFixture, tmp_path: Path) -> None: +def test_sign_invalid_artifact( + id_token: IdentityToken, + caplog: pytest.LogCaptureFixture, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(sigstore.oidc, "detect_credential", lambda: id_token._raw_token) + artifact = tmp_path / "pkg-1.0.0.exe" artifact.touch(exist_ok=False) @@ -180,8 +204,12 @@ def test_sign_invalid_artifact(caplog: pytest.LogCaptureFixture, tmp_path: Path) @online def test_sign_fail_to_sign( - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, tmp_path: Path + id_token: IdentityToken, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + tmp_path: Path, ) -> None: + monkeypatch.setattr(sigstore.oidc, "detect_credential", lambda: id_token._raw_token) monkeypatch.setattr(pypi_attestations._cli, "Attestation", stub(sign=raiser(AttestationError))) copied_artifact = tmp_path / artifact_path.name shutil.copy(artifact_path, copied_artifact) @@ -329,19 +357,23 @@ def test_verify_attestation_invalid_artifact( assert "Invalid Python package distribution" in caplog.text -def test_get_identity_token_oauth_flow(monkeypatch: pytest.MonkeyPatch) -> None: +@online +@pytest.mark.parametrize("staging", [True, False]) +def test_get_identity_token_oauth_flow(staging: bool, monkeypatch: pytest.MonkeyPatch) -> None: # If no ambient credential is available, default to the OAuth2 flow monkeypatch.setattr(sigstore.oidc, "detect_credential", lambda: None) identity_token = stub() class MockIssuer: - @staticmethod - def staging() -> stub: - return stub(identity_token=lambda: identity_token) + def __init__(self, *args: object, **kwargs: object) -> None: + pass + + def identity_token(self) -> sigstore.oidc.IdentityToken: + return identity_token # type: ignore monkeypatch.setattr(pypi_attestations._cli, "Issuer", MockIssuer) - assert pypi_attestations._cli.get_identity_token(stub(staging=True)) == identity_token + assert pypi_attestations._cli.get_identity_token(stub(staging=staging)) == identity_token def test_validate_files(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: diff --git a/test/test_impl.py b/test/test_impl.py index 19990d9..ba2d44a 100644 --- a/test/test_impl.py +++ b/test/test_impl.py @@ -14,6 +14,7 @@ from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import ec from pydantic import Base64Bytes, BaseModel, TypeAdapter, ValidationError +from sigstore._internal.trust import ClientTrustConfig from sigstore.dsse import DigestSet, StatementBuilder, Subject from sigstore.models import Bundle from sigstore.oidc import IdentityToken @@ -23,7 +24,7 @@ import pypi_attestations._impl as impl ONLINE_TESTS = ( - "CI" in os.environ or "TEST_INTERACTIVE" in os.environ + "CI" in os.environ or "EXTREMELY_DANGEROUS_PUBLIC_OIDC_BEACON" in os.environ ) and "TEST_OFFLINE" not in os.environ online = pytest.mark.skipif(not ONLINE_TESTS, reason="online tests not enabled") @@ -69,7 +70,8 @@ def test_invalid_unknown_dist(self) -> None: class TestAttestation: @online def test_roundtrip(self, id_token: IdentityToken) -> None: - sign_ctx = SigningContext.staging() + trust_config = ClientTrustConfig.staging() + sign_ctx = SigningContext.from_trust_config(trust_config) with sign_ctx.signer(id_token) as signer: attestation = impl.Attestation.sign(signer, dist) @@ -103,7 +105,9 @@ def in_validity_period(_: IdentityToken) -> bool: monkeypatch.setattr(IdentityToken, "in_validity_period", in_validity_period) - sign_ctx = SigningContext.staging() + trust_config = ClientTrustConfig.staging() + sign_ctx = SigningContext.from_trust_config(trust_config) + with sign_ctx.signer(id_token, cache=False) as signer: with pytest.raises(impl.AttestationError): impl.Attestation.sign(signer, dist) @@ -115,12 +119,13 @@ def test_multiple_signatures( def get_bundle(*_: Any) -> Bundle: # Duplicate the signature to trigger a Conversion error bundle = Bundle.from_json(gh_signed_dist_bundle_path.read_bytes()) - bundle._inner.dsse_envelope.signatures.append(bundle._inner.dsse_envelope.signatures[0]) + bundle._inner.dsse_envelope.signatures.append(bundle._inner.dsse_envelope.signatures[0]) # type: ignore[union-attr] return bundle monkeypatch.setattr(sigstore.sign.Signer, "sign_dsse", get_bundle) - sign_ctx = SigningContext.staging() + trust_config = ClientTrustConfig.staging() + sign_ctx = SigningContext.from_trust_config(trust_config) with pytest.raises(impl.AttestationError): with sign_ctx.signer(id_token) as signer: @@ -485,9 +490,17 @@ def test_verify_different_wheel_tag_order(self) -> None: assert subject_name != dist.name +def test_from_bundle_not_dsse() -> None: + bundle = Bundle.from_json(dist_bundle_path.read_bytes()) + bundle._inner.dsse_envelope = None + + with pytest.raises(impl.ConversionError, match="bundle does not contain a DSSE envelope"): + impl.Attestation.from_bundle(bundle) + + def test_from_bundle_missing_signatures() -> None: bundle = Bundle.from_json(dist_bundle_path.read_bytes()) - bundle._inner.dsse_envelope.signatures = [] # noqa: SLF001 + bundle._inner.dsse_envelope.signatures = [] # type: ignore # noqa: SLF001 with pytest.raises(impl.ConversionError, match="expected exactly one signature, got 0"): impl.Attestation.from_bundle(bundle) @@ -724,8 +737,8 @@ def test_fails_cert_with_no_digest_or_ref(self) -> None: .issuer_name(orig_cert.issuer) .public_key(orig_cert.public_key()) .serial_number(orig_cert.serial_number) - .not_valid_before(orig_cert.not_valid_before) - .not_valid_after(orig_cert.not_valid_after) + .not_valid_before(orig_cert.not_valid_before_utc) + .not_valid_after(orig_cert.not_valid_after_utc) ) for ext in orig_cert.extensions: