"""CRL views for the PKI application."""
import binascii
from typing import Any
from cryptography import x509
from cryptography.hazmat.primitives import serialization
from django import forms
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.db.models import ProtectedError, QuerySet
from django.forms import Form
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic import ListView
from django.views.generic.detail import DetailView
from django.views.generic.edit import FormView
from pki.models import CaModel, CertificateModel, CrlModel
from trustpoint.views.base import BulkDeleteView, ContextDataMixin
[docs]
class CrlContextMixin(ContextDataMixin):
"""Mixin which adds context_data for the PKI -> CRLs pages."""
[docs]
context_page_category = 'pki'
[docs]
context_page_name = 'crls'
[docs]
class CrlTableView(CrlContextMixin, ListView[CrlModel]):
"""Table view for all CRLs."""
[docs]
template_name = 'pki/crls/crls.html'
[docs]
context_object_name = 'crls'
[docs]
def get_queryset(self) -> QuerySet[CrlModel]:
"""Return all CRL models with related CA information."""
return (super().get_queryset()
.select_related('ca')
.order_by('-this_update'))
[docs]
class CrlBulkDeleteConfirmView(BulkDeleteView):
"""View to confirm the deletion of multiple CRLs."""
[docs]
success_url = reverse_lazy('pki:crls')
[docs]
ignore_url = reverse_lazy('pki:crls')
[docs]
template_name = 'pki/crls/confirm_delete.html'
[docs]
context_object_name = 'crls'
[docs]
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
"""Handle GET requests."""
queryset = self.get_queryset()
if not queryset.exists():
messages.error(request, _('No CRLs selected for deletion.'))
return HttpResponseRedirect(self.success_url)
return super().get(request, *args, **kwargs)
[docs]
class CrlDownloadView(CrlContextMixin, DetailView[CrlModel]):
"""View for downloading a single CRL."""
[docs]
success_url = reverse_lazy('pki:crls')
[docs]
ignore_url = reverse_lazy('pki:crls')
[docs]
template_name = 'pki/crls/download.html'
[docs]
context_object_name = 'crl'
[docs]
def get_queryset(self) -> QuerySet[CrlModel]:
"""Return all CRL models with related CA information."""
return super().get_queryset().select_related('ca')
[docs]
def get_object(self, queryset: QuerySet[CrlModel] | None = None) -> CrlModel:
"""Get the CRL object, handling both CRL primary keys and CA primary keys."""
if queryset is None:
queryset = self.get_queryset()
pk = self.kwargs.get('pk')
if pk is None:
raise Http404
try:
return queryset.get(pk=pk)
except CrlModel.DoesNotExist:
pass
try:
ca = CaModel.objects.get(pk=pk)
active_crl = ca.get_active_crl()
if active_crl:
return active_crl
except (CaModel.DoesNotExist, AttributeError):
pass
raise Http404
[docs]
def get(
self,
request: HttpRequest,
pk: str | None = None,
file_format: str | None = None,
*args: tuple[Any],
**kwargs: dict[str, Any],
) -> HttpResponse:
"""HTTP GET Method.
If only the CRL primary key are passed in the url, the download summary will be displayed.
If value for file_format is also provided, a file download will be performed.
Args:
request: The HttpRequest object.
pk: A string containing the CRL primary key.
file_format: The format of the CRL to download.
*args: Positional arguments.
**kwargs: Keyword arguments.
Returns:
HttpResponse: The HTTP response with either the download summary or a file download.
Raises:
Http404
"""
if not pk:
raise Http404
if file_format is None:
return super().get(request, *args, **kwargs)
crl_model = self.get_object()
if file_format == 'pem':
file_bytes = crl_model.crl_pem.encode()
mime_type = 'application/x-pem-file'
file_extension = '.crl'
elif file_format == 'der':
pem_lines = [
line.strip()
for line in crl_model.crl_pem.splitlines()
if line and not line.startswith('-----')
]
b64data = ''.join(pem_lines)
try:
file_bytes = binascii.a2b_base64(b64data)
mime_type = 'application/pkix-crl'
file_extension = '.crl.der'
except (binascii.Error, ValueError) as exc:
messages.error(
request,
_('Failed to convert CRL to DER: %s') % str(exc),
)
return HttpResponseRedirect(self.success_url)
elif file_format in ['pkcs7_pem', 'pkcs7_der']:
if file_format == 'pkcs7_pem':
file_bytes = crl_model.crl_pem.encode()
mime_type = 'application/x-pem-file'
file_extension = '.p7c'
else:
pem_lines = [
line.strip()
for line in crl_model.crl_pem.splitlines()
if line and not line.startswith('-----')
]
b64data = ''.join(pem_lines)
try:
file_bytes = binascii.a2b_base64(b64data)
mime_type = 'application/pkcs7-mime'
file_extension = '.p7c.der'
except (binascii.Error, ValueError) as exc:
messages.error(
request,
_('Failed to convert CRL to PKCS#7 DER: %s') % str(exc),
)
return HttpResponseRedirect(self.success_url)
else:
raise Http404
ca_name = crl_model.ca.unique_name if crl_model.ca else 'no_ca'
ca_name_safe = ''.join(c if c.isalnum() or c in '._-' else '_' for c in ca_name)
if crl_model.crl_number:
file_name = f'{ca_name_safe}_crl_{crl_model.crl_number}{file_extension}'
else:
file_name = f'{ca_name_safe}_crl{file_extension}'
response = HttpResponse(file_bytes, content_type=mime_type)
response['Content-Disposition'] = f'attachment; filename="{file_name}"'
return response
[docs]
class CrlDetailView(CrlContextMixin, DetailView[CrlModel]):
"""Detail view for a single CRL."""
[docs]
success_url = reverse_lazy('pki:crls')
[docs]
ignore_url = reverse_lazy('pki:crls')
[docs]
template_name = 'pki/crls/details.html'
[docs]
context_object_name = 'crl'
[docs]
def get_queryset(self) -> QuerySet[CrlModel]:
"""Return CRL models with related CA information."""
return super().get_queryset().select_related('ca')
[docs]
def get_object(self, queryset: QuerySet[CrlModel] | None = None) -> CrlModel:
"""Get the CRL object, handling both CRL primary keys and CA primary keys."""
if queryset is None:
queryset = self.get_queryset()
pk = self.kwargs.get('pk')
if pk is None:
raise Http404
try:
return queryset.get(pk=pk)
except CrlModel.DoesNotExist:
pass
try:
ca = CaModel.objects.get(pk=pk)
active_crl = ca.get_active_crl()
if active_crl:
return active_crl
except (CaModel.DoesNotExist, AttributeError):
pass
raise Http404
[docs]
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
"""Add additional context data for the CRL detail view."""
context = super().get_context_data(**kwargs)
crl = context['crl']
crypto_crl = crl.get_crl_as_crypto()
# Extract issuer information for display
issuer_rdns = crypto_crl.issuer
issuer_parts = []
for rdn in issuer_rdns:
oid_name = getattr(rdn.oid, '_name', str(rdn.oid))
issuer_parts.append(f'{oid_name}={rdn.value}')
context['crl_issuer'] = ', '.join(issuer_parts)
revoked_certs = []
serial_numbers = [f'{revoked_cert.serial_number:x}'.upper() for revoked_cert in crypto_crl]
certificates_by_serial = {}
if serial_numbers:
certificates = CertificateModel.objects.filter(serial_number__in=serial_numbers)
certificates_by_serial = {cert.serial_number: cert.id for cert in certificates}
for revoked_cert in crypto_crl:
serial_hex = f'{revoked_cert.serial_number:x}'.upper()
cert_id = certificates_by_serial.get(serial_hex)
revoked_certs.append({
'serial_number': revoked_cert.serial_number,
'serial_number_hex': serial_hex,
'revocation_date': revoked_cert.revocation_date_utc,
'reason': getattr(revoked_cert, 'reason', None),
'certificate_id': cert_id,
})
context['revoked_certificates'] = revoked_certs
extensions = [
{
'oid': ext.oid.dotted_string,
'name': getattr(ext.oid, '_name', str(ext.oid)),
'critical': ext.critical,
'value': ext.value,
}
for ext in crypto_crl.extensions
]
context['extensions'] = extensions
return context
[docs]
class CrlImportView(CrlContextMixin, FormView[CrlImportForm]):
"""View for importing CRLs."""
[docs]
template_name = 'pki/crls/import.html'
[docs]
success_url = reverse_lazy('pki:crls')
def _convert_to_pem(self, file_content: bytes) -> str:
"""Convert file content to PEM format by automatically detecting the format.
Args:
file_content: The raw file content as bytes
Returns:
str: The CRL in PEM format
Raises:
ValidationError: If the conversion fails for all supported formats
"""
# List of conversion functions to try in order
converters = [
self._try_pem,
self._try_der,
self._try_pkcs7_pem,
self._try_pkcs7_der,
]
for converter in converters:
try:
return converter(file_content)
except (ValueError, UnicodeDecodeError):
continue
raise ValidationError(_('Unable to parse CRL. The file may be corrupted or in an unsupported format.'))
def _try_pem(self, file_content: bytes) -> str:
"""Try to load as PEM format."""
pem_str = file_content.decode('utf-8')
x509.load_pem_x509_crl(pem_str.encode())
return pem_str
def _try_der(self, file_content: bytes) -> str:
"""Try to load as DER format."""
crl = x509.load_der_x509_crl(file_content)
return crl.public_bytes(serialization.Encoding.PEM).decode('utf-8')
def _try_pkcs7_pem(self, file_content: bytes) -> str:
"""Try to load as PKCS#7 PEM format."""
pem_str = file_content.decode('utf-8')
# TODO (FHK): For now, assume the PKCS#7 contains a CRL and try to load it directly # noqa: FIX002
x509.load_pem_x509_crl(pem_str.encode())
return pem_str
def _try_pkcs7_der(self, file_content: bytes) -> str:
"""Try to load as PKCS#7 DER format."""
# TODO (FHK): For now, assume the PKCS#7 contains a CRL and try to load it directly # noqa: FIX002
crl = x509.load_der_x509_crl(file_content)
return crl.public_bytes(serialization.Encoding.PEM).decode('utf-8')