-
Notifications
You must be signed in to change notification settings - Fork 88
[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,31 @@ | ||
| import json | ||
| import logging | ||
| from typing import Annotated, Literal, Union, get_args | ||
| from urllib.parse import urlparse | ||
|
|
||
| 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 BaseModel, ConfigDict, Field | ||
| 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, | ||
| VerificationMaterial, | ||
| ) | ||
| 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 _PermissivePolicy: | ||
|
|
@@ -39,6 +58,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 +92,121 @@ 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 | ||
| 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_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. |
||
| else: | ||
| raise VerificationError( | ||
| "Attestation has no Sigstore certificate and no custom " | ||
| "verification key is configured (ATTESTATION_VERIFICATION_KEY)" | ||
| ) | ||
Uh oh!
There was an error while loading. Please reload this page.