Skip to content

Commit a585379

Browse files
jkuwoodruffw
andauthored
Drop protobufs, bump sigstore version (#144)
* refactor: drop sigstore-protobuf-specs dependency Closes #131. * refactor: fixup tests * ignores * conftest: give EXTREMELY_DANGEROUS_PUBLIC_OIDC_BEACON precedence * test: remove TEST_INTERACTIVE * remove interactive fallback * mark test as online * Update for sigstore 4.0 * Update import for ClientTrustConfig * Use force_tlog_version = 1 when signing for now: This makes sure we don't get rekor v2 entries before we want them * tests: Update expected error message when using wrong instance * README: Update example to sigstore 4.0 * tests: lint fix * pyproject: Add ceiling for sigstore version --------- Co-authored-by: William Woodruff <william@astral.sh>
1 parent 7aa2a7d commit a585379

File tree

8 files changed

+128
-63
lines changed

8 files changed

+128
-63
lines changed

.github/workflows/tests.yml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ permissions: {}
1010

1111
env:
1212
FORCE_COLOR: "1"
13-
PYTHONDEVMODE: "1" # -X dev
14-
PYTHONWARNDEFAULTENCODING: "1" # -X warn_default_encoding
13+
PYTHONDEVMODE: "1" # -X dev
14+
PYTHONWARNDEFAULTENCODING: "1" # -X warn_default_encoding
1515

1616
jobs:
1717
test:
@@ -24,8 +24,6 @@ jobs:
2424
- "3.12"
2525
- "3.13"
2626
runs-on: ubuntu-latest
27-
permissions:
28-
id-token: write # unit tests use the ambient OIDC credential
2927
steps:
3028
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
3129
with:
@@ -40,6 +38,10 @@ jobs:
4038

4139
- name: test
4240
run: make test INSTALL_EXTRA=test
41+
env:
42+
# Use the pubic OIDC beacon for online tests, rather than relying
43+
# on the workflow's own ID token.
44+
EXTREMELY_DANGEROUS_PUBLIC_OIDC_BEACON: 1
4345

4446
test-offline:
4547
runs-on: ubuntu-latest

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,18 @@ from pathlib import Path
3434

3535
from pypi_attestations import Attestation, Distribution
3636
from sigstore.oidc import Issuer
37+
from sigstore.models import ClientTrustConfig
3738
from sigstore.sign import SigningContext
3839
from sigstore.verify import Verifier, policy
3940

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

4243
# Sign a Python artifact
43-
issuer = Issuer.production()
44-
identity_token = issuer.identity_token()
45-
signing_ctx = SigningContext.production()
46-
with signing_ctx.signer(identity_token, cache=True) as signer:
44+
trust_config = ClientTrustConfig.production()
45+
issuer: Issuer = Issuer(trust_config.signing_config.get_oidc_url())
46+
signing_ctx = SigningContext.from_trust_config(trust_config)
47+
48+
with signing_ctx.signer(issuer.identity_token(), cache=True) as signer:
4749
attestation = Attestation.sign(signer, dist)
4850

4951
print(attestation.model_dump_json())

pyproject.toml

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,16 @@ readme = "README.md"
1010
license = "Apache-2.0"
1111
license-files = ["LICENSE"]
1212
authors = [{ name = "Trail of Bits", email = "opensource@trailofbits.com" }]
13-
classifiers = [
14-
"Programming Language :: Python :: 3",
15-
]
13+
classifiers = ["Programming Language :: Python :: 3"]
1614
dependencies = [
1715
"cryptography",
1816
"packaging",
1917
"pyasn1 ~= 0.6",
2018
"pydantic >= 2.10.0",
2119
"requests",
2220
"rfc3986",
23-
"sigstore >= 3.5.3, < 3.7",
24-
"sigstore-protobuf-specs",
21+
"sigstore >= 4.0, < 5.0",
22+
"sigstore-models",
2523
]
2624
requires-python = ">=3.9"
2725

@@ -108,10 +106,6 @@ pyupgrade.keep-runtime-typing = true
108106
[tool.interrogate]
109107
# don't enforce documentation coverage for packaging, testing, the virtual
110108
# environment, or the CLI (which is documented separately).
111-
exclude = [
112-
"env",
113-
"test",
114-
"src/pypi_attestations/__main__.py",
115-
]
109+
exclude = ["env", "test", "src/pypi_attestations/__main__.py"]
116110
ignore-semiprivate = true
117111
fail-under = 100

src/pypi_attestations/_cli.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
)
2222
from pydantic import ValidationError
2323
from rfc3986 import exceptions, uri_reference, validators
24-
from sigstore.models import Bundle, InvalidBundle
24+
from sigstore.models import Bundle, ClientTrustConfig, InvalidBundle
2525
from sigstore.oidc import IdentityError, IdentityToken, Issuer
2626
from sigstore.sign import SigningContext
2727
from sigstore.verify import policy
@@ -254,8 +254,11 @@ def get_identity_token(args: argparse.Namespace) -> IdentityToken:
254254
if oidc_token is not None:
255255
return IdentityToken(oidc_token)
256256

