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’ll 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 custom (non-PEP 740) support for verifying attestations signed with a per-domain public key configured via `ATTESTATION_VERIFICATION_KEYS`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,31 @@ | ||
| import json | ||
| from typing import Annotated, Literal, Union, get_args | ||
|
|
||
| from urllib.parse import urlparse | ||
|
|
||
| from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm | ||
| from cryptography.hazmat.primitives import hashes, serialization | ||
| from cryptography.hazmat.primitives.asymmetric import padding as crypto_padding | ||
| from cryptography.hazmat.primitives.asymmetric import rsa | ||
| 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 | ||
|
|
||
| from pulpcore.plugin.util import get_domain | ||
|
|
||
| _verification_key_cache = {} | ||
|
|
||
| SLSA_PROVENANCE_V02 = "https://slsa.dev/provenance/v0.2" | ||
|
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. Do you know why we are using v0.2? It might be something AI choose for me when I added this to services, but I've forgotten. The PyPI docs say the supported SLSA predicate-type is
Member
Author
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. I will look into it. I checked their data, and they currently use only v1. |
||
|
|
||
|
|
||
| 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,139 @@ class Provenance(BaseModel): | |
| attestation_bundles: list[AttestationBundle] | ||
|
|
||
|
|
||
| def _load_verification_key(domain_name): | ||
| """Load the attestation verification public key configured for the domain, with caching.""" | ||
| keys = settings.ATTESTATION_VERIFICATION_KEYS | ||
| if not isinstance(keys, dict): | ||
| raise VerificationError( | ||
| "ATTESTATION_VERIFICATION_KEYS must map domain names to public key paths" | ||
| ) | ||
| key_path = keys.get(domain_name) | ||
| if not key_path: | ||
| return None | ||
| if key_path not in _verification_key_cache: | ||
| try: | ||
| with open(key_path, "rb") as f: | ||
| key = serialization.load_pem_public_key(f.read()) | ||
| except (OSError, ValueError, UnsupportedAlgorithm) as e: | ||
| raise VerificationError( | ||
| f"could not load the verification key for domain '{domain_name}' " | ||
| f"from {key_path}: {e}" | ||
| ) | ||
| if not isinstance(key, rsa.RSAPublicKey): | ||
| raise VerificationError( | ||
| f"the verification key for domain '{domain_name}' in {key_path} " | ||
| "must be an RSA public key" | ||
| ) | ||
| _verification_key_cache[key_path] = key | ||
| return _verification_key_cache[key_path] | ||
|
|
||
|
|
||
| def _has_certificate(attestation): | ||
| """Check whether the attestation carries a certificate, rejecting unparseable ones.""" | ||
| vm = attestation.verification_material | ||
| if vm is None: | ||
| return False | ||
| try: | ||
| load_der_x509_certificate(vm.certificate) | ||
| except ValueError as e: | ||
| raise VerificationError(f"invalid X.509 certificate in verification_material: {e}") | ||
| return True | ||
|
|
||
|
|
||
| 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: | ||
|
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. Follow up on v02, there are 3 newer versions: 1.0, 1.1, 1.2 https://slsa.dev/spec/v1.2/verification_summary#change-history. Hmm, what to do... |
||
| 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. | ||
|
|
||
| Each attestation is routed to one of two coexisting verification paths: | ||
| 1. Attestations with a valid Sigstore certificate follow the standard PEP 740 path. | ||
| 2. Attestations without a certificate are verified against the key configured for the | ||
| current domain in ATTESTATION_VERIFICATION_KEYS using RSA PKCS1v15 signatures, with | ||
| support for SLSA v0.2 publisher enrichment. | ||
| Attestations with a certificate that can't be parsed are rejected. | ||
| """ | ||
| dist = Distribution(name=filename, digest=sha256) | ||
| for bundle in provenance.attestation_bundles: | ||
|
Comment on lines
199
to
210
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. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '115,140p' pulp_python/app/provenance.py
sed -n '232,265p' pulp_python/app/provenance.py
sed -n '93,117p' docs/user/guides/attestation.mdRepository: pulp/pulp_python Length of output: 3961 🏁 Script executed: printf '%s\n' '--- documentation ---'
nl -ba docs/user/guides/attestation.md | sed -n '93,130p'
printf '%s\n' '--- provenance and upload callers ---'
nl -ba pulp_python/app/pypi/serializers.py | sed -n '120,145p;400,430p;660,690p'
nl -ba pulp_python/app/tasks/upload.py | sed -n '125,150p'
printf '%s\n' '--- setting references and entrypoints ---'
rg -n -F --glob '*.py' -- 'verify_provenance(' pulp_python
rg -n -F --glob '*.py' -- 'ATTESTATION_VERIFICATION_KEY' pulp_pythonRepository: pulp/pulp_python Length of output: 6970 Load the verification key only for certificate-less attestations.
Lazy-loading on the custom-key branch is required separately from translating key-load exceptions. Exception translation alone would still make certificate-based verification depend on the optional RSA key. Suggested fix """
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:
if _has_valid_certificate(attestation):
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)
- elif verification_key:
- stmt = _verify_statement_subject(attestation, dist)
- _enrich_publisher_from_statement(stmt, publisher)
- _verify_signature(attestation, verification_key)
- _verify_embedded_key(attestation, verification_key)
else:
- raise VerificationError(
- "Attestation has no Sigstore certificate and no custom "
- "verification key configured via ATTESTATION_VERIFICATION_KEY "
- "(embedded key in verification_material is optional)"
- )
+ verification_key = _load_verification_key()
+ if verification_key:
+ stmt = _verify_statement_subject(attestation, dist)
+ _enrich_publisher_from_statement(stmt, publisher)
+ _verify_signature(attestation, verification_key)
+ _verify_embedded_key(attestation, verification_key)
+ else:
+ raise VerificationError(
+ "Attestation has no Sigstore certificate and no custom "
+ "verification key configured via ATTESTATION_VERIFICATION_KEY "
+ "(embedded key in verification_material is optional)"
+ )🤖 Prompt for AI Agents |
||
| 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_certificate(attestation): | ||
| 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: | ||
| domain_name = get_domain().name | ||
| verification_key = _load_verification_key(domain_name) | ||
| if verification_key: | ||
| stmt = _verify_statement_subject(attestation, dist) | ||
| _enrich_publisher_from_statement(stmt, publisher) | ||
| _verify_signature(attestation, verification_key) | ||
| else: | ||
| raise VerificationError( | ||
| "Attestation has no Sigstore certificate, and " | ||
| f"ATTESTATION_VERIFICATION_KEYS has no key for domain '{domain_name}'" | ||
| ) | ||
Uh oh!
There was an error while loading. Please reload this page.