Skip to content

Commit a2e48d3

Browse files
authored
Move free functions to Attestation (#24)
Modified functions: - sigstore_to_pypi : from_bundle - pypi_to_sigstore : to_bundle
1 parent 5d3fc53 commit a2e48d3

File tree

4 files changed

+94
-84
lines changed

4 files changed

+94
-84
lines changed

README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ Use these APIs to create a PEP 740-compliant `Attestation` object by signing a P
7676
```python
7777
from pathlib import Path
7878
79-
from pypi_attestation_models import Attestation, AttestationPayload
79+
from pypi_attestation_models import Attestation
8080
from sigstore.oidc import Issuer
8181
from sigstore.sign import SigningContext
8282
from sigstore.verify import Verifier, policy
@@ -88,7 +88,7 @@ issuer = Issuer.production()
8888
identity_token = issuer.identity_token()
8989
signing_ctx = SigningContext.production()
9090
with signing_ctx.signer(identity_token, cache=True) as signer:
91-
attestation = AttestationPayload.from_dist(artifact_path).sign(signer)
91+
attestation = Attestation.sign(signer, artifact_path)
9292
9393
print(attestation.model_dump_json())
9494
@@ -102,25 +102,25 @@ attestation.verify(verifier, policy, attestation_path)
102102
103103
### Low-level model conversions
104104
These conversions assume that any Sigstore Bundle used as an input was created
105-
by signing an `AttestationPayload` object.
105+
by signing a distribution file.
106+
106107
```python
107108
from pathlib import Path
108-
from pypi_attestation_models import pypi_to_sigstore, sigstore_to_pypi, Attestation
109+
from pypi_attestation_models import Attestation
109110
from sigstore.models import Bundle
110111
111112
# Sigstore Bundle -> PEP 740 Attestation object
112113
bundle_path = Path("test_package-0.0.1-py3-none-any.whl.sigstore")
113114
with bundle_path.open("rb") as f:
114115
sigstore_bundle = Bundle.from_json(f.read())
115-
attestation_object = sigstore_to_pypi(sigstore_bundle)
116+
attestation_object = Attestation.from_bundle(sigstore_bundle)
116117
print(attestation_object.model_dump_json())
117118
118-
119119
# PEP 740 Attestation object -> Sigstore Bundle
120120
attestation_path = Path("attestation.json")
121121
with attestation_path.open("rb") as f:
122122
attestation = Attestation.model_validate_json(f.read())
123-
bundle = pypi_to_sigstore(attestation)
123+
bundle = attestation.to_bundle()
124124
print(bundle.to_json())
125125
```
126126

src/pypi_attestation_models/__init__.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@
1010
TransparencyLogEntry,
1111
VerificationError,
1212
VerificationMaterial,
13-
pypi_to_sigstore,
14-
sigstore_to_pypi,
1513
)
1614

1715
__all__ = [
@@ -22,6 +20,4 @@
2220
"TransparencyLogEntry",
2321
"VerificationError",
2422
"VerificationMaterial",
25-
"pypi_to_sigstore",
26-
"sigstore_to_pypi",
2723
]

src/pypi_attestation_models/_impl.py

Lines changed: 59 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ def sign(cls, signer: Signer, dist: Path) -> Attestation:
113113
)
114114
bundle = signer.sign_dsse(stmt)
115115

116-
return sigstore_to_pypi(bundle)
116+
return Attestation.from_bundle(bundle)
117117

118118
def verify(
119119
self, verifier: Verifier, policy: VerificationPolicy, dist: Path
@@ -129,7 +129,7 @@ def verify(
129129
# our minimum supported Python is >=3.11
130130
expected_digest = _sha256_streaming(io).hex()
131131

132-
bundle = pypi_to_sigstore(self)
132+
bundle = self.to_bundle()
133133
try:
134134
type_, payload = verifier.verify_dsse(bundle, policy)
135135
except sigstore.errors.VerificationError as err:
@@ -168,6 +168,63 @@ def verify(
168168

169169
return statement.predicate_type, statement.predicate
170170

171+
def to_bundle(self) -> Bundle:
172+
"""Convert a PyPI attestation object as defined in PEP 740 into a Sigstore Bundle."""
173+
cert_bytes = self.verification_material.certificate
174+
statement = self.envelope.statement
175+
signature = self.envelope.signature
176+
177+
evp = DsseEnvelope(
178+
_Envelope(
179+
payload=statement,
180+
payload_type=DsseEnvelope._TYPE, # noqa: SLF001
181+
signatures=[_Signature(sig=signature)],
182+
)
183+
)
184+
185+
tlog_entry = self.verification_material.transparency_entries[0]
186+
try:
187+
certificate = x509.load_der_x509_certificate(cert_bytes)
188+
except ValueError as err:
189+
raise ConversionError("invalid X.509 certificate") from err
190+
191+
try:
192+
log_entry = LogEntry._from_dict_rekor(tlog_entry) # noqa: SLF001
193+
except (ValidationError, sigstore.errors.Error) as err:
194+
raise ConversionError("invalid transparency log entry") from err
195+
196+
return Bundle._from_parts( # noqa: SLF001
197+
cert=certificate,
198+
content=evp,
199+
log_entry=log_entry,
200+
)
201+
202+
@classmethod
203+
def from_bundle(cls, sigstore_bundle: Bundle) -> Attestation:
204+
"""Convert a Sigstore Bundle into a PyPI attestation as defined in PEP 740."""
205+
certificate = sigstore_bundle.signing_certificate.public_bytes(
206+
encoding=serialization.Encoding.DER
207+
)
208+
209+
envelope = sigstore_bundle._inner.dsse_envelope # noqa: SLF001
210+
211+
if len(envelope.signatures) != 1:
212+
raise ConversionError(f"expected exactly one signature, got {len(envelope.signatures)}")
213+
214+
return cls(
215+
version=1,
216+
verification_material=VerificationMaterial(
217+
certificate=base64.b64encode(certificate),
218+
transparency_entries=[
219+
TransparencyLogEntry(sigstore_bundle.log_entry._to_dict_rekor()) # noqa: SLF001
220+
],
221+
),
222+
envelope=Envelope(
223+
statement=base64.b64encode(envelope.payload),
224+
signature=base64.b64encode(envelope.signatures[0].sig),
225+
),
226+
)
227+
171228

172229
class Envelope(BaseModel):
173230
"""The attestation envelope, containing the attested-for payload and its signature."""
@@ -186,62 +243,6 @@ class Envelope(BaseModel):
186243
"""
187244

188245

189-
def sigstore_to_pypi(sigstore_bundle: Bundle) -> Attestation:
190-
"""Convert a Sigstore Bundle into a PyPI attestation as defined in PEP 740."""
191-
certificate = sigstore_bundle.signing_certificate.public_bytes(
192-
encoding=serialization.Encoding.DER
193-
)
194-
195-
envelope = sigstore_bundle._inner.dsse_envelope # noqa: SLF001
196-
197-
if len(envelope.signatures) != 1:
198-
raise ConversionError(f"expected exactly one signature, got {len(envelope.signatures)}")
199-
200-
return Attestation(
201-
version=1,
202-
verification_material=VerificationMaterial(
203-
certificate=base64.b64encode(certificate),
204-
transparency_entries=[TransparencyLogEntry(sigstore_bundle.log_entry._to_dict_rekor())], # noqa: SLF001
205-
),
206-
envelope=Envelope(
207-
statement=base64.b64encode(envelope.payload),
208-
signature=base64.b64encode(envelope.signatures[0].sig),
209-
),
210-
)
211-
212-
213-
def pypi_to_sigstore(pypi_attestation: Attestation) -> Bundle:
214-
"""Convert a PyPI attestation object as defined in PEP 740 into a Sigstore Bundle."""
215-
cert_bytes = pypi_attestation.verification_material.certificate
216-
statement = pypi_attestation.envelope.statement
217-
signature = pypi_attestation.envelope.signature
218-
219-
evp = DsseEnvelope(
220-
_Envelope(
221-
payload=statement,
222-
payload_type=DsseEnvelope._TYPE, # noqa: SLF001
223-
signatures=[_Signature(sig=signature)],
224-
)
225-
)
226-
227-
tlog_entry = pypi_attestation.verification_material.transparency_entries[0]
228-
try:
229-
certificate = x509.load_der_x509_certificate(cert_bytes)
230-
except ValueError as err:
231-
raise ConversionError("invalid X.509 certificate") from err
232-
233-
try:
234-
log_entry = LogEntry._from_dict_rekor(tlog_entry) # noqa: SLF001
235-
except (ValidationError, sigstore.errors.Error) as err:
236-
raise ConversionError("invalid transparency log entry") from err
237-
238-
return Bundle._from_parts( # noqa: SLF001
239-
cert=certificate,
240-
content=evp,
241-
log_entry=log_entry,
242-
)
243-
244-
245246
def _ultranormalize_dist_filename(dist: str) -> str:
246247
"""Return an "ultranormalized" form of the given distribution filename.
247248

test/test_impl.py

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,11 @@ def test_roundtrip(self, id_token: IdentityToken) -> None:
4040
attestation.verify(verifier, policy.UnsafeNoOp(), artifact_path)
4141

4242
# converting to a bundle and verifying as a bundle also works
43-
bundle = impl.pypi_to_sigstore(attestation)
43+
bundle = attestation.to_bundle()
4444
verifier.verify_dsse(bundle, policy.UnsafeNoOp())
4545

4646
# converting back also works
47-
roundtripped_attestation = impl.sigstore_to_pypi(bundle)
47+
roundtripped_attestation = impl.Attestation.from_bundle(bundle)
4848
roundtripped_attestation.verify(verifier, policy.UnsafeNoOp(), artifact_path)
4949

5050
def test_sign_invalid_dist_filename(self, tmp_path: Path) -> None:
@@ -69,7 +69,7 @@ def test_verify_github_attested(self) -> None:
6969
)
7070

7171
bundle = Bundle.from_json(gh_signed_bundle_path.read_bytes())
72-
attestation = impl.sigstore_to_pypi(bundle)
72+
attestation = impl.Attestation.from_bundle(bundle)
7373

7474
predicate_type, predicate = attestation.verify(verifier, pol, gh_signed_artifact_path)
7575
assert predicate_type == "https://docs.pypi.org/attestations/publish/v1"
@@ -89,7 +89,7 @@ def test_verify(self) -> None:
8989
assert predicate is None
9090

9191
# convert the attestation to a bundle and verify it that way too
92-
bundle = impl.pypi_to_sigstore(attestation)
92+
bundle = attestation.to_bundle()
9393
verifier.verify_dsse(bundle, policy.UnsafeNoOp())
9494

9595
def test_verify_digest_mismatch(self, tmp_path: Path) -> None:
@@ -178,7 +178,10 @@ def test_verify_too_many_subjects(self) -> None:
178178

179179
verifier = pretend.stub(
180180
verify_dsse=pretend.call_recorder(
181-
lambda bundle, policy: ("application/vnd.in-toto+json", statement.encode())
181+
lambda bundle, policy: (
182+
"application/vnd.in-toto+json",
183+
statement.encode(),
184+
)
182185
)
183186
)
184187
pol = pretend.stub()
@@ -203,7 +206,10 @@ def test_verify_subject_missing_name(self) -> None:
203206

204207
verifier = pretend.stub(
205208
verify_dsse=pretend.call_recorder(
206-
lambda bundle, policy: ("application/vnd.in-toto+json", statement.encode())
209+
lambda bundle, policy: (
210+
"application/vnd.in-toto+json",
211+
statement.encode(),
212+
)
207213
)
208214
)
209215
pol = pretend.stub()
@@ -219,7 +225,8 @@ def test_verify_subject_invalid_name(self) -> None:
219225
.subjects(
220226
[
221227
_Subject(
222-
name="foo-bar-invalid-wheel.whl", digest=_DigestSet(root={"sha256": "abcd"})
228+
name="foo-bar-invalid-wheel.whl",
229+
digest=_DigestSet(root={"sha256": "abcd"}),
223230
),
224231
]
225232
)
@@ -230,7 +237,10 @@ def test_verify_subject_invalid_name(self) -> None:
230237

231238
verifier = pretend.stub(
232239
verify_dsse=pretend.call_recorder(
233-
lambda bundle, policy: ("application/vnd.in-toto+json", statement.encode())
240+
lambda bundle, policy: (
241+
"application/vnd.in-toto+json",
242+
statement.encode(),
243+
)
234244
)
235245
)
236246
pol = pretend.stub()
@@ -241,28 +251,28 @@ def test_verify_subject_invalid_name(self) -> None:
241251
attestation.verify(verifier, pol, artifact_path)
242252

243253

244-
def test_sigstore_to_pypi_missing_signatures() -> None:
254+
def test_from_bundle_missing_signatures() -> None:
245255
bundle = Bundle.from_json(bundle_path.read_bytes())
246256
bundle._inner.dsse_envelope.signatures = [] # noqa: SLF001
247257

248258
with pytest.raises(impl.ConversionError, match="expected exactly one signature, got 0"):
249-
impl.sigstore_to_pypi(bundle)
259+
impl.Attestation.from_bundle(bundle)
250260

251261

252-
def test_pypi_to_sigstore_invalid_cert() -> None:
262+
def test_to_bundle_invalid_cert() -> None:
253263
attestation = impl.Attestation.model_validate_json(attestation_path.read_bytes())
254264
attestation.verification_material.certificate = b"foo"
255265

256266
with pytest.raises(impl.ConversionError, match="invalid X.509 certificate"):
257-
impl.pypi_to_sigstore(attestation)
267+
attestation.to_bundle()
258268

259269

260-
def test_pypi_to_sigstore_invalid_tlog_entry() -> None:
270+
def test_to_bundle_invalid_tlog_entry() -> None:
261271
attestation = impl.Attestation.model_validate_json(attestation_path.read_bytes())
262272
attestation.verification_material.transparency_entries[0].clear()
263273

264274
with pytest.raises(impl.ConversionError, match="invalid transparency log entry"):
265-
impl.pypi_to_sigstore(attestation)
275+
attestation.to_bundle()
266276

267277

268278
class TestPackaging:
@@ -295,7 +305,10 @@ def test_exception_types(self) -> None:
295305
("foo-1.0-1whatever-py3-none-any.whl", "foo-1.0-1whatever-py3-none-any.whl"),
296306
# wheel: compressed tag sets are sorted, even when conflicting or nonsense
297307
("foo-1.0-py3.py2-none-any.whl", "foo-1.0-py2.py3-none-any.whl"),
298-
("foo-1.0-py3.py2-none.abi3.cp37-any.whl", "foo-1.0-py2.py3-abi3.cp37.none-any.whl"),
308+
(
309+
"foo-1.0-py3.py2-none.abi3.cp37-any.whl",
310+
"foo-1.0-py2.py3-abi3.cp37.none-any.whl",
311+
),
299312
(
300313
"foo-1.0-py3.py2-none.abi3.cp37-linux_x86_64.any.whl",
301314
"foo-1.0-py2.py3-abi3.cp37.none-any.linux_x86_64.whl",

0 commit comments

Comments
 (0)