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 +36 -0
- pki/core/algorithms.py +123 -0
- pki/core/certificate.py +120 -0
- pki/core/crl.py +374 -0
- pki/core/identity.py +124 -0
- pki/core/providers.py +123 -0
- pki/core/revocation.py +313 -0
- pki/core/selectors.py +41 -0
- pki/core/trust_store.py +224 -0
- pki/core/validation.py +257 -0
- pki_core-0.3.1.dist-info/METADATA +358 -0
- pki_core-0.3.1.dist-info/RECORD +15 -0
- pki_core-0.3.1.dist-info/WHEEL +5 -0
- pki_core-0.3.1.dist-info/licenses/LICENSE +28 -0
- pki_core-0.3.1.dist-info/top_level.txt +1 -0
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"
|
pki/core/certificate.py
ADDED
|
@@ -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
|