from datetime import datetime
from cryptography import x509
from cryptography.x509 import CertificateRevocationList
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
from pyeudiw.tools.http import http_get_sync, DEFAULT_HTTPC_PARAMS
from pyeudiw.x509.exceptions import CRLHTTPError, CRLParseError, CRLReadError
from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate
[docs]
class CRLHelper:
"""
Helper class to handle CRL (Certificate Revocation List) operations.
"""
def __init__(
self,
crl: CertificateRevocationList,
uri: str
) -> None:
"""
Initialize the CRLHelper with a CRL object.
:param crl: The CRL object to be used.
:type crl: CertificateRevocationList
:param uri: The URI of the CRL.
:type uri: str
:raises CRLReadError: If the CRL object is invalid or if the URI is not provided.
"""
self.revocation_list = crl
if not uri:
raise CRLReadError("CRL URI is required.")
self.uri = uri
[docs]
def is_revoked(self, serial_number: str | int) -> bool:
"""
Check if a certificate with the given serial number is revoked.
:param serial_number: The serial number of the certificate to check. Can be in hex format (string) or integer.
:type serial_number: str | int
:raises CRLReadError: If the serial number is invalid or if the revocation list is not loaded.
:return: True if the certificate is revoked, False otherwise.
:rtype: bool
"""
try:
if isinstance(serial_number, str):
serial_number = int(serial_number, 16)
except ValueError:
raise CRLReadError(f"Invalid serial number format: {serial_number}")
try:
return self.revocation_list.get_revoked_certificate_by_serial_number(serial_number) is not None
except Exception as e:
raise CRLReadError(f"Failed to check revocation status: {e}")
[docs]
def get_revocation_date(self, serial_number: str | int) -> datetime | None:
"""
Get the revocation date of a certificate with the given serial number.
:param serial_number: The serial number of the certificate to check. Can be in hex format (string) or integer.
:type serial_number: str | int
:raises CRLReadError: If the serial number is invalid or if the revocation list is not loaded.
:return: The revocation date if revoked, None otherwise.
:rtype: str | None
"""
try:
if isinstance(serial_number, str):
serial_number = int(serial_number, 16)
except ValueError:
raise CRLReadError(f"Invalid serial number format: {serial_number}")
try:
cert = self.revocation_list.get_revoked_certificate_by_serial_number(serial_number)
return cert.revocation_date_utc if cert else None
except Exception as e:
raise CRLReadError(f"Failed to get revocation date: {e}")
[docs]
def is_crl_expired(self) -> bool:
"""
Check if the CRL is valid (not expired).
:raises CRLReadError: If the CRL is not loaded or if the expiration date cannot be determined.
:return: True if the CRL is valid, False otherwise.
:rtype: bool
"""
try:
exp = self.revocation_list.next_update
if exp is None:
return False
return exp < datetime.now(exp.tzinfo)
except Exception as e:
raise CRLReadError(f"Failed to check CRL validity: {e}")
[docs]
def update(self, httpc_params: dict = DEFAULT_HTTPC_PARAMS) -> None:
"""
Update the CRL by fetching it from the URI.
This method fetches the CRL file from the specified URI and loads it into the CRL object.
:param httpc_params: Optional HTTP client parameters.
:type httpc_params: dict | None
:raises CRLHTTPError: If the HTTP request fails or the response is not valid.
:raises CRLParseError: If the CRL file is not in the expected format.
"""
response = http_get_sync([self.uri], httpc_params)
if response[0].status_code != 200:
raise CRLHTTPError(f"Failed to fetch CRL from {self.uri}: {response[0].status_code}")
self.revocation_list = CRLHelper._parse_crl(
response[0].text.encode("utf-8"),
)
[docs]
def serialize(self) -> dict[str, str]:
"""
Serialize the CRL to a specified encoding format.
:param encoding: The encoding format. Can be "pem" or "der". Defaults to "pem".
:type encoding: str
:return: The serialized CRL with the uri.
:rtype: dict[str, str]
"""
return {
"pem": self.revocation_list.public_bytes(serialization.Encoding.PEM).decode("utf-8"),
"uri": self.uri,
}
@staticmethod
def _parse_crl(crl: str | bytes) -> CertificateRevocationList:
"""
Parse a CRL from a given PEM or DER formatted string or bytes.
:param crl: The CRL in PEM or DER format.
:type crl: str | bytes
:raises CRLParseError: If the CRL file is not in the expected format.
:return: The parsed CRL object.
:rtype: CertificateRevocationList
"""
if isinstance(crl, str) and crl.startswith("-----BEGIN X509 CRL-----"):
rev_list = x509.load_pem_x509_crl(crl.encode() if isinstance(crl, str) else crl, default_backend())
elif isinstance(crl, bytes) and crl.startswith(b"-----BEGIN X509 CRL-----"):
rev_list = x509.load_pem_x509_crl(crl, default_backend())
else:
rev_list = x509.load_der_x509_crl(
crl.encode() if isinstance(crl, str) else crl,
default_backend()
)
return rev_list
[docs]
@staticmethod
def from_url(crl_url: str, httpc_params: dict = DEFAULT_HTTPC_PARAMS) -> "CRLHelper":
"""
Load a CRL from a given URL.
This method fetches the CRL file from the specified URL and loads it into a CRL object.
:param crl_url: URL of the CRL file.
:type crl_url: str
:param httpc_params: Optional HTTP client parameters.
:type httpc_params: dict | None
:raises CRLHTTPError: If the HTTP request fails or the response is not valid.
:raises CRLParseError: If the CRL file is not in the expected format.
:return: An instance of CRLHelper containing the loaded CRL.
:rtype: CRLHelper
"""
response = http_get_sync([crl_url], httpc_params)
if response[0].status_code != 200:
raise CRLHTTPError(f"Failed to fetch CRL from {crl_url}: {response[0].status_code}")
return CRLHelper.from_crl(
response[0].text.encode("utf-8"),
uri=crl_url
)
[docs]
@staticmethod
def from_certificate(cert: str | bytes) -> list["CRLHelper"]:
"""
Load CRL distribution points from a given certificate.
This method extracts the CRL distribution points from the certificate and loads them into CRLHelper instances.
:param cert: The certificate in PEM or DER format.
:type cert: str | bytes
:raises CRLReadError: If the certificate does not contain CRL distribution points or if loading fails.
:return: A list of CRLHelper instances containing the loaded CRLs.
:rtype: list[CRLHelper]
"""
if isinstance(cert, str) and cert.startswith("-----BEGIN CERTIFICATE-----"):
parsed_cert: x509.Certificate = load_pem_x509_certificate(cert.encode(), default_backend())
elif isinstance(cert, bytes) and cert.startswith(b"-----BEGIN CERTIFICATE-----"):
parsed_cert: x509.Certificate = load_pem_x509_certificate(cert, default_backend())
else:
parsed_cert: x509.Certificate = load_der_x509_certificate(
cert.encode() if isinstance(cert, str) else cert,
default_backend()
)
try:
crl_distribution_points = parsed_cert.extensions.get_extension_for_class(x509.CRLDistributionPoints)
except x509.ExtensionNotFound:
raise CRLReadError("No CRL distribution points found in the certificate.")
crl_helpers = []
for crl_url in crl_distribution_points.value:
try:
crl_helper = CRLHelper.from_url(crl_url.full_name[0].value)
crl_helpers.append(crl_helper)
except (CRLHTTPError, CRLParseError, CRLReadError) as e:
raise CRLReadError(f"Failed to load CRL from certificate: {e}")
return crl_helpers
[docs]
@staticmethod
def from_crl(crl: str | bytes, uri: str) -> "CRLHelper":
"""
Load a CRL from a given PEM or DER formatted string or bytes.
:param crl: The CRL in PEM or DER format.
:type crl: str | bytes
:raises CRLParseError: If the CRL file is not in the expected format.
:return: An instance of CRLHelper containing the loaded CRL.
:rtype: CRLHelper
"""
try:
return CRLHelper(
crl=CRLHelper._parse_crl(crl),
uri=uri
)
except Exception as e:
raise CRLParseError(f"Failed to parse CRL: {e}")