Repository navigation
[PULP-2199] Add SLSA attestation verification support #1327
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| -----BEGIN PRIVATE KEY----- | ||
| MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQDLYCPSdloA94Te | ||
| +68CLqtHJ3qTKttyWyM1uHkcb+AEAExtQGLoKtysmE2QQz8xwpiBKsByAlPTAHZZ | ||
| pYIGzof45xEwiRzBqKxE4um1Uhtq+WCth96UeUkjz2G0xMxkFycjHDdpQQ92Hlg+ | ||
| mvU8VOrs4Xi7PvG5E+lTY7zez89QQ2ZY3OcbFSHVkC1b0COjYqJ7m5TmVWaRyNbW | ||
| Nn+00/uZ+hFzcsxAuKa9B9s4ngWELLA1TnWp5yi4q2Pkold4prI3PwF7IfGWtrfo | ||
| TgyzCKji12QLLjbd5KiKecGqx9zaV8xs7GnCif47JmOFAEDW5dtUstepH8ysy8wC | ||
| IXoZYvOSOsOv2mVz99JikyaWeV0rjNk7p9UPrhiaGKOfzfn+g6bzCJYBlo425pmJ | ||
| S/d1EoBoqgeCGniyLkFByQ9zDmzr1NlCkNKtNII1qNoRSMyqJY0qKPAd2bNMHUfT | ||
| FB3Eb/AvcOjb4fa5kkxl4gMQwasNaFmDgusqt1JjHK4XcL8UIcvzGPj8LsDrx5iT | ||
| HCFSF1gJvAXiLG0o6KFgrHfTBw2V3mxaKZNGr2NA5IMTs2UeGud590VmSvsKhr6R | ||
| tekHHGLCrZT9kPxbicBq6OZOhSb0usSpclSvwMzMSIOFNzACXq33nPYqlY0r1vGN | ||
| 5Gi2P9W2CxAYbnOGgqpzHbt4YoLVrQIDAQABAoICAA2SR3xZPtL8XCGFKhM7NLLK | ||
| 2k3NI60TPTDt3nxx+sD0RCVbkT4XniI7skauNh6xSFFWSQFSpnADgoz46R8LKTJd | ||
| jl1uyOcjb7DTyO9l9e5tixtetbuysZlyJ/2oJFDe5VMHzwAhwfu1NVj4KOVIcA+J | ||
| UZqCg3RA9TuwrCnc3uNm8VgnZZn+ZFjxW2raY4ZuTpQbuGlRHvHGNTqWPcS+C0wl | ||
| zoRQZNDs1t5GZ+/0m5RLvHZ82zKQpRGts5IjmIgJ7QVCxGvdwqwMBWRl0PMhi3jt | ||
| EVVYVXuj2/C3BKAg4NwGf93E6PR9FjoG6x00/HQFYrLZkbVM1JuzUynRPL9KRcvz | ||
| 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 | ||
| Y3acysRQ2KI9Ero+Ga3hDfaohueVFg== | ||
| -----END PRIVATE KEY----- |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| -----BEGIN PUBLIC KEY----- | ||
| MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAy2Aj0nZaAPeE3vuvAi6r | ||
| Ryd6kyrbclsjNbh5HG/gBABMbUBi6CrcrJhNkEM/McKYgSrAcgJT0wB2WaWCBs6H | ||
| +OcRMIkcwaisROLptVIbavlgrYfelHlJI89htMTMZBcnIxw3aUEPdh5YPpr1PFTq | ||
| 7OF4uz7xuRPpU2O83s/PUENmWNznGxUh1ZAtW9Ajo2Kie5uU5lVmkcjW1jZ/tNP7 | ||
| mfoRc3LMQLimvQfbOJ4FhCywNU51qecouKtj5KJXeKayNz8BeyHxlra36E4Mswio | ||
| 4tdkCy423eSoinnBqsfc2lfMbOxpwon+OyZjhQBA1uXbVLLXqR/MrMvMAiF6GWLz | ||
| kjrDr9plc/fSYpMmlnldK4zZO6fVD64Ymhijn835/oOm8wiWAZaONuaZiUv3dRKA | ||
| aKoHghp4si5BQckPcw5s69TZQpDSrTSCNajaEUjMqiWNKijwHdmzTB1H0xQdxG/w | ||
| L3Do2+H2uZJMZeIDEMGrDWhZg4LrKrdSYxyuF3C/FCHL8xj4/C7A68eYkxwhUhdY | ||
| CbwF4ixtKOihYKx30wcNld5sWimTRq9jQOSDE7NlHhrnefdFZkr7Coa+kbXpBxxi | ||
| wq2U/ZD8W4nAaujmToUm9LrEqXJUr8DMzEiDhTcwAl6t95z2KpWNK9bxjeRotj/V | ||
| tgsQGG5zhoKqcx27eGKC1a0CAwEAAQ== | ||
| -----END PUBLIC KEY----- |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Added support for verifying SLSA attestations with a configured public key. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,55 @@ | ||
| import json | ||
| import logging | ||
| from typing import Annotated, Literal, Union, get_args | ||
| from urllib.parse import urlparse | ||
|
|
||
| from pydantic import BaseModel, ConfigDict, Field | ||
| from cryptography.exceptions import InvalidSignature | ||
| from cryptography.hazmat.primitives import hashes, serialization | ||
| from cryptography.hazmat.primitives.asymmetric import padding as crypto_padding | ||
| from cryptography.x509 import load_der_x509_certificate | ||
| from django.conf import settings | ||
| from pydantic import Base64Bytes, BaseModel, ConfigDict, Field, model_validator | ||
| from pydantic.alias_generators import to_snake | ||
| from pypi_attestations import Attestation as _UpstreamAttestation | ||
| from pypi_attestations import ( | ||
| Attestation, | ||
| Distribution, | ||
| Envelope, # noqa - needed in module namespace for Pydantic model rebuild | ||
| Publisher, | ||
| VerificationError, | ||
| ) | ||
| from pypi_attestations import VerificationMaterial as _UpstreamVerificationMaterial | ||
| from sigstore.dsse import Envelope as DSSEEnvelope | ||
| from sigstore.dsse import _pae | ||
|
|
||
| log = logging.getLogger(__name__) | ||
|
|
||
| _verification_key_cache = {} | ||
|
|
||
| SLSA_PROVENANCE_V02 = "https://slsa.dev/provenance/v0.2" | ||
|
|
||
|
|
||
| class VerificationMaterial(_UpstreamVerificationMaterial): | ||
| """Extended verification material that supports optional certificate and public key. | ||
|
|
||
| PEP 740 requires a certificate, but this extension allows attestations signed | ||
| with a custom key where the certificate is absent. The public_key field is | ||
| accepted as an extra field and only present in the output when provided. | ||
| """ | ||
|
|
||
| model_config = ConfigDict(extra="allow") | ||
|
|
||
| certificate: Base64Bytes | None = None | ||
|
|
||
| @model_validator(mode="after") | ||
| def _validate_fields(self): | ||
| # public_key cannot be present when certificate is present | ||
| unexpected = set(self.model_extra or {}) - {"public_key"} | ||
| if unexpected: | ||
| raise ValueError(f"unexpected fields in verification_material: {unexpected}") | ||
| public_key = getattr(self, "public_key", None) | ||
| if self.certificate is not None and public_key is not None: | ||
| raise ValueError("verification_material cannot contain both certificate and public_key") | ||
| return self | ||
|
|
||
|
|
||
| class _PermissivePolicy: | ||
|
|
@@ -39,6 +82,21 @@ def _as_policy(self): | |
| ExtendedPublisher = Annotated[_ExtendedPublisherUnion, Field(union_mode="left_to_right")] | ||
|
|
||
|
|
||
| class Attestation(_UpstreamAttestation): | ||
| """ | ||
| Attestation object as defined in PEP 740. | ||
|
|
||
| Inherits from the upstream pypi_attestations.Attestation to keep Sigstore | ||
| verification methods (to_bundle, verify), but makes verification_material | ||
| optional to support attestations signed with a custom key instead of Sigstore. | ||
| """ | ||
|
|
||
| verification_material: VerificationMaterial | None = None | ||
| """ | ||
| Cryptographic materials used to verify `message_signature`. | ||
| """ | ||
|
|
||
|
|
||
| class AttestationBundle(BaseModel): | ||
| """ | ||
| AttestationBundle object as defined in PEP740. | ||
|
|
@@ -58,14 +116,149 @@ class Provenance(BaseModel): | |
| attestation_bundles: list[AttestationBundle] | ||
|
|
||
|
|
||
| def _load_verification_key(): | ||
| """Load the configured attestation verification public key, with caching.""" | ||
| key_path = getattr(settings, "ATTESTATION_VERIFICATION_KEY", None) | ||
| if not key_path: | ||
| return None | ||
| if key_path not in _verification_key_cache: | ||
| with open(key_path, "rb") as f: | ||
| _verification_key_cache[key_path] = serialization.load_pem_public_key(f.read()) | ||
| return _verification_key_cache[key_path] | ||
|
|
||
|
|
||
| def _has_valid_certificate(attestation): | ||
| """Check whether the attestation contains a valid X.509 certificate.""" | ||
| try: | ||
| vm = attestation.verification_material | ||
| if vm is None: | ||
| return False | ||
| cert_bytes = vm.certificate | ||
| if cert_bytes is None: | ||
| return False | ||
| load_der_x509_certificate(cert_bytes) | ||
| return True | ||
| except (ValueError, Exception): | ||
| return False | ||
|
|
||
|
|
||
| def _verify_statement_subject(attestation, dist): | ||
| """Validate that the in-toto statement subject matches the distribution. | ||
|
|
||
| Returns the parsed statement dict for downstream use. | ||
| """ | ||
| try: | ||
| stmt = json.loads(attestation.envelope.statement) | ||
| except (json.JSONDecodeError, UnicodeDecodeError) as e: | ||
| raise VerificationError(f"invalid statement: {e}") | ||
|
|
||
| subjects = stmt.get("subject", []) | ||
| if len(subjects) != 1: | ||
| raise VerificationError("expected exactly one subject in statement") | ||
|
|
||
| subject = subjects[0] | ||
| name = subject.get("name", "") | ||
| if name != dist.name: | ||
| raise VerificationError(f"subject does not match distribution name: {name} != {dist.name}") | ||
|
|
||
| digest = subject.get("digest", {}).get("sha256") | ||
| if digest != dist.digest: | ||
| raise VerificationError("subject does not match distribution digest") | ||
|
|
||
| return stmt | ||
|
|
||
|
|
||
| def _enrich_publisher_from_statement(stmt, publisher): | ||
| """Populate publisher fields from an SLSA v0.2 provenance statement.""" | ||
| if stmt.get("predicateType") != SLSA_PROVENANCE_V02: | ||
| return | ||
|
|
||
| predicate = stmt.get("predicate", {}) | ||
| builder_id = predicate.get("builder", {}).get("id") | ||
| build_type = predicate.get("buildType") | ||
|
|
||
| if builder_id: | ||
| publisher.builder_id = builder_id | ||
| try: | ||
| hostname = urlparse(builder_id).hostname | ||
| if hostname: | ||
| publisher.kind = hostname | ||
| except Exception: | ||
| pass | ||
|
|
||
| if build_type: | ||
| publisher.build_type = build_type | ||
|
|
||
|
|
||
| def _verify_signature(attestation, public_key): | ||
| """Verify the attestation's RSA signature over the DSSE PAE bytes.""" | ||
| statement_bytes = attestation.envelope.statement | ||
| signature_bytes = attestation.envelope.signature | ||
| pae = _pae(DSSEEnvelope._TYPE, statement_bytes) | ||
| try: | ||
| public_key.verify( | ||
| signature_bytes, | ||
| pae, | ||
| crypto_padding.PKCS1v15(), | ||
| hashes.SHA256(), | ||
| ) | ||
| except InvalidSignature as e: | ||
| raise VerificationError(f"signature verification failed: {e}") | ||
|
|
||
|
|
||
| def _verify_embedded_key(attestation, server_key): | ||
| """Verify that the embedded public key matches the server-configured key.""" | ||
| vm = attestation.verification_material | ||
| public_key = getattr(vm, "public_key", None) if vm else None | ||
| if public_key is None: | ||
| return | ||
| try: | ||
| embedded_key = serialization.load_pem_public_key(public_key.encode()) | ||
| except (ValueError, Exception) as e: | ||
| raise VerificationError(f"invalid embedded public key: {e}") | ||
| server_key_bytes = server_key.public_bytes( | ||
| serialization.Encoding.PEM, | ||
| serialization.PublicFormat.SubjectPublicKeyInfo, | ||
| ) | ||
| embedded_key_bytes = embedded_key.public_bytes( | ||
| serialization.Encoding.PEM, | ||
| serialization.PublicFormat.SubjectPublicKeyInfo, | ||
| ) | ||
| if server_key_bytes != embedded_key_bytes: | ||
| raise VerificationError( | ||
| "embedded public key does not match server-configured ATTESTATION_VERIFICATION_KEY" | ||
| ) | ||
|
|
||
|
|
||
| def verify_provenance(filename, sha256, provenance, offline=True): | ||
| """Verify the provenance object is valid for the package.""" | ||
| """Verify the provenance object is valid for the package. | ||
|
|
||
| Attestations with valid Sigstore certificates are verified through the | ||
| standard Sigstore path. Attestations without certificates are verified | ||
| against a custom public key configured via ATTESTATION_VERIFICATION_KEY. | ||
| Currently, it supports RSA PKCS1v15 signatures and SLSA v0.2 provenance | ||
| publisher enrichment. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The publisher enrichment is technically a side-effect of the method. Not sure it's the best thing to copy. |
||
| """ | ||
| dist = Distribution(name=filename, digest=sha256) | ||
| verification_key = _load_verification_key() | ||
| for bundle in provenance.attestation_bundles: | ||
| publisher = bundle.publisher | ||
| policy = publisher._as_policy() | ||
| for attestation in bundle.attestations: | ||
| sig_bundle = attestation.to_bundle() | ||
| checkpoint = sig_bundle.log_entry._inner.inclusion_proof.checkpoint | ||
| staging = "sigstage.dev" in checkpoint.envelope | ||
| attestation.verify(policy, dist, staging=staging, offline=offline) | ||
| if _has_valid_certificate(attestation): | ||
| policy = publisher._as_policy() | ||
| sig_bundle = attestation.to_bundle() | ||
| checkpoint = sig_bundle.log_entry._inner.inclusion_proof.checkpoint | ||
| staging = "sigstage.dev" in checkpoint.envelope | ||
| attestation.verify(policy, dist, staging=staging, offline=offline) | ||
| else: | ||
| stmt = _verify_statement_subject(attestation, dist) | ||
| _enrich_publisher_from_statement(stmt, publisher) | ||
| if verification_key: | ||
| _verify_signature(attestation, verification_key) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Only one key to verify against across all repositories and all domains. I wonder if we could do something better. What services has is custom built for their use-case, so I don't really want to just move it over as is. |
||
| _verify_embedded_key(attestation, verification_key) | ||
| else: | ||
| raise VerificationError( | ||
| "Attestation has no Sigstore certificate or no custom " | ||
| "verification key configured via ATTESTATION_VERIFICATION_KEY " | ||
| "(embedded key in verification_material is optional)" | ||
| ) | ||
Uh oh!
There was an error while loading. Please reload this page.