pypi_attestations
The pypi-attestations
APIs.
1"""The `pypi-attestations` APIs.""" 2 3__version__ = "0.0.20" 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]
150class Attestation(BaseModel): 151 """Attestation object as defined in PEP 740.""" 152 153 version: Literal[1] 154 """ 155 The attestation format's version, which is always 1. 156 """ 157 158 verification_material: VerificationMaterial 159 """ 160 Cryptographic materials used to verify `message_signature`. 161 """ 162 163 envelope: Envelope 164 """ 165 The enveloped attestation statement and signature. 166 """ 167 168 @property 169 def statement(self) -> dict[str, Any]: 170 """Return the statement within this attestation's envelope. 171 172 The value returned here is a dictionary, in the shape of an 173 in-toto statement. 174 """ 175 return json.loads(self.envelope.statement) # type: ignore[no-any-return] 176 177 @classmethod 178 def sign(cls, signer: Signer, dist: Distribution) -> Attestation: 179 """Create an envelope, with signature, from the given Python distribution. 180 181 On failure, raises `AttestationError`. 182 """ 183 try: 184 stmt = ( 185 StatementBuilder() 186 .subjects( 187 [ 188 Subject( 189 name=dist.name, 190 digest=DigestSet(root={"sha256": dist.digest}), 191 ) 192 ] 193 ) 194 .predicate_type(AttestationType.PYPI_PUBLISH_V1) 195 .build() 196 ) 197 except DsseError as e: 198 raise AttestationError(str(e)) 199 200 try: 201 bundle = signer.sign_dsse(stmt) 202 except (ExpiredCertificate, ExpiredIdentity) as e: 203 raise AttestationError(str(e)) 204 205 try: 206 return Attestation.from_bundle(bundle) 207 except ConversionError as e: 208 raise AttestationError(str(e)) 209 210 @property 211 def certificate_claims(self) -> dict[str, str]: 212 """Return the claims present in the certificate. 213 214 We only return claims present in `_FULCIO_CLAIMS_OIDS`. 215 Values are decoded and returned as strings. 216 """ 217 certificate = x509.load_der_x509_certificate(self.verification_material.certificate) 218 claims = {} 219 for extension in certificate.extensions: 220 if extension.oid in _FULCIO_CLAIMS_OIDS: 221 # 1.3.6.1.4.1.57264.1.8 through 1.3.6.1.4.1.57264.1.22 are formatted as DER-encoded 222 # strings; the ASN.1 tag is UTF8String (0x0C) and the tag class is universal. 223 value = extension.value.value 224 claims[extension.oid.dotted_string] = _der_decode_utf8string(value) 225 226 return claims 227 228 def verify( 229 self, 230 identity: VerificationPolicy | Publisher, 231 dist: Distribution, 232 *, 233 staging: bool = False, 234 ) -> tuple[str, Optional[dict[str, Any]]]: 235 """Verify against an existing Python distribution. 236 237 The `identity` can be an object confirming to 238 `sigstore.policy.VerificationPolicy` or a `Publisher`, which will be 239 transformed into an appropriate verification policy. 240 241 By default, Sigstore's production verifier will be used. The 242 `staging` parameter can be toggled to enable the staging verifier 243 instead. 244 245 On failure, raises an appropriate subclass of `AttestationError`. 246 """ 247 # NOTE: Can't do `isinstance` with `Publisher` since it's 248 # a `_GenericAlias`; instead we punch through to the inner 249 # `_Publisher` union. 250 # Use of typing.get_args is needed for Python < 3.10 251 if isinstance(identity, get_args(_Publisher)): 252 policy = identity._as_policy() # noqa: SLF001 253 else: 254 policy = identity 255 256 if staging: 257 verifier = Verifier.staging() 258 else: 259 verifier = Verifier.production() 260 261 bundle = self.to_bundle() 262 try: 263 type_, payload = verifier.verify_dsse(bundle, policy) 264 except sigstore.errors.VerificationError as err: 265 raise VerificationError(str(err)) from err 266 267 if type_ != DsseEnvelope._TYPE: # noqa: SLF001 268 raise VerificationError(f"expected JSON envelope, got {type_}") 269 270 try: 271 statement = _Statement.model_validate_json(payload) 272 except ValidationError as e: 273 raise VerificationError(f"invalid statement: {str(e)}") 274 275 if len(statement.subjects) != 1: 276 raise VerificationError("too many subjects in statement (must be exactly one)") 277 subject = statement.subjects[0] 278 279 if not subject.name: 280 raise VerificationError("invalid subject: missing name") 281 282 try: 283 # We always ultranormalize when signing, but other signers may not. 284 subject_name = _ultranormalize_dist_filename(subject.name) 285 except ValueError as e: 286 raise VerificationError(f"invalid subject: {str(e)}") 287 288 if subject_name != dist.name: 289 raise VerificationError( 290 f"subject does not match distribution name: {subject_name} != {dist.name}" 291 ) 292 293 digest = subject.digest.root.get("sha256") 294 if digest is None or digest != dist.digest: 295 raise VerificationError("subject does not match distribution digest") 296 297 try: 298 AttestationType(statement.predicate_type) 299 except ValueError: 300 raise VerificationError(f"unknown attestation type: {statement.predicate_type}") 301 302 return statement.predicate_type, statement.predicate 303 304 def to_bundle(self) -> Bundle: 305 """Convert a PyPI attestation object as defined in PEP 740 into a Sigstore Bundle.""" 306 cert_bytes = self.verification_material.certificate 307 statement = self.envelope.statement 308 signature = self.envelope.signature 309 310 evp = DsseEnvelope( 311 _Envelope( 312 payload=statement, 313 payload_type=DsseEnvelope._TYPE, # noqa: SLF001 314 signatures=[_Signature(sig=signature)], 315 ) 316 ) 317 318 tlog_entry = self.verification_material.transparency_entries[0] 319 try: 320 certificate = x509.load_der_x509_certificate(cert_bytes) 321 except ValueError as err: 322 raise ConversionError("invalid X.509 certificate") from err 323 324 try: 325 log_entry = LogEntry._from_dict_rekor(tlog_entry) # noqa: SLF001 326 except (ValidationError, sigstore.errors.Error) as err: 327 raise ConversionError("invalid transparency log entry") from err 328 329 return Bundle._from_parts( # noqa: SLF001 330 cert=certificate, 331 content=evp, 332 log_entry=log_entry, 333 ) 334 335 @classmethod 336 def from_bundle(cls, sigstore_bundle: Bundle) -> Attestation: 337 """Convert a Sigstore Bundle into a PyPI attestation as defined in PEP 740.""" 338 certificate = sigstore_bundle.signing_certificate.public_bytes( 339 encoding=serialization.Encoding.DER 340 ) 341 342 envelope = sigstore_bundle._inner.dsse_envelope # noqa: SLF001 343 344 if len(envelope.signatures) != 1: 345 raise ConversionError(f"expected exactly one signature, got {len(envelope.signatures)}") 346 347 return cls( 348 version=1, 349 verification_material=VerificationMaterial( 350 certificate=base64.b64encode(certificate), 351 transparency_entries=[ 352 sigstore_bundle.log_entry._to_rekor().to_dict() # noqa: SLF001 353 ], 354 ), 355 envelope=Envelope( 356 statement=base64.b64encode(envelope.payload), 357 signature=base64.b64encode(envelope.signatures[0].sig), 358 ), 359 )
Attestation object as defined in PEP 740.
Cryptographic materials used to verify message_signature
.
168 @property 169 def statement(self) -> dict[str, Any]: 170 """Return the statement within this attestation's envelope. 171 172 The value returned here is a dictionary, in the shape of an 173 in-toto statement. 174 """ 175 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.
177 @classmethod 178 def sign(cls, signer: Signer, dist: Distribution) -> Attestation: 179 """Create an envelope, with signature, from the given Python distribution. 180 181 On failure, raises `AttestationError`. 182 """ 183 try: 184 stmt = ( 185 StatementBuilder() 186 .subjects( 187 [ 188 Subject( 189 name=dist.name, 190 digest=DigestSet(root={"sha256": dist.digest}), 191 ) 192 ] 193 ) 194 .predicate_type(AttestationType.PYPI_PUBLISH_V1) 195 .build() 196 ) 197 except DsseError as e: 198 raise AttestationError(str(e)) 199 200 try: 201 bundle = signer.sign_dsse(stmt) 202 except (ExpiredCertificate, ExpiredIdentity) as e: 203 raise AttestationError(str(e)) 204 205 try: 206 return Attestation.from_bundle(bundle) 207 except ConversionError as e: 208 raise AttestationError(str(e))
Create an envelope, with signature, from the given Python distribution.
On failure, raises AttestationError
.
210 @property 211 def certificate_claims(self) -> dict[str, str]: 212 """Return the claims present in the certificate. 213 214 We only return claims present in `_FULCIO_CLAIMS_OIDS`. 215 Values are decoded and returned as strings. 216 """ 217 certificate = x509.load_der_x509_certificate(self.verification_material.certificate) 218 claims = {} 219 for extension in certificate.extensions: 220 if extension.oid in _FULCIO_CLAIMS_OIDS: 221 # 1.3.6.1.4.1.57264.1.8 through 1.3.6.1.4.1.57264.1.22 are formatted as DER-encoded 222 # strings; the ASN.1 tag is UTF8String (0x0C) and the tag class is universal. 223 value = extension.value.value 224 claims[extension.oid.dotted_string] = _der_decode_utf8string(value) 225 226 return claims
Return the claims present in the certificate.
We only return claims present in _FULCIO_CLAIMS_OIDS
.
Values are decoded and returned as strings.
228 def verify( 229 self, 230 identity: VerificationPolicy | Publisher, 231 dist: Distribution, 232 *, 233 staging: bool = False, 234 ) -> tuple[str, Optional[dict[str, Any]]]: 235 """Verify against an existing Python distribution. 236 237 The `identity` can be an object confirming to 238 `sigstore.policy.VerificationPolicy` or a `Publisher`, which will be 239 transformed into an appropriate verification policy. 240 241 By default, Sigstore's production verifier will be used. The 242 `staging` parameter can be toggled to enable the staging verifier 243 instead. 244 245 On failure, raises an appropriate subclass of `AttestationError`. 246 """ 247 # NOTE: Can't do `isinstance` with `Publisher` since it's 248 # a `_GenericAlias`; instead we punch through to the inner 249 # `_Publisher` union. 250 # Use of typing.get_args is needed for Python < 3.10 251 if isinstance(identity, get_args(_Publisher)): 252 policy = identity._as_policy() # noqa: SLF001 253 else: 254 policy = identity 255 256 if staging: 257 verifier = Verifier.staging() 258 else: 259 verifier = Verifier.production() 260 261 bundle = self.to_bundle() 262 try: 263 type_, payload = verifier.verify_dsse(bundle, policy) 264 except sigstore.errors.VerificationError as err: 265 raise VerificationError(str(err)) from err 266 267 if type_ != DsseEnvelope._TYPE: # noqa: SLF001 268 raise VerificationError(f"expected JSON envelope, got {type_}") 269 270 try: 271 statement = _Statement.model_validate_json(payload) 272 except ValidationError as e: 273 raise VerificationError(f"invalid statement: {str(e)}") 274 275 if len(statement.subjects) != 1: 276 raise VerificationError("too many subjects in statement (must be exactly one)") 277 subject = statement.subjects[0] 278 279 if not subject.name: 280 raise VerificationError("invalid subject: missing name") 281 282 try: 283 # We always ultranormalize when signing, but other signers may not. 284 subject_name = _ultranormalize_dist_filename(subject.name) 285 except ValueError as e: 286 raise VerificationError(f"invalid subject: {str(e)}") 287 288 if subject_name != dist.name: 289 raise VerificationError( 290 f"subject does not match distribution name: {subject_name} != {dist.name}" 291 ) 292 293 digest = subject.digest.root.get("sha256") 294 if digest is None or digest != dist.digest: 295 raise VerificationError("subject does not match distribution digest") 296 297 try: 298 AttestationType(statement.predicate_type) 299 except ValueError: 300 raise VerificationError(f"unknown attestation type: {statement.predicate_type}") 301 302 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
.
304 def to_bundle(self) -> Bundle: 305 """Convert a PyPI attestation object as defined in PEP 740 into a Sigstore Bundle.""" 306 cert_bytes = self.verification_material.certificate 307 statement = self.envelope.statement 308 signature = self.envelope.signature 309 310 evp = DsseEnvelope( 311 _Envelope( 312 payload=statement, 313 payload_type=DsseEnvelope._TYPE, # noqa: SLF001 314 signatures=[_Signature(sig=signature)], 315 ) 316 ) 317 318 tlog_entry = self.verification_material.transparency_entries[0] 319 try: 320 certificate = x509.load_der_x509_certificate(cert_bytes) 321 except ValueError as err: 322 raise ConversionError("invalid X.509 certificate") from err 323 324 try: 325 log_entry = LogEntry._from_dict_rekor(tlog_entry) # noqa: SLF001 326 except (ValidationError, sigstore.errors.Error) as err: 327 raise ConversionError("invalid transparency log entry") from err 328 329 return Bundle._from_parts( # noqa: SLF001 330 cert=certificate, 331 content=evp, 332 log_entry=log_entry, 333 )
Convert a PyPI attestation object as defined in PEP 740 into a Sigstore Bundle.
335 @classmethod 336 def from_bundle(cls, sigstore_bundle: Bundle) -> Attestation: 337 """Convert a Sigstore Bundle into a PyPI attestation as defined in PEP 740.""" 338 certificate = sigstore_bundle.signing_certificate.public_bytes( 339 encoding=serialization.Encoding.DER 340 ) 341 342 envelope = sigstore_bundle._inner.dsse_envelope # noqa: SLF001 343 344 if len(envelope.signatures) != 1: 345 raise ConversionError(f"expected exactly one signature, got {len(envelope.signatures)}") 346 347 return cls( 348 version=1, 349 verification_material=VerificationMaterial( 350 certificate=base64.b64encode(certificate), 351 transparency_entries=[ 352 sigstore_bundle.log_entry._to_rekor().to_dict() # noqa: SLF001 353 ], 354 ), 355 envelope=Envelope( 356 statement=base64.b64encode(envelope.payload), 357 signature=base64.b64encode(envelope.signatures[0].sig), 358 ), 359 )
Convert a Sigstore Bundle into a PyPI attestation as defined in PEP 740.
639class AttestationBundle(BaseModel): 640 """AttestationBundle object as defined in PEP 740.""" 641 642 publisher: Publisher 643 """ 644 The publisher associated with this set of attestations. 645 """ 646 647 attestations: list[Attestation] 648 """ 649 The list of attestations included in this bundle. 650 """
AttestationBundle object as defined in PEP 740.
The publisher associated with this set of attestations.
Base error for all APIs.
109class AttestationType(str, Enum): 110 """Attestation types known to PyPI.""" 111 112 SLSA_PROVENANCE_V1 = "https://slsa.dev/provenance/v1" 113 PYPI_PUBLISH_V1 = "https://docs.pypi.org/attestations/publish/v1"
Attestation types known to PyPI.
120class ConversionError(AttestationError): 121 """The base error for all errors during conversion."""
The base error for all errors during conversion.
81class Distribution(BaseModel): 82 """Represents a Python package distribution. 83 84 A distribution is identified by its (sdist or wheel) filename, which 85 provides the package name and version (at a minimum) plus a SHA-256 86 digest, which uniquely identifies its contents. 87 """ 88 89 name: str 90 digest: str 91 92 @field_validator("name") 93 @classmethod 94 def _validate_name(cls, v: str) -> str: 95 return _ultranormalize_dist_filename(v) 96 97 @classmethod 98 def from_file(cls, dist: Path) -> Distribution: 99 """Construct a `Distribution` from the given path.""" 100 name = dist.name 101 with dist.open(mode="rb", buffering=0) as io: 102 # Replace this with `hashlib.file_digest()` once 103 # our minimum supported Python is >=3.11 104 digest = _sha256_streaming(io).hex() 105 106 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.
97 @classmethod 98 def from_file(cls, dist: Path) -> Distribution: 99 """Construct a `Distribution` from the given path.""" 100 name = dist.name 101 with dist.open(mode="rb", buffering=0) as io: 102 # Replace this with `hashlib.file_digest()` once 103 # our minimum supported Python is >=3.11 104 digest = _sha256_streaming(io).hex() 105 106 return cls(name=name, digest=digest)
Construct a Distribution
from the given path.
362class Envelope(BaseModel): 363 """The attestation envelope, containing the attested-for payload and its signature.""" 364 365 statement: Base64Bytes 366 """ 367 The attestation statement. 368 369 This is represented as opaque bytes on the wire (encoded as base64), 370 but it MUST be an JSON in-toto v1 Statement. 371 """ 372 373 signature: Base64Bytes 374 """ 375 A signature for the above statement, encoded as base64. 376 """
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.
516class GitHubPublisher(_PublisherBase): 517 """A GitHub-based Trusted Publisher.""" 518 519 kind: Literal["GitHub"] = "GitHub" 520 521 repository: str 522 """ 523 The fully qualified publishing repository slug, e.g. `foo/bar` for 524 repository `bar` owned by `foo`. 525 """ 526 527 workflow: str 528 """ 529 The filename of the GitHub Actions workflow that performed the publishing 530 action. 531 """ 532 533 environment: Optional[str] = None 534 """ 535 The optional name GitHub Actions environment that the publishing 536 action was performed from. 537 """ 538 539 def _as_policy(self) -> VerificationPolicy: 540 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
.
608class GitLabPublisher(_PublisherBase): 609 """A GitLab-based Trusted Publisher.""" 610 611 kind: Literal["GitLab"] = "GitLab" 612 613 repository: str 614 """ 615 The fully qualified publishing repository slug, e.g. `foo/bar` for 616 repository `bar` owned by `foo` or `foo/baz/bar` for repository 617 `bar` owned by group `foo` and subgroup `baz`. 618 """ 619 620 workflow_filepath: str 621 """ 622 The path for the CI/CD configuration file. This is usually ".gitlab-ci.yml", 623 but can be customized. 624 """ 625 626 environment: Optional[str] = None 627 """ 628 The optional environment that the publishing action was performed from. 629 """ 630 631 def _as_policy(self) -> VerificationPolicy: 632 return _GitLabTrustedPublisherPolicy(self.repository, self.workflow_filepath)
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
.
653class Provenance(BaseModel): 654 """Provenance object as defined in PEP 740.""" 655 656 version: Literal[1] = 1 657 """ 658 The provenance object's version, which is always 1. 659 """ 660 661 attestation_bundles: list[AttestationBundle] 662 """ 663 One or more attestation "bundles". 664 """
Provenance object as defined in PEP 740.
124class VerificationError(AttestationError): 125 """The PyPI Attestation failed verification.""" 126 127 def __init__(self: VerificationError, msg: str) -> None: 128 """Initialize an `VerificationError`.""" 129 super().__init__(f"Verification failed: {msg}")
The PyPI Attestation failed verification.
127 def __init__(self: VerificationError, msg: str) -> None: 128 """Initialize an `VerificationError`.""" 129 super().__init__(f"Verification failed: {msg}")
Initialize an VerificationError
.
135class VerificationMaterial(BaseModel): 136 """Cryptographic materials used to verify attestation objects.""" 137 138 certificate: Base64Bytes 139 """ 140 The signing certificate, as `base64(DER(cert))`. 141 """ 142 143 transparency_entries: Annotated[list[TransparencyLogEntry], MinLen(1)] 144 """ 145 One or more transparency log entries for this attestation's signature 146 and certificate. 147 """
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.