pki-core 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pki/core/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ """Generic X.509 certificate utilities."""
2
+
3
+ from .algorithms import AlgorithmPolicy
4
+ from .certificate import CertificateError
5
+ from .crl import CRLRefreshError
6
+ from .providers import AuthProvider, HeuristicRule, ProviderRegistry, TrustStoreSource
7
+ from .revocation import CRL, OCSP, RevocationCheck, RevocationPolicy, RevocationResult
8
+ from .trust_store import build_bundle_for_provider
9
+ from .validation import (
10
+ CertificatePolicy,
11
+ ValidationResult,
12
+ ValidationStatus,
13
+ validate_certificate,
14
+ verify_chain,
15
+ )
16
+
17
+ __all__ = [
18
+ "CRL",
19
+ "OCSP",
20
+ "AlgorithmPolicy",
21
+ "AuthProvider",
22
+ "CRLRefreshError",
23
+ "CertificateError",
24
+ "CertificatePolicy",
25
+ "HeuristicRule",
26
+ "ProviderRegistry",
27
+ "RevocationCheck",
28
+ "RevocationPolicy",
29
+ "RevocationResult",
30
+ "TrustStoreSource",
31
+ "ValidationResult",
32
+ "ValidationStatus",
33
+ "build_bundle_for_provider",
34
+ "validate_certificate",
35
+ "verify_chain",
36
+ ]
pki/core/algorithms.py ADDED
@@ -0,0 +1,123 @@
1
+ """Cryptographic algorithm policy enforcement.
2
+
3
+ Validates that a certificate's public key and signature algorithm meet
4
+ minimum requirements. pki-core provides a generic :class:`AlgorithmPolicy`
5
+ dataclass whose defaults match broadly accepted minimums (RSA 2048+,
6
+ ECC P-256/P-384, SHA-256+). Provider packs such as ``pki-federal`` define
7
+ their own policy constants aligned with specific standards (e.g., SP 800-78).
8
+
9
+ Example::
10
+
11
+ from pki.core.algorithms import AlgorithmPolicy, check_algorithms
12
+
13
+ # Use defaults (RSA 2048+, P-256/P-384, SHA-256+)
14
+ result, detail = check_algorithms(cert, AlgorithmPolicy())
15
+
16
+ # ECC P-384 only, no RSA
17
+ strict = AlgorithmPolicy(
18
+ min_rsa_bits=0,
19
+ allowed_curves=frozenset({"secp384r1"}),
20
+ )
21
+ result, detail = check_algorithms(cert, strict)
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ from dataclasses import dataclass, field
28
+
29
+ from cryptography import x509
30
+ from cryptography.hazmat.primitives.asymmetric import ec, rsa
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ # Defaults that match broadly accepted minimums.
35
+ _DEFAULT_CURVES = frozenset({"secp256r1", "secp384r1"})
36
+ _DEFAULT_HASHES = frozenset({"sha256", "sha384", "sha512"})
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class AlgorithmPolicy:
41
+ """Approved cryptographic algorithms for certificate validation.
42
+
43
+ Defaults are broadly accepted minimums. Provider packs (e.g.,
44
+ ``pki-federal``) should define constants with values aligned to their
45
+ governing standard.
46
+
47
+ Attributes:
48
+ min_rsa_bits: Minimum RSA key size in bits. Set to ``0`` to
49
+ disallow RSA entirely.
50
+ allowed_curves: Frozenset of allowed elliptic curve names
51
+ (lowercase, as returned by ``key.curve.name``).
52
+ allowed_hashes: Frozenset of allowed signature hash algorithm
53
+ names (lowercase, as returned by
54
+ ``cert.signature_hash_algorithm.name``).
55
+ """
56
+
57
+ min_rsa_bits: int = 2048
58
+ allowed_curves: frozenset[str] = field(default_factory=lambda: _DEFAULT_CURVES)
59
+ allowed_hashes: frozenset[str] = field(default_factory=lambda: _DEFAULT_HASHES)
60
+
61
+
62
+ def check_algorithms(
63
+ cert: x509.Certificate,
64
+ policy: AlgorithmPolicy,
65
+ ) -> tuple[bool, str]:
66
+ """Check that a certificate's algorithms comply with *policy*.
67
+
68
+ Validates:
69
+
70
+ 1. **Public key type and size** — RSA keys must be at least
71
+ ``policy.min_rsa_bits``. EC keys must use a curve in
72
+ ``policy.allowed_curves``. Other key types (DSA, Ed25519, etc.)
73
+ are rejected.
74
+ 2. **Signature hash algorithm** — must be in ``policy.allowed_hashes``.
75
+
76
+ Args:
77
+ cert: The certificate to check.
78
+ policy: Algorithm requirements.
79
+
80
+ Returns:
81
+ A ``(passed, detail)`` tuple. *passed* is ``True`` if the
82
+ certificate meets all requirements, ``False`` otherwise.
83
+ *detail* is a human-readable message.
84
+ """
85
+ public_key = cert.public_key()
86
+
87
+ # Check public key type and size
88
+ if isinstance(public_key, rsa.RSAPublicKey):
89
+ if policy.min_rsa_bits == 0:
90
+ return False, "RSA keys are not allowed by algorithm policy"
91
+ key_size = public_key.key_size
92
+ if key_size < policy.min_rsa_bits:
93
+ return (
94
+ False,
95
+ f"RSA key size {key_size} bits is below minimum {policy.min_rsa_bits}",
96
+ )
97
+ elif isinstance(public_key, ec.EllipticCurvePublicKey):
98
+ curve_name = public_key.curve.name.lower()
99
+ if curve_name not in policy.allowed_curves:
100
+ return (
101
+ False,
102
+ f"EC curve {public_key.curve.name} is not in allowed set "
103
+ f"{sorted(policy.allowed_curves)}",
104
+ )
105
+ else:
106
+ key_type = type(public_key).__name__
107
+ return False, f"Unsupported public key type: {key_type}"
108
+
109
+ # Check signature hash algorithm
110
+ hash_alg = cert.signature_hash_algorithm
111
+ if hash_alg is None:
112
+ # EdDSA and other algorithms don't have a separate hash
113
+ return False, "Certificate has no signature hash algorithm (unsupported signature type)"
114
+
115
+ hash_name = hash_alg.name.lower()
116
+ if hash_name not in policy.allowed_hashes:
117
+ return (
118
+ False,
119
+ f"Signature hash algorithm {hash_alg.name} is not in allowed set "
120
+ f"{sorted(policy.allowed_hashes)}",
121
+ )
122
+
123
+ return True, "Algorithm check passed"
@@ -0,0 +1,120 @@
1
+ """X.509 certificate parsing utilities."""
2
+
3
+ import logging
4
+ import re
5
+ from datetime import UTC, datetime
6
+ from hashlib import sha256
7
+
8
+ from cryptography import x509
9
+ from cryptography.hazmat.primitives.serialization import Encoding
10
+ from cryptography.x509.oid import ExtensionOID, NameOID
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class CertificateError(Exception):
16
+ """Raised when certificate parsing or validation fails."""
17
+
18
+
19
+ def load_certificate(data: bytes) -> x509.Certificate:
20
+ """Load an x509 certificate from PEM or DER bytes."""
21
+ try:
22
+ return x509.load_pem_x509_certificate(data)
23
+ except Exception:
24
+ pass
25
+ try:
26
+ return x509.load_der_x509_certificate(data)
27
+ except Exception as e:
28
+ raise CertificateError(f"Failed to parse certificate: {e}") from e
29
+
30
+
31
+ def get_name_attr(name: x509.Name, oid: x509.ObjectIdentifier) -> str | None:
32
+ """Get first attribute value for an OID from an x509 Name."""
33
+ attrs = name.get_attributes_for_oid(oid)
34
+ return str(attrs[0].value) if attrs else None
35
+
36
+
37
+ def get_policy_oids(cert: x509.Certificate) -> list[str]:
38
+ """Extract certificate policy OIDs."""
39
+ try:
40
+ ext = cert.extensions.get_extension_for_oid(ExtensionOID.CERTIFICATE_POLICIES)
41
+ return [p.policy_identifier.dotted_string for p in ext.value] # type: ignore[attr-defined]
42
+ except x509.ExtensionNotFound:
43
+ return []
44
+
45
+
46
+ def extract_email(cert: x509.Certificate) -> str | None:
47
+ """Extract email from SAN (rfc822Name) or subject."""
48
+ try:
49
+ san = cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
50
+ emails = san.value.get_values_for_type(x509.RFC822Name) # type: ignore[attr-defined]
51
+ if emails:
52
+ return emails[0]
53
+ except x509.ExtensionNotFound:
54
+ pass
55
+
56
+ return get_name_attr(cert.subject, NameOID.EMAIL_ADDRESS)
57
+
58
+
59
+ def extract_san_uris(cert: x509.Certificate) -> list[str]:
60
+ """Extract URI values from Subject Alternative Name."""
61
+ try:
62
+ san = cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
63
+ return list(san.value.get_values_for_type(x509.UniformResourceIdentifier)) # type: ignore[attr-defined]
64
+ except x509.ExtensionNotFound:
65
+ return []
66
+
67
+
68
+ _UUID_RE = re.compile(
69
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
70
+ re.IGNORECASE,
71
+ )
72
+
73
+
74
+ def extract_san_uuid(cert: x509.Certificate) -> str | None:
75
+ """Extract UUID from SAN URI (urn:uuid:...).
76
+
77
+ Returns the UUID only if it matches the standard 8-4-4-4-12 hex format.
78
+ """
79
+ for uri in extract_san_uris(cert):
80
+ if uri.lower().startswith("urn:uuid:"):
81
+ candidate = uri[9:]
82
+ if _UUID_RE.match(candidate):
83
+ return candidate
84
+ logger.warning("Ignoring malformed UUID in SAN: %s", candidate)
85
+ return None
86
+
87
+
88
+ def extract_san_fascn(cert: x509.Certificate) -> str | None:
89
+ """Extract FASC-N from SAN OtherName (OID 2.16.840.1.101.3.6.6)."""
90
+ try:
91
+ san = cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
92
+ for general_name in san.value: # type: ignore[attr-defined]
93
+ if (
94
+ isinstance(general_name, x509.OtherName)
95
+ and general_name.type_id.dotted_string == "2.16.840.1.101.3.6.6"
96
+ ):
97
+ return general_name.value.hex()
98
+ except (x509.ExtensionNotFound, Exception):
99
+ pass
100
+ return None
101
+
102
+
103
+ def cert_fingerprint(cert: x509.Certificate) -> str:
104
+ """SHA-256 fingerprint of the DER encoding."""
105
+ return sha256(cert.public_bytes(Encoding.DER)).hexdigest()
106
+
107
+
108
+ def cert_to_pem(cert: x509.Certificate) -> str:
109
+ """Convert an x509 certificate to PEM string."""
110
+ return cert.public_bytes(Encoding.PEM).decode("ascii")
111
+
112
+
113
+ def is_expired(cert: x509.Certificate) -> bool:
114
+ """Return True if the certificate has expired."""
115
+ return cert.not_valid_after_utc < datetime.now(UTC)
116
+
117
+
118
+ def is_not_yet_valid(cert: x509.Certificate) -> bool:
119
+ """Return True if the certificate is not yet valid (before notBefore)."""
120
+ return cert.not_valid_before_utc > datetime.now(UTC)
pki/core/crl.py ADDED
@@ -0,0 +1,374 @@
1
+ """CRL fetching and revocation checking with stale-while-revalidate caching."""
2
+
3
+ import hashlib
4
+ import logging
5
+ import os
6
+ import threading
7
+ import time
8
+ from dataclasses import dataclass
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+
12
+ import httpx
13
+ from cryptography import x509
14
+ from platformdirs import user_cache_dir
15
+
16
+ from .certificate import CertificateError
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class CRLRefreshError(CertificateError):
22
+ """Raised when a background CRL refresh fails."""
23
+
24
+
25
+ @dataclass
26
+ class CRLConfig:
27
+ """Configuration for CRL cache behaviour.
28
+
29
+ ``cache_dir`` defaults to the platform-standard user cache location
30
+ based on ``app_name`` (e.g. ``~/.cache/pki-core/crls`` on Linux).
31
+ Pass an explicit ``cache_dir`` to override.
32
+
33
+ Attributes:
34
+ app_name: Application name for default cache directory.
35
+ cache_dir: Explicit cache directory (overrides app_name default).
36
+ cache_ttl: Seconds before a cached CRL is considered stale and
37
+ triggers a background refresh. The stale copy is still served
38
+ while refreshing.
39
+ strict: If True (default), raise ``CertificateError`` when a CRL
40
+ cannot be fetched or verified. If False, log a warning and
41
+ allow the request.
42
+ fetch_timeout: HTTP fetch timeout in seconds.
43
+ max_crl_bytes: Maximum CRL response size in bytes. Responses
44
+ larger than this are rejected. Default is 10 MB; DoD CRLs
45
+ may require a higher limit.
46
+ max_acceptable_age: Maximum age in seconds of a cached CRL before
47
+ it is considered too old to trust, even as a stale fallback.
48
+ If a cached CRL exceeds this age and cannot be refreshed,
49
+ the check fails (in strict mode) rather than serving an
50
+ unacceptably stale copy. Default is 0 (disabled — any cached
51
+ CRL is acceptable as a stale fallback).
52
+ """
53
+
54
+ app_name: str = "pki-core"
55
+ cache_dir: str = ""
56
+ cache_ttl: int = 3600 # seconds
57
+ strict: bool = True
58
+ fetch_timeout: int = 10 # seconds
59
+ max_crl_bytes: int = 10 * 1024 * 1024 # 10 MB
60
+ max_acceptable_age: int = 0 # 0 = disabled
61
+
62
+ def __post_init__(self):
63
+ if not self.cache_dir:
64
+ self.cache_dir = str(Path(user_cache_dir(self.app_name)) / "crls")
65
+
66
+
67
+ def get_crl_distribution_points(cert: x509.Certificate) -> list[str]:
68
+ """Extract HTTP(S) CRL distribution point URLs from a certificate."""
69
+ try:
70
+ ext = cert.extensions.get_extension_for_class(x509.CRLDistributionPoints)
71
+ except x509.ExtensionNotFound:
72
+ return []
73
+
74
+ urls = []
75
+ for dp in ext.value:
76
+ if dp.full_name is None:
77
+ continue
78
+ for name in dp.full_name:
79
+ if isinstance(name, x509.UniformResourceIdentifier):
80
+ url = name.value
81
+ if url.startswith("http://") or url.startswith("https://"):
82
+ urls.append(url)
83
+ return urls
84
+
85
+
86
+ def verify_crl(
87
+ crl: x509.CertificateRevocationList,
88
+ issuer_certs: list[x509.Certificate],
89
+ strict: bool = True,
90
+ ) -> bool:
91
+ """Verify a CRL's signature and freshness against a set of CA certificates.
92
+
93
+ Checks that:
94
+ 1. A CA certificate whose subject matches the CRL issuer is present.
95
+ 2. The CRL signature is valid for that CA's public key.
96
+ 3. The CRL's ``nextUpdate`` has not passed (stale CRL protection).
97
+
98
+ Returns True if the CRL is valid. In strict mode, raises
99
+ ``CertificateError`` on any failure. In non-strict mode, logs a warning
100
+ and returns False.
101
+ """
102
+ crl_issuer_dn = crl.issuer
103
+
104
+ # Find matching issuer cert by parsed DN comparison (handles attribute
105
+ # ordering and encoding differences that string comparison would miss).
106
+ issuer_cert = None
107
+ for ca in issuer_certs:
108
+ if ca.subject == crl_issuer_dn:
109
+ issuer_cert = ca
110
+ break
111
+
112
+ if issuer_cert is None:
113
+ msg = f"No CA certificate found for CRL issuer: {crl_issuer_dn.rfc4514_string()}"
114
+ if strict:
115
+ raise CertificateError(msg)
116
+ logger.warning(msg)
117
+ return False
118
+
119
+ # Verify signature.
120
+ if not crl.is_signature_valid(issuer_cert.public_key()): # type: ignore[arg-type]
121
+ msg = f"CRL signature verification failed for issuer: {crl_issuer_dn.rfc4514_string()}"
122
+ if strict:
123
+ raise CertificateError(msg)
124
+ logger.warning(msg)
125
+ return False
126
+
127
+ # Verify nextUpdate hasn't passed.
128
+ if crl.next_update_utc and crl.next_update_utc < datetime.now(UTC):
129
+ msg = f"CRL has expired (nextUpdate={crl.next_update_utc.isoformat()})"
130
+ if strict:
131
+ raise CertificateError(msg)
132
+ logger.warning(msg)
133
+ return False
134
+
135
+ logger.debug("CRL signature valid, issuer=%s", crl_issuer_dn.rfc4514_string())
136
+ return True
137
+
138
+
139
+ def load_ca_certs_from_pem(pem_data: str | bytes) -> list[x509.Certificate]:
140
+ """Parse all certificates from a PEM bundle.
141
+
142
+ Useful for loading a CA bundle file into a list suitable for passing
143
+ as ``issuer_certs`` to :func:`check_revocation` or :func:`verify_crl`.
144
+ """
145
+ if isinstance(pem_data, str):
146
+ pem_data = pem_data.encode()
147
+ return x509.load_pem_x509_certificates(pem_data)
148
+
149
+
150
+ def check_revocation(
151
+ cert: x509.Certificate,
152
+ config: CRLConfig,
153
+ issuer_certs: list[x509.Certificate] | None = None,
154
+ ) -> None:
155
+ """Check certificate revocation against CRL distribution points.
156
+
157
+ Uses a stale-while-revalidate cache strategy:
158
+ - Fresh cache -> used immediately, no network I/O.
159
+ - Stale cache -> used immediately; background thread refreshes it.
160
+ - No cache -> blocking fetch (first-ever request only).
161
+
162
+ Raises CertificateError if the certificate's serial number appears in any CRL.
163
+ If a CRL cannot be fetched and no cached copy exists, behaviour is controlled
164
+ by config.strict (default True -> raise).
165
+
166
+ If ``issuer_certs`` is provided, each CRL's signature and freshness are
167
+ verified against the CA certificates before the revocation check. Without
168
+ ``issuer_certs``, signature verification is skipped (backward compatible).
169
+ """
170
+ urls = get_crl_distribution_points(cert)
171
+ if not urls:
172
+ logger.warning(
173
+ "Certificate serial=%s has no CRL distribution points; skipping revocation check",
174
+ format(cert.serial_number, "x"),
175
+ )
176
+ return
177
+
178
+ for url in urls:
179
+ try:
180
+ crl = get_crl(url, config)
181
+ except CertificateError:
182
+ raise
183
+ except Exception as e:
184
+ logger.error("CRL unavailable for %s: %s", url, e)
185
+ if config.strict:
186
+ raise CertificateError("Could not verify revocation status; CRL unavailable") from e
187
+ logger.warning("strict=false -- allowing without revocation check for %s", url)
188
+ continue
189
+
190
+ # Verify CRL signature if CA certs are available.
191
+ if issuer_certs is not None:
192
+ verify_crl(crl, issuer_certs, strict=config.strict)
193
+ else:
194
+ logger.debug(
195
+ "No issuer_certs provided; skipping CRL signature verification for %s", url
196
+ )
197
+
198
+ if crl.get_revoked_certificate_by_serial_number(cert.serial_number) is not None:
199
+ raise CertificateError(
200
+ f"Certificate has been revoked (serial {format(cert.serial_number, 'x')})"
201
+ )
202
+
203
+
204
+ def get_crl(url: str, config: CRLConfig) -> x509.CertificateRevocationList:
205
+ """Return a parsed CRL for the given URL using a file-backed cache.
206
+
207
+ Strategy (stale-while-revalidate):
208
+ 1. Fresh cache -> return immediately.
209
+ 2. Stale cache -> return stale copy; spawn background thread to refresh.
210
+ 3. No cache -> fetch synchronously, cache result, return.
211
+ """
212
+ cache_dir = Path(config.cache_dir)
213
+ cache_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
214
+
215
+ url_hash = hashlib.sha256(url.encode()).hexdigest()[:24]
216
+ cache_file = cache_dir / f"{url_hash}.crl"
217
+
218
+ try:
219
+ st = cache_file.stat()
220
+ age = time.time() - st.st_mtime
221
+ data = cache_file.read_bytes()
222
+ except FileNotFoundError:
223
+ data = None
224
+ age = float("inf")
225
+
226
+ if data is not None:
227
+ if age < config.cache_ttl:
228
+ return parse_crl_bytes(data)
229
+
230
+ # Cache is stale — check if it's too old to serve even as a fallback
231
+ if config.max_acceptable_age and age > config.max_acceptable_age:
232
+ logger.warning(
233
+ "CRL cache for %s is %.0fs old (max_acceptable_age=%d) -- forcing refresh",
234
+ url,
235
+ age,
236
+ config.max_acceptable_age,
237
+ )
238
+ return refresh_crl(url, cache_file, config)
239
+
240
+ logger.debug(
241
+ "CRL stale (%.0fs old) for %s -- serving cache, refreshing in background",
242
+ age,
243
+ url,
244
+ )
245
+ threading.Thread(
246
+ target=_refresh_crl_background,
247
+ args=(url, cache_file, config),
248
+ daemon=True,
249
+ ).start()
250
+ return parse_crl_bytes(data)
251
+
252
+ logger.debug("No CRL cache for %s -- fetching synchronously", url)
253
+ return refresh_crl(url, cache_file, config)
254
+
255
+
256
+ def _refresh_crl_background(url: str, cache_file: Path, config: CRLConfig) -> None:
257
+ """Wrapper for background CRL refresh that logs exceptions.
258
+
259
+ Raises CRLRefreshError so that callers using a custom thread pool or
260
+ callback mechanism can observe the failure. In the default daemon-thread
261
+ usage, the exception is logged and then discarded.
262
+ """
263
+ try:
264
+ refresh_crl(url, cache_file, config)
265
+ except Exception as e:
266
+ logger.error("Background CRL refresh failed for %s: %s", url, e)
267
+ raise CRLRefreshError(f"Background CRL refresh failed for {url}: {e}") from e
268
+
269
+
270
+ def refresh_crl(
271
+ url: str, cache_file: Path, config: CRLConfig | None = None
272
+ ) -> x509.CertificateRevocationList:
273
+ """Fetch a CRL from url, write it to cache_file, and return parsed CRL.
274
+
275
+ Called directly (blocking) when no cache exists, or from ``_refresh_crl_background``
276
+ when the cache is stale. Raises ``ValueError`` if the response exceeds
277
+ ``config.max_crl_bytes``.
278
+ """
279
+ if config is None:
280
+ config = CRLConfig()
281
+ try:
282
+ resp = httpx.get(url, timeout=config.fetch_timeout, follow_redirects=True)
283
+ resp.raise_for_status()
284
+ data = resp.content
285
+ if len(data) > config.max_crl_bytes:
286
+ raise ValueError(
287
+ f"CRL from {url} exceeds size limit ({len(data)} > {config.max_crl_bytes} bytes)"
288
+ )
289
+ crl = parse_crl_bytes(data)
290
+ tmp = cache_file.with_suffix(".tmp")
291
+ fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
292
+ try:
293
+ os.write(fd, data)
294
+ finally:
295
+ os.close(fd)
296
+ tmp.rename(cache_file)
297
+ logger.debug("CRL cached from %s (%d bytes)", url, len(data))
298
+ return crl
299
+ except CertificateError:
300
+ raise
301
+ except Exception as e:
302
+ logger.error("Failed to fetch/cache CRL from %s: %s", url, e)
303
+ raise
304
+
305
+
306
+ def parse_crl_bytes(data: bytes) -> x509.CertificateRevocationList:
307
+ """Load a CRL from DER bytes (common format) with PEM fallback."""
308
+ try:
309
+ return x509.load_der_x509_crl(data)
310
+ except Exception:
311
+ pass
312
+ try:
313
+ return x509.load_pem_x509_crl(data)
314
+ except Exception as e:
315
+ raise CertificateError(f"Failed to parse CRL: {e}") from e
316
+
317
+
318
+ def prefetch_crls(cert: x509.Certificate, config: CRLConfig) -> dict[str, str]:
319
+ """Proactively fetch and cache CRLs for a certificate.
320
+
321
+ Intended for scheduled invocation (cron / Lambda) to keep the CRL cache
322
+ warm so that real requests never block on a network fetch.
323
+
324
+ Returns a dict mapping each CRL URL to "refreshed", "skipped (fresh)",
325
+ or "error: <message>".
326
+ """
327
+ urls = get_crl_distribution_points(cert)
328
+ if not urls:
329
+ return {}
330
+
331
+ cache_dir = Path(config.cache_dir)
332
+ cache_dir.mkdir(parents=True, exist_ok=True)
333
+ results: dict[str, str] = {}
334
+
335
+ for url in urls:
336
+ url_hash = hashlib.sha256(url.encode()).hexdigest()[:24]
337
+ cache_file = cache_dir / f"{url_hash}.crl"
338
+ if cache_file.exists():
339
+ age = time.time() - cache_file.stat().st_mtime
340
+ if age < config.cache_ttl:
341
+ results[url] = "skipped (fresh)"
342
+ continue
343
+ try:
344
+ refresh_crl(url, cache_file, config)
345
+ results[url] = "refreshed"
346
+ except Exception as e:
347
+ results[url] = f"error: {e}"
348
+
349
+ return results
350
+
351
+
352
+ def get_crl_max_age(cert: x509.Certificate, config: CRLConfig) -> float | None:
353
+ """Return the age in seconds of the oldest cached CRL for a certificate.
354
+
355
+ Returns None if no CRL distribution points are found.
356
+ Returns float("inf") if any CRL has no cached file yet.
357
+ """
358
+ urls = get_crl_distribution_points(cert)
359
+ if not urls:
360
+ return None
361
+
362
+ cache_dir = Path(config.cache_dir)
363
+ max_age: float | None = None
364
+
365
+ for url in urls:
366
+ url_hash = hashlib.sha256(url.encode()).hexdigest()[:24]
367
+ cache_file = cache_dir / f"{url_hash}.crl"
368
+ if not cache_file.exists():
369
+ return float("inf")
370
+ age = time.time() - cache_file.stat().st_mtime
371
+ if max_age is None or age > max_age:
372
+ max_age = age
373
+
374
+ return max_age