pypi_attestations
The pypi-attestations
APIs.
1"""The `pypi-attestations` APIs.""" 2 3__version__ = "0.0.16" 4 5from ._impl import ( 6 Attestation, 7 AttestationBundle, 8 AttestationError, 9 AttestationType, 10 ConversionError, 11 Distribution, 12 Envelope, 13 GitHubPublisher, 14 GitLabPublisher, 15 Provenance, 16 Publisher, 17 TransparencyLogEntry, 18 VerificationError, 19 VerificationMaterial, 20) 21 22__all__ = [ 23 "Attestation", 24 "AttestationBundle", 25 "AttestationError", 26 "AttestationType", 27 "ConversionError", 28 "Distribution", 29 "Envelope", 30 "GitHubPublisher", 31 "GitLabPublisher", 32 "Provenance", 33 "Publisher", 34 "TransparencyLogEntry", 35 "VerificationError", 36 "VerificationMaterial", 37]
135class Attestation(BaseModel): 136 """Attestation object as defined in PEP 740.""" 137 138 version: Literal[1] 139 """ 140 The attestation format's version, which is always 1. 141 """ 142 143 verification_material: VerificationMaterial 144 """ 145 Cryptographic materials used to verify `message_signature`. 146 """ 147 148 envelope: Envelope 149 """ 150 The enveloped attestation statement and signature. 151 """ 152 153 @property 154 def statement(self) -> dict[str, Any]: 155 """Return the statement within this attestation's envelope. 156 157 The value returned here is a dictionary, in the shape of an 158 in-toto statement. 159 """ 160 return json.loads(self.envelope.statement) # type: ignore[no-any-return] 161 162 @classmethod 163 def sign(cls, signer: Signer, dist: Distribution) -> Attestation: 164 """Create an envelope, with signature, from the given Python distribution. 165 166 On failure, raises `AttestationError`. 167 """ 168 try: 169 stmt = ( 170 StatementBuilder() 171 .subjects( 172 [ 173 Subject( 174 name=dist.name, 175 digest=DigestSet(root={"sha256": dist.digest}), 176 ) 177 ] 178 ) 179 .predicate_type(AttestationType.PYPI_PUBLISH_V1) 180 .build() 181 ) 182 except DsseError as e: 183 raise AttestationError(str(e)) 184 185 try: 186 bundle = signer.sign_dsse(stmt) 187 except (ExpiredCertificate, ExpiredIdentity) as e: 188 raise AttestationError(str(e)) 189 190 try: 191 return Attestation.from_bundle(bundle) 192 except ConversionError as e: 193 raise AttestationError(str(e)) 194 195 def verify( 196 self, 197 identity: VerificationPolicy | Publisher, 198 dist: Distribution, 199 *, 200 staging: bool = False, 201 ) -> tuple[str, Optional[dict[str, Any]]]: 202 """Verify against an existing Python distribution. 203 204 The `identity` can be an object confirming to 205 `sigstore.policy.VerificationPolicy` or a `Publisher`, which will be 206 transformed into an appropriate verification policy. 207 208 By default, Sigstore's production verifier will be used. The 209 `staging` parameter can be toggled to enable the staging verifier 210 instead. 211 212 On failure, raises an appropriate subclass of `AttestationError`. 213 """ 214 # NOTE: Can't do `isinstance` with `Publisher` since it's 215 # a `_GenericAlias`; instead we punch through to the inner 216 # `_Publisher` union. 217 # Use of typing.get_args is needed for Python < 3.10 218 if isinstance(identity, get_args(_Publisher)): 219 policy = identity._as_policy() # noqa: SLF001 220 else: 221 policy = identity 222 223 if staging: 224 verifier = Verifier.staging() 225 else: 226 verifier = Verifier.production() 227 228 bundle = self.to_bundle() 229 try: 230 type_, payload = verifier.verify_dsse(bundle, policy) 231 except sigstore.errors.VerificationError as err: 232 raise VerificationError(str(err)) from err 233 234 if type_ != DsseEnvelope._TYPE: # noqa: SLF001 235 raise VerificationError(f"expected JSON envelope, got {type_}") 236 237 try: 238 statement = _Statement.model_validate_json(payload) 239 except ValidationError as e: 240 raise VerificationError(f"invalid statement: {str(e)}") 241 242 if len(statement.subjects) != 1: 243 raise VerificationError("too many subjects in statement (must be exactly one)") 244 subject = statement.subjects[0] 245 246 if not subject.name: 247 raise VerificationError("invalid subject: missing name") 248 249 try: 250 # We always ultranormalize when signing, but other signers may not. 251 subject_name = _ultranormalize_dist_filename(subject.name) 252 except ValueError as e: 253 raise VerificationError(f"invalid subject: {str(e)}") 254 255 if subject_name != dist.name: 256 raise VerificationError( 257 f"subject does not match distribution name: {subject_name} != {dist.name}" 258 ) 259 260 digest = subject.digest.root.get("sha256") 261 if digest is None or digest != dist.digest: 262 raise VerificationError("subject does not match distribution digest") 263 264 try: 265 AttestationType(statement.predicate_type) 266 except ValueError: 267 raise VerificationError(f"unknown attestation type: {statement.predicate_type}") 268 269 return statement.predicate_type, statement.predicate 270 271 def to_bundle(self) -> Bundle: 272 """Convert a PyPI attestation object as defined in PEP 740 into a Sigstore Bundle.""" 273 cert_bytes = self.verification_material.certificate 274 statement = self.envelope.statement 275 signature = self.envelope.signature 276 277 evp = DsseEnvelope( 278 _Envelope( 279 payload=statement, 280 payload_type=DsseEnvelope._TYPE, # noqa: SLF001 281 signatures=[_Signature(sig=signature)], 282 ) 283 ) 284 285 tlog_entry = self.verification_material.transparency_entries[0] 286 try: 287 certificate = x509.load_der_x509_certificate(cert_bytes) 288 except ValueError as err: 289 raise ConversionError("invalid X.509 certificate") from err 290 291 try: 292 log_entry = LogEntry._from_dict_rekor(tlog_entry) # noqa: SLF001 293 except (ValidationError, sigstore.errors.Error) as err: 294 raise ConversionError("invalid transparency log entry") from err 295 296 return Bundle._from_parts( # noqa: SLF001 297 cert=certificate, 298 content=evp, 299 log_entry=log_entry, 300 ) 301 302 @classmethod 303 def from_bundle(cls, sigstore_bundle: Bundle) -> Attestation: 304 """Convert a Sigstore Bundle into a PyPI attestation as defined in PEP 740.""" 305 certificate = sigstore_bundle.signing_certificate.public_bytes( 306 encoding=serialization.Encoding.DER 307 ) 308 309 envelope = sigstore_bundle._inner.dsse_envelope # noqa: SLF001 310 311 if len(envelope.signatures) != 1: 312 raise ConversionError(f"expected exactly one signature, got {len(envelope.signatures)}") 313 314 return cls( 315 version=1, 316 verification_material=VerificationMaterial( 317 certificate=base64.b64encode(certificate), 318 transparency_entries=[ 319 sigstore_bundle.log_entry._to_rekor().to_dict() # noqa: SLF001 320 ], 321 ), 322 envelope=Envelope( 323 statement=base64.b64encode(envelope.payload), 324 signature=base64.b64encode(envelope.signatures[0].sig), 325 ), 326 )
Attestation object as defined in PEP 740.
Cryptographic materials used to verify message_signature
.
153 @property 154 def statement(self) -> dict[str, Any]: 155 """Return the statement within this attestation's envelope. 156 157 The value returned here is a dictionary, in the shape of an 158 in-toto statement. 159 """ 160 return json.loads(self.envelope.statement) # type: ignore[no-any-return]
Return the statement within this attestation's envelope.
The value returned here is a dictionary, in the shape of an in-toto statement.
162 @classmethod 163 def sign(cls, signer: Signer, dist: Distribution) -> Attestation: 164 """Create an envelope, with signature, from the given Python distribution. 165 166 On failure, raises `AttestationError`. 167 """ 168 try: 169 stmt = ( 170 StatementBuilder() 171 .subjects( 172 [ 173 Subject( 174 name=dist.name, 175 digest=DigestSet(root={"sha256": dist.digest}), 176 ) 177 ] 178 ) 179 .predicate_type(AttestationType.PYPI_PUBLISH_V1) 180 .build() 181 ) 182 except DsseError as e: 183 raise AttestationError(str(e)) 184 185 try: 186 bundle = signer.sign_dsse(stmt) 187 except (ExpiredCertificate, ExpiredIdentity) as e: 188 raise AttestationError(str(e)) 189 190 try: 191 return Attestation.from_bundle(bundle) 192 except ConversionError as e: 193 raise AttestationError(str(e))
Create an envelope, with signature, from the given Python distribution.
On failure, raises AttestationError
.
195 def verify( 196 self, 197 identity: VerificationPolicy | Publisher, 198 dist: Distribution, 199 *, 200 staging: bool = False, 201 ) -> tuple[str, Optional[dict[str, Any]]]: 202 """Verify against an existing Python distribution. 203 204 The `identity` can be an object confirming to 205 `sigstore.policy.VerificationPolicy` or a `Publisher`, which will be 206 transformed into an appropriate verification policy. 207 208 By default, Sigstore's production verifier will be used. The 209 `staging` parameter can be toggled to enable the staging verifier 210 instead. 211 212 On failure, raises an appropriate subclass of `AttestationError`. 213 """ 214 # NOTE: Can't do `isinstance` with `Publisher` since it's 215 # a `_GenericAlias`; instead we punch through to the inner 216 # `_Publisher` union. 217 # Use of typing.get_args is needed for Python < 3.10 218 if isinstance(identity, get_args(_Publisher)): 219 policy = identity._as_policy() # noqa: SLF001 220 else: 221 policy = identity 222 223 if staging: 224 verifier = Verifier.staging() 225 else: 226 verifier = Verifier.production() 227 228 bundle = self.to_bundle() 229 try: 230 type_, payload = verifier.verify_dsse(bundle, policy) 231 except sigstore.errors.VerificationError as err: 232 raise VerificationError(str(err)) from err 233 234 if type_ != DsseEnvelope._TYPE: # noqa: SLF001 235 raise VerificationError(f"expected JSON envelope, got {type_}") 236 237 try: 238 statement = _Statement.model_validate_json(payload) 239 except ValidationError as e: 240 raise VerificationError(f"invalid statement: {str(e)}") 241 242 if len(statement.subjects) != 1: 243 raise VerificationError("too many subjects in statement (must be exactly one)") 244 subject = statement.subjects[0] 245 246 if not subject.name: 247 raise VerificationError("invalid subject: missing name") 248 249 try: 250 # We always ultranormalize when signing, but other signers may not. 251 subject_name = _ultranormalize_dist_filename(subject.name) 252 except ValueError as e: 253 raise VerificationError(f"invalid subject: {str(e)}") 254 255 if subject_name != dist.name: 256 raise VerificationError( 257 f"subject does not match distribution name: {subject_name} != {dist.name}" 258 ) 259 260 digest = subject.digest.root.get("sha256") 261 if digest is None or digest != dist.digest: 262 raise VerificationError("subject does not match distribution digest") 263 264 try: 265 AttestationType(statement.predicate_type) 266 except ValueError: 267 raise VerificationError(f"unknown attestation type: {statement.predicate_type}") 268 269 return statement.predicate_type, statement.predicate
Verify against an existing Python distribution.
The identity
can be an object confirming to
sigstore.policy.VerificationPolicy
or a Publisher
, which will be
transformed into an appropriate verification policy.
By default, Sigstore's production verifier will be used. The
staging
parameter can be toggled to enable the staging verifier
instead.
On failure, raises an appropriate subclass of AttestationError
.
271 def to_bundle(self) -> Bundle: 272 """Convert a PyPI attestation object as defined in PEP 740 into a Sigstore Bundle.""" 273 cert_bytes = self.verification_material.certificate 274 statement = self.envelope.statement 275 signature = self.envelope.signature 276 277 evp = DsseEnvelope( 278 _Envelope( 279 payload=statement, 280 payload_type=DsseEnvelope._TYPE, # noqa: SLF001 281 signatures=[_Signature(sig=signature)], 282 ) 283 ) 284 285 tlog_entry = self.verification_material.transparency_entries[0] 286 try: 287 certificate = x509.load_der_x509_certificate(cert_bytes) 288 except ValueError as err: 289 raise ConversionError("invalid X.509 certificate") from err 290 291 try: 292 log_entry = LogEntry._from_dict_rekor(tlog_entry) # noqa: SLF001 293 except (ValidationError, sigstore.errors.Error) as err: 294 raise ConversionError("invalid transparency log entry") from err 295 296 return Bundle._from_parts( # noqa: SLF001 297 cert=certificate, 298 content=evp, 299 log_entry=log_entry, 300 )
Convert a PyPI attestation object as defined in PEP 740 into a Sigstore Bundle.
302 @classmethod 303 def from_bundle(cls, sigstore_bundle: Bundle) -> Attestation: 304 """Convert a Sigstore Bundle into a PyPI attestation as defined in PEP 740.""" 305 certificate = sigstore_bundle.signing_certificate.public_bytes( 306 encoding=serialization.Encoding.DER 307 ) 308 309 envelope = sigstore_bundle._inner.dsse_envelope # noqa: SLF001 310 311 if len(envelope.signatures) != 1: 312 raise ConversionError(f"expected exactly one signature, got {len(envelope.signatures)}") 313 314 return cls( 315 version=1, 316 verification_material=VerificationMaterial( 317 certificate=base64.b64encode(certificate), 318 transparency_entries=[ 319 sigstore_bundle.log_entry._to_rekor().to_dict() # noqa: SLF001 320 ], 321 ), 322 envelope=Envelope( 323 statement=base64.b64encode(envelope.payload), 324 signature=base64.b64encode(envelope.signatures[0].sig), 325 ), 326 )
Convert a Sigstore Bundle into a PyPI attestation as defined in PEP 740.
Configuration for the model, should be a dictionary conforming to [ConfigDict
][pydantic.config.ConfigDict].
Metadata about the fields defined on the model,
mapping of field names to [FieldInfo
][pydantic.fields.FieldInfo] objects.
This replaces Model.__fields__
from Pydantic V1.
553class AttestationBundle(BaseModel): 554 """AttestationBundle object as defined in PEP 740.""" 555 556 publisher: Publisher 557 """ 558 The publisher associated with this set of attestations. 559 """ 560 561 attestations: list[Attestation] 562 """ 563 The list of attestations included in this bundle. 564 """
AttestationBundle object as defined in PEP 740.
The publisher associated with this set of attestations.
Configuration for the model, should be a dictionary conforming to [ConfigDict
][pydantic.config.ConfigDict].
Metadata about the fields defined on the model,
mapping of field names to [FieldInfo
][pydantic.fields.FieldInfo] objects.
This replaces Model.__fields__
from Pydantic V1.
Base error for all APIs.
94class AttestationType(str, Enum): 95 """Attestation types known to PyPI.""" 96 97 SLSA_PROVENANCE_V1 = "https://slsa.dev/provenance/v1" 98 PYPI_PUBLISH_V1 = "https://docs.pypi.org/attestations/publish/v1"
Attestation types known to PyPI.
105class ConversionError(AttestationError): 106 """The base error for all errors during conversion."""
The base error for all errors during conversion.
66class Distribution(BaseModel): 67 """Represents a Python package distribution. 68 69 A distribution is identified by its (sdist or wheel) filename, which 70 provides the package name and version (at a minimum) plus a SHA-256 71 digest, which uniquely identifies its contents. 72 """ 73 74 name: str 75 digest: str 76 77 @field_validator("name") 78 @classmethod 79 def _validate_name(cls, v: str) -> str: 80 return _ultranormalize_dist_filename(v) 81 82 @classmethod 83 def from_file(cls, dist: Path) -> Distribution: 84 """Construct a `Distribution` from the given path.""" 85 name = dist.name 86 with dist.open(mode="rb", buffering=0) as io: 87 # Replace this with `hashlib.file_digest()` once 88 # our minimum supported Python is >=3.11 89 digest = _sha256_streaming(io).hex() 90 91 return cls(name=name, digest=digest)
Represents a Python package distribution.
A distribution is identified by its (sdist or wheel) filename, which provides the package name and version (at a minimum) plus a SHA-256 digest, which uniquely identifies its contents.
82 @classmethod 83 def from_file(cls, dist: Path) -> Distribution: 84 """Construct a `Distribution` from the given path.""" 85 name = dist.name 86 with dist.open(mode="rb", buffering=0) as io: 87 # Replace this with `hashlib.file_digest()` once 88 # our minimum supported Python is >=3.11 89 digest = _sha256_streaming(io).hex() 90 91 return cls(name=name, digest=digest)
Construct a Distribution
from the given path.
Configuration for the model, should be a dictionary conforming to [ConfigDict
][pydantic.config.ConfigDict].
Metadata about the fields defined on the model,
mapping of field names to [FieldInfo
][pydantic.fields.FieldInfo] objects.
This replaces Model.__fields__
from Pydantic V1.
329class Envelope(BaseModel): 330 """The attestation envelope, containing the attested-for payload and its signature.""" 331 332 statement: Base64Bytes 333 """ 334 The attestation statement. 335 336 This is represented as opaque bytes on the wire (encoded as base64), 337 but it MUST be an JSON in-toto v1 Statement. 338 """ 339 340 signature: Base64Bytes 341 """ 342 A signature for the above statement, encoded as base64. 343 """
The attestation envelope, containing the attested-for payload and its signature.
The attestation statement.
This is represented as opaque bytes on the wire (encoded as base64), but it MUST be an JSON in-toto v1 Statement.
A signature for the above statement, encoded as base64.
Configuration for the model, should be a dictionary conforming to [ConfigDict
][pydantic.config.ConfigDict].
Metadata about the fields defined on the model,
mapping of field names to [FieldInfo
][pydantic.fields.FieldInfo] objects.
This replaces Model.__fields__
from Pydantic V1.
484class GitHubPublisher(_PublisherBase): 485 """A GitHub-based Trusted Publisher.""" 486 487 kind: Literal["GitHub"] = "GitHub" 488 489 repository: str 490 """ 491 The fully qualified publishing repository slug, e.g. `foo/bar` for 492 repository `bar` owned by `foo`. 493 """ 494 495 workflow: str 496 """ 497 The filename of the GitHub Actions workflow that performed the publishing 498 action. 499 """ 500 501 environment: Optional[str] = None 502 """ 503 The optional name GitHub Actions environment that the publishing 504 action was performed from. 505 """ 506 507 def _as_policy(self) -> VerificationPolicy: 508 return _GitHubTrustedPublisherPolicy(self.repository, self.workflow)
A GitHub-based Trusted Publisher.
The fully qualified publishing repository slug, e.g. foo/bar
for
repository bar
owned by foo
.
The optional name GitHub Actions environment that the publishing action was performed from.
Configuration for the model, should be a dictionary conforming to [ConfigDict
][pydantic.config.ConfigDict].
Metadata about the fields defined on the model,
mapping of field names to [FieldInfo
][pydantic.fields.FieldInfo] objects.
This replaces Model.__fields__
from Pydantic V1.
511class GitLabPublisher(_PublisherBase): 512 """A GitLab-based Trusted Publisher.""" 513 514 kind: Literal["GitLab"] = "GitLab" 515 516 repository: str 517 """ 518 The fully qualified publishing repository slug, e.g. `foo/bar` for 519 repository `bar` owned by `foo` or `foo/baz/bar` for repository 520 `bar` owned by group `foo` and subgroup `baz`. 521 """ 522 523 environment: Optional[str] = None 524 """ 525 The optional environment that the publishing action was performed from. 526 """ 527 528 def _as_policy(self) -> VerificationPolicy: 529 policies: list[VerificationPolicy] = [ 530 policy.OIDCIssuerV2("https://gitlab.com"), 531 policy.OIDCSourceRepositoryURI(f"https://gitlab.com/{self.repository}"), 532 ] 533 534 if not self.claims: 535 raise VerificationError("refusing to build a policy without claims") 536 537 if ref := self.claims.get("ref"): 538 policies.append( 539 policy.OIDCBuildConfigURI( 540 f"https://gitlab.com/{self.repository}//.gitlab-ci.yml@{ref}" 541 ) 542 ) 543 else: 544 raise VerificationError("refusing to build a policy without a ref claim") 545 546 return policy.AllOf(policies)
A GitLab-based Trusted Publisher.
The fully qualified publishing repository slug, e.g. foo/bar
for
repository bar
owned by foo
or foo/baz/bar
for repository
bar
owned by group foo
and subgroup baz
.
Configuration for the model, should be a dictionary conforming to [ConfigDict
][pydantic.config.ConfigDict].
Metadata about the fields defined on the model,
mapping of field names to [FieldInfo
][pydantic.fields.FieldInfo] objects.
This replaces Model.__fields__
from Pydantic V1.
567class Provenance(BaseModel): 568 """Provenance object as defined in PEP 740.""" 569 570 version: Literal[1] = 1 571 """ 572 The provenance object's version, which is always 1. 573 """ 574 575 attestation_bundles: list[AttestationBundle] 576 """ 577 One or more attestation "bundles". 578 """
Provenance object as defined in PEP 740.
Configuration for the model, should be a dictionary conforming to [ConfigDict
][pydantic.config.ConfigDict].
Metadata about the fields defined on the model,
mapping of field names to [FieldInfo
][pydantic.fields.FieldInfo] objects.
This replaces Model.__fields__
from Pydantic V1.
109class VerificationError(AttestationError): 110 """The PyPI Attestation failed verification.""" 111 112 def __init__(self: VerificationError, msg: str) -> None: 113 """Initialize an `VerificationError`.""" 114 super().__init__(f"Verification failed: {msg}")
The PyPI Attestation failed verification.
112 def __init__(self: VerificationError, msg: str) -> None: 113 """Initialize an `VerificationError`.""" 114 super().__init__(f"Verification failed: {msg}")
Initialize an VerificationError
.
120class VerificationMaterial(BaseModel): 121 """Cryptographic materials used to verify attestation objects.""" 122 123 certificate: Base64Bytes 124 """ 125 The signing certificate, as `base64(DER(cert))`. 126 """ 127 128 transparency_entries: Annotated[list[TransparencyLogEntry], MinLen(1)] 129 """ 130 One or more transparency log entries for this attestation's signature 131 and certificate. 132 """
Cryptographic materials used to verify attestation objects.
The signing certificate, as base64(DER(cert))
.
One or more transparency log entries for this attestation's signature and certificate.
Configuration for the model, should be a dictionary conforming to [ConfigDict
][pydantic.config.ConfigDict].
Metadata about the fields defined on the model,
mapping of field names to [FieldInfo
][pydantic.fields.FieldInfo] objects.
This replaces Model.__fields__
from Pydantic V1.