Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
Comment on lines +41 to +44
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a ton for doing this @jku!


test-offline:
runs-on: ubuntu-latest
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,18 @@ from pathlib import Path

from pypi_attestations import Attestation, Distribution
from sigstore.oidc import Issuer
from sigstore.models import ClientTrustConfig
from sigstore.sign import SigningContext
from sigstore.verify import Verifier, policy

dist = Distribution.from_file(Path("test_package-0.0.1-py3-none-any.whl"))

# Sign a Python artifact
issuer = Issuer.production()
identity_token = issuer.identity_token()
signing_ctx = SigningContext.production()
with signing_ctx.signer(identity_token, cache=True) as signer:
trust_config = ClientTrustConfig.production()
issuer: Issuer = Issuer(trust_config.signing_config.get_oidc_url())
signing_ctx = SigningContext.from_trust_config(trust_config)

with signing_ctx.signer(issuer.identity_token(), cache=True) as signer:
attestation = Attestation.sign(signer, dist)

print(attestation.model_dump_json())
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 = "opensource@trailofbits.com" }]
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 >= 4.0",
"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
15 changes: 11 additions & 4 deletions src/pypi_attestations/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
)
from pydantic import ValidationError
from rfc3986 import exceptions, uri_reference, validators
from sigstore.models import Bundle, InvalidBundle
from sigstore.models import Bundle, ClientTrustConfig, InvalidBundle
from sigstore.oidc import IdentityError, IdentityToken, Issuer
from sigstore.sign import SigningContext
from sigstore.verify import policy
Expand Down Expand Up @@ -254,8 +254,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 +427,11 @@ 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()
# Make sure we use rekor v1 until attestations are compatible with v2
trust_config.force_tlog_version = 1
Comment on lines +430 to +432
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not an issue for this PR, but I kind of wish we had a public API for this operation that wasn't setting an attribute/property directly 😅 -- I can see us perhaps wanting to remove this attribute at some point.


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"
)
Comment on lines +12 to +14
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

copying from the other PR:

unfortunately GitHub cached raw.githubusercontent.com quite aggressively when I last tried this -- it usually works but the error rate was still annoyingly high. This is why conformance uses the clumsy looking "git clone" approach https://github.com/sigstore/sigstore-conformance/blob/e0997c248c40ee615c1e8aa1e3ee043a62920951/test/conftest.py#L131

maybe this is not a show stopper here: we could do this and improve if it fails too often

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, not a blocker IMO. We can re-evaluate if the error rate ends up being annoying.

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")
68 changes: 47 additions & 21 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 @@ -245,10 +273,7 @@ def test_verify_attestation_command(caplog: pytest.LogCaptureFixture) -> None:
artifact_path.as_posix(),
]
)
assert (
"Verification failed: failed to build chain: unable to get local issuer certificate"
in caplog.text
)
assert "Verification failed: failed to build timestamp certificate chain" in caplog.text
assert "OK:" not in caplog.text


Expand Down Expand Up @@ -329,19 +354,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 Expand Up @@ -463,10 +492,7 @@ def test_verify_pypi_command_env_fail(caplog: pytest.LogCaptureFixture) -> None:
pypi_wheel_url,
]
)
assert (
"Verification failed: failed to build chain: unable to get local issuer certificate"
in caplog.text
)
assert "Verification failed: failed to build timestamp certificate chain" in caplog.text
assert "OK:" not in caplog.text


Expand Down
Loading