257-
# Fallback to interactive OAuth-2 Flow
258-
issuer: Issuer = Issuer.staging() if args.staging else Issuer.production()
257+
if args.staging:
258+
trust_config = ClientTrustConfig.staging()
259+
else:
260+
trust_config = ClientTrustConfig.production()
261+
issuer: Issuer = Issuer(trust_config.signing_config.get_oidc_url())
259262
return issuer.identity_token()
260263

261264

@@ -424,7 +427,11 @@ def _sign(args: argparse.Namespace) -> None:
424427
except IdentityError as identity_error:
425428
_die(f"Failed to detect identity: {identity_error}")
426429

427-
signing_ctx = SigningContext.staging() if args.staging else SigningContext.production()
430+
trust_config = ClientTrustConfig.staging() if args.staging else ClientTrustConfig.production()
431+
# Make sure we use rekor v1 until attestations are compatible with v2
432+
trust_config.force_tlog_version = 1
433+
434+
signing_ctx = SigningContext.from_trust_config(trust_config)
428435

429436
# Validates that every file we want to sign exist but none of their attestations
430437
_validate_files(args.files, should_exist=True)

src/pypi_attestations/_impl.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,14 @@
2727
from sigstore._utils import _sha256_streaming
2828
from sigstore.dsse import DigestSet, StatementBuilder, Subject, _Statement
2929
from sigstore.dsse import Envelope as DsseEnvelope
30-
from sigstore.dsse import Error as DsseError
31-
from sigstore.models import Bundle, LogEntry
30+
from sigstore.errors import Error as SigstoreError
31+
from sigstore.models import Bundle
32+
from sigstore.models import TransparencyLogEntry as _TransparencyLogEntry
3233
from sigstore.sign import ExpiredCertificate, ExpiredIdentity
3334
from sigstore.verify import Verifier, policy
34-
from sigstore_protobuf_specs.io.intoto import Envelope as _Envelope
35-
from sigstore_protobuf_specs.io.intoto import Signature as _Signature
35+
from sigstore_models.intoto import Envelope as _Envelope
36+
from sigstore_models.intoto import Signature as _Signature
37+
from sigstore_models.rekor.v1 import TransparencyLogEntry as _TransparencyLogEntryInner
3638

3739
if TYPE_CHECKING: # pragma: no cover
3840
from pathlib import Path
@@ -198,7 +200,7 @@ def sign(cls, signer: Signer, dist: Distribution) -> Attestation:
198200
.predicate_type(AttestationType.PYPI_PUBLISH_V1)
199201
.build()
200202
)
201-
except DsseError as e:
203+
except SigstoreError as e:
202204
raise AttestationError(str(e))
203205

204206
try:
@@ -327,9 +329,9 @@ def to_bundle(self) -> Bundle:
327329

328330
evp = DsseEnvelope(
329331
_Envelope(
330-
payload=statement,
332+
payload=base64.b64encode(statement),
331333
payload_type=DsseEnvelope._TYPE, # noqa: SLF001
332-
signatures=[_Signature(sig=signature)],
334+
signatures=[_Signature(sig=base64.b64encode(signature))],
333335
)
334336
)
335337

@@ -340,7 +342,8 @@ def to_bundle(self) -> Bundle:
340342
raise ConversionError("invalid X.509 certificate") from err
341343

342344
try:
343-
log_entry = LogEntry._from_dict_rekor(tlog_entry) # noqa: SLF001
345+
inner = _TransparencyLogEntryInner.from_dict(tlog_entry)
346+
log_entry = _TransparencyLogEntry(inner)
344347
except (ValidationError, sigstore.errors.Error) as err:
345348
raise ConversionError("invalid transparency log entry") from err
346349

@@ -359,6 +362,9 @@ def from_bundle(cls, sigstore_bundle: Bundle) -> Attestation:
359362

