|
1 | 1 | import json |
2 | | -import logging |
3 | 2 | from typing import Annotated, Literal, Union, get_args |
4 | 3 | from urllib.parse import urlparse |
5 | 4 |
|
6 | | -from cryptography.exceptions import InvalidSignature |
| 5 | +from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm |
7 | 6 | from cryptography.hazmat.primitives import hashes, serialization |
8 | 7 | from cryptography.hazmat.primitives.asymmetric import padding as crypto_padding |
| 8 | +from cryptography.hazmat.primitives.asymmetric import rsa |
9 | 9 | from cryptography.x509 import load_der_x509_certificate |
10 | 10 | from django.conf import settings |
11 | 11 | from pydantic import BaseModel, ConfigDict, Field |
|
21 | 21 | from sigstore.dsse import Envelope as DSSEEnvelope |
22 | 22 | from sigstore.dsse import _pae |
23 | 23 |
|
24 | | -log = logging.getLogger(__name__) |
| 24 | +from pulpcore.plugin.util import get_domain |
25 | 25 |
|
26 | 26 | _verification_key_cache = {} |
27 | 27 |
|
@@ -92,28 +92,44 @@ class Provenance(BaseModel): |
92 | 92 | attestation_bundles: list[AttestationBundle] |
93 | 93 |
|
94 | 94 |
|
95 | | -def _load_verification_key(): |
96 | | - """Load the configured attestation verification public key, with caching.""" |
97 | | - key_path = getattr(settings, "ATTESTATION_VERIFICATION_KEY", None) |
| 95 | +def _load_verification_key(domain_name): |
| 96 | + """Load the attestation verification public key configured for the domain, with caching.""" |
| 97 | + keys = settings.ATTESTATION_VERIFICATION_KEYS |
| 98 | + if not isinstance(keys, dict): |
| 99 | + raise VerificationError( |
| 100 | + "ATTESTATION_VERIFICATION_KEYS must map domain names to public key paths" |
| 101 | + ) |
| 102 | + key_path = keys.get(domain_name) |
98 | 103 | if not key_path: |
99 | 104 | return None |
100 | 105 | if key_path not in _verification_key_cache: |
101 | | - with open(key_path, "rb") as f: |
102 | | - _verification_key_cache[key_path] = serialization.load_pem_public_key(f.read()) |
| 106 | + try: |
| 107 | + with open(key_path, "rb") as f: |
| 108 | + key = serialization.load_pem_public_key(f.read()) |
| 109 | + except (OSError, ValueError, UnsupportedAlgorithm) as e: |
| 110 | + raise VerificationError( |
| 111 | + f"could not load the verification key for domain '{domain_name}' " |
| 112 | + f"from {key_path}: {e}" |
| 113 | + ) |
| 114 | + if not isinstance(key, rsa.RSAPublicKey): |
| 115 | + raise VerificationError( |
| 116 | + f"the verification key for domain '{domain_name}' in {key_path} " |
| 117 | + "must be an RSA public key" |
| 118 | + ) |
| 119 | + _verification_key_cache[key_path] = key |
103 | 120 | return _verification_key_cache[key_path] |
104 | 121 |
|
105 | 122 |
|
106 | | -def _has_valid_certificate(attestation): |
107 | | - """Check whether the attestation contains a valid X.509 certificate.""" |
108 | | - try: |
109 | | - vm = attestation.verification_material |
110 | | - if vm is None: |
111 | | - return False |
112 | | - cert_bytes = vm.certificate |
113 | | - load_der_x509_certificate(cert_bytes) |
114 | | - return True |
115 | | - except (ValueError, Exception): |
| 123 | +def _has_certificate(attestation): |
| 124 | + """Check whether the attestation carries a certificate, rejecting unparseable ones.""" |
| 125 | + vm = attestation.verification_material |
| 126 | + if vm is None: |
116 | 127 | return False |
| 128 | + try: |
| 129 | + load_der_x509_certificate(vm.certificate) |
| 130 | + except ValueError as e: |
| 131 | + raise VerificationError(f"invalid X.509 certificate in verification_material: {e}") |
| 132 | + return True |
117 | 133 |
|
118 | 134 |
|
119 | 135 | def _verify_statement_subject(attestation, dist): |
@@ -185,28 +201,30 @@ def verify_provenance(filename, sha256, provenance, offline=True): |
185 | 201 |
|
186 | 202 | Each attestation is routed to one of two coexisting verification paths: |
187 | 203 | 1. Attestations with a valid Sigstore certificate follow the standard PEP 740 path. |
188 | | - 2. Attestations without a certificate are verified against the server-configured |
189 | | - ATTESTATION_VERIFICATION_KEY using RSA PKCS1v15 signatures, with support for SLSA |
190 | | - v0.2 publisher enrichment. |
| 204 | + 2. Attestations without a certificate are verified against the key configured for the |
| 205 | + current domain in ATTESTATION_VERIFICATION_KEYS using RSA PKCS1v15 signatures, with |
| 206 | + support for SLSA v0.2 publisher enrichment. |
| 207 | + Attestations with a certificate that can't be parsed are rejected. |
191 | 208 | """ |
192 | 209 | dist = Distribution(name=filename, digest=sha256) |
193 | 210 | for bundle in provenance.attestation_bundles: |
194 | 211 | publisher = bundle.publisher |
195 | 212 | policy = publisher._as_policy() |
196 | 213 | for attestation in bundle.attestations: |
197 | | - if _has_valid_certificate(attestation): |
| 214 | + if _has_certificate(attestation): |
198 | 215 | sig_bundle = attestation.to_bundle() |
199 | 216 | checkpoint = sig_bundle.log_entry._inner.inclusion_proof.checkpoint |
200 | 217 | staging = "sigstage.dev" in checkpoint.envelope |
201 | 218 | attestation.verify(policy, dist, staging=staging, offline=offline) |
202 | 219 | else: |
203 | | - verification_key = _load_verification_key() |
| 220 | + domain_name = get_domain().name |
| 221 | + verification_key = _load_verification_key(domain_name) |
204 | 222 | if verification_key: |
205 | 223 | stmt = _verify_statement_subject(attestation, dist) |
206 | 224 | _enrich_publisher_from_statement(stmt, publisher) |
207 | 225 | _verify_signature(attestation, verification_key) |
208 | 226 | else: |
209 | 227 | raise VerificationError( |
210 | | - "Attestation has no Sigstore certificate and no custom " |
211 | | - "verification key configured via ATTESTATION_VERIFICATION_KEY" |
| 228 | + "Attestation has no Sigstore certificate, and " |
| 229 | + f"ATTESTATION_VERIFICATION_KEYS has no key for domain '{domain_name}'" |
212 | 230 | ) |
0 commit comments