Skip to content

Commit 96e7492

Browse files
authored
Switch to in-toto statements (#18)
* WIP: switch to in-toto statements Signed-off-by: William Woodruff <[email protected]> * begin rewriting tests Signed-off-by: William Woodruff <[email protected]> * test_impl: delete-o-rama Signed-off-by: William Woodruff <[email protected]> * docstring Signed-off-by: William Woodruff <[email protected]> * simplify errors Signed-off-by: William Woodruff <[email protected]> * test_impl: more coverage Signed-off-by: William Woodruff <[email protected]> * test_impl: more cov Signed-off-by: William Woodruff <[email protected]> * more cov Signed-off-by: William Woodruff <[email protected]> * add TODO Signed-off-by: William Woodruff <[email protected]> * ultranormalized dist filenames Signed-off-by: William Woodruff <[email protected]> * lintage Signed-off-by: William Woodruff <[email protected]> * test_impl: ensure dupe tag sets are handled Signed-off-by: William Woodruff <[email protected]> * src, test: improve name handling Signed-off-by: William Woodruff <[email protected]> * test: github attestation test Signed-off-by: William Woodruff <[email protected]> --------- Signed-off-by: William Woodruff <[email protected]>
1 parent 153c9a3 commit 96e7492

File tree

10 files changed

+673
-308
lines changed

10 files changed

+673
-308
lines changed

.github/workflows/tests.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ jobs:
1616
- "3.11"
1717
- "3.12"
1818
runs-on: ubuntu-latest
19+
permissions:
20+
id-token: write # unit tests use the ambient OIDC credential
1921
steps:
2022
- uses: actions/checkout@v4
2123

pyproject.toml

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,18 @@ dynamic = ["version"]
88
description = "A library to convert between Sigstore Bundles and PEP-740 Attestation objects"
99
readme = "README.md"
1010
license = { file = "LICENSE" }
11-
authors = [
12-
{ name = "Trail of Bits", email = "[email protected]" },
13-
]
11+
authors = [{ name = "Trail of Bits", email = "[email protected]" }]
1412
classifiers = [
1513
"Programming Language :: Python :: 3",
1614
"License :: OSI Approved :: Apache Software License",
1715
]
18-
dependencies = ["cryptography", "pydantic", "sigstore~=3.0.0"]
16+
dependencies = [
17+
"cryptography",
18+
"packaging",
19+
"pydantic",
20+
"sigstore~=3.0.0",
21+
"sigstore-protobuf-specs",
22+
]
1923
requires-python = ">=3.9"
2024

2125
[project.optional-dependencies]
@@ -34,7 +38,6 @@ lint = [
3438
dev = ["pypi-attestation-models[doc,test,lint]", "twine", "wheel", "build"]
3539

3640

37-
3841
[project.urls]
3942
Homepage = "https://pypi.org/project/pypi-attestation-models"
4043
Documentation = "https://trailofbits.github.io/pypi-attestation-models/"
@@ -72,7 +75,7 @@ line-length = 100
7275
target-version = "py39"
7376

7477
[tool.ruff.lint]
75-
select = ["ALL"]
78+
select = ["E", "F", "I", "W", "UP", "ANN", "D", "COM", "ISC", "TCH", "SLF"]
7679
# ANN101 and ANN102 are deprecated
7780
# D203 and D213 are incompatible with D211 and D212 respectively.
7881
# COM812 and ISC001 can cause conflicts when using ruff as a formatter.
@@ -82,8 +85,9 @@ ignore = ["ANN101", "ANN102", "D203", "D213", "COM812", "ISC001"]
8285
[tool.ruff.lint.per-file-ignores]
8386

8487
"test/**/*.py" = [
85-
"D", # no docstrings in tests
86-
"S101", # asserts are expected in tests
88+
"D", # no docstrings in tests
89+
"S101", # asserts are expected in tests
90+
"SLF001", # private APIs are expected in tests
8791
]
8892

8993
[tool.interrogate]

src/pypi_attestation_models/__init__.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44

55
from ._impl import (
66
Attestation,
7-
AttestationPayload,
7+
AttestationError,
88
ConversionError,
9-
InvalidAttestationError,
9+
Envelope,
1010
TransparencyLogEntry,
1111
VerificationError,
1212
VerificationMaterial,
@@ -16,9 +16,9 @@
1616

1717
__all__ = [
1818
"Attestation",
19-
"AttestationPayload",
19+
"AttestationError",
20+
"Envelope",
2021
"ConversionError",
21-
"InvalidAttestationError",
2222
"TransparencyLogEntry",
2323
"VerificationError",
2424
"VerificationMaterial",

src/pypi_attestation_models/_impl.py

Lines changed: 165 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,22 @@
55

66
from __future__ import annotations
77

8-
import binascii
9-
from base64 import b64decode, b64encode
8+
import base64
109
from typing import TYPE_CHECKING, Annotated, Any, Literal, NewType
1110

12-
import rfc8785
1311
import sigstore.errors
1412
from annotated_types import MinLen # noqa: TCH002
1513
from cryptography import x509
1614
from cryptography.hazmat.primitives import serialization
17-
from pydantic import BaseModel
15+
from packaging.utils import parse_sdist_filename, parse_wheel_filename
16+
from pydantic import Base64Bytes, BaseModel
1817
from pydantic_core import ValidationError
1918
from sigstore._utils import _sha256_streaming
19+
from sigstore.dsse import Envelope as DsseEnvelope
20+
from sigstore.dsse import _DigestSet, _Statement, _StatementBuilder, _Subject
2021
from sigstore.models import Bundle, LogEntry
22+
from sigstore_protobuf_specs.io.intoto import Envelope as _Envelope
23+
from sigstore_protobuf_specs.io.intoto import Signature as _Signature
2124

2225
if TYPE_CHECKING:
2326
from pathlib import Path # pragma: no cover
@@ -27,19 +30,15 @@
2730
from sigstore.verify.policy import VerificationPolicy # pragma: no cover
2831

2932

30-
class ConversionError(ValueError):
31-
"""The base error for all errors during conversion."""
32-
33+
class AttestationError(ValueError):
34+
"""Base error for all APIs."""
3335

34-
class InvalidAttestationError(ConversionError):
35-
"""The PyPI Attestation given as input is not valid."""
3636

37-
def __init__(self: InvalidAttestationError, msg: str) -> None:
38-
"""Initialize an `InvalidAttestationError`."""
39-
super().__init__(f"Could not convert input Attestation: {msg}")
37+
class ConversionError(AttestationError):
38+
"""The base error for all errors during conversion."""
4039

4140

42-
class VerificationError(ValueError):
41+
class VerificationError(AttestationError):
4342
"""The PyPI Attestation failed verification."""
4443

4544
def __init__(self: VerificationError, msg: str) -> None:
@@ -53,7 +52,7 @@ def __init__(self: VerificationError, msg: str) -> None:
5352
class VerificationMaterial(BaseModel):
5453
"""Cryptographic materials used to verify attestation objects."""
5554

56-
certificate: str
55+
certificate: Base64Bytes
5756
"""
5857
The signing certificate, as `base64(DER(cert))`.
5958
"""
@@ -78,62 +77,99 @@ class Attestation(BaseModel):
7877
Cryptographic materials used to verify `message_signature`.
7978
"""
8079

81-
message_signature: str
80+
envelope: Envelope
8281
"""
83-
The attestation's signature, as `base64(raw-sig)`, where `raw-sig`
84-
is the raw bytes of the signing operation over the attestation payload.
82+
The enveloped attestation statement and signature.
8583
"""
8684

85+
@classmethod
86+
def sign(cls, signer: Signer, dist: Path) -> Attestation:
87+
"""Create an envelope, with signature, from a distribution file."""
88+
with dist.open(mode="rb", buffering=0) as io:
89+
# Replace this with `hashlib.file_digest()` once
90+
# our minimum supported Python is >=3.11
91+
digest = _sha256_streaming(io).hex()
92+
93+
stmt = (
94+
_StatementBuilder()
95+
.subjects(
96+
[
97+
_Subject(
98+
name=_ultranormalize_dist_filename(dist.name),
99+
digest=_DigestSet(root={"sha256": digest}),
100+
)
101+
]
102+
)
103+
.predicate_type("https://docs.pypi.org/attestations/publish/v1")
104+
.build()
105+
)
106+
bundle = signer.sign_dsse(stmt)
107+
108+
return sigstore_to_pypi(bundle)
109+
87110
def verify(self, verifier: Verifier, policy: VerificationPolicy, dist: Path) -> None:
88111
"""Verify against an existing Python artifact.
89112
90-
On failure, raises:
91-
- `InvalidAttestationError` if the attestation could not be converted to
92-
a Sigstore Bundle.
93-
- `VerificationError` if the attestation could not be verified.
113+
On failure, raises an appropriate subclass of `AttestationError`.
94114
"""
95-
payload_to_verify = AttestationPayload.from_dist(dist)
115+
with dist.open(mode="rb", buffering=0) as io:
116+
# Replace this with `hashlib.file_digest()` once
117+
# our minimum supported Python is >=3.11
118+
expected_digest = _sha256_streaming(io).hex()
119+
96120
bundle = pypi_to_sigstore(self)
97121
try:
98-
verifier.verify_artifact(bytes(payload_to_verify), bundle, policy)
122+
type_, payload = verifier.verify_dsse(bundle, policy)
99123
except sigstore.errors.VerificationError as err:
100124
raise VerificationError(str(err)) from err
101125

126+
if type_ != DsseEnvelope._TYPE: # noqa: SLF001
127+
raise VerificationError(f"expected JSON envelope, got {type_}")
102128

103-
class AttestationPayload(BaseModel):
104-
"""Attestation Payload object as defined in PEP 740."""
129+
try:
130+
statement = _Statement.model_validate_json(payload)
131+
except ValidationError as e:
132+
raise VerificationError(f"invalid statement: {str(e)}")
105133

106-
distribution: str
107-
"""
108-
The file name of the Python package distribution.
109-
"""
134+
if len(statement.subjects) != 1:
135+
raise VerificationError("too many subjects in statement (must be exactly one)")
136+
subject = statement.subjects[0]
110137

111-
digest: str
112-
"""
113-
The SHA-256 digest of the distribution's contents, as a hexadecimal string.
114-
"""
138+
if not subject.name:
139+
raise VerificationError("invalid subject: missing name")
115140

116-
@classmethod
117-
def from_dist(cls, dist: Path) -> AttestationPayload:
118-
"""Create an `AttestationPayload` from a distribution file."""
119-
with dist.open(mode="rb", buffering=0) as io:
120-
# Replace this with `hashlib.file_digest()` once
121-
# our minimum supported Python is >=3.11
122-
digest = _sha256_streaming(io).hex()
141+
try:
142+
# We always ultranormalize when signing, but other signers may not.
143+
subject_name = _ultranormalize_dist_filename(subject.name)
144+
except ValueError as e:
145+
raise VerificationError(f"invalid subject: {str(e)}")
146+
147+
normalized = _ultranormalize_dist_filename(dist.name)
148+
if subject_name != normalized:
149+
raise VerificationError(
150+
f"subject does not match distribution name: {subject_name} != {normalized}"
151+
)
152+
153+
digest = subject.digest.root.get("sha256")
154+
if digest is None or digest != expected_digest:
155+
raise VerificationError("subject does not match distribution digest")
123156

124-
return AttestationPayload(
125-
distribution=dist.name,
126-
digest=digest,
127-
)
128157

129-
def sign(self, signer: Signer) -> Attestation:
130-
"""Create a PEP 740 attestation by signing this payload."""
131-
sigstore_bundle = signer.sign_artifact(bytes(self))
132-
return sigstore_to_pypi(sigstore_bundle)
158+
class Envelope(BaseModel):
159+
"""The attestation envelope, containing the attested-for payload and its signature."""
133160

134-
def __bytes__(self: AttestationPayload) -> bytes:
135-
"""Convert to bytes using a canonicalized JSON representation (from RFC8785)."""
136-
return rfc8785.dumps(self.model_dump())
161+
statement: Base64Bytes
162+
"""
163+
The attestation statement.
164+
165+
This is represented as opaque bytes on the wire (encoded as base64),
166+
but it MUST be an JSON in-toto v1 Statement.
167+
"""
168+
169+
signature: Base64Bytes
170+
"""
171+
A signature for the above statement, encoded as base64.
172+
"""
137173

138174

139175
def sigstore_to_pypi(sigstore_bundle: Bundle) -> Attestation:
@@ -142,38 +178,103 @@ def sigstore_to_pypi(sigstore_bundle: Bundle) -> Attestation:
142178
encoding=serialization.Encoding.DER
143179
)
144180

145-
signature = sigstore_bundle._inner.message_signature.signature # noqa: SLF001
181+
envelope = sigstore_bundle._inner.dsse_envelope # noqa: SLF001
182+
183+
if len(envelope.signatures) != 1:
184+
raise ConversionError(f"expected exactly one signature, got {len(envelope.signatures)}")
185+
146186
return Attestation(
147187
version=1,
148188
verification_material=VerificationMaterial(
149-
certificate=b64encode(certificate).decode("ascii"),
189+
certificate=base64.b64encode(certificate),
150190
transparency_entries=[TransparencyLogEntry(sigstore_bundle.log_entry._to_dict_rekor())], # noqa: SLF001
151191
),
152-
message_signature=b64encode(signature).decode("ascii"),
192+
envelope=Envelope(
193+
statement=base64.b64encode(envelope.payload),
194+
signature=base64.b64encode(envelope.signatures[0].sig),
195+
),
153196
)
154197

155198

156199
def pypi_to_sigstore(pypi_attestation: Attestation) -> Bundle:
157200
"""Convert a PyPI attestation object as defined in PEP 740 into a Sigstore Bundle."""
158-
try:
159-
certificate_bytes = b64decode(pypi_attestation.verification_material.certificate)
160-
signature_bytes = b64decode(pypi_attestation.message_signature)
161-
except binascii.Error as err:
162-
raise InvalidAttestationError(str(err)) from err
201+
cert_bytes = pypi_attestation.verification_material.certificate
202+
statement = pypi_attestation.envelope.statement
203+
signature = pypi_attestation.envelope.signature
204+
205+
evp = DsseEnvelope(
206+
_Envelope(
207+
payload=statement,
208+
payload_type=DsseEnvelope._TYPE, # noqa: SLF001
209+
signatures=[_Signature(sig=signature)],
210+
)
211+
)
163212

164213
tlog_entry = pypi_attestation.verification_material.transparency_entries[0]
165214
try:
166-
certificate = x509.load_der_x509_certificate(certificate_bytes)
215+
certificate = x509.load_der_x509_certificate(cert_bytes)
167216
except ValueError as err:
168-
raise InvalidAttestationError(str(err)) from err
217+
raise ConversionError("invalid X.509 certificate") from err
169218

170219
try:
171220
log_entry = LogEntry._from_dict_rekor(tlog_entry) # noqa: SLF001
172221
except (ValidationError, sigstore.errors.Error) as err:
173-
raise InvalidAttestationError(str(err)) from err
222+
raise ConversionError("invalid transparency log entry") from err
174223

175-
return Bundle.from_parts(
224+
return Bundle._from_parts( # noqa: SLF001
176225
cert=certificate,
177-
sig=signature_bytes,
226+
content=evp,
178227
log_entry=log_entry,
179228
)
229+
230+
231+
def _ultranormalize_dist_filename(dist: str) -> str:
232+
"""Return an "ultranormalized" form of the given distribution filename.
233+
234+
This form is equivalent to the normalized form for sdist and wheel
235+
filenames, with the additional stipulation that compressed tag sets,
236+
if present, are also sorted alphanumerically.
237+
238+
Raises `ValueError` on any invalid distribution filename.
239+
"""
240+
# NOTE: .whl and .tar.gz are assumed lowercase, since `packaging`
241+
# already rejects non-lowercase variants.
242+
if dist.endswith(".whl"):
243+
# `parse_wheel_filename` raises a supertype of ValueError on failure.
244+
name, ver, build, tags = parse_wheel_filename(dist)
245+
246+
# The name has been normalized to replace runs of `[.-_]+` with `-`,
247+
# which then needs to be replaced with `_` for the wheel.
248+
name = name.replace("-", "_")
249+
250+
# `parse_wheel_filename` normalizes the name and version for us,
251+
# so all we need to do is re-compress the tag set in a canonical
252+
# order.
253+
# NOTE(ww): This is written in a not very efficient manner, since
254+
# I wasn't feeling smart.
255+
impls, abis, platforms = set(), set(), set()
256+
for tag in tags:
257+
impls.add(tag.interpreter)
258+
abis.add(tag.abi)
259+
platforms.add(tag.platform)
260+
261+
impl_tag = ".".join(sorted(impls))
262+
abi_tag = ".".join(sorted(abis))
263+
platform_tag = ".".join(sorted(platforms))
264+
265+
if build:
266+
parts = "-".join(
267+
[name, str(ver), f"{build[0]}{build[1]}", impl_tag, abi_tag, platform_tag]
268+
)
269+
else:
270+
parts = "-".join([name, str(ver), impl_tag, abi_tag, platform_tag])
271+
272+
return f"{parts}.whl"
273+
274+
elif dist.endswith(".tar.gz"):
275+
# `parse_sdist_filename` raises a supertype of ValueError on failure.
276+
name, ver = parse_sdist_filename(dist)
277+
name = name.replace("-", "_")
278+
return f"{name}-{ver}.tar.gz"
279+
else:
280+
raise ValueError(f"unknown distribution format: {dist}")
8.18 KB
Binary file not shown.

0 commit comments

Comments
 (0)