360363
envelope = sigstore_bundle._inner.dsse_envelope # noqa: SLF001
361364

365+
if not envelope:
366+
raise ConversionError("bundle does not contain a DSSE envelope")
367+
362368
if len(envelope.signatures) != 1:
363369
raise ConversionError(f"expected exactly one signature, got {len(envelope.signatures)}")
364370

@@ -367,7 +373,7 @@ def from_bundle(cls, sigstore_bundle: Bundle) -> Attestation:
367373
verification_material=VerificationMaterial(
368374
certificate=base64.b64encode(certificate),
369375
transparency_entries=[
370-
sigstore_bundle.log_entry._to_rekor().to_dict() # noqa: SLF001
376+
sigstore_bundle.log_entry._inner.to_dict() # noqa: SLF001
371377
],
372378
),
373379
envelope=Envelope(

test/conftest.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,20 @@
66

77
@pytest.fixture(scope="session")
88
def id_token() -> oidc.IdentityToken:
9+
if "EXTREMELY_DANGEROUS_PUBLIC_OIDC_BEACON" in os.environ:
10+
import requests
11+
12+
resp = requests.get(
13+
"https://raw.githubusercontent.com/sigstore-conformance/extremely-dangerous-public-oidc-beacon/refs/heads/current-token/oidc-token.txt"
14+
)
15+
resp.raise_for_status()
16+
id_token = resp.text.strip()
17+
return oidc.IdentityToken(id_token)
18+
919
if "CI" in os.environ:
1020
token = oidc.detect_credential()
1121
if token is None:
1222
pytest.fail("misconfigured CI: no ambient OIDC credential")
1323
return oidc.IdentityToken(token)
14-
else:
15-
return oidc.Issuer.staging().identity_token()
24+
25+
pytest.fail("no OIDC token available for tests")

test/test_cli.py

Lines changed: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import requests
1313
import sigstore.oidc
1414
from pretend import raiser, stub
15-
from sigstore.oidc import IdentityError
15+
from sigstore.oidc import IdentityError, IdentityToken
1616

1717
import pypi_attestations._cli
1818
from pypi_attestations._cli import (
@@ -24,7 +24,7 @@
2424
from pypi_attestations._impl import Attestation, AttestationError, ConversionError, Distribution
2525

2626
ONLINE_TESTS = (
27-
"CI" in os.environ or "TEST_INTERACTIVE" in os.environ
27+
"CI" in os.environ or "EXTREMELY_DANGEROUS_PUBLIC_OIDC_BEACON" in os.environ
2828
) and "TEST_OFFLINE" not in os.environ
2929

3030
online = pytest.mark.skipif(not ONLINE_TESTS, reason="online tests not enabled")
@@ -75,7 +75,9 @@ def default_sign(_: argparse.Namespace) -> None:
7575

7676

7777
@online
78-
def test_get_identity_token(monkeypatch: pytest.MonkeyPatch) -> None:
78+
def test_get_identity_token(id_token: IdentityToken, monkeypatch: pytest.MonkeyPatch) -> None:
79+
monkeypatch.setattr(sigstore.oidc, "detect_credential", lambda: id_token._raw_token)
80+
7981
# Happy paths
8082
identity_token = get_identity_token(argparse.Namespace(staging=True))
8183
assert identity_token.in_validity_period()
@@ -92,7 +94,11 @@ def return_invalid_token() -> str:
9294

9395

9496
@online
95-
def test_sign_command(tmp_path: Path) -> None:
97+
def test_sign_command(
98+
id_token: IdentityToken, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
99+
) -> None:
100+
monkeypatch.setattr(sigstore.oidc, "detect_credential", lambda: id_token._raw_token)
101+
96102
# Happy path
97103
copied_artifact = tmp_path / artifact_path.name
98104
shutil.copy(artifact_path, copied_artifact)
@@ -112,7 +118,11 @@ def test_sign_command(tmp_path: Path) -> None:
112118

113119

114120
@online
115-
def test_sign_missing_file(caplog: pytest.LogCaptureFixture) -> None:
121+
def test_sign_missing_file(
122+
id_token: IdentityToken, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
123+
) -> None:
124+
monkeypatch.setattr(sigstore.oidc, "detect_credential", lambda: id_token._raw_token)
125+
116126
# Missing file
117127
with pytest.raises(SystemExit):
118128
run_main_with_command(
@@ -127,7 +137,14 @@ def test_sign_missing_file(caplog: pytest.LogCaptureFixture) -> None:
127137

128138

129139
@online
130-
def test_sign_signature_already_exists(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
140+
def test_sign_signature_already_exists(
141+
id_token: IdentityToken,
142+
tmp_path: Path,
143+
caplog: pytest.LogCaptureFixture,
144+
monkeypatch: pytest.MonkeyPatch,
145+
) -> None:
146+
monkeypatch.setattr(sigstore.oidc, "detect_credential", lambda: id_token._raw_token)
147+
131148
artifact = tmp_path / artifact_path.with_suffix(".copy2.whl").name
132149
artifact.touch(exist_ok=False)
133150

@@ -168,7 +185,14 @@ def return_invalid_token() -> str:
168185

169186

170187
@online
171-
def test_sign_invalid_artifact(caplog: pytest.LogCaptureFixture, tmp_path: Path) -> None:
188+
def test_sign_invalid_artifact(
189+
id_token: IdentityToken,
190+
caplog: pytest.LogCaptureFixture,
191+
tmp_path: Path,
192+
monkeypatch: pytest.MonkeyPatch,
193+
) -> None:
194+
monkeypatch.setattr(sigstore.oidc, "detect_credential", lambda: id_token._raw_token)
195+
172196
artifact = tmp_path / "pkg-1.0.0.exe"
173197
artifact.touch(exist_ok=False)
174198

@@ -180,8 +204,12 @@ def test_sign_invalid_artifact(caplog: pytest.LogCaptureFixture, tmp_path: Path)
180204

181205
@online
182206
def test_sign_fail_to_sign(
183-
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, tmp_path: Path
207+
id_token: IdentityToken,
208+
monkeypatch: pytest.MonkeyPatch,
209+
caplog: pytest.LogCaptureFixture,
210+
tmp_path: Path,
184211
) -> None:
212+
monkeypatch.setattr(sigstore.oidc, "detect_credential", lambda: id_token._raw_token)
185213
monkeypatch.setattr(pypi_attestations._cli, "Attestation", stub(sign=raiser(AttestationError)))
186214
copied_artifact = tmp_path / artifact_path.name
187215
shutil.copy(artifact_path, copied_artifact)
@@ -245,10 +273,7 @@ def test_verify_attestation_command(caplog: pytest.LogCaptureFixture) -> None:
245273
artifact_path.as_posix(),
246274
]
247275
)
248-
assert (
249-
"Verification failed: failed to build chain: unable to get local issuer certificate"
250-
in caplog.text
251-
)
276+
assert "Verification failed: failed to build timestamp certificate chain" in caplog.text
252277
assert "OK:" not in caplog.text
253278

254279

@@ -329,19 +354,23 @@ def test_verify_attestation_invalid_artifact(
329354
assert "Invalid Python package distribution" in caplog.text
330355

331356

332-
def test_get_identity_token_oauth_flow(monkeypatch: pytest.MonkeyPatch) -> None:
357+
@online
358+
@pytest.mark.parametrize("staging", [True, False])
359+
def test_get_identity_token_oauth_flow(staging: bool, monkeypatch: pytest.MonkeyPatch) -> None:
333360
# If no ambient credential is available, default to the OAuth2 flow
334361
monkeypatch.setattr(sigstore.oidc, "detect_credential", lambda: None)
335362
identity_token = stub()
336363

337364
class MockIssuer:
338-
@staticmethod
339-
def staging() -> stub:
340-
return stub(identity_token=lambda: identity_token)
365+
def __init__(self, *args: object, **kwargs: object) -> None:
366+
pass
367+
368+
def identity_token(self) -> sigstore.oidc.IdentityToken:
369+
return identity_token # type: ignore
341370

342371
monkeypatch.setattr(pypi_attestations._cli, "Issuer", MockIssuer)
343372

344-
assert pypi_attestations._cli.get_identity_token(stub(staging=True)) == identity_token
373+
assert pypi_attestations._cli.get_identity_token(stub(staging=staging)) == identity_token
345374

346375

347376
def test_validate_files(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
@@ -463,10 +492,7 @@ def test_verify_pypi_command_env_fail(caplog: pytest.LogCaptureFixture) -> None:
463492
pypi_wheel_url,
464493
]
465494
)
466-
assert (
467-
"Verification failed: failed to build chain: unable to get local issuer certificate"
468-
in caplog.text
469-
)
495+
assert "Verification failed: failed to build timestamp certificate chain" in caplog.text
470496
assert "OK:" not in caplog.text
471497

472498

0 commit comments

Comments
 (0)