Skip to content

refactor: drop sigstore-protobuf-specs dependency #132

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

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
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
10 changes: 6 additions & 4 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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
Expand Down
14 changes: 4 additions & 10 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,16 @@ readme = "README.md"
license = "Apache-2.0"
license-files = ["LICENSE"]
authors = [{ name = "Trail of Bits", email = "[email protected]" }]
classifiers = [
"Programming Language :: Python :: 3",
]
classifiers = ["Programming Language :: Python :: 3"]
dependencies = [
"cryptography",
"packaging",
"pyasn1 ~= 0.6",
"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"

Expand Down Expand Up @@ -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
12 changes: 9 additions & 3 deletions src/pypi_attestations/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()


Expand Down Expand Up @@ -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)
Expand Down
24 changes: 15 additions & 9 deletions src/pypi_attestations/_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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))],
)
)

Expand All @@ -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

Expand All @@ -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)}")

Expand All @@ -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(
Expand Down
14 changes: 12 additions & 2 deletions test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
58 changes: 45 additions & 13 deletions test/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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")
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand All @@ -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(
Expand All @@ -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)

Expand Down Expand Up @@ -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)

Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
Loading