From b1ac8bcedee2d1e44faab5557fc680e9c03b83ec Mon Sep 17 00:00:00 2001 From: Mathias Ertl Date: Fri, 11 Oct 2024 22:09:59 +0200 Subject: [PATCH] store certificate revocation lists in database --- ca/django_ca/conf.py | 104 ++++- ca/django_ca/constants.py | 2 +- ca/django_ca/management/actions.py | 12 +- ca/django_ca/management/commands/dump_crl.py | 118 +++++- ca/django_ca/managers.py | 232 +++++++++++- .../0047_certificaterevocationlist.py | 33 ++ ca/django_ca/models.py | 356 +++++++++--------- ca/django_ca/pydantic/type_aliases.py | 32 +- ca/django_ca/pydantic/validators.py | 37 +- ca/django_ca/querysets.py | 79 +++- ca/django_ca/tests/base/assertions.py | 44 ++- ca/django_ca/tests/base/fixtures.py | 76 +++- ca/django_ca/tests/base/utils.py | 34 +- .../tests/commands/test_cache_crls.py | 51 +-- ca/django_ca/tests/commands/test_dump_crl.py | 221 +++++------ ca/django_ca/tests/commands/test_init_ca.py | 13 +- ca/django_ca/tests/commands/test_sign_cert.py | 6 +- .../tests/fixtures/root.attribute.crl | Bin 0 -> 426 bytes ca/django_ca/tests/fixtures/root.ca.crl | Bin 0 -> 426 bytes ca/django_ca/tests/fixtures/root.crl | Bin 0 -> 408 bytes ca/django_ca/tests/fixtures/root.user.crl | Bin 0 -> 426 bytes .../models/test_certificate_authority.py | 291 +++----------- .../test_certificate_revocation_list.py | 267 +++++++++++++ .../tests/pydantic/test_type_aliases.py | 48 --- ca/django_ca/tests/tasks/conftest.py | 7 +- ca/django_ca/tests/tasks/test_cache_crls.py | 8 +- ca/django_ca/tests/test_settings.py | 81 +++- ca/django_ca/tests/test_verification.py | 57 +-- ca/django_ca/tests/test_views.py | 267 +------------ .../tests/utils/test_get_crl_cache_key.py | 54 +++ ca/django_ca/tests/views/__init__.py | 0 .../test_certificate_revocation_list_view.py | 307 +++++++++++++++ ca/django_ca/typehints.py | 5 + ca/django_ca/urls.py | 12 +- ca/django_ca/utils.py | 33 +- ca/django_ca/views.py | 140 +++++-- docs/source/changelog/TBR_2.1.0.rst | 51 +++ docs/source/deprecation.rst | 22 +- docs/source/python/models.rst | 11 + docs/source/settings.rst | 19 +- 40 files changed, 2058 insertions(+), 1072 deletions(-) create mode 100644 ca/django_ca/migrations/0047_certificaterevocationlist.py create mode 100644 ca/django_ca/tests/fixtures/root.attribute.crl create mode 100644 ca/django_ca/tests/fixtures/root.ca.crl create mode 100644 ca/django_ca/tests/fixtures/root.crl create mode 100644 ca/django_ca/tests/fixtures/root.user.crl create mode 100644 ca/django_ca/tests/models/test_certificate_revocation_list.py create mode 100644 ca/django_ca/tests/utils/test_get_crl_cache_key.py create mode 100644 ca/django_ca/tests/views/__init__.py create mode 100644 ca/django_ca/tests/views/test_certificate_revocation_list_view.py diff --git a/ca/django_ca/conf.py b/ca/django_ca/conf.py index 84357d05f..008793621 100644 --- a/ca/django_ca/conf.py +++ b/ca/django_ca/conf.py @@ -20,13 +20,19 @@ from importlib.util import find_spec from typing import Annotated, Any, Literal, Optional, Union, cast -from annotated_types import Ge, Le, MinLen -from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, field_validator, model_validator +from annotated_types import Ge, Le +from pydantic import ( + BaseModel, + BeforeValidator, + ConfigDict, + Field, + field_validator, + model_validator, +) from cryptography import x509 from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import ec -from cryptography.hazmat.primitives.serialization import Encoding from cryptography.x509.oid import NameOID from django.conf import settings as _settings @@ -35,26 +41,26 @@ from django.utils.translation import gettext_lazy as _ from django_ca import constants -from django_ca.deprecation import RemovedInDjangoCA220Warning +from django_ca.deprecation import RemovedInDjangoCA220Warning, RemovedInDjangoCA230Warning from django_ca.pydantic import NameModel from django_ca.pydantic.type_aliases import ( - CertificateRevocationListEncodingTypeAlias, + CertificateRevocationListReasonCode, EllipticCurveTypeAlias, HashAlgorithmTypeAlias, PowerOfTwoInt, Serial, UniqueElementsTuple, ) -from django_ca.pydantic.validators import name_oid_parser, timedelta_as_number_parser +from django_ca.pydantic.validators import crl_scope_validator, name_oid_parser, timedelta_as_number_parser from django_ca.typehints import ( AllowedHashTypes, CertificateRevocationListScopes, ConfigurableExtension, ConfigurableExtensionKeys, ParsableKeyType, + Self, ) -CRLEncodings = Annotated[frozenset[CertificateRevocationListEncodingTypeAlias], MinLen(1)] # BeforeValidator currently does not work together with Le(), see: # https://github.com/pydantic/pydantic/issues/10459 # TimedeltaAsDays = Annotated[timedelta, BeforeValidator(timedelta_as_number_parser("days"))] @@ -244,23 +250,87 @@ def _subject_validator(value: Any) -> Any: Subject = Annotated[x509.Name, BeforeValidator(_subject_validator)] -class CertificateRevocationListProfileOverride(BaseModel): +class CertificateRevociationListBaseModel(BaseModel): + """Base model for CRL profiles and overrides.""" + + encodings: Optional[Any] = None + scope: Optional[CertificateRevocationListScopes] = None + only_contains_ca_certs: bool = False + only_contains_user_certs: bool = False + only_contains_attribute_certs: bool = False + only_some_reasons: Optional[frozenset[CertificateRevocationListReasonCode]] = None + + @field_validator("encodings") + @classmethod + def warn_encodings(cls, v: Any) -> Any: + """Validator to warn that encodings is now unused.""" + warnings.warn( + "encodings: Setting has no effect starting with django-ca 2.1.0.", + RemovedInDjangoCA230Warning, + stacklevel=1, + ) + return v + + @model_validator(mode="after") + def validate_scope(self) -> Self: + """Validate the scope of the CRL.""" + if "scope" in self.model_fields_set: + warnings.warn( + "scope: Setting is deprecated and will be removed in django-ca 2.3.0. Use " + "`only_contains_ca_certs` and `only_contains_user_certs` instead.", + RemovedInDjangoCA230Warning, + stacklevel=1, + ) + if self.scope == "user": + self.only_contains_user_certs = True + if self.scope == "ca": + self.only_contains_ca_certs = True + if self.scope == "attribute": + self.only_contains_attribute_certs = True + self.scope = None + + crl_scope_validator( + self.only_contains_ca_certs, + self.only_contains_user_certs, + self.only_contains_attribute_certs, + None, # already validated by type alias for field + ) + return self + + +class CertificateRevocationListProfileOverride(CertificateRevociationListBaseModel): """Model for overriding fields of a CRL Profile.""" - encodings: Optional[CRLEncodings] = None expires: Optional[timedelta] = None - scope: Optional[CertificateRevocationListScopes] = None skip: bool = False -class CertificateRevocationListProfile(BaseModel): +class CertificateRevocationListProfile(CertificateRevociationListBaseModel): """Model for profiles for CRL generation.""" - encodings: CRLEncodings expires: timedelta = timedelta(days=1) - scope: Optional[CertificateRevocationListScopes] = None OVERRIDES: dict[Serial, CertificateRevocationListProfileOverride] = Field(default_factory=dict) + @model_validator(mode="after") + def validate_overrides(self) -> Self: + """Validate that overrides do not create an invalid scope.""" + # pylint: disable-next=no-member # pylint doesn't recognize the type of OVERRIDES. + for override in self.OVERRIDES.values(): + only_contains_user_certs = self.only_contains_user_certs + only_contains_ca_certs = self.only_contains_ca_certs + only_contains_attribute_certs = self.only_contains_attribute_certs + if "only_contains_ca_certs" in override.model_fields_set: + only_contains_ca_certs = override.only_contains_ca_certs + if "only_contains_user_certs" in override.model_fields_set: + only_contains_user_certs = override.only_contains_user_certs + if "only_contains_attribute_certs" in override.model_fields_set: + only_contains_attribute_certs = override.only_contains_attribute_certs + + crl_scope_validator( # only_some_reasons is already validated by type alias for field + only_contains_ca_certs, only_contains_user_certs, only_contains_attribute_certs, None + ) + return self + class KeyBackendConfigurationModel(BaseModel): """Base model for a key backend configuration.""" @@ -300,12 +370,8 @@ class SettingsModel(BaseModel): CA_ACME_MAX_CERT_VALIDITY: AcmeCertValidity = timedelta(days=90) CA_CRL_PROFILES: dict[str, CertificateRevocationListProfile] = { - "user": CertificateRevocationListProfile( - expires=timedelta(days=1), scope="user", encodings=[Encoding.PEM, Encoding.DER] - ), - "ca": CertificateRevocationListProfile( - expires=timedelta(days=1), scope="ca", encodings=[Encoding.PEM, Encoding.DER] - ), + "user": CertificateRevocationListProfile(only_contains_user_certs=True), + "ca": CertificateRevocationListProfile(only_contains_ca_certs=True), } CA_DEFAULT_CA: Optional[Serial] = None CA_DEFAULT_DSA_SIGNATURE_HASH_ALGORITHM: HashAlgorithmTypeAlias = hashes.SHA256() diff --git a/ca/django_ca/constants.py b/ca/django_ca/constants.py index a364a9e78..b76142454 100644 --- a/ca/django_ca/constants.py +++ b/ca/django_ca/constants.py @@ -637,7 +637,7 @@ class ReasonFlags(enum.Enum): remove_from_crl = "removeFromCRL" -#: Mapping of RFC 5280, section 5.3.1 reason codes too cryptography reason codes +#: Mapping of RFC 5280, section 5.3.1 reason codes to cryptography reason codes REASON_CODES = { 0: ReasonFlags.unspecified, 1: ReasonFlags.key_compromise, diff --git a/ca/django_ca/management/actions.py b/ca/django_ca/management/actions.py index 95b03f891..f4f5ee241 100644 --- a/ca/django_ca/management/actions.py +++ b/ca/django_ca/management/actions.py @@ -18,7 +18,7 @@ import getpass import typing from datetime import timedelta -from typing import Any, Optional +from typing import Any, Literal, Optional from pydantic import BaseModel @@ -162,17 +162,21 @@ class ExpiresAction(SingleValueAction[str, timedelta]): 3 """ + def __init__(self, unit: Literal["days", "hours", "minutes", "seconds"] = "days", **kwargs: Any) -> None: + super().__init__(**kwargs) + self.unit = unit + def parse_value(self, value: str) -> timedelta: """Parse the value for this action.""" # NOTE: Making this a member of ExpiresAction causes an infinite loop for some reason try: - days = int(value) + parsed = int(value) except ValueError as ex: raise argparse.ArgumentError(self, f"{value}: Value must be an integer.") from ex - if days <= 0: + if parsed <= 0: raise argparse.ArgumentError(self, f"{value}: Value must not be negative.") - return timedelta(days=days) + return timedelta(**{self.unit: parsed}) # type: ignore[misc] # mypy does not expect Literal as str class FormatAction(SingleValueAction[str, Encoding]): diff --git a/ca/django_ca/management/commands/dump_crl.py b/ca/django_ca/management/commands/dump_crl.py index 6862f63ee..3d2789619 100644 --- a/ca/django_ca/management/commands/dump_crl.py +++ b/ca/django_ca/management/commands/dump_crl.py @@ -17,16 +17,20 @@ """ import typing +import warnings +from datetime import datetime, timedelta, timezone as tz from typing import Any, Optional +from cryptography import x509 from cryptography.hazmat.primitives.serialization import Encoding from django.core.management.base import CommandError, CommandParser +from django_ca.deprecation import RemovedInDjangoCA230Warning +from django_ca.management.actions import ExpiresAction from django_ca.management.base import BinaryCommand from django_ca.management.mixins import UsePrivateKeyMixin -from django_ca.models import CertificateAuthority -from django_ca.typehints import AllowedHashTypes +from django_ca.models import CertificateAuthority, CertificateRevocationList class Command(UsePrivateKeyMixin, BinaryCommand): @@ -38,20 +42,54 @@ def add_arguments(self, parser: CommandParser) -> None: parser.add_argument( "-e", "--expires", - type=int, - default=86400, + action=ExpiresAction, + default=timedelta(days=1), metavar="SECONDS", help="Seconds until a new CRL will be available (default: %(default)s).", ) parser.add_argument( "path", nargs="?", default="-", help='Path for the output file. Use "-" for stdout.' ) - parser.add_argument( + + scope_group = parser.add_argument_group("Scope", "Options that affect the scope of the CRL.") + only_certs_group = scope_group.add_mutually_exclusive_group() + only_certs_group.add_argument( "-s", "--scope", choices=["ca", "user", "attribute"], help="Limit the scope for the CRL (default: %(default)s).", ) + only_certs_group.add_argument( + "--only-contains-ca-certs", + action="store_true", + default=False, + help="Only include CA certificates in the CRL.", + ) + only_certs_group.add_argument( + "--only-contains-user-certs", + action="store_true", + default=False, + help="Only include end-entity certificates in the CRL.", + ) + only_certs_group.add_argument( + "--only-contains-attribute-certs", + action="store_true", + default=False, + help="Only include attribute certificates in the CRL (NOTE: Attribute certificates are not " + "supported, and the CRL will always be empty).", + ) + scope_group.add_argument( + "--only-some-reasons", + dest="reasons", + action="append", + choices=[ + reason.name + for reason in x509.ReasonFlags + if reason not in (x509.ReasonFlags.unspecified, x509.ReasonFlags.remove_from_crl) + ], + help="Only include certificates revoked for the given reason. Can be given multiple " + "times to include multiple reasons.", + ) include_idp_group = parser.add_mutually_exclusive_group() include_idp_group.add_argument( @@ -77,37 +115,77 @@ def handle( path: str, ca: CertificateAuthority, encoding: Encoding, - algorithm: Optional[AllowedHashTypes], scope: Optional[typing.Literal["ca", "user", "attribute"]], + only_contains_ca_certs: bool, + only_contains_user_certs: bool, + only_contains_attribute_certs: bool, include_issuing_distribution_point: Optional[bool], - expires: int, + expires: timedelta, + reasons: Optional[list[str]], **options: Any, ) -> None: - key_backend_options, algorithm = self.get_signing_options(ca, algorithm, options) + key_backend_options, _algorithm = self.get_signing_options(ca, ca.algorithm, options) - if include_issuing_distribution_point is True and ca.parent is None and scope is None: - raise CommandError( - "Cannot add IssuingDistributionPoint extension to CRLs with no scope for root CAs." + if include_issuing_distribution_point is not None: + warnings.warn( + "--include-issuing-distribution-point and --exclude-issuing-distribution-point no longer " + "have any effect and will be removed in django-ca 2.3.0.", + RemovedInDjangoCA230Warning, + stacklevel=1, + ) + if options.get("algorithm"): + warnings.warn( + "--algorithm no longer has any effect and will be removed in django-ca 2.3.0.", + RemovedInDjangoCA230Warning, + stacklevel=1, + ) + if scope is not None: + warnings.warn( + "--scope is deprecated and will be removed in django-ca 2.3.0. Use " + "--only-contains-{ca,user,attribute}-certs instead.", + RemovedInDjangoCA230Warning, + stacklevel=1, ) + # Handle deprecated scope parameter. + if scope == "user": + only_contains_user_certs = True + elif scope == "ca": + only_contains_ca_certs = True + elif scope == "attribute": + only_contains_attribute_certs = True + + next_update = datetime.now(tz=tz.utc) + expires + only_some_reasons = None + if reasons is not None: + only_some_reasons = frozenset([x509.ReasonFlags[reason] for reason in reasons]) + # Actually create the CRL try: - crl = ca.get_crl( - key_backend_options, - include_issuing_distribution_point=include_issuing_distribution_point, - scope=scope, - algorithm=algorithm, - expires=expires, - ).public_bytes(encoding) + crl = CertificateRevocationList.objects.create_certificate_revocation_list( + ca=ca, + key_backend_options=key_backend_options, + next_update=next_update, + only_contains_ca_certs=only_contains_ca_certs, + only_contains_user_certs=only_contains_user_certs, + only_contains_attribute_certs=only_contains_attribute_certs, + only_some_reasons=only_some_reasons, + ) + if encoding == Encoding.PEM: + data = crl.pem + else: + if crl.data is None: # pragma: no cover # just to make mypy happy + raise CommandError("CRL was not generated.") + data = bytes(crl.data) except Exception as ex: # Note: all parameters are already sanitized by parser actions raise CommandError(ex) from ex if path == "-": - self.stdout.write(crl, ending=b"") + self.stdout.write(data, ending=b"") else: try: with open(path, "wb") as stream: - stream.write(crl) + stream.write(data) except OSError as ex: raise CommandError(ex) from ex diff --git a/ca/django_ca/managers.py b/ca/django_ca/managers.py index ab2b1cc0b..5cadb2a0a 100644 --- a/ca/django_ca/managers.py +++ b/ca/django_ca/managers.py @@ -15,16 +15,20 @@ import typing from collections.abc import Iterable -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone as tz from typing import Any, Generic, Optional, TypeVar, Union from pydantic import BaseModel from cryptography import x509 +from cryptography.hazmat.primitives.serialization import Encoding from cryptography.x509.oid import AuthorityInformationAccessOID, ExtensionOID -from django.db import models +from django.conf import settings +from django.db import models, transaction +from django.db.models.functions import Coalesce from django.urls import reverse +from django.utils import timezone from django_ca import constants from django_ca.conf import model_settings @@ -34,6 +38,7 @@ from django_ca.modelfields import LazyCertificateSigningRequest from django_ca.openssh import SshHostCaExtension, SshUserCaExtension from django_ca.profiles import Profile, profiles +from django_ca.pydantic.validators import crl_scope_validator from django_ca.signals import post_create_ca, post_issue_cert, pre_create_ca from django_ca.typehints import ( AllowedHashTypes, @@ -55,27 +60,32 @@ AcmeOrder, Certificate, CertificateAuthority, + CertificateRevocationList, ) from django_ca.querysets import ( AcmeAccountQuerySet, AcmeAuthorizationQuerySet, CertificateAuthorityQuerySet, CertificateQuerySet, + CertificateRevocationListQuerySet, ) + CertificateAuthorityManagerBase = models.Manager[CertificateAuthority] + CertificateManagerBase = models.Manager[Certificate] + CertificateRevocationListManagerBase = models.Manager[CertificateRevocationList] AcmeAccountManagerBase = models.Manager[AcmeAccount] AcmeAuthorizationManagerBase = models.Manager[AcmeAuthorization] AcmeCertificateManagerBase = models.Manager[AcmeCertificate] AcmeChallengeManagerBase = models.Manager[AcmeChallenge] AcmeOrderManagerBase = models.Manager[AcmeOrder] - CertificateAuthorityManagerBase = models.Manager[CertificateAuthority] - CertificateManagerBase = models.Manager[Certificate] QuerySetTypeVar = TypeVar("QuerySetTypeVar", CertificateAuthorityQuerySet, CertificateQuerySet) else: + CertificateAuthorityManagerBase = CertificateManagerBase = models.Manager + CertificateRevocationListManagerBase = models.Manager AcmeAccountManagerBase = AcmeAuthorizationManagerBase = AcmeCertificateManagerBase = ( AcmeChallengeManagerBase - ) = AcmeOrderManagerBase = CertificateAuthorityManagerBase = CertificateManagerBase = models.Manager + ) = AcmeOrderManagerBase = models.Manager QuerySetTypeVar = TypeVar("QuerySetTypeVar") @@ -101,6 +111,13 @@ def exclude(self, *args: Any, **kwargs: Any) -> QuerySetTypeVar: ... def order_by(self, *fields: str) -> QuerySetTypeVar: ... + def for_certificate_revocation_list( + self, + reasons: Optional[Iterable[x509.ReasonFlags]] = None, + now: Optional[datetime] = None, + grace_timedelta: timedelta = timedelta(minutes=10), + ) -> "CertificateQuerySet": ... + def get_by_serial_or_cn(self, identifier: str) -> X509CertMixinTypeVar: ... def valid(self) -> QuerySetTypeVar: ... @@ -685,6 +702,211 @@ def create_cert( # noqa: PLR0913 return obj +class CertificateRevocationListManager(CertificateRevocationListManagerBase): + """The model manager for :py:class:`~django_ca.models.CertificateRevocationList`. + + .. versionadded:: 2.1.0 + """ + + if typing.TYPE_CHECKING: + # See CertificateManagerMixin for description on this branch + # + # pylint: disable=missing-function-docstring,unused-argument; just defining stubs here + + def reasons( + self, only_some_reasons: Optional[frozenset[x509.ReasonFlags]] + ) -> "CertificateRevocationListQuerySet": ... + + def scope( + self, + ca: "CertificateAuthority", + only_contains_ca_certs: bool = False, + only_contains_user_certs: bool = False, + only_contains_attribute_certs: bool = False, + only_some_reasons: Optional[frozenset[x509.ReasonFlags]] = None, + ) -> "CertificateRevocationListQuerySet": ... + + def _add_issuing_distribution_point_extension( + self, + builder: x509.CertificateRevocationListBuilder, + *, + only_contains_ca_certs: bool, + only_contains_user_certs: bool, + only_contains_attribute_certs: bool, + only_some_reasons: Optional[frozenset[x509.ReasonFlags]], + ) -> x509.CertificateRevocationListBuilder: + # We can only add the IDP extension if one of these properties is set, see RFC 5280, 5.2.5. + if ( + only_contains_user_certs + or only_contains_ca_certs + or only_contains_attribute_certs + or only_some_reasons + ): + return builder.add_extension( + x509.IssuingDistributionPoint( + full_name=None, + relative_name=None, + indirect_crl=False, + only_contains_ca_certs=only_contains_ca_certs, + only_contains_user_certs=only_contains_user_certs, + only_contains_attribute_certs=only_contains_attribute_certs, + only_some_reasons=only_some_reasons, + ), + critical=True, # "is a critical CRL extension" - RFC 5280, section 5.2.5 + ) + + return builder + + def _add_revoked_certificates( + self, + builder: x509.CertificateRevocationListBuilder, + ca: "CertificateAuthority", + now: datetime, + *, + only_contains_ca_certs: bool, + only_contains_user_certs: bool, + only_contains_attribute_certs: bool, # pylint: disable=unused-argument + only_some_reasons: Optional[frozenset[x509.ReasonFlags]], + ) -> x509.CertificateRevocationListBuilder: + # Add certificate authorities if applicable + if only_contains_ca_certs is True or only_contains_user_certs is False: + for child_ca in ca.children.for_certificate_revocation_list(now=now, reasons=only_some_reasons): + builder = builder.add_revoked_certificate(child_ca.get_revocation()) + + # Add certificates if applicable + if only_contains_user_certs is True or only_contains_ca_certs is False: + certs = ca.certificate_set.for_certificate_revocation_list(now=now, reasons=only_some_reasons) + for cert in certs: + builder = builder.add_revoked_certificate(cert.get_revocation()) + + return builder + + @transaction.atomic + def create_certificate_revocation_list( + self, + ca: "CertificateAuthority", + key_backend_options: BaseModel, + *, + next_update: Optional[datetime] = None, + only_contains_ca_certs: bool = False, + only_contains_user_certs: bool = False, + only_contains_attribute_certs: bool = False, + only_some_reasons: Optional[frozenset[x509.ReasonFlags]] = None, + ) -> "CertificateRevocationList": + """Create or update a certificate revocation list. + + Apart from `ca` and `key_backend_options`, all arguments are optional and must be passed as keyword + arguments. + + Parameters + ---------- + ca : :py:class:`~django_ca.models.CertificateAuthority` + The certificate authority to generate the CRL for. + key_backend_options : BaseModel + Key backend options for using the private key. + next_update : datetime, optional + When the CRL will be updated again, defaults to one day. + only_contains_ca_certs : bool, optional + Set to ``True`` to generate a CRL that contains only CA certificates. + only_contains_user_certs : bool, optional + Set to ``True`` to generate a CRL that contains only end-entity certificates. + only_contains_attribute_certs : bool, optional + Set to ``True`` to generate a CRL that contains only attribute certificates. Note that this is not + supported and will always return an empty CRL. + only_some_reasons : frozenset[:py:class:`~cg:cryptography.x509.ReasonFlags`], optional + Pass a set of :py:class:`~cg:cryptography.x509.ReasonFlags` to limit the CRL to certificates that + have been revoked for that reason. + """ + # Parameter validation + crl_scope_validator( + only_contains_ca_certs, only_contains_user_certs, only_contains_attribute_certs, only_some_reasons + ) + + # Compute last_update/next_update timestamps + last_update = datetime.now(tz=tz.utc).replace(microsecond=0) + if next_update is None: + next_update = last_update + timedelta(days=1) + else: + next_update = next_update.replace(microsecond=0) + + if settings.USE_TZ is False: + last_update = timezone.make_naive(last_update, timezone=tz.utc) + + if timezone.is_aware(next_update): + next_update = timezone.make_naive(next_update, timezone=tz.utc) + + # Initialize builder + builder = x509.CertificateRevocationListBuilder() + builder = builder.issuer_name(ca.pub.loaded.subject) + builder = builder.last_update(last_update) + builder = builder.next_update(next_update) + + # Add AuthorityKeyIdentifier extension from the certificate authority + builder = builder.add_extension(ca.get_authority_key_identifier(), critical=False) + + # Add the IssuingDistributionPoint extension + builder = self._add_issuing_distribution_point_extension( + builder, + only_contains_ca_certs=only_contains_ca_certs, + only_contains_user_certs=only_contains_user_certs, + only_contains_attribute_certs=only_contains_attribute_certs, + only_some_reasons=only_some_reasons, + ) + + builder = self._add_revoked_certificates( + builder, + ca, + now=last_update, + only_contains_ca_certs=only_contains_ca_certs, + only_contains_user_certs=only_contains_user_certs, + only_contains_attribute_certs=only_contains_attribute_certs, + only_some_reasons=only_some_reasons, + ) + + # Create subquery for the current CRL number with the given scope. + number_subquery = ( + self.scope( + ca=ca, + only_contains_ca_certs=only_contains_ca_certs, + only_contains_user_certs=only_contains_user_certs, + only_contains_attribute_certs=only_contains_attribute_certs, + only_some_reasons=only_some_reasons, + ) + .order_by("-number") + .values(new_number=models.F("number") + 1)[:1] + ) + + # Create database object (as late as possible so any exception above would not hit the database) + obj = self.create( + ca=ca, + number=Coalesce(models.Subquery(number_subquery, default=1), 0), + only_contains_ca_certs=only_contains_ca_certs, + only_contains_user_certs=only_contains_user_certs, + only_contains_attribute_certs=only_contains_attribute_certs, + only_some_reasons=only_some_reasons, + last_update=last_update, + next_update=next_update, + ) + + # Refresh the object from the database, since we need to access the number. See: + # https://docs.djangoproject.com/en/5.1/ref/models/expressions/#f-assignments-persist-after-model-save + obj.refresh_from_db() + + # Add the CRL Number extension + builder = builder.add_extension(x509.CRLNumber(crl_number=obj.number), critical=False) + + # Create the signed CRL + crl = ca.key_backend.sign_certificate_revocation_list( + ca=ca, use_private_key_options=key_backend_options, builder=builder, algorithm=ca.algorithm + ) + + # Store CRL in the database + obj.data = crl.public_bytes(Encoding.DER) + obj.save() + + return obj + + class AcmeAccountManager(AcmeAccountManagerBase): """Model manager for :py:class:`~django_ca.models.AcmeAccount`.""" diff --git a/ca/django_ca/migrations/0047_certificaterevocationlist.py b/ca/django_ca/migrations/0047_certificaterevocationlist.py new file mode 100644 index 000000000..c4cc89893 --- /dev/null +++ b/ca/django_ca/migrations/0047_certificaterevocationlist.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.2 on 2024-10-14 17:20 + +import django.db.models.deletion +import django_ca.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_ca', '0046_rename_expires_certificate_not_after_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='CertificateRevocationList', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('number', models.PositiveIntegerField(db_index=True, help_text='Monotonically increasing number for the CRLNumber extension.')), + ('last_update', models.DateTimeField(help_text="The CRL's activation time.")), + ('next_update', models.DateTimeField(help_text="The CRL's next update time.")), + ('only_contains_ca_certs', models.BooleanField(default=False)), + ('only_contains_user_certs', models.BooleanField(default=False)), + ('only_contains_attribute_certs', models.BooleanField(default=False)), + ('only_some_reasons', models.JSONField(decoder=django_ca.models.ReasonDecoder, default=None, encoder=django_ca.models.ReasonEncoder, null=True)), + ('data', models.BinaryField(null=True)), + ('ca', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ca.certificateauthority', verbose_name='Certificate Authority')), + ], + options={ + 'indexes': [models.Index(fields=['ca', 'number', 'only_contains_user_certs', 'only_contains_ca_certs', 'only_some_reasons'], name='django_ca_c_ca_id_bcb21f_idx')], + }, + ), + ] diff --git a/ca/django_ca/models.py b/ca/django_ca/models.py index 9f272951d..70d2a7400 100644 --- a/ca/django_ca/models.py +++ b/ca/django_ca/models.py @@ -25,7 +25,7 @@ import typing from collections.abc import Iterable from datetime import datetime, timedelta, timezone as tz -from typing import Optional, Union +from typing import Literal, Optional, Union import josepy as jose from acme import challenges, messages @@ -56,7 +56,7 @@ from django_ca.acme.constants import BASE64_URL_ALPHABET, IdentifierType, Status from django_ca.conf import CertificateRevocationListProfile, model_settings from django_ca.constants import REVOCATION_REASONS, ReasonFlags -from django_ca.deprecation import RemovedInDjangoCA230Warning, deprecate_argument +from django_ca.deprecation import RemovedInDjangoCA230Warning, deprecate_argument, deprecate_function from django_ca.extensions import get_extension_name from django_ca.key_backends import KeyBackend, key_backends from django_ca.managers import ( @@ -67,6 +67,7 @@ AcmeOrderManager, CertificateAuthorityManager, CertificateManager, + CertificateRevocationListManager, ) from django_ca.modelfields import ( AuthorityInformationAccessField, @@ -87,12 +88,12 @@ AcmeOrderQuerySet, CertificateAuthorityQuerySet, CertificateQuerySet, + CertificateRevocationListQuerySet, ) from django_ca.signals import post_revoke_cert, post_sign_cert, pre_revoke_cert, pre_sign_cert from django_ca.typehints import ( AllowedHashTypes, CertificateExtension, - CertificateRevocationListScopes, ConfigurableExtension, ConfigurableExtensionDict, EndEntityCertificateExtension, @@ -151,7 +152,7 @@ def json_validator(value: Union[str, bytes, bytearray]) -> None: """Validated that the given data is valid JSON.""" try: json.loads(value) - except Exception as e: + except Exception as e: # pragma: no cover raise ValidationError(_("Must be valid JSON: %(message)s") % {"message": str(e)}) from e @@ -163,6 +164,25 @@ def pem_validator(value: str) -> None: raise ValidationError(_("Not a valid PEM.")) +class ReasonEncoder(json.JSONEncoder): + """Encoder for revocation reasons.""" + + def default(self, o: Union[x509.ReasonFlags, Iterable[x509.ReasonFlags]]) -> Union[str, list[str]]: + if isinstance(o, Iterable): + return sorted(elem.name for elem in o) + # if isinstance(o, x509.ReasonFlags): + # return o.name + raise TypeError(f"Object of type {o.__class__.__name__} is not serializable with this encoder.") + + +class ReasonDecoder(json.JSONDecoder): + """Decoder for revocation reasons.""" + + def decode(self, s: str) -> frozenset[x509.ReasonFlags]: # type: ignore[override] # _w is internal arg + decoded: list[str] = super().decode(s) + return frozenset(x509.ReasonFlags[elem] for elem in decoded) + + class DjangoCAModel(models.Model): """Abstract base model for all django-ca models.""" @@ -645,34 +665,28 @@ def cache_crls(self, key_backend_options: BaseModel) -> None: Support for passing a custom hash algorithm to this function was removed. """ for crl_profile in model_settings.CA_CRL_PROFILES.values(): + now = datetime.now(tz=tz.utc) + # If there is an override for the current CA, create a new profile model with values updated from # the override. if crl_profile_override := crl_profile.OVERRIDES.get(self.serial): if crl_profile_override.skip: continue - config = crl_profile.model_dump() + config = crl_profile.model_dump(exclude_unset=True) config.update(crl_profile_override.model_dump(exclude_unset=True)) crl_profile = CertificateRevocationListProfile.model_validate(config) - expires = int(crl_profile.expires.total_seconds()) - crl = self.get_crl( + crl = CertificateRevocationList.objects.create_certificate_revocation_list( + ca=self, key_backend_options=key_backend_options, - expires=expires, - algorithm=self.algorithm, - scope=crl_profile.scope, + next_update=now + crl_profile.expires, + only_contains_ca_certs=crl_profile.only_contains_ca_certs, + only_contains_user_certs=crl_profile.only_contains_user_certs, + only_contains_attribute_certs=crl_profile.only_contains_attribute_certs, + only_some_reasons=crl_profile.only_some_reasons, ) - - for encoding in crl_profile.encodings: - cache_key = get_crl_cache_key(self.serial, encoding, scope=crl_profile.scope) - - if expires >= 600: # pragma: no branch - # for longer expiries we subtract a random value so that regular CRL regeneration is - # distributed a bit - expires -= random.randint(1, 5) * 60 - - encoded_crl = crl.public_bytes(encoding) - cache.set(cache_key, encoded_crl, expires) + crl.cache() def get_end_entity_certificate_extensions( self, public_key: CertificateIssuerPublicKeyTypes @@ -995,10 +1009,16 @@ def get_authority_key_identifier_extension(self) -> x509.Extension[x509.Authorit value=self.get_authority_key_identifier(), ) - def get_crl_certs( - self, scope: typing.Literal[None, "ca", "user", "attribute"], now: datetime + @deprecate_function(RemovedInDjangoCA230Warning) + def get_crl_certs( # pragma: no cover + self, scope: typing.Literal[None, "ca", "user"], now: datetime ) -> Iterable[X509CertMixin]: - """Get CRLs for the given scope.""" + """Get CRLs for the given scope. + + .. deprecated:: 2.1.0 + + This function is deprecated and will be removed in django-ca 2.3.0. + """ ca_qs = self.children.filter(not_after__gt=now).revoked() cert_qs = self.certificate_set.filter(not_after__gt=now).revoked() @@ -1006,29 +1026,37 @@ def get_crl_certs( return ca_qs if scope == "user": return cert_qs - if scope == "attribute": - return [] # not really supported if scope is None: return itertools.chain(ca_qs, cert_qs) - raise ValueError('scope must be either None, "ca", "user" or "attribute"') - + raise ValueError('scope must be either None, "ca" or "user".') + + @deprecate_function(RemovedInDjangoCA230Warning) + @deprecate_argument("algorithm", RemovedInDjangoCA230Warning) + @deprecate_argument("counter", RemovedInDjangoCA230Warning) + @deprecate_argument("full_name", RemovedInDjangoCA230Warning) + @deprecate_argument("relative_name", RemovedInDjangoCA230Warning) + @deprecate_argument("include_issuing_distribution_point", RemovedInDjangoCA230Warning) def get_crl( self, key_backend_options: BaseModel, expires: int = 86400, - algorithm: Optional[AllowedHashTypes] = None, - scope: Optional[CertificateRevocationListScopes] = None, - counter: Optional[str] = None, - full_name: Optional[Iterable[x509.GeneralName]] = None, - relative_name: Optional[x509.RelativeDistinguishedName] = None, - include_issuing_distribution_point: Optional[bool] = None, + algorithm: Optional[AllowedHashTypes] = None, # pylint: disable=unused-argument + scope: Optional[Literal[None, "ca", "user", "attribute"]] = None, + counter: Optional[str] = None, # pylint: disable=unused-argument + full_name: Optional[Iterable[x509.GeneralName]] = None, # pylint: disable=unused-argument + relative_name: Optional[x509.RelativeDistinguishedName] = None, # pylint: disable=unused-argument + include_issuing_distribution_point: Optional[bool] = None, # pylint: disable=unused-argument ) -> x509.CertificateRevocationList: """Generate a Certificate Revocation List (CRL). - The ``full_name`` and ``relative_name`` parameters describe how to retrieve the CRL and are used in - the `Issuing Distribution Point extension `_. - The former defaults to the ``crl_url`` field, pass ``None`` to not include the value. At most one of - the two may be set. + .. deprecated:: 2.1.0 + + This function is deprecated and will be removed in django-ca 2.3.0. + + .. versionchanged:: 2.1.0 + + The `algorithm`, `counter`, `full_name`, `relative_name` and `include_issuing_distribution_point` + parameters no longer have any effect. Using them will issue an additional warning. Parameters ---------- @@ -1036,148 +1064,20 @@ def get_crl( Options required for using the private key of the certificate authority. expires : int The time in seconds when this CRL expires. Note that you should generate a new CRL until then. - algorithm : :class:`~cg:cryptography.hazmat.primitives.hashes.HashAlgorithm`, optional - The hash algorithm used to generate the signature of the CRL. By default, the algorithm used for - signing the CA is used. If a value is passed for an Ed25519/Ed448 CA, `ValueError` is raised. scope : {None, 'ca', 'user', 'attribute'}, optional What to include in the CRL: Use ``"ca"`` to include only revoked certificate authorities and ``"user"`` to include only certificates or ``None`` (the default) to include both. - ``"attribute"`` is reserved for future use and always produces an empty CRL. - counter : str, optional - Override the counter-variable for the CRL Number extension. Passing the same key to multiple - invocations will yield a different sequence then what would ordinarily be returned. The default is - to use the scope as the key. - full_name : list of :py:class:`~cg:cryptography.x509.GeneralName`, optional - List of general names to use in the Issuing Distribution Point extension. If not passed, use - the full names of the first distribution point in ``sign_crl_distribution_points`` (if present) - that has full names set. - relative_name : :py:class:`~cg:cryptography.x509.RelativeDistinguishedName`, optional - Used in Issuing Distribution Point extension, retrieve the CRL relative to the issuer. - include_issuing_distribution_point: bool, optional - Force the inclusion/exclusion of the IssuingDistributionPoint extension. By default, the inclusion - is automatically determined. - - Returns - ------- - bytes - The CRL in the requested format. """ - # pylint: disable=too-many-locals; It's not easy to create a CRL. Sorry. - - now = datetime.now(tz=tz.utc) - now_naive = now.replace(tzinfo=None) - - # Default to the algorithm used by the certificate authority itself (None in case of Ed448/Ed25519 - # based certificate authorities). - if algorithm is None: - algorithm = self.algorithm - - builder = x509.CertificateRevocationListBuilder() - builder = builder.issuer_name(self.pub.loaded.subject) - builder = builder.last_update(now_naive) - builder = builder.next_update(now_naive + timedelta(seconds=expires)) - - parsed_full_name = None - if full_name is not None: - parsed_full_name = full_name - - # CRLs for root CAs with scope "ca" (or no scope - this includes CAs) do not set a full_name in the - # IssuingDistributionPoint extension by default. For full path validation with CRLs, the CRL is also - # used for validating the Root CA (which does not contain a CRL Distribution Point). But the Full Name - # in the CRL IDP and the CA CRL DP have to match. See also: - # https://github.com/mathiasertl/django-ca/issues/64 - elif scope in ("ca", None) and self.parent is None: - parsed_full_name = None - - # If CA_DEFAULT_HOSTNAME is set, CRLs with scope "ca" add the same URL in the IssuingDistributionPoint - # extension that is also added in the CRL Distribution Points extension for CAs issued by this CA. - # See also: - # https://github.com/mathiasertl/django-ca/issues/64 - elif scope == "ca" and model_settings.CA_DEFAULT_HOSTNAME: - crl_path = reverse("django_ca:ca-crl", kwargs={"serial": self.serial}) - parsed_full_name = [ - x509.UniformResourceIdentifier(f"http://{model_settings.CA_DEFAULT_HOSTNAME}{crl_path}") - ] - elif scope in ("user", None) and self.sign_crl_distribution_points: - full_names = [] - for dpoint in self.sign_crl_distribution_points.value: - if dpoint.full_name: - full_names += dpoint.full_name - if full_names: - parsed_full_name = full_names - - # Keyword arguments for the IssuingDistributionPoint extension - only_contains_attribute_certs = False - only_contains_ca_certs = False - only_contains_user_certs = False - indirect_crl = False - - if scope == "ca": - only_contains_ca_certs = True - elif scope == "user": - only_contains_user_certs = True - elif scope == "attribute": - # sorry, nothing we support right now - only_contains_attribute_certs = True - - if settings.USE_TZ is True: - crl_certificates = self.get_crl_certs(scope, now) - else: - crl_certificates = self.get_crl_certs(scope, now_naive) - - for cert in crl_certificates: - builder = builder.add_revoked_certificate(cert.get_revocation()) - - # Validate that the user has selected a usable algorithm - validate_public_key_parameters(self.key_type, algorithm) - - # We can only add the IDP extension if one of these properties is set, see RFC 5280, 5.2.5. - if include_issuing_distribution_point is None: - include_issuing_distribution_point = ( - only_contains_attribute_certs - or only_contains_user_certs - or only_contains_ca_certs - or parsed_full_name is not None - or relative_name is not None - ) - - if include_issuing_distribution_point is True: - builder = builder.add_extension( - x509.IssuingDistributionPoint( - indirect_crl=indirect_crl, - only_contains_attribute_certs=only_contains_attribute_certs, - only_contains_ca_certs=only_contains_ca_certs, - only_contains_user_certs=only_contains_user_certs, - full_name=parsed_full_name, - only_some_reasons=None, - relative_name=relative_name, - ), - critical=True, - ) - - # Add AuthorityKeyIdentifier from CA - aki = self.get_authority_key_identifier() - builder = builder.add_extension(aki, critical=False) - - # Add the CRLNumber extension (RFC 5280, 5.2.3) - if counter is None: - counter = scope or "all" - crl_number_data = json.loads(self.crl_number) - crl_number = int(crl_number_data["scope"].get(counter, 0)) - builder = builder.add_extension(x509.CRLNumber(crl_number=crl_number), critical=False) - - # Get the backend. - if self.is_usable(options=key_backend_options) is False: - raise ValueError("Backend cannot be used for signing by this process.") - - # increase crl_number for the given scope and save - crl_number_data["scope"][counter] = crl_number + 1 - self.crl_number = json.dumps(crl_number_data) - self.save() - - return self.key_backend.sign_certificate_revocation_list( - ca=self, use_private_key_options=key_backend_options, builder=builder, algorithm=algorithm + next_update = datetime.now(tz=tz.utc) + timedelta(seconds=expires) + crl = CertificateRevocationList.objects.create_certificate_revocation_list( + ca=self, + key_backend_options=key_backend_options, + next_update=next_update, + only_contains_ca_certs=scope == "ca", + only_contains_user_certs=scope == "user", + only_contains_attribute_certs=scope == "attribute", ) + return crl.loaded @property def path_length(self) -> Optional[int]: @@ -1296,6 +1196,112 @@ def root(self) -> CertificateAuthority: return self.ca.root +class CertificateRevocationList(DjangoCAModel): + """The `CertificateRevocationList` is used to store CRLs in the database. + + Only one of `only_contains_ca_certs`, `only_contains_ca_certs` and `only_contains_attribute_certs` can be + ``True``. + + .. versionadded:: 2.1.0 + """ + + #: Certificate Authority that the CRL is generated for. + ca = models.ForeignKey( + CertificateAuthority, on_delete=models.CASCADE, verbose_name=_("Certificate Authority") + ) + #: CRL Number used in this CRL. + number = models.PositiveIntegerField( + db_index=True, help_text=_("Monotonically increasing number for the CRLNumber extension.") + ) + #: When the CRL was generated. + last_update = models.DateTimeField(help_text=_("The CRL's activation time.")) + #: When the CRL expires. + next_update = models.DateTimeField(help_text=_("The CRL's next update time.")) + + #: True if the CRL contains only CA certificates. + only_contains_ca_certs = models.BooleanField(default=False) + + #: True if the CRL contains only end-entity certificates. + only_contains_user_certs = models.BooleanField(default=False) + + #: True if the CRL contains only attribute certificates. + only_contains_attribute_certs = models.BooleanField(default=False) + + #: Optional list of revocation reasons. If set, the CRL only contains certificates revoked for the given + #: reasons. + only_some_reasons = models.JSONField( + null=True, default=None, encoder=ReasonEncoder, decoder=ReasonDecoder + ) + + #: The DER-encoded binary data of the CRL. + data = models.BinaryField(null=True) + + objects: CertificateRevocationListManager = CertificateRevocationListManager.from_queryset( + CertificateRevocationListQuerySet + )() + + class Meta: + indexes = ( + # Index to speed-up lookups of the most recent CRL with the given scope. + models.Index( + fields=[ + "ca", + "number", + "only_contains_user_certs", + "only_contains_ca_certs", + "only_contains_attribute_certs", + "only_some_reasons", + ] + ), + ) + + def __str__(self) -> str: + return f"{self.number} (next update: {self.next_update})" + + @cached_property + def loaded(self) -> x509.CertificateRevocationList: + """The CRL loaded into a :class:`cg:cryptography.x509.CertificateRevocationList` object.""" + if self.data is None: + raise ValueError("CRL is not yet generated for this object.") + return x509.load_der_x509_crl(bytes(self.data)) + + @cached_property + def pem(self) -> bytes: + """The CRL encoded in PEM format.""" + return self.loaded.public_bytes(Encoding.PEM) + + def cache(self) -> None: + """Cache this instance.""" + if self.data is None: + raise ValueError("CRL is not yet generated for this object.") + + now = datetime.now(tz=tz.utc) + if self.loaded.next_update_utc is not None: + expires_seconds = (self.loaded.next_update_utc - now).total_seconds() + else: # pragma: no cover # we never generate CRLs without a next_update flag. + expires_seconds = 86400 + for encoding in [Encoding.PEM, Encoding.DER]: + cache_key = get_crl_cache_key( + self.ca.serial, + encoding, + only_contains_ca_certs=self.only_contains_ca_certs, + only_contains_user_certs=self.only_contains_user_certs, + only_contains_attribute_certs=self.only_contains_attribute_certs, + only_some_reasons=self.only_some_reasons, + ) + + if expires_seconds >= 600: # pragma: no branch + # for longer expiries we subtract a random value so that regular CRL regeneration is + # distributed a bit + expires_seconds -= random.randint(1, 5) * 60 + + if encoding == Encoding.PEM: + encoded_crl = self.pem + else: + encoded_crl = bytes(self.data) + cache.set(cache_key, encoded_crl, expires_seconds) + + class CertificateOrder(DjangoCAModel): """An order for a certificate that is issued asynchronously (usually via the API).""" diff --git a/ca/django_ca/pydantic/type_aliases.py b/ca/django_ca/pydantic/type_aliases.py index 4bff388ba..3e21a22fb 100644 --- a/ca/django_ca/pydantic/type_aliases.py +++ b/ca/django_ca/pydantic/type_aliases.py @@ -21,9 +21,9 @@ from pydantic_core import core_schema from pydantic_core.core_schema import IsInstanceSchema, LiteralSchema +from cryptography import x509 from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import ec -from cryptography.hazmat.primitives.serialization import Encoding from django_ca import constants from django_ca.pydantic.validators import ( @@ -33,10 +33,12 @@ non_empty_validator, oid_parser, oid_validator, + reason_flag_crl_scope_validator, + reason_flag_validator, serial_validator, unique_validator, ) -from django_ca.typehints import AllowedHashTypes, CertificateRevocationListEncodings +from django_ca.typehints import AllowedHashTypes T = TypeVar("T", bound=type[Any]) @@ -48,12 +50,12 @@ def _get_cryptography_schema( json_serializer: Optional[Callable[[T], str]] = None, str_loader: Optional[Callable[[str], T]] = None, ) -> GetPydanticSchema: - if json_serializer is None: + if json_serializer is None: # pragma: no branch def json_serializer(instance: T) -> str: return name_mapping[type(instance)] - if str_loader is None: + if str_loader is None: # pragma: no branch def str_loader(value: str) -> T: return type_mapping[value]() # type: ignore[no-any-return,misc] # false positive @@ -65,7 +67,7 @@ def str_loader(value: str) -> T: ] ) - if isinstance(cls, list): + if isinstance(cls, list): # pragma: no cover python_schema: Union[LiteralSchema, IsInstanceSchema] = core_schema.literal_schema(cls) else: python_schema = core_schema.is_instance_schema(cls) @@ -91,6 +93,11 @@ def str_loader(value: str) -> T: ), ] +#: A subset of :class:`~cg:cryptography.x509.ReasonFlags` that allows only reason codes valid in a certificate +#: revocation list (CRL). +CertificateRevocationListReasonCode = Annotated[ + x509.ReasonFlags, BeforeValidator(reason_flag_validator), AfterValidator(reason_flag_crl_scope_validator) +] #: A type alias for :py:class:`~cg:cryptography.hazmat.primitives.asymmetric.ec.EllipticCurve` instances. #: @@ -114,21 +121,6 @@ def str_loader(value: str) -> T: ), ] -#: A type alias for :py:class:`~cg:cryptography.hazmat.primitives.serialization.Encoding` instances. -#: -#: This type alias validates names from -#: :py:attr:`~django_ca.constants.CERTIFICATE_REVOCATION_LIST_ENCODING_TYPES` and serializes to the canonical -#: name in JSON. Models using this type alias can be used with strict schema validation. -CertificateRevocationListEncodingTypeAlias = Annotated[ - CertificateRevocationListEncodings, - _get_cryptography_schema( - list(constants.CERTIFICATE_REVOCATION_LIST_ENCODING_NAMES), - constants.CERTIFICATE_REVOCATION_LIST_ENCODING_TYPES, - constants.CERTIFICATE_REVOCATION_LIST_ENCODING_NAMES, - json_serializer=lambda v: v.name, - str_loader=lambda v: Encoding[v], - ), -] #: A type alias for an integer that is a power of two, e.g. an RSA/DSA KeySize. #: diff --git a/ca/django_ca/pydantic/validators.py b/ca/django_ca/pydantic/validators.py index e68403868..8b3a65832 100644 --- a/ca/django_ca/pydantic/validators.py +++ b/ca/django_ca/pydantic/validators.py @@ -14,9 +14,9 @@ """Validators for Pydantic models.""" import base64 -from collections.abc import Sequence +from collections.abc import Iterable, Sequence from datetime import timedelta -from typing import Any, Callable, Literal, TypeVar, Union +from typing import Any, Callable, Literal, Optional, TypeVar, Union from urllib.parse import urlsplit import idna @@ -42,6 +42,25 @@ def base64_encoded_str_validator(value: Any) -> Any: return value +def crl_scope_validator( + only_contains_ca_certs: bool, + only_contains_user_certs: bool, + only_contains_attribute_certs: bool, + only_some_reasons: Optional[Iterable[x509.ReasonFlags]], +) -> None: + """Validate the scope of a certificate revocation list (CRL).""" + contains = (only_contains_ca_certs, only_contains_user_certs, only_contains_attribute_certs) + if len([c for c in contains if c is True]) > 1: + raise ValueError( + "Only one of `only_contains_ca_certs`, `only_contains_user_certs` and " + "`only_contains_attribute_certs` can be set." + ) + + if only_some_reasons is not None: + for reason in only_some_reasons: + reason_flag_crl_scope_validator(reason) + + def dns_validator(name: str) -> str: """IDNA encoding for domains. @@ -172,6 +191,20 @@ def pem_csr_validator(value: bytes) -> bytes: return value +def reason_flag_validator(value: Any) -> Any: + """Validate a ``str`` to a :class:`~cg:cryptography.x509.ReasonFlags`.""" + if isinstance(value, str): + return x509.ReasonFlags[value] + return value + + +def reason_flag_crl_scope_validator(value: x509.ReasonFlags) -> x509.ReasonFlags: + """Ensure that the :class:`~cg:cryptography.x509.ReasonFlags` is allowed in a CRL scope.""" + if value in (x509.ReasonFlags.unspecified, x509.ReasonFlags.remove_from_crl): + raise ValueError("unspecified and remove_from_crl are not valid for `only_some_reasons`.") + return value + + def serial_validator(value: str) -> str: """Validator for serials.""" value = value.replace(":", "").upper() diff --git a/ca/django_ca/querysets.py b/ca/django_ca/querysets.py index c2ce2a1a0..434d93f3d 100644 --- a/ca/django_ca/querysets.py +++ b/ca/django_ca/querysets.py @@ -15,7 +15,11 @@ import abc import typing -from typing import TYPE_CHECKING, Any, Generic, TypeVar +from collections.abc import Iterable +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any, Generic, Optional, TypeVar + +from cryptography import x509 from django.core.exceptions import ImproperlyConfigured from django.db import models @@ -30,12 +34,16 @@ if not TYPE_CHECKING: # Inverting TYPE_CHECKING check here to make pylint==2.9.3 happy: # https://github.com/PyCQA/pylint/issues/4697 + CertificateQuerySetBase = CertificateAuthorityQuerySetBase = models.QuerySet + CertificateRevocationListQuerySetBase = models.QuerySet AcmeAccountQuerySetBase = AcmeAuthorizationQuerySetBase = AcmeCertificateQuerySetBase = ( AcmeChallengeQuerySetBase - ) = AcmeOrderQuerySetBase = CertificateQuerySetBase = CertificateAuthorityQuerySetBase = models.QuerySet + ) = AcmeOrderQuerySetBase = models.QuerySet QuerySetTypeVar = TypeVar("QuerySetTypeVar", bound=models.QuerySet) else: # pragma: no cover # only used for type checking + from typing import Self + from django_ca.models import ( AcmeAccount, AcmeAuthorization, @@ -44,6 +52,7 @@ AcmeOrder, Certificate, CertificateAuthority, + CertificateRevocationList, X509CertMixin, ) @@ -54,18 +63,19 @@ AcmeOrderQuerySetBase = models.QuerySet[AcmeOrder] CertificateAuthorityQuerySetBase = models.QuerySet[CertificateAuthority] CertificateQuerySetBase = models.QuerySet[Certificate] + CertificateRevocationListQuerySetBase = models.QuerySet[CertificateRevocationList] QuerySetTypeVar = TypeVar("QuerySetTypeVar", bound=models.QuerySet[X509CertMixin]) -class QuerySetProtocol( +class X509CertMixinQuerySetProtocol( typing.Protocol[X509CertMixinTypeVar] ): # pragma: nocover; pylint: disable=missing-function-docstring """Protocol used for a generic-self in mixins. Note that I couldn't get this to work in functions that should return the same type as well. So:: - def filter(self: QuerySetProtocol) -> QuerySetProtocol: + def filter(self: X509CertMixinQuerySetProtocol) -> X509CertMixinQuerySetProtocol: ... ... doesn't work, unfortunately. @@ -75,14 +85,18 @@ def filter(self: QuerySetProtocol) -> QuerySetProtocol: model: X509CertMixinTypeVar + def filter(self, *args: Any, **kwargs: Any) -> "Self": ... + def get(self, *args: Any, **kwargs: Any) -> X509CertMixinTypeVar: ... + def revoked(self) -> "Self": ... + class DjangoCAMixin(Generic[X509CertMixinTypeVar], metaclass=abc.ABCMeta): """Mixin with common methods for CertificateAuthority and Certificate models.""" def get_by_serial_or_cn( - self: QuerySetProtocol[X509CertMixinTypeVar], identifier: str + self: X509CertMixinQuerySetProtocol[X509CertMixinTypeVar], identifier: str ) -> X509CertMixinTypeVar: """Get a model by serial *or* by common name. @@ -109,6 +123,28 @@ def get_by_serial_or_cn( except self.model.DoesNotExist: return self.get(startswith_query) + def for_certificate_revocation_list( + self: X509CertMixinQuerySetProtocol[X509CertMixinTypeVar], + *, + now: datetime, + reasons: Optional[Iterable[x509.ReasonFlags]], + grace_timedelta: timedelta = timedelta(minutes=10), + ) -> X509CertMixinQuerySetProtocol[X509CertMixinTypeVar]: + """Get certificates for a certificate revocation list (CRL). + + .. versionadded:: 2.1.0 + """ + # Include certificates expired up to 10 minutes ago to account for a potential clock skew by a client. + not_before = now + grace_timedelta + not_after = now - grace_timedelta + + qs = self.filter(not_before__lt=not_before, not_after__gt=not_after).revoked() + + if reasons is not None: + reason_names = [reason.name for reason in reasons] + qs = self.filter(revoked_reason__in=reason_names) + return qs + class CertificateAuthorityQuerySet(DjangoCAMixin["CertificateAuthority"], CertificateAuthorityQuerySetBase): """QuerySet for the CertificateAuthority model.""" @@ -215,6 +251,39 @@ def revoked(self) -> "CertificateQuerySet": return self.filter(revoked=True) +class CertificateRevocationListQuerySet(CertificateRevocationListQuerySetBase): + """Queryset for :class:`~django_ca.models.CertificateRevocationList`.""" + + def newest(self) -> Optional["CertificateRevocationList"]: + """Get the instance with the highest CRL number.""" + return self.order_by("-number").first() + + def reasons( + self, only_some_reasons: Optional[frozenset[x509.ReasonFlags]] + ) -> "CertificateRevocationListQuerySet": + """Return CRLs with the given set of reasons.""" + if only_some_reasons is None: + return self.filter(only_some_reasons__isnull=True) + reason_names = [reason.name for reason in only_some_reasons] + return self.filter(only_some_reasons=sorted(reason_names)) + + def scope( + self, + ca: "CertificateAuthority", + only_contains_ca_certs: bool = False, + only_contains_user_certs: bool = False, + only_contains_attribute_certs: bool = False, + only_some_reasons: Optional[frozenset[x509.ReasonFlags]] = None, + ) -> "CertificateRevocationListQuerySet": + """Return CRLs with the given scope.""" + return self.filter( + ca=ca, + only_contains_ca_certs=only_contains_ca_certs, + only_contains_user_certs=only_contains_user_certs, + only_contains_attribute_certs=only_contains_attribute_certs, + ).reasons(only_some_reasons) + + class AcmeAccountQuerySet(AcmeAccountQuerySetBase): """QuerySet for :py:class:`~django_ca.models.AcmeAccount`.""" diff --git a/ca/django_ca/tests/base/assertions.py b/ca/django_ca/tests/base/assertions.py index 82423ee7e..663ec4d3e 100644 --- a/ca/django_ca/tests/base/assertions.py +++ b/ca/django_ca/tests/base/assertions.py @@ -37,7 +37,7 @@ from django_ca.conf import model_settings from django_ca.constants import ReasonFlags -from django_ca.deprecation import RemovedInDjangoCA220Warning +from django_ca.deprecation import RemovedInDjangoCA220Warning, RemovedInDjangoCA230Warning from django_ca.key_backends.storages import StoragesUsePrivateKeyOptions from django_ca.models import Certificate, CertificateAuthority, X509CertMixin from django_ca.signals import post_create_ca, post_issue_cert, post_sign_cert, pre_create_ca, pre_sign_cert @@ -217,7 +217,7 @@ def assert_crl( # noqa: PLR0913 if idp is not None: # pragma: no branch extensions.append(idp) - if last_update is None: + if last_update is None: # pragma: no branch last_update = now.replace(microsecond=0) extensions.append(signer.get_authority_key_identifier_extension()) extensions.append( @@ -244,7 +244,11 @@ def assert_crl( # noqa: PLR0913 assert parsed_crl.issuer == signer.pub.loaded.subject assert parsed_crl.last_update_utc == last_update assert parsed_crl.next_update_utc == expires_timestamp - assert list(parsed_crl.extensions) == extensions + + def ext_sorter(ext: x509.Extension[x509.ExtensionType]) -> str: + return ext.oid.dotted_string + + assert sorted(parsed_crl.extensions, key=ext_sorter) == sorted(extensions, key=ext_sorter) entries = {e.serial_number: e for e in parsed_crl} assert sorted(entries) == sorted(c.pub.loaded.serial_number for c in expected) @@ -370,6 +374,33 @@ def assert_improperly_configured(msg: str) -> Iterator[None]: yield +def assert_issuing_distribution_point( + extension: x509.Extension[x509.IssuingDistributionPoint], + full_name: Optional[Iterable[x509.GeneralName]] = None, + relative_name: Optional[x509.RelativeDistinguishedName] = None, + only_contains_user_certs: bool = False, + only_contains_ca_certs: bool = False, + only_some_reasons: Optional[frozenset[x509.ReasonFlags]] = None, + indirect_crl: bool = False, + only_contains_attribute_certs: bool = False, + critical: bool = True, +) -> None: + """Shortcut for asserting an Issuing Point Distribution extension.""" + assert extension == x509.Extension( + oid=ExtensionOID.ISSUING_DISTRIBUTION_POINT, + critical=critical, + value=x509.IssuingDistributionPoint( + full_name=full_name, + relative_name=relative_name, + only_contains_user_certs=only_contains_user_certs, + only_contains_ca_certs=only_contains_ca_certs, + only_contains_attribute_certs=only_contains_attribute_certs, + indirect_crl=indirect_crl, + only_some_reasons=only_some_reasons, + ), + ) + + def assert_post_issue_cert(post: Mock, cert: Certificate) -> None: """Assert that the post_issue_cert signal was called with the expected certificate.""" post.assert_called_once_with(cert=cert, signal=post_issue_cert, sender=Certificate) @@ -445,6 +476,13 @@ def assert_removed_in_220(match: Optional[Union[str, "re.Pattern[str]"]] = None) yield +@contextmanager +def assert_removed_in_230(match: Optional[Union[str, "re.Pattern[str]"]] = None) -> Iterator[None]: + """Assert that a ``RemovedInDjangoCA200Warning`` is emitted.""" + with pytest.warns(RemovedInDjangoCA230Warning, match=match): + yield + + @contextmanager def assert_validation_error(errors: dict[str, list[str]]) -> Iterator[None]: """Context manager to assert that a ValidationError is thrown.""" diff --git a/ca/django_ca/tests/base/fixtures.py b/ca/django_ca/tests/base/fixtures.py index 915def0dc..41a4ef0be 100644 --- a/ca/django_ca/tests/base/fixtures.py +++ b/ca/django_ca/tests/base/fixtures.py @@ -42,7 +42,8 @@ from django_ca.key_backends.hsm.models import HSMCreatePrivateKeyOptions from django_ca.key_backends.hsm.session import SessionPool from django_ca.key_backends.storages import StoragesBackend -from django_ca.models import Certificate, CertificateAuthority +from django_ca.models import Certificate, CertificateAuthority, CertificateRevocationList +from django_ca.tests.base import constants from django_ca.tests.base.conftest_helpers import ( all_ca_names, all_cert_names, @@ -53,7 +54,7 @@ usable_ca_names, usable_cert_names, ) -from django_ca.tests.base.constants import CERT_DATA +from django_ca.tests.base.constants import CERT_DATA, TIMESTAMPS @pytest.fixture(params=all_cert_names) @@ -232,6 +233,77 @@ def rfc4514_subject(subject: x509.Name) -> Iterator[str]: yield x509.Name(reversed(list(subject))).rfc4514_string() +@pytest.fixture() +def root_crl(root: CertificateAuthority) -> Iterator[CertificateRevocationList]: + """Fixture for the global CRL object for the Root CA.""" + with open(constants.FIXTURES_DIR / "root.crl", "rb") as stream: + crl_data = stream.read() + last_update = TIMESTAMPS["everything_valid"] + next_update = last_update + timedelta(seconds=86400) + crl = CertificateRevocationList.objects.create( + ca=root, number=0, last_update=last_update, next_update=next_update, data=crl_data + ) + crl.cache() + yield crl + + +@pytest.fixture() +def root_ca_crl(root: CertificateAuthority) -> Iterator[CertificateRevocationList]: + """Fixture for the user CRL object for the Root CA.""" + with open(constants.FIXTURES_DIR / "root.ca.crl", "rb") as stream: + crl_data = stream.read() + last_update = TIMESTAMPS["everything_valid"] + next_update = last_update + timedelta(seconds=86400) + crl = CertificateRevocationList.objects.create( + ca=root, + number=0, + last_update=last_update, + next_update=next_update, + data=crl_data, + only_contains_ca_certs=True, + ) + crl.cache() + yield crl + + +@pytest.fixture() +def root_user_crl(root: CertificateAuthority) -> Iterator[CertificateRevocationList]: + """Fixture for the user CRL object for the Root CA.""" + with open(constants.FIXTURES_DIR / "root.user.crl", "rb") as stream: + crl_data = stream.read() + last_update = TIMESTAMPS["everything_valid"] + next_update = last_update + timedelta(seconds=86400) + crl = CertificateRevocationList.objects.create( + ca=root, + number=0, + last_update=last_update, + next_update=next_update, + data=crl_data, + only_contains_user_certs=True, + ) + crl.cache() + yield crl + + +@pytest.fixture() +def root_attribute_crl(root: CertificateAuthority) -> Iterator[CertificateRevocationList]: + """Fixture for the attribute CRL object for the Root CA.""" + with open(constants.FIXTURES_DIR / "root.attribute.crl", "rb") as stream: + crl_data = stream.read() + last_update = TIMESTAMPS["everything_valid"] + next_update = last_update + timedelta(seconds=86400) + crl = CertificateRevocationList.objects.create( + ca=root, + number=0, + last_update=last_update, + next_update=next_update, + data=crl_data, + only_contains_attribute_certs=True, + ) + crl.cache() + yield crl + + @pytest.fixture() def secondary_backend(request: "SubRequest") -> Iterator[StoragesBackend]: """Return a :py:class:`~django_ca.key_backends.storages.StoragesBackend` for the secondary key backend.""" diff --git a/ca/django_ca/tests/base/utils.py b/ca/django_ca/tests/base/utils.py index a468acda1..91a0b89cc 100644 --- a/ca/django_ca/tests/base/utils.py +++ b/ca/django_ca/tests/base/utils.py @@ -34,6 +34,7 @@ CertificateIssuerPrivateKeyTypes, CertificateIssuerPublicKeyTypes, ) +from cryptography.hazmat.primitives.serialization import Encoding from cryptography.x509.oid import AuthorityInformationAccessOID, ExtensionOID, NameOID from django.conf import settings @@ -50,6 +51,7 @@ from django_ca.tests.acme.views.constants import SERVER_NAME from django_ca.tests.base.constants import CERT_DATA, FIXTURES_DIR from django_ca.typehints import AllowedHashTypes, ArgumentGroup, CertificateExtension, ParsableKeyType +from django_ca.utils import get_crl_cache_key class DummyModel(BaseModel): @@ -346,6 +348,25 @@ def country(value: str) -> x509.NameAttribute: return x509.NameAttribute(NameOID.COUNTRY_NAME, value) +def crl_cache_key( + serial: str, + encoding: Encoding = Encoding.DER, + only_contains_ca_certs: bool = False, + only_contains_user_certs: bool = False, + only_contains_attribute_certs: bool = False, + only_some_reasons: Optional[Iterable[x509.ReasonFlags]] = None, +) -> str: + """Shortcut to get a CRL cache key.""" + return get_crl_cache_key( + serial, + encoding, + only_contains_ca_certs=only_contains_ca_certs, + only_contains_user_certs=only_contains_user_certs, + only_contains_attribute_certs=only_contains_attribute_certs, + only_some_reasons=only_some_reasons, + ) + + def crl_distribution_points( *distribution_points: x509.DistributionPoint, critical: bool = False ) -> x509.Extension[x509.CRLDistributionPoints]: @@ -443,19 +464,6 @@ def get_idp( ) -def idp_full_name(ca: CertificateAuthority) -> Optional[list[x509.UniformResourceIdentifier]]: - """Get the IDP full name for `ca`.""" - if ca.sign_crl_distribution_points is None: # pragma: no cover - return None - full_names = [] - for dpoint in ca.sign_crl_distribution_points.value: - if dpoint.full_name: # pragma: no branch - full_names += dpoint.full_name - if full_names: # pragma: no branch - return full_names - return None # pragma: no cover - - def iso_format(value: datetime, timespec: str = "seconds") -> str: """Convert a timestamp to ISO, with 'Z' instead of '+00:00'.""" return value.isoformat(timespec=timespec).replace("+00:00", "Z") diff --git a/ca/django_ca/tests/commands/test_cache_crls.py b/ca/django_ca/tests/commands/test_cache_crls.py index f865ff389..2c0b66994 100644 --- a/ca/django_ca/tests/commands/test_cache_crls.py +++ b/ca/django_ca/tests/commands/test_cache_crls.py @@ -18,16 +18,13 @@ from cryptography.hazmat.primitives.serialization import Encoding from django.core.cache import cache -from django.urls import reverse import pytest -from pytest_django.fixtures import SettingsWrapper from django_ca.models import Certificate, CertificateAuthority from django_ca.tests.base.assertions import assert_crl -from django_ca.tests.base.constants import CERT_DATA, TIMESTAMPS -from django_ca.tests.base.utils import cmd, get_idp, idp_full_name, uri -from django_ca.utils import get_crl_cache_key +from django_ca.tests.base.constants import TIMESTAMPS +from django_ca.tests.base.utils import cmd, crl_cache_key, get_idp # freeze time as otherwise CRLs might have rounding errors pytestmark = [pytest.mark.freeze_time(TIMESTAMPS["everything_valid"]), pytest.mark.usefixtures("clear_cache")] @@ -35,53 +32,26 @@ def assert_crl_by_ca(ca: CertificateAuthority, expected: Optional[list[Certificate]] = None) -> None: """Assert all cached CRLs for the given CA.""" - key = get_crl_cache_key(ca.serial, Encoding.DER, "ca") + key = crl_cache_key(ca.serial, only_contains_ca_certs=True) crl = cache.get(key) assert crl is not None - if ca.parent: - url_path = reverse("django_ca:ca-crl", kwargs={"serial": ca.serial}) - idp = get_idp(full_name=[uri(f"http://localhost:8000{url_path}")], only_contains_ca_certs=True) - else: - idp = get_idp(only_contains_ca_certs=True) - + idp = get_idp(only_contains_ca_certs=True) assert_crl(crl, signer=ca, algorithm=ca.algorithm, encoding=Encoding.DER, idp=idp) - key = get_crl_cache_key(ca.serial, Encoding.DER, "user") + key = crl_cache_key(ca.serial, only_contains_user_certs=True) crl = cache.get(key) assert crl is not None - idp = get_idp(full_name=idp_full_name(ca), only_contains_user_certs=True) + idp = get_idp(only_contains_user_certs=True) assert_crl(crl, signer=ca, algorithm=ca.algorithm, encoding=Encoding.DER, idp=idp, expected=expected) -def test_cmd(settings: SettingsWrapper, usable_cas: list[CertificateAuthority]) -> None: +def test_cmd(usable_cas: list[CertificateAuthority]) -> None: """Test the basic command.""" - settings.CA_CRL_PROFILES = { - "user": { - "expires": 86400, - "scope": "user", - "encodings": ["PEM", "DER"], - "OVERRIDES": { - CERT_DATA["pwd"]["serial"]: {"skip": True}, - }, - }, - "ca": { - "expires": 86400, - "scope": "ca", - "encodings": ["PEM", "DER"], - "OVERRIDES": { - CERT_DATA["pwd"]["serial"]: {"skip": True}, - }, - }, - } - stdout, stderr = cmd("cache_crls") assert stdout == "" assert stderr == "" for ca in usable_cas: - if ca.name == "pwd": - # TODO: not supported yet - continue assert_crl_by_ca(ca) @@ -90,9 +60,6 @@ def test_with_serial(usable_cert: Certificate) -> None: """Test passing an explicit serial.""" usable_cert.revoke() ca = usable_cert.ca - if CERT_DATA[ca.name].get("password"): - # TODO: not yet possible - return stdout, stderr = cmd("cache_crls", ca.serial) assert stdout == "" @@ -103,10 +70,6 @@ def test_with_serial(usable_cert: Certificate) -> None: @pytest.mark.freeze_time(TIMESTAMPS["everything_valid"]) # otherwise CRLs might have rounding errors def test_with_serial_with_empty_crl(usable_ca: CertificateAuthority) -> None: """Test passing an explicit serial.""" - if CERT_DATA[usable_ca.name].get("password"): - # TODO: not yet possible - return - stdout, stderr = cmd("cache_crls", usable_ca.serial) assert stdout == "" assert stderr == "" diff --git a/ca/django_ca/tests/commands/test_dump_crl.py b/ca/django_ca/tests/commands/test_dump_crl.py index 1ac36b1b9..26e5852a2 100644 --- a/ca/django_ca/tests/commands/test_dump_crl.py +++ b/ca/django_ca/tests/commands/test_dump_crl.py @@ -22,9 +22,8 @@ from unittest import mock from cryptography import x509 -from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.serialization import Encoding -from cryptography.x509.oid import CRLEntryExtensionOID, NameOID +from cryptography.x509.oid import CRLEntryExtensionOID from django.utils import timezone @@ -36,16 +35,11 @@ assert_command_error, assert_crl, assert_e2e_command_error, + assert_removed_in_230, assert_revoked, ) from django_ca.tests.base.constants import CERT_DATA, TIMESTAMPS -from django_ca.tests.base.utils import ( - cmd, - crl_distribution_points, - distribution_point, - get_idp, - idp_full_name, -) +from django_ca.tests.base.utils import cmd, cmd_e2e, get_idp # freeze time as otherwise CRLs might have rounding errors pytestmark = [pytest.mark.freeze_time(TIMESTAMPS["everything_valid"])] @@ -58,32 +52,29 @@ def dump_crl(*args: Any, **kwargs: Any) -> bytes: return out -def test_command(usable_ca: CertificateAuthority) -> None: - """Test the command for every usable CA.""" - stdout = dump_crl(ca=usable_ca, scope="user") - expected_idp = get_idp(full_name=idp_full_name(usable_ca), only_contains_user_certs=True) - assert_crl(stdout, signer=usable_ca, algorithm=usable_ca.algorithm, idp=expected_idp) +def dump_crl_e2e(serial: str, *args: str) -> bytes: + """Run a dump_crl command via cmd_e2e().""" + out, err = cmd_e2e(["dump_crl", f"--ca={serial}", *args], stdout=BytesIO(), stderr=BytesIO()) + assert err == b"" + return out -def test_rsa_ca_with_sha512(usable_root: CertificateAuthority) -> None: - """Test creating a CRL from an RSA key with a custom algorithm.""" - hash_cls = hashes.SHA512 - assert not isinstance(usable_root.algorithm, hash_cls) # make sure that test has a point - stdout = dump_crl(ca=usable_root, scope="user", algorithm=hash_cls()) - expected_idp = get_idp(full_name=idp_full_name(usable_root), only_contains_user_certs=True) - assert_crl(stdout, signer=usable_root, algorithm=hash_cls(), idp=expected_idp) +@pytest.mark.parametrize("encoding", (Encoding.DER, Encoding.PEM)) +def test_full_crl(usable_ca: CertificateAuthority, encoding: Encoding) -> None: + """Test the command for every usable CA.""" + stdout = dump_crl(ca=usable_ca, encoding=encoding) + assert_crl(stdout, signer=usable_ca, algorithm=usable_ca.algorithm, encoding=encoding) def test_file(tmp_path: Path, usable_root: CertificateAuthority) -> None: """Test dumping to a file.""" path = os.path.join(tmp_path, "crl-test.crl") - stdout = dump_crl(path, ca=usable_root, scope="user") + stdout = dump_crl(path, ca=usable_root) assert stdout == b"" with open(path, "rb") as stream: crl = stream.read() - expected_idp = get_idp(full_name=idp_full_name(usable_root), only_contains_user_certs=True) - assert_crl(crl, signer=usable_root, algorithm=usable_root.algorithm, idp=expected_idp) + assert_crl(crl, signer=usable_root, algorithm=usable_root.algorithm) def test_file_with_destination_does_not_exist(tmp_path: Path, usable_root: CertificateAuthority) -> None: @@ -91,7 +82,7 @@ def test_file_with_destination_does_not_exist(tmp_path: Path, usable_root: Certi path = os.path.join(tmp_path, "test", "crl-test.crl") with assert_command_error(rf"^\[Errno 2\] No such file or directory: '{re.escape(path)}'$"): - dump_crl(path, ca=usable_root, scope="user") + dump_crl(path, ca=usable_root) def test_pwd_ca_with_missing_password(settings: SettingsWrapper, usable_pwd: CertificateAuthority) -> None: @@ -101,18 +92,19 @@ def test_pwd_ca_with_missing_password(settings: SettingsWrapper, usable_pwd: Cer dump_crl(ca=usable_pwd, scope="user") -def test_pwd_ca_with_wrong_password(usable_pwd: CertificateAuthority) -> None: +@pytest.mark.usefixtures("usable_pwd") +def test_pwd_ca_with_wrong_password() -> None: """Test creating a CRL for a CA with a password with the wrong password.""" - with assert_command_error(r"^Could not decrypt private key - bad password\?$"): - dump_crl(ca=usable_pwd, scope="user", password=b"wrong") + # NOTE: we use e2e here as this also covers some code in management.base.BinaryOutputWrapper + assert_e2e_command_error( + ["dump_crl", "--password=wrong"], b"Could not decrypt private key - bad password?", b"" + ) def test_pwd_ca(usable_pwd: CertificateAuthority) -> None: """Test creating a CRL for a CA with a password with the wrong password.""" - stdout = dump_crl(ca=usable_pwd, scope="user", password=CERT_DATA["pwd"]["password"]) - - expected_idp = get_idp(full_name=idp_full_name(usable_pwd), only_contains_user_certs=True) - assert_crl(stdout, signer=usable_pwd, algorithm=usable_pwd.algorithm, idp=expected_idp) + stdout = dump_crl(ca=usable_pwd, password=CERT_DATA["pwd"]["password"]) + assert_crl(stdout, signer=usable_pwd, algorithm=usable_pwd.algorithm) def test_pwd_ca_with_password_in_settings( @@ -122,10 +114,8 @@ def test_pwd_ca_with_password_in_settings( settings.CA_PASSWORDS = {usable_pwd.serial: CERT_DATA["pwd"]["password"]} # This works because CA_PASSWORDS is set - stdout = dump_crl(ca=usable_pwd, scope="user") - - expected_idp = get_idp(full_name=idp_full_name(usable_pwd), only_contains_user_certs=True) - assert_crl(stdout, signer=usable_pwd, algorithm=usable_pwd.algorithm, idp=expected_idp) + stdout = dump_crl(ca=usable_pwd) + assert_crl(stdout, signer=usable_pwd, algorithm=usable_pwd.algorithm) def test_no_scope_with_root_ca(usable_root: CertificateAuthority) -> None: @@ -138,79 +128,10 @@ def test_no_scope_with_root_ca(usable_root: CertificateAuthority) -> None: def test_no_scope_with_child_ca(usable_child: CertificateAuthority) -> None: - """Test no-scope CRL for child CA.""" - idp = get_idp(full_name=idp_full_name(usable_child)) + """Test full CRL for child CA.""" stdout = dump_crl(ca=usable_child, scope=None) assert_crl( - stdout, - encoding=Encoding.PEM, - expires=86400, - signer=usable_child, - idp=idp, - algorithm=usable_child.algorithm, - ) - - -def test_include_issuing_distribution_point(usable_root: CertificateAuthority) -> None: - """Test forcing the inclusion of the IssuingDistributionPoint extension. - - Note: The only case where it is not included is for CRLs for root CAs with no scope, in which case not - enough information is available to even add the extension, so the test here asserts that the call - raises an extension. - """ - assert_e2e_command_error( - ["dump_crl", f"--ca={usable_root.serial}", "--include-issuing-distribution-point"], - b"Cannot add IssuingDistributionPoint extension to CRLs with no scope for root CAs.", - b"", - ) - - -def test_exclude_issuing_distribution_point_with_root_ca(usable_root: CertificateAuthority) -> None: - """Test forcing the exclusion of the IssuingDistributionPoint extension.""" - # For Root CAs, there should not be an IssuingDistributionPoint extension, test that forced exclusion - # does not break this. - stdout = dump_crl(ca=usable_root, include_issuing_distribution_point=False) - assert_crl( - stdout, - encoding=Encoding.PEM, - expires=86400, - signer=usable_root, - idp=None, - algorithm=usable_root.algorithm, - ) - - -def test_exclude_issuing_distribution_point_with_child_ca(usable_child: CertificateAuthority) -> None: - """Test forcing the exclusion of the IssuingDistributionPoint extension.""" - stdout = dump_crl(ca=usable_child, include_issuing_distribution_point=False) - assert_crl( - stdout, - encoding=Encoding.PEM, - expires=86400, - signer=usable_child, - idp=None, - algorithm=usable_child.algorithm, - ) - - -def test_no_full_name_in_sign_crl_distribution_point(usable_child: CertificateAuthority) -> None: - """Test dumping a CRL where the CRL Distribution Point extension for signing has only an RDN.""" - usable_child.sign_crl_distribution_points = crl_distribution_points( - distribution_point( - relative_name=x509.RelativeDistinguishedName( - [x509.NameAttribute(oid=NameOID.COMMON_NAME, value="example.com")] - ) - ) - ) - usable_child.save() - stdout = dump_crl(ca=usable_child) - assert_crl( - stdout, - encoding=Encoding.PEM, - expires=86400, - signer=usable_child, - idp=None, - algorithm=usable_child.algorithm, + stdout, encoding=Encoding.PEM, expires=86400, signer=usable_child, algorithm=usable_child.algorithm ) @@ -219,20 +140,18 @@ def test_disabled(usable_root: CertificateAuthority) -> None: usable_root.enabled = False usable_root.save() - stdout = dump_crl(ca=usable_root, scope="user") - expected_idp = get_idp(full_name=idp_full_name(usable_root), only_contains_user_certs=True) - assert_crl(stdout, signer=usable_root, algorithm=usable_root.algorithm, idp=expected_idp) + stdout = dump_crl(ca=usable_root) + assert_crl(stdout, signer=usable_root, algorithm=usable_root.algorithm) -@pytest.mark.parametrize("reason", list(x509.ReasonFlags)) +@pytest.mark.parametrize("reason", [x509.ReasonFlags.unspecified, x509.ReasonFlags.key_compromise]) def test_revoked_with_reason( usable_root: CertificateAuthority, root_cert: Certificate, reason: x509.ReasonFlags ) -> None: """Test revoked certificates.""" - idp = get_idp(full_name=idp_full_name(usable_root), only_contains_user_certs=True) root_cert.revoke(reason=reason) # type: ignore[arg-type] - stdout = dump_crl(ca=usable_root, scope="user") + stdout = dump_crl(ca=usable_root) # unspecified is not included (see RFC 5280, 5.3.1) if reason == x509.ReasonFlags.unspecified: @@ -248,14 +167,13 @@ def test_revoked_with_reason( [root_cert], signer=usable_root, algorithm=usable_root.algorithm, - idp=idp, entry_extensions=entry_extensions, ) def test_compromised_timestamp(usable_root: CertificateAuthority, root_cert: Certificate) -> None: """Test creating a CRL with a compromised cert with a compromised timestamp.""" - idp = get_idp(full_name=idp_full_name(usable_root), only_contains_user_certs=True) + idp = get_idp(only_contains_user_certs=True) stamp = timezone.now().replace(microsecond=0) - timedelta(10) root_cert.revoke(compromised=stamp) @@ -264,7 +182,7 @@ def test_compromised_timestamp(usable_root: CertificateAuthority, root_cert: Cer critical=False, value=x509.InvalidityDate(stamp.replace(tzinfo=None)), ) - stdout = dump_crl(ca=usable_root, scope="user") + stdout = dump_crl(ca=usable_root, only_contains_user_certs=True) assert_crl( stdout, [root_cert], @@ -277,26 +195,83 @@ def test_compromised_timestamp(usable_root: CertificateAuthority, root_cert: Cer def test_ca_crl(usable_root: CertificateAuthority, child: CertificateAuthority) -> None: """Test creating a CA CRL.""" - stdout = dump_crl(ca=usable_root, scope="ca") + stdout = dump_crl(ca=usable_root, only_contains_ca_certs=True) idp = get_idp(only_contains_ca_certs=True) assert_crl(stdout, signer=usable_root, algorithm=usable_root.algorithm, idp=idp) # revoke the CA and see if it's there child.revoke() assert_revoked(child) - stdout = dump_crl(ca=usable_root, scope="ca") + stdout = dump_crl(ca=usable_root, only_contains_ca_certs=True) assert_crl(stdout, [child], signer=usable_root, algorithm=usable_root.algorithm, idp=idp, crl_number=1) -def test_hash_algorithm_not_allowed(ed_ca: CertificateAuthority) -> None: - """Test creating a CRL with a hash algorithm and for a CA that does not allow it.""" - with assert_command_error(rf"^{ed_ca.key_type} keys do not allow an algorithm for signing\.$"): - dump_crl(ca=ed_ca, algorithm=hashes.SHA512()) +def test_user_crl(usable_root: CertificateAuthority, root_cert: Certificate) -> None: + """Test creating a user CRL.""" + stdout = dump_crl(ca=usable_root, only_contains_user_certs=True) + idp = get_idp(only_contains_user_certs=True) + assert_crl(stdout, signer=usable_root, idp=idp) + + # revoke the CA and see if it's there + root_cert.revoke() + assert_revoked(root_cert) + stdout = dump_crl(ca=usable_root, only_contains_user_certs=True) + assert_crl(stdout, [root_cert], signer=usable_root, idp=idp, crl_number=1) + + +def test_attribute_crl(usable_root: CertificateAuthority) -> None: + """Test creating an attribute CRL.""" + stdout = dump_crl(ca=usable_root, only_contains_attribute_certs=True) + idp = get_idp(only_contains_attribute_certs=True) + assert_crl(stdout, signer=usable_root, idp=idp) + + +def test_only_some_reasons(usable_root: CertificateAuthority) -> None: + """Test the only-some-reasons parameter.""" + stdout = dump_crl_e2e( + usable_root.serial, "--only-some-reasons=key_compromise", "--only-some-reasons=aa_compromise" + ) + idp = get_idp( + only_some_reasons=frozenset([x509.ReasonFlags.key_compromise, x509.ReasonFlags.aa_compromise]) + ) + assert_crl(stdout, signer=usable_root, idp=idp) + + +@pytest.mark.parametrize("scope", ("ca", "user", "attribute")) +def test_deprecated_scope(usable_root: CertificateAuthority, scope: str) -> None: + """Test passing the deprecated scope parameter.""" + with assert_removed_in_230( + r"^--scope is deprecated and will be removed in django-ca 2\.3\.0\. Use " + r"--only-contains-{ca,user,attribute}-certs instead\.$" + ): + stdout = dump_crl(ca=usable_root, scope=scope) + # pylint: disable-next=unexpected-keyword-arg + idp = get_idp(**{f"only_contains_{scope}_certs": True}) # type: ignore[arg-type] + assert_crl(stdout, signer=usable_root, idp=idp) + + +def test_deprecated_algorithm(usable_root: CertificateAuthority) -> None: + """Test passing the deprecated algorithm parameter.""" + with assert_removed_in_230( + r"^--algorithm no longer has any effect and will be removed in django-ca 2\.3\.0\.$" + ): + stdout = dump_crl(ca=usable_root, algorithm="SHA-256") + assert_crl(stdout, signer=usable_root) + + +def test_deprecated_include_issuing_distribution_point(usable_root: CertificateAuthority) -> None: + """Test passing the deprecated include_issuing_distribution_point parameter.""" + with assert_removed_in_230( + r"^--include-issuing-distribution-point and --exclude-issuing-distribution-point no longer " + r"have any effect and will be removed in django-ca 2\.3\.0\.$" + ): + stdout = dump_crl(ca=usable_root, include_issuing_distribution_point=True) + assert_crl(stdout, signer=usable_root) def test_unknown_error(usable_root: CertificateAuthority) -> None: """Test that creating a CRL fails for an unknown reason.""" - method = "django_ca.models.CertificateAuthority.get_crl" + method = "django_ca.managers.crl_scope_validator" with mock.patch(method, side_effect=Exception("foo")), assert_command_error("foo"): dump_crl(ca=usable_root) diff --git a/ca/django_ca/tests/commands/test_init_ca.py b/ca/django_ca/tests/commands/test_init_ca.py index 64475646b..0615ce695 100644 --- a/ca/django_ca/tests/commands/test_init_ca.py +++ b/ca/django_ca/tests/commands/test_init_ca.py @@ -66,12 +66,12 @@ certificate_policies, cmd, cmd_e2e, + crl_cache_key, crl_distribution_points, distribution_point, dns, extended_key_usage, get_idp, - idp_full_name, issuer_alternative_name, key_usage, name_constraints, @@ -80,7 +80,6 @@ uri, ) from django_ca.typehints import AllowedHashTypes, EllipticCurves, HashAlgorithms, ParsableKeyType -from django_ca.utils import get_crl_cache_key use_options = StoragesUsePrivateKeyOptions(password=None) @@ -120,13 +119,13 @@ def assert_ocsp_responder_certificate(ca: CertificateAuthority) -> None: def assert_crls(ca: CertificateAuthority) -> None: """Assert CRLs.""" - cache_key = get_crl_cache_key(ca.serial, Encoding.PEM, scope="user") - user_idp = get_idp(full_name=idp_full_name(ca), only_contains_user_certs=True) + cache_key = crl_cache_key(ca.serial, Encoding.PEM, only_contains_user_certs=True) + user_idp = get_idp(only_contains_user_certs=True) crl = cache.get(cache_key) assert_crl(crl, signer=ca, algorithm=ca.algorithm, idp=user_idp) - cache_key = get_crl_cache_key(ca.serial, Encoding.PEM, scope="ca") - ca_idp = get_idp(full_name=None, only_contains_ca_certs=True) + cache_key = crl_cache_key(ca.serial, Encoding.PEM, only_contains_ca_certs=True) + ca_idp = get_idp(only_contains_ca_certs=True) crl = cache.get(cache_key) assert_crl(crl, signer=ca, algorithm=ca.algorithm, idp=ca_idp) @@ -176,7 +175,7 @@ def init_ca_e2e( def test_basic(ca_name: str, subject: x509.Name, rfc4514_subject: str, key_backend: StoragesBackend) -> None: """Basic tests for the command.""" ca = init_ca_e2e(ca_name, rfc4514_subject) - assert_ca_properties(ca, ca_name, crl_number='{"scope": {"user": 1, "ca": 1}}') + assert_ca_properties(ca, ca_name, crl_number='{"scope": {}}') assert_certificate(ca, subject) # test the private key diff --git a/ca/django_ca/tests/commands/test_sign_cert.py b/ca/django_ca/tests/commands/test_sign_cert.py index 0b33527b4..075471e1f 100644 --- a/ca/django_ca/tests/commands/test_sign_cert.py +++ b/ca/django_ca/tests/commands/test_sign_cert.py @@ -708,10 +708,10 @@ def test_revoked_ca(root: CertificateAuthority, rfc4514_subject: str) -> None: sign_cert(root, rfc4514_subject, stdin=csr) -def test_invalid_algorithm(usable_ed448: CertificateAuthority, rfc4514_subject: str) -> None: +def test_invalid_algorithm(ed_ca: CertificateAuthority, rfc4514_subject: str) -> None: """Test passing an invalid algorithm.""" - with assert_command_error(r"^Ed448 keys do not allow an algorithm for signing\.$"): - sign_cert(usable_ed448, rfc4514_subject, algorithm=hashes.SHA512()) + with assert_command_error(r"^Ed(448|25519) keys do not allow an algorithm for signing\.$"): + sign_cert(ed_ca, rfc4514_subject, algorithm=hashes.SHA512()) def test_no_cn_or_san(usable_root: CertificateAuthority, hostname: str) -> None: diff --git a/ca/django_ca/tests/fixtures/root.attribute.crl b/ca/django_ca/tests/fixtures/root.attribute.crl new file mode 100644 index 0000000000000000000000000000000000000000..74637db8888130987425bacc06c37acd7fedf910 GIT binary patch literal 426 zcmXqLVq9j>*w4hsXu!+HsnzDu_MMlJk(-slK-y5!K%9*^l!ci`peR4TL@%`>F}ENm zRWCU|SDe?##K6?Z#L(OT2%?}|Qz&7J+k^0KbK$K{c+p5h67Ri7flsaF7x}E)oAjw@MF@-#nT@E E0HIr}cmMzZ literal 0 HcmV?d00001 diff --git a/ca/django_ca/tests/fixtures/root.ca.crl b/ca/django_ca/tests/fixtures/root.ca.crl new file mode 100644 index 0000000000000000000000000000000000000000..8f44e12a20b0fd589c0122f2c3dba13e0233d8e9 GIT binary patch literal 426 zcmXqLVq9j>*w4hsXu!+HsnzDu_MMlJk(-slK-y5!K%9*^l!ci`peR4TL@%`>F}ENm zRWCU|SDe?##K6?Z#L(OT2%?}|Qz&-i44*R1ZC zdwqKUqKQru_&1+Z4`CJGb@*Fy*!}BwClza2yfZZJH1Mfq+B3280n7545}H$mr@UEW z;#~gx-$w6GHy6z3O;Is;TNvGDy5!<~pFaX?OVY3DOuJ+qbgxHgYP-z|`_G;NOW2>7 zoaLETx=8A!!S_Ys3X!)f%)&k@@koY6*FG~ae)F#=xaTzM-#MHq9vR;`r!psJX2*1# yS*d+csx0oB{@#82o%C+-viuNFURXbkOMM!fTlZZ<@Bc?O-M^>wGfAN8co6`b4xxDf literal 0 HcmV?d00001 diff --git a/ca/django_ca/tests/fixtures/root.crl b/ca/django_ca/tests/fixtures/root.crl new file mode 100644 index 0000000000000000000000000000000000000000..4adc67aba01f590930848a865f0788657630213f GIT binary patch literal 408 zcmXqLVw_@7$Hd5Jz{|#|)#lOmotKf3o0Y*p+ECI!oQ*k@g_%d7C_leMFSQ~uw;(4~ zFF8L~oY%<2z|_dZ(A)qBqM%$;D0hLrfv$l(P^+vmi-dt#gGj=m>)J~i{qBFeaMIIw zR=@o$wxe4NxIhX-SeP-bW^Q6+WRRKgr2eH~&SGWHa<5lgk7h zr5&mWC>G26>g%!H&gr*q!cs<-mtD)71up-XapC0q(iGpd*)}4lJmeC8EZ8t{`TyG| zYU=-lT{v;E$)h&or$^jvqfgl(3z+Zd+TWgJ?Qn5fGWV_2y&uXlb04qZPp!B5@tC=! zBVYEZ_DAp6wN)um?=|D(Id-UI-U$7&qDt}NGrPlSZ2J^Tn%=CCedj49#rMdzwqb8| z!l`d88J`myCh<7*+0+X8XuC(we(T0OLFn4fcduGKxvp_P@w?$`ZTYiQf8%u3gzDs+ i%QilnyX;aMS92*}(0JJ;c`L?rZqS1wW1&)43l#uI?V(!$ literal 0 HcmV?d00001 diff --git a/ca/django_ca/tests/fixtures/root.user.crl b/ca/django_ca/tests/fixtures/root.user.crl new file mode 100644 index 0000000000000000000000000000000000000000..52bb0a9b2a62d045b404e95d923142a928bd01f3 GIT binary patch literal 426 zcmXqLVq9j>*w4hsXu!+HsnzDu_MMlJk(-slK-y5!K%9*^l!ci`peR4TL@%`>F}ENm zRWCU|SDe?##K6?Z#L(OT2%?}|Qz&p0ZzTHBxH zpi-6Y+R^wYaWTtXXU(H;dLDZUsPatjm@3!1&!$y7wu0fnRaZ|Q?Y`Rm>dR((3YSHs z{_T)daGifr_Q;PI-s9f|Dt84m+ga8Lz0)!e*qb1I#OvtnqE}O%KJb`je|g3x07eC> Ap#T5? literal 0 HcmV?d00001 diff --git a/ca/django_ca/tests/models/test_certificate_authority.py b/ca/django_ca/tests/models/test_certificate_authority.py index 62e4ce30b..5dea2459f 100644 --- a/ca/django_ca/tests/models/test_certificate_authority.py +++ b/ca/django_ca/tests/models/test_certificate_authority.py @@ -35,7 +35,6 @@ from django.core.exceptions import ValidationError from django.core.files.storage import storages from django.db import connection -from django.urls import reverse import pytest from freezegun import freeze_time @@ -46,25 +45,36 @@ from django_ca.key_backends.storages import StoragesUsePrivateKeyOptions from django_ca.models import Certificate, CertificateAuthority from django_ca.pydantic import CertificatePoliciesModel -from django_ca.tests.base.assertions import assert_certificate, assert_crl, assert_sign_cert_signals +from django_ca.tests.base.assertions import ( + assert_certificate, + assert_crl, + assert_removed_in_230, + assert_sign_cert_signals, +) from django_ca.tests.base.constants import CERT_DATA, TIMESTAMPS from django_ca.tests.base.utils import ( authority_information_access, basic_constraints, certificate_policies, + crl_cache_key, crl_distribution_points, distribution_point, get_idp, - idp_full_name, issuer_alternative_name, uri, ) from django_ca.tests.models.base import assert_bundle from django_ca.typehints import PolicyQualifier -from django_ca.utils import get_crl_cache_key key_backend_options = StoragesUsePrivateKeyOptions(password=None) +CACHE_KEY_KWARGS = { + "only_contains_ca_certs": False, + "only_contains_user_certs": False, + "only_contains_attribute_certs": False, + "only_some_reasons": None, +} + @contextmanager def generate_ocsp_key( @@ -114,195 +124,13 @@ def test_root(root: CertificateAuthority, child: CertificateAuthority) -> None: @pytest.mark.freeze_time(TIMESTAMPS["everything_valid"]) @pytest.mark.usefixtures("clear_cache") -def test_full_crl( - usable_root: CertificateAuthority, child: CertificateAuthority, root_cert: Certificate -) -> None: +@pytest.mark.usefixtures("child") # to make sure that they don't show up when they're not revoked. +@pytest.mark.usefixtures("root_cert") # to make sure that they don't show up when they're not revoked. +def test_get_crl(usable_root: CertificateAuthority) -> None: """Test getting the CRL for a CertificateAuthority.""" - full_name = "http://localhost/crl" - idp = get_idp(full_name=[uri(full_name)]) - - crl = usable_root.get_crl(key_backend_options, full_name=[uri(full_name)]) - assert_crl(crl, idp=idp, signer=usable_root) - - usable_root.sign_crl_distribution_points = crl_distribution_points(distribution_point([uri(full_name)])) - usable_root.save() - crl = usable_root.get_crl(key_backend_options) - assert_crl(crl, crl_number=1, signer=usable_root) - - # revoke a cert and a ca - root_cert.revoke() - child.revoke() - crl = usable_root.get_crl(key_backend_options) - assert_crl(crl, expected=[root_cert, child], crl_number=2, signer=usable_root) - - # unrevoke cert (so we have all three combinations) - root_cert.revoked = False - root_cert.revoked_date = None - root_cert.revoked_reason = "" - root_cert.save() - - crl = usable_root.get_crl(key_backend_options) - assert_crl(crl, expected=[child], crl_number=3, signer=usable_root) - - -@pytest.mark.freeze_time(TIMESTAMPS["everything_valid"]) -def test_intermediate_crl(usable_child: CertificateAuthority, child_cert: Certificate) -> None: - """Test getting the CRL of an intermediate CA.""" - full_name = "http://localhost/crl" - idp = get_idp(full_name=[uri(full_name)]) - - crl = usable_child.get_crl(key_backend_options, full_name=[uri(full_name)]) - assert_crl(crl, idp=idp, signer=usable_child) - - # Revoke a cert - child_cert.revoke() - crl = usable_child.get_crl(key_backend_options, full_name=[uri(full_name)]) - assert_crl(crl, expected=[child_cert], idp=idp, crl_number=1, signer=usable_child) - - -@pytest.mark.freeze_time(TIMESTAMPS["everything_valid"]) -@pytest.mark.usefixtures("clear_cache") -def test_full_crl_without_timezone_support( - settings: SettingsWrapper, - usable_root: CertificateAuthority, - child: CertificateAuthority, - root_cert: Certificate, -) -> None: - """Test full CRL but with timezone support disabled.""" - settings.USE_TZ = False - # otherwise we get TZ warnings for preloaded objects - - usable_root.refresh_from_db() - child.refresh_from_db() - root_cert.refresh_from_db() - - test_full_crl(usable_root, child, root_cert) - - -@pytest.mark.freeze_time(TIMESTAMPS["everything_valid"]) -def test_ca_crl(usable_root: CertificateAuthority, ec: CertificateAuthority, child_cert: Certificate) -> None: - """Test getting a CA CRL.""" - idp = get_idp(only_contains_ca_certs=True) # root CAs don't have a full name (GitHub issue #64) - - crl = usable_root.get_crl(key_backend_options, scope="ca") - assert_crl(crl, idp=idp, signer=usable_root) - - # revoke ca and cert, CRL only contains CA - child_cert.ca.revoke() - ec.revoke() - child_cert.revoke() - crl = usable_root.get_crl(key_backend_options, scope="ca") - assert_crl(crl, expected=[child_cert.ca], idp=idp, crl_number=1, signer=usable_root) - - -@pytest.mark.freeze_time(TIMESTAMPS["everything_valid"]) -def test_intermediate_ca_crl(usable_child: CertificateAuthority) -> None: - """Test getting the CRL for an intermediate CA.""" - # Intermediate CAs have a DP in the CRL that has the CA url - full_name = [uri(f"http://{model_settings.CA_DEFAULT_HOSTNAME}/django_ca/crl/ca/{usable_child.serial}/")] - idp = get_idp(full_name=full_name, only_contains_ca_certs=True) - - crl = usable_child.get_crl(key_backend_options, scope="ca") - assert_crl(crl, idp=idp, signer=usable_child) - - -@pytest.mark.freeze_time(TIMESTAMPS["everything_valid"]) -def test_user_crl(usable_root: CertificateAuthority, root_cert: Certificate, child_cert: Certificate) -> None: - """Test getting a user CRL.""" - idp = get_idp(full_name=idp_full_name(usable_root), only_contains_user_certs=True) - - crl = usable_root.get_crl(key_backend_options, scope="user") - assert_crl(crl, idp=idp, signer=usable_root) - - # revoke ca and cert, CRL only contains cert - root_cert.revoke() - child_cert.revoke() - child_cert.ca.revoke() - crl = usable_root.get_crl(key_backend_options, scope="user") - assert_crl(crl, expected=[root_cert], idp=idp, crl_number=1, signer=usable_root) - - -@pytest.mark.freeze_time(TIMESTAMPS["everything_valid"]) -def test_attr_crl(usable_root: CertificateAuthority, root_cert: Certificate, child_cert: Certificate) -> None: - """Test getting an Attribute CRL (always an empty list).""" - idp = get_idp(only_contains_attribute_certs=True) - - crl = usable_root.get_crl(key_backend_options, scope="attribute") - assert_crl(crl, idp=idp, signer=usable_root) - - # revoke ca and cert, CRL is empty (we don't know attribute certs) - root_cert.revoke() - child_cert.revoke() - child_cert.ca.revoke() - crl = usable_root.get_crl(key_backend_options, scope="attribute") - assert_crl(crl, idp=idp, crl_number=1, signer=usable_root) - - -@pytest.mark.freeze_time(TIMESTAMPS["everything_valid"]) -def test_no_idp(usable_child: CertificateAuthority) -> None: - """Test a CRL with no IDP.""" - # CRLs require a full name (or only_some_reasons) if it's a full CRL - usable_child.sign_crl_distribution_points = None - usable_child.save() - crl = usable_child.get_crl(key_backend_options) - assert_crl(crl, idp=None, signer=usable_child) - - -@pytest.mark.freeze_time(TIMESTAMPS["everything_valid"]) -def test_counter(usable_root: CertificateAuthority) -> None: - """Test the counter for CRLs.""" - crl = usable_root.get_crl(key_backend_options, counter="test") - assert_crl(crl, crl_number=0, signer=usable_root) - crl = usable_root.get_crl(key_backend_options, counter="test") - assert_crl(crl, crl_number=1, signer=usable_root) - - crl = usable_root.get_crl(key_backend_options) # test with no counter - assert_crl(crl, crl_number=0, signer=usable_root) - - -@pytest.mark.freeze_time(TIMESTAMPS["everything_valid"]) -def test_no_auth_key_identifier(usable_root: CertificateAuthority) -> None: - """Test getting the CRL from a CA with no AuthorityKeyIdentifier.""" - - # All CAs have an authority key identifier, so we mock that this exception is not present - def side_effect(cls: Any) -> NoReturn: - raise x509.ExtensionNotFound("mocked", x509.SubjectKeyIdentifier.oid) - - full_name = "http://localhost/crl" - idp = get_idp(full_name=[uri(full_name)]) - - with mock.patch("cryptography.x509.extensions.Extensions.get_extension_for_oid", side_effect=side_effect): - crl = usable_root.get_crl(key_backend_options, full_name=[uri(full_name)]) - # Note that we still get an AKI because the value comes from the public key in this case - assert_crl(crl, idp=idp, signer=usable_root, algorithm=usable_root.algorithm) - - -@pytest.mark.freeze_time(TIMESTAMPS["everything_valid"]) -def test_get_crl_with_wrong_algorithm(ed_ca: CertificateAuthority) -> None: - """Test that we validate the algorithm if passed by the user.""" - # DSA/RSA/EC keys cannot trigger this condition, as the algorithm would default to the one used by - # the certificate authority itself. - - with pytest.raises(ValueError, match=r"^Ed(25519|448) keys do not allow an algorithm for signing\.$"): - ed_ca.get_crl(key_backend_options, algorithm=hashes.SHA256()) - - -def test_validate_json(root: CertificateAuthority) -> None: - """Test the json validator.""" - # Validation works if we're not revoked - root.full_clean() - - root.crl_number = "{" - # Note: we do not use self.assertValidationError, b/c the JSON message might be system dependent - with pytest.raises(ValidationError, match="Must be valid JSON:"): - root.full_clean() - # self.assertTrue(re.match("Must be valid JSON: ", exc_cm.exception.message_dict["crl_number"][0])) - - -def test_crl_invalid_scope(root: CertificateAuthority) -> None: - """Try getting a CRL with an invalid scope.""" - with pytest.raises(ValueError, match=r'^scope must be either None, "ca", "user" or "attribute"$'): - root.get_crl(key_backend_options, scope="foobar") # type: ignore[arg-type] + with assert_removed_in_230(r"^get_crl\(\) is deprecated and will be removed in django-ca 2\.3\.$"): + crl = usable_root.get_crl(key_backend_options) + assert_crl(crl, signer=usable_root) @pytest.mark.usefixtures("clear_cache") @@ -310,17 +138,12 @@ def test_crl_invalid_scope(root: CertificateAuthority) -> None: def test_cache_crls(settings: SettingsWrapper, usable_ca: CertificateAuthority) -> None: """Test caching of CRLs.""" ca_private_key_options = StoragesUsePrivateKeyOptions(password=CERT_DATA[usable_ca.name].get("password")) - der_user_key = get_crl_cache_key(usable_ca.serial, Encoding.DER, "user") - pem_user_key = get_crl_cache_key(usable_ca.serial, Encoding.PEM, "user") - der_ca_key = get_crl_cache_key(usable_ca.serial, Encoding.DER, "ca") - pem_ca_key = get_crl_cache_key(usable_ca.serial, Encoding.PEM, "ca") - user_idp = get_idp(full_name=idp_full_name(usable_ca), only_contains_user_certs=True) - if usable_ca.parent is None: - ca_idp = get_idp(full_name=None, only_contains_ca_certs=True) - else: - crl_path = reverse("django_ca:ca-crl", kwargs={"serial": usable_ca.serial}) - full_name = [uri(f"http://{model_settings.CA_DEFAULT_HOSTNAME}{crl_path}")] - ca_idp = get_idp(full_name=full_name, only_contains_ca_certs=True) + der_user_key = crl_cache_key(usable_ca.serial, only_contains_user_certs=True) + pem_user_key = crl_cache_key(usable_ca.serial, Encoding.PEM, only_contains_user_certs=True) + der_ca_key = crl_cache_key(usable_ca.serial, only_contains_ca_certs=True) + pem_ca_key = crl_cache_key(usable_ca.serial, Encoding.PEM, only_contains_ca_certs=True) + user_idp = get_idp(full_name=None, only_contains_user_certs=True) + ca_idp = get_idp(full_name=None, only_contains_ca_certs=True) assert cache.get(der_ca_key) is None assert cache.get(pem_ca_key) is None @@ -334,7 +157,6 @@ def test_cache_crls(settings: SettingsWrapper, usable_ca: CertificateAuthority) assert_crl( der_user_crl, idp=user_idp, - crl_number=0, encoding=Encoding.DER, signer=usable_ca, algorithm=usable_ca.algorithm, @@ -342,7 +164,6 @@ def test_cache_crls(settings: SettingsWrapper, usable_ca: CertificateAuthority) assert_crl( pem_user_crl, idp=user_idp, - crl_number=0, encoding=Encoding.PEM, signer=usable_ca, algorithm=usable_ca.algorithm, @@ -353,7 +174,6 @@ def test_cache_crls(settings: SettingsWrapper, usable_ca: CertificateAuthority) assert_crl( der_ca_crl, idp=ca_idp, - crl_number=0, encoding=Encoding.DER, signer=usable_ca, algorithm=usable_ca.algorithm, @@ -361,7 +181,6 @@ def test_cache_crls(settings: SettingsWrapper, usable_ca: CertificateAuthority) assert_crl( pem_ca_crl, idp=ca_idp, - crl_number=0, encoding=Encoding.PEM, signer=usable_ca, algorithm=usable_ca.algorithm, @@ -411,7 +230,9 @@ def test_cache_crls(settings: SettingsWrapper, usable_ca: CertificateAuthority) # clear caches and skip generation cache.clear() - crl_profiles = {k: v.model_dump() for k, v in model_settings.CA_CRL_PROFILES.items()} + crl_profiles = { + k: v.model_dump(exclude={"encodings", "scope"}) for k, v in model_settings.CA_CRL_PROFILES.items() + } crl_profiles["ca"]["OVERRIDES"][usable_ca.serial] = {"skip": True} crl_profiles["user"]["OVERRIDES"][usable_ca.serial] = {"skip": True} @@ -425,42 +246,50 @@ def test_cache_crls(settings: SettingsWrapper, usable_ca: CertificateAuthority) @pytest.mark.usefixtures("clear_cache") -def test_cache_crls_algorithm(usable_root: CertificateAuthority) -> None: - """Test passing an explicit hash algorithm.""" - der_user_key = get_crl_cache_key(usable_root.serial, Encoding.DER, "user") - pem_user_key = get_crl_cache_key(usable_root.serial, Encoding.PEM, "user") - der_ca_key = get_crl_cache_key(usable_root.serial, Encoding.DER, "ca") - pem_ca_key = get_crl_cache_key(usable_root.serial, Encoding.PEM, "ca") +@pytest.mark.freeze_time(TIMESTAMPS["everything_valid"]) +@pytest.mark.parametrize( + "parameters", + ( + {"only_contains_ca_certs": True}, + {"only_contains_user_certs": True}, + {"only_contains_attribute_certs": True}, + {"only_contains_user_certs": True, "only_some_reasons": frozenset([x509.ReasonFlags.key_compromise])}, + ), +) +def test_cache_crls_with_profiles( + settings: SettingsWrapper, usable_root: CertificateAuthority, parameters: dict[str, Any] +) -> None: + settings.CA_CRL_PROFILES = {"test": parameters} + usable_root.cache_crls(key_backend_options) - assert cache.get(der_ca_key) is None - assert cache.get(pem_ca_key) is None - assert cache.get(der_user_key) is None - assert cache.get(pem_user_key) is None + der_key = crl_cache_key(usable_root.serial, **parameters) + pem_key = crl_cache_key(usable_root.serial, Encoding.PEM, **parameters) - usable_root.cache_crls(key_backend_options) + der_crl = x509.load_der_x509_crl(cache.get(der_key)) + pem_crl = x509.load_pem_x509_crl(cache.get(pem_key)) + idp = get_idp(**parameters) - der_user_crl = cache.get(der_user_key) - pem_user_crl = cache.get(pem_user_key) - assert isinstance(der_user_crl, bytes) - assert isinstance(pem_user_crl, bytes) + assert_crl(der_crl, idp=idp, signer=usable_root) + assert_crl(pem_crl, idp=idp, signer=usable_root) @pytest.mark.usefixtures("clear_cache") +@pytest.mark.freeze_time(TIMESTAMPS["everything_valid"]) def test_cache_crls_with_overrides(settings: SettingsWrapper, usable_root: CertificateAuthority) -> None: """Test CA overrides for CRL profiles.""" - ca_crl_profile = model_settings.CA_CRL_PROFILES["user"].model_dump() - ca_crl_profile["OVERRIDES"] = {usable_root.serial: {"encodings": frozenset([Encoding.PEM])}} + ca_crl_profile = model_settings.CA_CRL_PROFILES["user"].model_dump(exclude={"encodings", "scope"}) + ca_crl_profile["OVERRIDES"] = {usable_root.serial: {"expires": timedelta(days=3)}} - der_user_key = get_crl_cache_key(usable_root.serial, Encoding.DER, "user") - pem_user_key = get_crl_cache_key(usable_root.serial, Encoding.PEM, "user") + der_user_key = crl_cache_key(usable_root.serial, only_contains_user_certs=True) + pem_user_key = crl_cache_key(usable_root.serial, Encoding.PEM, only_contains_user_certs=True) settings.CA_CRL_PROFILES = {"user": ca_crl_profile} usable_root.cache_crls(key_backend_options) - der_user_crl = cache.get(der_user_key) - pem_user_crl = cache.get(pem_user_key) - assert der_user_crl is None # now None since not specified in override - assert isinstance(pem_user_crl, bytes) + der_user_crl = x509.load_der_x509_crl(cache.get(der_user_key)) + pem_user_crl = x509.load_pem_x509_crl(cache.get(pem_user_key)) + assert der_user_crl.next_update_utc == TIMESTAMPS["everything_valid"] + timedelta(days=3) + assert pem_user_crl.next_update_utc == TIMESTAMPS["everything_valid"] + timedelta(days=3) def test_max_path_length(root: CertificateAuthority, child: CertificateAuthority) -> None: diff --git a/ca/django_ca/tests/models/test_certificate_revocation_list.py b/ca/django_ca/tests/models/test_certificate_revocation_list.py new file mode 100644 index 000000000..c348958db --- /dev/null +++ b/ca/django_ca/tests/models/test_certificate_revocation_list.py @@ -0,0 +1,267 @@ +# This file is part of django-ca (https://github.com/mathiasertl/django-ca). +# +# django-ca is free software: you can redistribute it and/or modify it under the terms of the GNU General +# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# django-ca is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the +# implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License along with django-ca. If not, see +# . + +"""Test :class:`~django_ca.models.CertificateRevocationList`.""" + +from datetime import datetime, timedelta, timezone as tz + +from cryptography import x509 +from cryptography.x509.oid import ExtensionOID + +import pytest +from pytest_django.fixtures import SettingsWrapper + +from django_ca.constants import ReasonFlags +from django_ca.key_backends.storages import StoragesUsePrivateKeyOptions +from django_ca.models import Certificate, CertificateAuthority, CertificateRevocationList +from django_ca.tests.base.assertions import assert_issuing_distribution_point +from django_ca.tests.base.constants import TIMESTAMPS + +pytestmark = [pytest.mark.freeze_time(TIMESTAMPS["everything_valid"])] + +KEY_BACKEND_OPTIONS = StoragesUsePrivateKeyOptions.model_validate({}) + + +def assert_crl_number(crl: CertificateRevocationList, number: int) -> None: + """Test the given CRL number.""" + assert crl.number == number + assert crl.loaded.extensions.get_extension_for_oid(ExtensionOID.CRL_NUMBER) == x509.Extension( + oid=ExtensionOID.CRL_NUMBER, critical=False, value=x509.CRLNumber(number) + ) + + +def assert_no_idp(crl: CertificateRevocationList) -> None: + """Asert that the given CRL does *not* have an Issuing Distribution Point extension.""" + with pytest.raises(x509.ExtensionNotFound): + crl.loaded.extensions.get_extension_for_oid(ExtensionOID.ISSUING_DISTRIBUTION_POINT) + + +def test_create_empty_certificate_revocation_list(usable_ca: CertificateAuthority) -> None: + """Test creating an empty CRL.""" + key_backend_options = StoragesUsePrivateKeyOptions.model_validate({}, context={"ca": usable_ca}) + obj = CertificateRevocationList.objects.create_certificate_revocation_list(usable_ca, key_backend_options) + assert obj.ca == usable_ca + assert_crl_number(obj, 0) + assert_no_idp(obj) + + assert obj.last_update == TIMESTAMPS["everything_valid"] + assert obj.next_update == TIMESTAMPS["everything_valid"] + timedelta(days=1) + assert obj.only_contains_ca_certs is False + assert obj.only_contains_user_certs is False + assert obj.only_some_reasons is None + assert obj.pem.startswith(b"-----BEGIN X509 CRL-----\n") + assert obj.pem.endswith(b"\n-----END X509 CRL-----\n") + + # Assert properties of embedded CRL + crl = obj.loaded + assert isinstance(crl, x509.CertificateRevocationList) + assert crl.issuer == usable_ca.subject + assert crl.last_update_utc == TIMESTAMPS["everything_valid"] + assert crl.next_update_utc == TIMESTAMPS["everything_valid"] + timedelta(days=1) + assert crl.signature_hash_algorithm == usable_ca.algorithm + assert not list(crl) # CRL is empty + + +@pytest.mark.usefixtures("child_cert", "ec") # to make sure they *don't* show up in the CRL +def test_full_crl( + usable_root: CertificateAuthority, child: CertificateAuthority, root_cert: Certificate +) -> None: + """Test generating a full CRL parameters (and some of its properties).""" + obj = CertificateRevocationList.objects.create_certificate_revocation_list( + usable_root, KEY_BACKEND_OPTIONS + ) + assert obj.ca == usable_root + assert_crl_number(obj, 0) + assert str(obj) == f"0 (next update: {obj.next_update})" # just so we have str() tested too + + root_cert.revoke() + child.revoke() + + obj = CertificateRevocationList.objects.create_certificate_revocation_list( + usable_root, KEY_BACKEND_OPTIONS + ) + assert obj.ca == usable_root + assert_crl_number(obj, 1) + assert [rev.serial_number for rev in obj.loaded] == [ + child.get_revocation().serial_number, + root_cert.get_revocation().serial_number, + ] + + +@pytest.mark.usefixtures("child_cert", "ec") # to make sure they *don't* show up in the CRL +def test_user_certs_crl( + usable_root: CertificateAuthority, child: CertificateAuthority, root_cert: Certificate +) -> None: + """Test generating a CRL that contains only CA certs.""" + root_cert.revoke() + child.revoke() + + obj = CertificateRevocationList.objects.create_certificate_revocation_list( + usable_root, KEY_BACKEND_OPTIONS, only_contains_ca_certs=True + ) + crl = obj.loaded + assert [rev.serial_number for rev in crl] == [child.get_revocation().serial_number] + idp = crl.extensions.get_extension_for_oid(ExtensionOID.ISSUING_DISTRIBUTION_POINT) + assert_issuing_distribution_point(idp, only_contains_ca_certs=True) # type: ignore[arg-type] + + +@pytest.mark.usefixtures("child_cert", "ec") # to make sure they *don't* show up in the CRL +def test_ca_certs_crl( + usable_root: CertificateAuthority, child: CertificateAuthority, root_cert: Certificate +) -> None: + """Test generating a CRL that contains only user certs.""" + root_cert.revoke() + child.revoke() + + obj = CertificateRevocationList.objects.create_certificate_revocation_list( + usable_root, KEY_BACKEND_OPTIONS, only_contains_user_certs=True + ) + crl = obj.loaded + assert [rev.serial_number for rev in crl] == [root_cert.get_revocation().serial_number] + idp = crl.extensions.get_extension_for_oid(ExtensionOID.ISSUING_DISTRIBUTION_POINT) + assert_issuing_distribution_point(idp, only_contains_user_certs=True) # type: ignore[arg-type] + + +@pytest.mark.parametrize( + "reasons", + ( + frozenset([x509.ReasonFlags.key_compromise]), + frozenset([x509.ReasonFlags.key_compromise, x509.ReasonFlags.affiliation_changed]), + ), +) +def test_with_reasons( + usable_root: CertificateAuthority, root_cert: Certificate, reasons: frozenset[x509.ReasonFlags] +) -> None: + """Generate a CRL with only one reason.""" + root_cert.revoke(ReasonFlags.key_compromise) + + obj = CertificateRevocationList.objects.create_certificate_revocation_list( + usable_root, KEY_BACKEND_OPTIONS, only_some_reasons=reasons + ) + assert obj.only_some_reasons == reasons + crl = obj.loaded + assert [rev.serial_number for rev in crl] == [root_cert.get_revocation().serial_number] + idp = crl.extensions.get_extension_for_oid(ExtensionOID.ISSUING_DISTRIBUTION_POINT) + assert_issuing_distribution_point(idp, only_some_reasons=reasons) # type: ignore[arg-type] + + +@pytest.mark.parametrize( + "reasons", + ( + frozenset([x509.ReasonFlags.key_compromise]), + frozenset([x509.ReasonFlags.key_compromise, x509.ReasonFlags.aa_compromise]), + ), +) +def test_with_reasons_not_included( + usable_root: CertificateAuthority, root_cert: Certificate, reasons: frozenset[x509.ReasonFlags] +) -> None: + """Generate a CRL with only some reasons, where the certificate is revoked for a different reason.""" + root_cert.revoke(ReasonFlags.affiliation_changed) + + obj = CertificateRevocationList.objects.create_certificate_revocation_list( + usable_root, KEY_BACKEND_OPTIONS, only_some_reasons=reasons + ) + assert obj.only_some_reasons == reasons + crl = obj.loaded + assert not list(crl) + idp = crl.extensions.get_extension_for_oid(ExtensionOID.ISSUING_DISTRIBUTION_POINT) + assert_issuing_distribution_point(idp, only_some_reasons=reasons) # type: ignore[arg-type] + + +def test_use_tz_is_false(usable_root: CertificateAuthority, settings: SettingsWrapper) -> None: + """Generate a CRL with settings.USE_TZ = False.""" + settings.USE_TZ = False + + obj = CertificateRevocationList.objects.create_certificate_revocation_list( + usable_root, KEY_BACKEND_OPTIONS + ) + assert obj.loaded.last_update_utc == TIMESTAMPS["everything_valid"] + assert obj.loaded.next_update_utc == TIMESTAMPS["everything_valid"] + timedelta(days=1) + + +def test_use_tz_is_false_with_next_update( + usable_root: CertificateAuthority, settings: SettingsWrapper +) -> None: + """Generate a CRL with settings.USE_TZ = False and passing a timezone-naive next_update.""" + next_update = datetime.now().replace(microsecond=10) + timedelta(days=2) + settings.USE_TZ = False + + obj = CertificateRevocationList.objects.create_certificate_revocation_list( + usable_root, KEY_BACKEND_OPTIONS, next_update=next_update + ) + assert obj.loaded.last_update_utc == TIMESTAMPS["everything_valid"] + assert obj.loaded.next_update_utc == TIMESTAMPS["everything_valid"] + timedelta(days=2) + + +def test_use_tz_is_false_with_tz_aware_next_update( + usable_root: CertificateAuthority, settings: SettingsWrapper +) -> None: + """Generate a CRL with settings.USE_TZ = False and passing a timezone-aware next_update.""" + next_update = datetime.now(tz=tz.utc).replace(microsecond=10) + timedelta(days=2) + settings.USE_TZ = False + + obj = CertificateRevocationList.objects.create_certificate_revocation_list( + usable_root, KEY_BACKEND_OPTIONS, next_update=next_update + ) + assert obj.loaded.last_update_utc == TIMESTAMPS["everything_valid"] + assert obj.loaded.next_update_utc == TIMESTAMPS["everything_valid"] + timedelta(days=2) + + +def test_invalid_scope(root: CertificateAuthority) -> None: + """Try generating a CRL where both `only_contains_ca_certs` and `only_contains_user_certs` are True.""" + match = r"^`only_contains_ca_certs` and `only_contains_user_certs` cannot both be set\.$" + match = ( + r"^Only one of `only_contains_ca_certs`, `only_contains_user_certs` and " + r"`only_contains_attribute_certs` can be set\.$" + ) + with pytest.raises(ValueError, match=match): + CertificateRevocationList.objects.create_certificate_revocation_list( + root, KEY_BACKEND_OPTIONS, only_contains_user_certs=True, only_contains_ca_certs=True + ) + + +def test_invalid_reasons(root: CertificateAuthority) -> None: + """Try creating a CRL with an invalid type set in reasons.""" + match = r"^Object of type ReasonFlags is not serializable with this encoder\.$" + with pytest.raises(TypeError, match=match): + CertificateRevocationList.objects.create( + ca=root, + number=1, + last_update=TIMESTAMPS["everything_valid"], + next_update=TIMESTAMPS["everything_valid"], + only_some_reasons=x509.ReasonFlags.key_compromise, + ) + + +def test_loaded_with_data_is_none(root: CertificateAuthority) -> None: + """Try accessing the `loaded` property when data has not yet been set.""" + crl = CertificateRevocationList.objects.create( + ca=root, + number=1, + last_update=TIMESTAMPS["everything_valid"], + next_update=TIMESTAMPS["everything_valid"], + ) + with pytest.raises(ValueError, match=r"^CRL is not yet generated for this object\.$"): + crl.loaded # noqa: B018 # this is what we test + + +def test_cache_with_data_is_none(root: CertificateAuthority) -> None: + """Try accessing the `loaded` property when data has not yet been set.""" + crl = CertificateRevocationList.objects.create( + ca=root, + number=1, + last_update=TIMESTAMPS["everything_valid"], + next_update=TIMESTAMPS["everything_valid"], + ) + with pytest.raises(ValueError, match=r"^CRL is not yet generated for this object\.$"): + crl.cache() diff --git a/ca/django_ca/tests/pydantic/test_type_aliases.py b/ca/django_ca/tests/pydantic/test_type_aliases.py index dca36a394..3a5813a9e 100644 --- a/ca/django_ca/tests/pydantic/test_type_aliases.py +++ b/ca/django_ca/tests/pydantic/test_type_aliases.py @@ -17,14 +17,12 @@ from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import ec -from cryptography.hazmat.primitives.serialization import Encoding import pytest from django_ca import constants from django_ca.pydantic.type_aliases import ( Base64EncodedBytes, - CertificateRevocationListEncodingTypeAlias, EllipticCurveTypeAlias, HashAlgorithmTypeAlias, Serial, @@ -43,12 +41,6 @@ class HashAlgorithmTypeAliasModel(BaseModel): value: HashAlgorithmTypeAlias -class CertificateRevocationListEncodingTypeAliasModel(BaseModel): - """Test CertificateRevocationListEncodingTypeAlias.""" - - value: CertificateRevocationListEncodingTypeAlias - - class JSONSerializableBytesModel(BaseModel): """Test class to test the Base64EncodedBytes type aliases.""" @@ -122,46 +114,6 @@ def test_hash_algorithm_unsupported_types(hash_obj: hashes.HashAlgorithm) -> Non HashAlgorithmTypeAliasModel(value=hash_obj) -@pytest.mark.parametrize("name,encoding", constants.CERTIFICATE_REVOCATION_LIST_ENCODING_TYPES.items()) -def test_crl_encoding(name: str, encoding: Encoding) -> None: - """Test CertificateRevocationListEncoding.""" - model = CertificateRevocationListEncodingTypeAliasModel(value=name) - assert model.value == encoding - - model = CertificateRevocationListEncodingTypeAliasModel(value=encoding) - assert model.value == encoding - - model = CertificateRevocationListEncodingTypeAliasModel.model_validate({"value": name}) - assert model.value == encoding - - model = CertificateRevocationListEncodingTypeAliasModel.model_validate({"value": name}, strict=True) - assert model.value == encoding - - assert model.model_dump()["value"] == encoding - assert model.model_dump(mode="json") == {"value": name} - - assert ( - CertificateRevocationListEncodingTypeAliasModel.model_validate_json(model.model_dump_json()).value - == encoding - ) - - -@pytest.mark.parametrize("value", ("", "wrong", True)) -def test_crl_encoding_errors(value: str) -> None: - """Test invalid values for CertificateRevocationListEncodingTypeAliasModel.""" - with pytest.raises(ValueError): - CertificateRevocationListEncodingTypeAliasModel(value=value) - - -@pytest.mark.parametrize("value", (Encoding.OpenSSH, Encoding.Raw, Encoding.SMIME, Encoding.X962)) -def test_crl_encoding_unsupported_encodings(value: Encoding) -> None: - """Test unsupported encodings.""" - with pytest.raises(ValueError, match=r"Input should be 'PEM' or 'DER'"): - CertificateRevocationListEncodingTypeAliasModel(value=value.name) - with pytest.raises(ValueError, match=r"Input should be 'PEM' or 'DER'"): - CertificateRevocationListEncodingTypeAliasModel(value=value) - - @pytest.mark.parametrize( "value,encoded", ( diff --git a/ca/django_ca/tests/tasks/conftest.py b/ca/django_ca/tests/tasks/conftest.py index 7448437fa..bf867470f 100644 --- a/ca/django_ca/tests/tasks/conftest.py +++ b/ca/django_ca/tests/tasks/conftest.py @@ -14,12 +14,11 @@ """Minor assertions for tasks.""" from cryptography import x509 -from cryptography.hazmat.primitives.serialization import Encoding from django.core.cache import cache from django_ca.models import CertificateAuthority -from django_ca.utils import get_crl_cache_key +from django_ca.tests.base.utils import crl_cache_key def assert_crl(ca: CertificateAuthority, crl: x509.CertificateRevocationList) -> None: @@ -35,10 +34,10 @@ def assert_crl(ca: CertificateAuthority, crl: x509.CertificateRevocationList) -> def assert_crls(ca: CertificateAuthority) -> None: """Assert that the correct CRLs have been generated.""" - key = get_crl_cache_key(ca.serial, Encoding.DER, "ca") + key = crl_cache_key(ca.serial, only_contains_ca_certs=True) crl = x509.load_der_x509_crl(cache.get(key)) assert_crl(ca, crl) - key = get_crl_cache_key(ca.serial, Encoding.DER, "user") + key = crl_cache_key(ca.serial, only_contains_user_certs=True) crl = x509.load_der_x509_crl(cache.get(key)) assert_crl(ca, crl) diff --git a/ca/django_ca/tests/tasks/test_cache_crls.py b/ca/django_ca/tests/tasks/test_cache_crls.py index 0af648256..ea100e4a0 100644 --- a/ca/django_ca/tests/tasks/test_cache_crls.py +++ b/ca/django_ca/tests/tasks/test_cache_crls.py @@ -17,8 +17,6 @@ import logging from unittest import mock -from cryptography.hazmat.primitives.serialization import Encoding - from django.core.cache import cache import pytest @@ -27,8 +25,8 @@ from django_ca.models import CertificateAuthority from django_ca.tasks import cache_crls from django_ca.tests.base.constants import CERT_DATA, TIMESTAMPS +from django_ca.tests.base.utils import crl_cache_key from django_ca.tests.tasks.conftest import assert_crls -from django_ca.utils import get_crl_cache_key pytestmark = [pytest.mark.usefixtures("clear_cache"), pytest.mark.freeze_time(TIMESTAMPS["everything_valid"])] @@ -47,7 +45,7 @@ def test_with_expired_certificate_authorities(usable_cas: list[CertificateAuthor cache_crls() for ca in usable_cas: - key = get_crl_cache_key(ca.serial, Encoding.DER, "ca") + key = crl_cache_key(ca.serial, only_contains_ca_certs=True) assert cache.get(key) is None @@ -61,7 +59,7 @@ def test_with_invalid_password(usable_pwd: CertificateAuthority) -> None: """Test passing an invalid password.""" password = base64.b64encode(b"wrong").decode() cache_crls([usable_pwd.serial], {usable_pwd.serial: {"password": password}}) - key = get_crl_cache_key(usable_pwd.serial, Encoding.DER, "ca") + key = crl_cache_key(usable_pwd.serial, only_contains_ca_certs=True) assert cache.get(key) is None diff --git a/ca/django_ca/tests/test_settings.py b/ca/django_ca/tests/test_settings.py index 6070e7de8..c93d900cb 100644 --- a/ca/django_ca/tests/test_settings.py +++ b/ca/django_ca/tests/test_settings.py @@ -38,11 +38,20 @@ update_database_setting_from_environment, ) from django_ca import conf -from django_ca.conf import KeyBackendConfigurationModel, model_settings -from django_ca.tests.base.assertions import assert_improperly_configured, assert_removed_in_220 +from django_ca.conf import CertificateRevocationListProfile, KeyBackendConfigurationModel, model_settings +from django_ca.tests.base.assertions import ( + assert_improperly_configured, + assert_removed_in_220, + assert_removed_in_230, +) from django_ca.tests.base.constants import FIXTURES_DIR from django_ca.tests.base.utils import cn, country, state +SCOPE_ERROR = ( + r"Only one of `only_contains_ca_certs`, `only_contains_user_certs` and `only_contains_attribute_certs` " + r"can be set\." +) + @pytest.mark.parametrize("value", (True, False) * 5) def test_settings_module(settings: SettingsWrapper, value: bool) -> None: @@ -359,6 +368,51 @@ def test_ca_acme_order_validity_limits(settings: SettingsWrapper, value: timedel settings.CA_ACME_ORDER_VALIDITY = value +def test_ca_crl_profiles_with_reason_codes(settings: SettingsWrapper) -> None: + """Test only_some_reasons for CA_CRL_PROFILES.""" + settings.CA_CRL_PROFILES = { + "ca": {"only_some_reasons": ["key_compromise", x509.ReasonFlags.ca_compromise]} + } + assert model_settings.CA_CRL_PROFILES == { + "ca": CertificateRevocationListProfile( + only_some_reasons=frozenset([x509.ReasonFlags.key_compromise, x509.ReasonFlags.ca_compromise]) + ) + } + + +@pytest.mark.parametrize("reason", (x509.ReasonFlags.unspecified, x509.ReasonFlags.remove_from_crl)) +def test_ca_crl_profiles_with_invalid_reason_codes( + settings: SettingsWrapper, reason: x509.ReasonFlags +) -> None: + """Test that an in valid only_some_reasons in CA_CRL_PROFILES raises an exception.""" + message = r"unspecified and remove_from_crl are not valid for `only_some_reasons`\." + with assert_improperly_configured(message): + settings.CA_CRL_PROFILES = {"ca": {"only_some_reasons": [reason]}} + + +def test_ca_crl_profiles_with_deprecated_encodings(settings: SettingsWrapper) -> None: + """Test that `encodings` in CA_CRL_PROFILES creates a warning.""" + msg = r"^encodings: Setting has no effect starting with django-ca 2\.1\.0\.$" + with assert_removed_in_230(msg): + settings.CA_CRL_PROFILES = {"ca": {"encodings": ["PEM"]}} + with assert_removed_in_230(msg): + assert model_settings.CA_CRL_PROFILES == {"ca": CertificateRevocationListProfile(encodings=["PEM"])} + + +@pytest.mark.parametrize("scope", ("user", "ca", "attribute")) +def test_ca_crl_profiles_with_deprecated_scope(settings: SettingsWrapper, scope: str) -> None: + """Set deprecated ca scope.""" + msg = ( + r"^scope: Setting is deprecated and will be removed in django-ca 2\.3\.0\. Use " + r"`only_contains_ca_certs` and `only_contains_user_certs` instead\.$" + ) + with assert_removed_in_230(msg): + settings.CA_CRL_PROFILES = {"ca": {"scope": scope}} + assert model_settings.CA_CRL_PROFILES == { + "ca": CertificateRevocationListProfile(**{f"only_contains_{scope}_certs": True}) + } + + @pytest.mark.parametrize( "value,parsed", ( @@ -671,3 +725,26 @@ def test_ca_use_celery_is_true_with_celery_not_installed(settings: SettingsWrapp with mock.patch.dict("sys.modules", celery=None): with assert_improperly_configured(msg): settings.CA_USE_CELERY = True + + +def test_ca_crl_profiles_invalid_scope(settings: SettingsWrapper) -> None: + """Test that setting both `only_contains_ca_certs` and `only_contains_user_certs` is an error.""" + with assert_improperly_configured(SCOPE_ERROR): + settings.CA_CRL_PROFILES = {"ca": {"only_contains_ca_certs": True, "only_contains_user_certs": True}} + + +@pytest.mark.parametrize( + "base,override", + ( + ("only_contains_ca_certs", "only_contains_user_certs"), + ("only_contains_user_certs", "only_contains_ca_certs"), + ("only_contains_attribute_certs", "only_contains_user_certs"), + ("only_contains_user_certs", "only_contains_attribute_certs"), + ), +) +def test_ca_crl_profiles_invalid_scope_by_override( + settings: SettingsWrapper, base: bool, override: bool +) -> None: + """Test that setting an invalid scope in an override.""" + with assert_improperly_configured(SCOPE_ERROR): + settings.CA_CRL_PROFILES = {"ca": {base: True, "OVERRIDES": {"123": {override: True}}}} diff --git a/ca/django_ca/tests/test_verification.py b/ca/django_ca/tests/test_verification.py index c5ae110bb..9306aa41f 100644 --- a/ca/django_ca/tests/test_verification.py +++ b/ca/django_ca/tests/test_verification.py @@ -180,7 +180,7 @@ def test_root_ca(ca_name: str) -> None: verify("-CAfile {0} {0}", *paths) # Create a CRL too and include it - with dumped(ca) as paths, crl(ca, scope="ca") as (crl_path, crl_parsed): + with dumped(ca) as paths, crl(ca, only_contains_ca_certs=True) as (crl_path, crl_parsed): verify("-CAfile {0} -crl_check_all {0}", *paths, crl_path=[crl_path]) # Try again with no scope @@ -190,7 +190,7 @@ def test_root_ca(ca_name: str) -> None: # Try with cert scope (fails because of wrong scope with ( dumped(ca) as paths, - crl(ca, scope="user") as (crl_path, crl_parsed), + crl(ca, only_contains_user_certs=True) as (crl_path, crl_parsed), pytest.raises(AssertionError), ): verify("-CAfile {0} -crl_check_all {0}", *paths, crl_path=[crl_path]) @@ -204,12 +204,12 @@ def test_root_ca_cert(ca_name: str) -> None: verify("-CAfile {0} {cert}", *paths, cert=cert) # Create a CRL too and include it - with crl(ca, scope="user") as (crl_path, crl_parsed): + with crl(ca, only_contains_user_certs=True) as (crl_path, crl_parsed): assert_scope(crl_parsed, user=True) verify("-CAfile {0} -crl_check {cert}", *paths, crl_path=[crl_path], cert=cert) # for crl_check_all, we also need the root CRL - with crl(ca, scope="ca") as (crl2_path, crl2): + with crl(ca, only_contains_ca_certs=True) as (crl2_path, crl2): assert_scope(crl2, ca=True) verify("-CAfile {0} -crl_check_all {cert}", *paths, crl_path=[crl_path, crl2_path], cert=cert) @@ -231,7 +231,7 @@ def test_ca_default_hostname(ca_name: str) -> None: verify("-trusted {0} -crl_check {cert}", *paths, crl_path=[crl_path], cert=cert) verify("-trusted {0} -crl_check_all {cert}", *paths, crl_path=[crl_path], cert=cert) - with crl(ca, scope="user") as (crl_path, crl_parsed): # test user-only CRL + with crl(ca, only_contains_user_certs=True) as (crl_path, crl_parsed): # test user-only CRL assert_scope(crl_parsed, user=True) verify("-trusted {0} -crl_check {cert}", *paths, crl_path=[crl_path], cert=cert) # crl_check_all does not work, b/c the scope is only "user" @@ -264,10 +264,13 @@ def test_intermediate_ca(ca_name: str) -> None: verify("-CAfile {0} -untrusted {1} {2}", *paths) # Try validation with CRLs - with crl(root, scope="ca") as (crl1_path, crl1), crl(child, scope="ca") as (crl2_path, crl2): + with ( + crl(root, only_contains_ca_certs=True) as (crl1_path, crl1), + crl(child, only_contains_ca_certs=True) as (crl2_path, crl2), + ): verify("-CAfile {0} -untrusted {1} -crl_check_all {2}", *paths, crl_path=[crl1_path, crl2_path]) - with sign_cert(child) as cert, crl(child, scope="user") as (crl3_path, crl3): + with sign_cert(child) as cert, crl(child, only_contains_user_certs=True) as (crl3_path, crl3): verify("-CAfile {0} -untrusted {1} {cert}", *paths, cert=cert) verify( "-CAfile {0} -untrusted {1} {cert}", *paths, cert=cert, crl_path=[crl1_path, crl3_path] @@ -275,8 +278,8 @@ def test_intermediate_ca(ca_name: str) -> None: with ( sign_cert(grandchild) as cert, - crl(child, scope="ca") as (crl4_path, crl4), - crl(grandchild, scope="user") as (crl6_path, crl6), + crl(child, only_contains_ca_certs=True) as (crl4_path, crl4), + crl(grandchild, only_contains_user_certs=True) as (crl6_path, crl6), ): verify("-CAfile {0} {cert}", *paths, untrusted=untrusted, cert=cert) verify( @@ -310,52 +313,33 @@ def test_intermediate_ca_default_hostname(ca_name: str, settings: SettingsWrappe with ( dumped(root, child, grandchild) as paths, - crl(root, scope="ca") as (root_ca_crl_path, root_ca_crl_parsed), + crl(root, only_contains_ca_certs=True) as (root_ca_crl_path, root_ca_crl_parsed), ): # Simple validation of the CAs verify("-trusted {0} {1}", *paths) verify("-trusted {0} -untrusted {1} {2}", *paths) - with crl(child, scope="ca") as (child_ca_crl_path, child_ca_crl_parsed): - assert_full_name(child_ca_crl_parsed, [uri(f"http://example.com{grandchild_ca_crl}")]) + with crl(child, only_contains_ca_certs=True) as (child_ca_crl_path, child_ca_crl_parsed): + assert_full_name(child_ca_crl_parsed, None) verify( "-trusted {0} -untrusted {1} -crl_check_all {2}", *paths, crl_path=[root_ca_crl_path, child_ca_crl_path], ) - # Globally scoped CRLs do not validate, as the CRL will contain a different full name from the - # CRLdp extension + # Globally scoped CRLs validates as well (no full name) with crl(child) as (child_crl_path, child_crl_parsed): - # assert_full_name(crl, None) - # assert_full_name(crl2, [uri(f"http://example.com{grandchild_ca_crl}")]) verify( "-trusted {0} -untrusted {1} -crl_check_all {2}", *paths, crl_path=[root_ca_crl_path, child_crl_path], - code=2, - stderr="[dD]ifferent CRL scope", - ) - - # Changing the default hostname setting should not change the validation result - settings.CA_DEFAULT_HOSTNAME = "example.net" - with crl(root, scope="ca") as (crl_path, crl_parsed), crl(child, scope="ca") as (crl2_path, crl2): - # Known but not easily fixable issue: If CA_DEFAULT_HOSTNAME is changed, CRLs will get wrong - # full name and validation fails. - assert_full_name(crl_parsed, None) - # assert_full_name(crl2, [uri(f"http://example.com{grandchild_ca_crl}")]) - verify( - "-trusted {0} -untrusted {1} -crl_check_all {2}", - *paths, - crl_path=[crl_path, crl2_path], - code=2, - stderr="[dD]ifferent CRL scope", + code=0, ) - # Again, global CRLs do not validate + # Again, global CRL validates settings.CA_DEFAULT_HOSTNAME = "example.net" with ( - crl(root, scope="ca") as (crl_path, crl_parsed), + crl(root, only_contains_ca_certs=True) as (crl_path, crl_parsed), crl(child) as (crl2_path, crl_parsed_2), ): assert_full_name(crl_parsed, None) @@ -363,6 +347,5 @@ def test_intermediate_ca_default_hostname(ca_name: str, settings: SettingsWrappe "-trusted {0} -untrusted {1} -crl_check_all {2}", *paths, crl_path=[crl_path, crl2_path], - code=2, - stderr="[dD]ifferent CRL scope", + code=0, ) diff --git a/ca/django_ca/tests/test_views.py b/ca/django_ca/tests/test_views.py index b061ca620..31b227852 100644 --- a/ca/django_ca/tests/test_views.py +++ b/ca/django_ca/tests/test_views.py @@ -13,273 +13,10 @@ """Test basic views.""" -from datetime import datetime, timedelta, timezone as tz -from http import HTTPStatus - -from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.serialization import Encoding - -from django.core.cache import cache from django.test import Client -from django.urls import include, path, re_path, reverse - -import pytest -from freezegun.api import FrozenDateTimeFactory -from pytest_django.fixtures import SettingsWrapper - -from django_ca.conf import model_settings -from django_ca.models import Certificate, CertificateAuthority -from django_ca.tests.base.assertions import assert_crl -from django_ca.tests.base.constants import CERT_DATA, TIMESTAMPS -from django_ca.tests.base.utils import get_idp, idp_full_name -from django_ca.views import CertificateRevocationListView - -app_name = "django_ca" -urlpatterns = [ - path("django_ca/", include("django_ca.urls")), - re_path(r"^crl/(?P[0-9A-F:]+)/$", CertificateRevocationListView.as_view(), name="default"), - re_path( - r"^full/(?P[0-9A-F:]+)/$", CertificateRevocationListView.as_view(scope=None), name="full" - ), - re_path( - r"^adv/(?P[0-9A-F:]+)/$", - CertificateRevocationListView.as_view( - content_type="text/plain", - expires=321, - type=Encoding.PEM, - ), - name="advanced", - ), - re_path( - r"^crl/ca/(?P[0-9A-F:]+)/$", - CertificateRevocationListView.as_view(scope="ca", type=Encoding.PEM), - name="ca_crl", - ), - re_path( - r"^include_idp/(?P[0-9A-F:]+)/$", - CertificateRevocationListView.as_view(scope=None, include_issuing_distribution_point=True), - name="include_idp", - ), - re_path( - r"^exclude_idp/(?P[0-9A-F:]+)/$", - CertificateRevocationListView.as_view(scope=None, include_issuing_distribution_point=False), - name="exclude_idp", - ), -] - - -@pytest.mark.freeze_time(TIMESTAMPS["everything_valid"]) -@pytest.mark.usefixtures("clear_cache") -@pytest.mark.urls(__name__) -class TestCertificateRevocationListView: - """Mixin with test cases for CertificateRevocationListView. - - Why is this a separate mixin: https://github.com/spulec/freezegun/issues/485 - """ - - def test_basic_response( - self, - client: Client, - freezer: FrozenDateTimeFactory, - usable_child: CertificateAuthority, - child_cert: Certificate, - ) -> None: - """Basic test.""" - # test the default view - url = reverse("default", kwargs={"serial": usable_child.serial}) - idp = get_idp(full_name=idp_full_name(usable_child), only_contains_user_certs=True) - response = client.get(url) - assert response.status_code == HTTPStatus.OK - assert response["Content-Type"] == "application/pkix-crl" - assert_crl( - response.content, expected=[], encoding=Encoding.DER, signer=usable_child, expires=600, idp=idp - ) - - # revoke a certificate - child_cert.revoke() - - # Advance time so that we see that the cached CRL now expires sooner. - last_update = datetime.now(tz=tz.utc) - freezer.tick(timedelta(seconds=10)) - - # fetch again - we should see a cached response - response = client.get(url) - assert response.status_code == HTTPStatus.OK - assert response["Content-Type"] == "application/pkix-crl" - assert_crl( - response.content, - expected=[], # still an empty response, because it was cached - encoding=Encoding.DER, - signer=usable_child, - expires=590, # time advanced by ten seconds above - idp=idp, - last_update=last_update, - ) - - # clear the cache and fetch again - cache.clear() - response = client.get(url) - assert response.status_code == HTTPStatus.OK - assert response["Content-Type"] == "application/pkix-crl" - assert_crl( - response.content, - expected=[child_cert], # now includes certificate - encoding=Encoding.DER, - signer=usable_child, - expires=600, # again 600 - idp=idp, - crl_number=1, # regenerated - ) - - def test_full_scope_with_child_ca(self, client: Client, usable_child: CertificateAuthority) -> None: - """Test getting CRL with full scope.""" - full_name = usable_child.sign_crl_distribution_points.value[0].full_name # type: ignore[union-attr] - idp = get_idp(full_name=full_name) - - response = client.get(reverse("full", kwargs={"serial": usable_child.serial})) - assert response.status_code == HTTPStatus.OK - assert response["Content-Type"] == "application/pkix-crl" - assert_crl(response.content, expected=[], encoding=Encoding.DER, expires=600, idp=idp) - - def test_full_scope_with_root_ca( - self, - client: Client, - usable_root: CertificateAuthority, - child: CertificateAuthority, - root_cert: Certificate, - ) -> None: - """Test getting CRL with full scope and a revoked CA and cert.""" - assert child.parent == usable_root # test assumption - assert root_cert.ca == usable_root # test assumption - - response = client.get(reverse("full", kwargs={"serial": usable_root.serial})) - assert response.status_code == HTTPStatus.OK - assert response["Content-Type"] == "application/pkix-crl" - assert_crl( - response.content, expected=[], encoding=Encoding.DER, expires=600, signer=usable_root, idp=None - ) - - def test_full_scope_with_root_ca_with_revoked_entities( - self, - client: Client, - usable_root: CertificateAuthority, - child: CertificateAuthority, - root_cert: Certificate, - ) -> None: - """Test getting CRL with full scope and a revoked CA and cert.""" - assert child.parent == usable_root # test assumption - assert root_cert.ca == usable_root # test assumption - child.revoke() - root_cert.revoke() - - response = client.get(reverse("full", kwargs={"serial": usable_root.serial})) - assert response.status_code == HTTPStatus.OK - assert response["Content-Type"] == "application/pkix-crl" - # full scope includes both CAs and certs: - assert_crl( - response.content, - expected=[child, root_cert], - encoding=Encoding.DER, - expires=600, - signer=usable_root, - idp=None, - ) - - def test_ca_crl( - self, client: Client, usable_root: CertificateAuthority, child: CertificateAuthority - ) -> None: - """Test getting a CA CRL.""" - idp = get_idp(only_contains_ca_certs=True) # root CAs don't have a full name (GitHub issue #64) - child.revoke() - - response = client.get(reverse("ca_crl", kwargs={"serial": usable_root.serial})) - assert response.status_code == HTTPStatus.OK - assert response["Content-Type"] == "text/plain" - assert_crl(response.content, expected=[child], expires=600, idp=idp, signer=usable_root) - - def test_password(self, client: Client, usable_pwd: CertificateAuthority) -> None: - """Test getting a CRL for a CA that is encrypted with a password.""" - # Make sure that the password is actually set - assert model_settings.CA_PASSWORDS[usable_pwd.serial] == CERT_DATA["pwd"]["password"] - - idp = get_idp(full_name=idp_full_name(usable_pwd), only_contains_user_certs=True) - response = client.get(reverse("default", kwargs={"serial": usable_pwd.serial})) - assert response.status_code == HTTPStatus.OK - assert response["Content-Type"] == "application/pkix-crl" - assert_crl(response.content, encoding=Encoding.DER, expires=600, idp=idp, signer=usable_pwd) - - def test_password_with_missing_password( - self, client: Client, usable_pwd: CertificateAuthority, settings: SettingsWrapper - ) -> None: - """Try getting the CRL for an encrypted CA where there is no password (which fails).""" - settings.CA_PASSWORDS = {} - response = client.get(reverse("default", kwargs={"serial": usable_pwd.serial})) - assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - assert response["Content-Type"] == "text/plain" - assert response.content == b"Error while retrieving the CRL." - - def test_password_with_cached_response( - self, client: Client, usable_pwd: CertificateAuthority, settings: SettingsWrapper - ) -> None: - """Test getting the CRL for an encrypted CA where the response was cached (by cache_crls()).""" - # Cache CRLs (NOTE: password is fetched from CA_PASSWORDS during model validation) - key_backend_options = usable_pwd.key_backend.get_use_private_key_options(usable_pwd, {}) - usable_pwd.cache_crls(key_backend_options) # cache CRLs for this CA - - # Clear password in settings, so this will now only work if we find the CRL in the cache. - settings.CA_PASSWORDS = {} - - idp = get_idp(full_name=idp_full_name(usable_pwd), only_contains_user_certs=True) - response = client.get(reverse("default", kwargs={"serial": usable_pwd.serial})) - assert response.status_code == HTTPStatus.OK - assert response["Content-Type"] == "application/pkix-crl" - assert_crl(response.content, encoding=Encoding.DER, idp=idp, signer=usable_pwd) - - def test_view_configuration(self, client: Client, usable_child: CertificateAuthority) -> None: - """Test fetching the CRL for a manually configured view that overrides some settings.""" - idp = get_idp(full_name=idp_full_name(usable_child), only_contains_user_certs=True) - response = client.get(reverse("advanced", kwargs={"serial": usable_child.serial})) - assert response.status_code == HTTPStatus.OK - assert response["Content-Type"] == "text/plain" - assert_crl(response.content, expires=321, idp=idp, algorithm=hashes.SHA256()) - - def test_force_idp_inclusion(self, client: Client, usable_child: CertificateAuthority) -> None: - """Test that forcing inclusion of CRLs works.""" - # View still works with self.ca, because it's the child CA - idp = get_idp(full_name=idp_full_name(usable_child)) - response = client.get(reverse("include_idp", kwargs={"serial": usable_child.serial})) - assert response.status_code == HTTPStatus.OK - assert response["Content-Type"] == "application/pkix-crl" - assert_crl(response.content, encoding=Encoding.DER, expires=600, idp=idp) - - def test_force_idp_inclusion_with_root(self, client: Client, usable_root: CertificateAuthority) -> None: - """Test forcing an IDP for a root CA (which fails, because it cannot have an IDP).""" - response = client.get(reverse("include_idp", kwargs={"serial": usable_root.serial})) - assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - assert response["Content-Type"] == "text/plain" - assert response.content == b"Error while retrieving the CRL." - - def test_force_idp_exclusion(self, client: Client, usable_child: CertificateAuthority) -> None: - """Test that forcing exclusion of CRLs works.""" - response = client.get(reverse("exclude_idp", kwargs={"serial": usable_child.serial})) - assert response.status_code == HTTPStatus.OK - assert response["Content-Type"] == "application/pkix-crl" - assert_crl(response.content, encoding=Encoding.DER, expires=600, idp=None) - - def test_force_encoding(self, client: Client, usable_root: CertificateAuthority) -> None: - """Test that forcing a different encoding.""" - idp = get_idp(full_name=idp_full_name(usable_root), only_contains_user_certs=True) - response = client.get(reverse("default", kwargs={"serial": usable_root.serial}), {"encoding": "PEM"}) - assert response.status_code == HTTPStatus.OK - assert response["Content-Type"] == "text/plain" - assert_crl(response.content, signer=usable_root, encoding=Encoding.PEM, expires=600, idp=idp) +from django.urls import reverse - def test_invalid_encoding(self, client: Client, usable_root: CertificateAuthority) -> None: - """Test that forcing an unsupported encoding.""" - response = client.get(reverse("default", kwargs={"serial": usable_root.serial}), {"encoding": "X962"}) - assert response.status_code == HTTPStatus.BAD_REQUEST - assert response["Content-Type"] == "text/plain" - assert response.content == b"X962: Invalid encoding requested." +from django_ca.models import CertificateAuthority def test_generic_ca_issuers_view(usable_root: CertificateAuthority, client: Client) -> None: diff --git a/ca/django_ca/tests/utils/test_get_crl_cache_key.py b/ca/django_ca/tests/utils/test_get_crl_cache_key.py new file mode 100644 index 000000000..22b293c1e --- /dev/null +++ b/ca/django_ca/tests/utils/test_get_crl_cache_key.py @@ -0,0 +1,54 @@ +# This file is part of django-ca (https://github.com/mathiasertl/django-ca). +# +# django-ca is free software: you can redistribute it and/or modify it under the terms of the GNU General +# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# django-ca is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the +# implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License along with django-ca. If not, see +# . + +"""Test :py:func:`~django_ca.utils.get_crl_cache_key` function.""" + +from typing import Any + +from cryptography import x509 +from cryptography.hazmat.primitives.serialization import Encoding + +import pytest + +from django_ca.utils import get_crl_cache_key + +DEFAULT_KWARGS = { + "serial": "123", + "encoding": Encoding.DER, + "only_contains_ca_certs": False, + "only_contains_user_certs": False, + "only_contains_attribute_certs": False, + "only_some_reasons": None, +} + + +@pytest.mark.parametrize( + "kwargs,expected", + ( + (DEFAULT_KWARGS, "crl_123_DER_False_False_False_None"), + ({**DEFAULT_KWARGS, "encoding": Encoding.PEM}, "crl_123_PEM_False_False_False_None"), + ({**DEFAULT_KWARGS, "only_contains_ca_certs": True}, "crl_123_DER_True_False_False_None"), + ({**DEFAULT_KWARGS, "only_contains_user_certs": True}, "crl_123_DER_False_True_False_None"), + ({**DEFAULT_KWARGS, "only_contains_attribute_certs": True}, "crl_123_DER_False_False_True_None"), + ( + { + **DEFAULT_KWARGS, + "only_some_reasons": [x509.ReasonFlags.key_compromise, x509.ReasonFlags.aa_compromise], + }, + "crl_123_DER_False_False_False_aa_compromise,key_compromise", + ), + ), +) +def test_function(kwargs: dict[str, Any], expected: str) -> None: + """Test generating a cache key.""" + assert get_crl_cache_key(**kwargs) == expected diff --git a/ca/django_ca/tests/views/__init__.py b/ca/django_ca/tests/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ca/django_ca/tests/views/test_certificate_revocation_list_view.py b/ca/django_ca/tests/views/test_certificate_revocation_list_view.py new file mode 100644 index 000000000..2ce28b65f --- /dev/null +++ b/ca/django_ca/tests/views/test_certificate_revocation_list_view.py @@ -0,0 +1,307 @@ +# This file is part of django-ca (https://github.com/mathiasertl/django-ca). +# +# django-ca is free software: you can redistribute it and/or modify it under the terms of the GNU General +# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# django-ca is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the +# implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License along with django-ca. If not, see +# . + +"""Test basic views.""" + +# pylint: disable=redefined-outer-name # because of test fixtures + +from collections.abc import Iterator +from http import HTTPStatus + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.serialization import Encoding + +from django.core.cache import cache +from django.test import Client +from django.urls import include, path, re_path, reverse + +import pytest +from pytest_django.fixtures import SettingsWrapper + +from django_ca import constants +from django_ca.models import Certificate, CertificateAuthority, CertificateRevocationList +from django_ca.tests.base.assertions import assert_crl, assert_removed_in_230 +from django_ca.tests.base.constants import CERT_DATA, TIMESTAMPS +from django_ca.tests.base.utils import get_idp +from django_ca.views import CertificateRevocationListView + +ROOT_SERIAL = CERT_DATA["root"]["serial"] + +pytestmark = [ + pytest.mark.freeze_time(TIMESTAMPS["everything_valid"]), + pytest.mark.usefixtures("clear_cache"), + pytest.mark.urls(__name__), +] + +app_name = "django_ca" +urlpatterns = [ + path("django_ca/", include("django_ca.urls")), # needed for fixtures + re_path(r"^crl/(?P[0-9A-F:]+)/$", CertificateRevocationListView.as_view(), name="default"), + re_path( + r"^crl/ca/(?P[0-9A-F:]+)/$", + CertificateRevocationListView.as_view(only_contains_ca_certs=True), + name="ca", + ), + re_path( + r"^crl/user/(?P[0-9A-F:]+)/$", + CertificateRevocationListView.as_view(only_contains_user_certs=True), + name="user", + ), + re_path( + r"^crl/reasons/(?P[0-9A-F:]+)/$", + CertificateRevocationListView.as_view(only_some_reasons=frozenset([x509.ReasonFlags.key_compromise])), + name="reasons", + ), + re_path( + r"^adv/(?P[0-9A-F:]+)/$", + CertificateRevocationListView.as_view(content_type="foo/bar", expires=321, type=Encoding.PEM), + name="advanced", + ), + re_path( # pragma: only django-ca<2.3.0 + r"^deprecated-full-scope/(?P[0-9A-F:]+)/$", + CertificateRevocationListView.as_view(scope=None), + name="deprecated-full-scope", + ), + re_path( # pragma: only django-ca<2.3.0 + r"^deprecated-ca-scope/(?P[0-9A-F:]+)/$", + CertificateRevocationListView.as_view(scope="ca"), + name="deprecated-ca-scope", + ), + re_path( # pragma: only django-ca<2.3.0 + r"^deprecated-user-scope/(?P[0-9A-F:]+)/$", + CertificateRevocationListView.as_view(scope="user"), + name="deprecated-user-scope", + ), + re_path( # pragma: only django-ca<2.3.0 + r"^deprecated-attribute-scope/(?P[0-9A-F:]+)/$", + CertificateRevocationListView.as_view(scope="attribute"), + name="deprecated-attribute-scope", + ), +] + + +@pytest.fixture() +def default_url(root: CertificateAuthority) -> Iterator[str]: + """Fixture for the default URL for the root CA.""" + yield reverse("default", kwargs={"serial": root.serial}) + + +@pytest.fixture() +def deprecated_scope() -> Iterator[None]: + """Warning for deprecated scope parameter.""" + msg = ( + "The scope parameter is deprecated and will be removed in django-ca 2.3.0, use " + "`only_contains_{ca,user,attribute}_cert` instead." + ) + with assert_removed_in_230(msg): + yield + + +def test_full_crl(client: Client, default_url: str, root_crl: CertificateRevocationList) -> None: + """Fetch a full CRL (= CA and user certs, all reasons).""" + response = client.get(default_url) + assert response.status_code == HTTPStatus.OK + assert response["Content-Type"] == "application/pkix-crl" + assert response.content == root_crl.data + + +def test_ca_crl(client: Client, root_ca_crl: CertificateRevocationList) -> None: + """Fetch a CA CRL.""" + response = client.get(reverse("ca", kwargs={"serial": root_ca_crl.ca.serial})) + assert response.status_code == HTTPStatus.OK + assert response["Content-Type"] == "application/pkix-crl" + assert response.content == root_ca_crl.data + + +def test_user_crl(client: Client, root_user_crl: CertificateRevocationList) -> None: + """Fetch a user CRL.""" + response = client.get(reverse("user", kwargs={"serial": root_user_crl.ca.serial})) + assert response.status_code == HTTPStatus.OK + assert response["Content-Type"] == "application/pkix-crl" + assert response.content == root_user_crl.data + + +def test_with_cache_miss(client: Client, default_url: str, root_crl: CertificateRevocationList) -> None: + """Fetch a full CRL with a cache miss.""" + cache.clear() # clear the cache to generate a cache miss + response = client.get(default_url) + assert response.status_code == HTTPStatus.OK + assert response["Content-Type"] == "application/pkix-crl" + assert response.content == root_crl.data + + +def test_regenerate_full_crl(client: Client, usable_root: CertificateAuthority, default_url: str) -> None: + """Fetch a full CRL where the CRL has to be regenerated.""" + response = client.get(default_url) + assert response.status_code == HTTPStatus.OK + assert response["Content-Type"] == "application/pkix-crl" + assert_crl(response.content, expected=[], encoding=Encoding.DER, signer=usable_root) + + +def test_regenerate_ca_crl(client: Client, usable_root: CertificateAuthority) -> None: + """Fetch a full CRL where the CRL has to be regenerated.""" + response = client.get(reverse("ca", kwargs={"serial": usable_root.serial})) + assert response.status_code == HTTPStatus.OK + assert response["Content-Type"] == "application/pkix-crl" + idp = get_idp(only_contains_ca_certs=True) + assert_crl(response.content, expected=[], encoding=Encoding.DER, signer=usable_root, idp=idp) + + +def test_regenerate_user_crl(client: Client, usable_root: CertificateAuthority) -> None: + """Fetch a full CRL where the CRL has to be regenerated.""" + response = client.get(reverse("user", kwargs={"serial": usable_root.serial})) + assert response.status_code == HTTPStatus.OK + assert response["Content-Type"] == "application/pkix-crl" + idp = get_idp(only_contains_user_certs=True) + assert_crl(response.content, expected=[], encoding=Encoding.DER, signer=usable_root, idp=idp) + + +def test_regenerate_full_crl_with_reasons( + client: Client, usable_root: CertificateAuthority, root_cert: Certificate +) -> None: + """Fetch a CRL with only some reasons where the CRL has to be regenerated.""" + root_cert.revoke(constants.ReasonFlags.key_compromise) + response = client.get(reverse("reasons", kwargs={"serial": usable_root.serial})) + assert response.status_code == HTTPStatus.OK + assert response["Content-Type"] == "application/pkix-crl" + idp = get_idp(only_some_reasons=frozenset([x509.ReasonFlags.key_compromise])) + assert_crl( + response.content, + expected=[root_cert], + encoding=Encoding.DER, + signer=usable_root, + idp=idp, + entry_extensions=( + ( + [ + x509.Extension( + oid=x509.CRLReason.oid, + critical=False, + value=x509.CRLReason(x509.ReasonFlags.key_compromise), + ) + ], + ) + ), + ) + + +def test_regenerate_full_crl_with_reasons_without_matching_certs( + client: Client, usable_root: CertificateAuthority, root_cert: Certificate +) -> None: + """Fetch a CRL with only some reasons where the CRL has to be regenerated, but no cert matches.""" + root_cert.revoke(constants.ReasonFlags.aa_compromise) + response = client.get(reverse("reasons", kwargs={"serial": usable_root.serial})) + assert response.status_code == HTTPStatus.OK + assert response["Content-Type"] == "application/pkix-crl" + idp = get_idp(only_some_reasons=frozenset([x509.ReasonFlags.key_compromise])) + assert_crl(response.content, expected=[], encoding=Encoding.DER, signer=usable_root, idp=idp) + + +def test_with_object_not_in_database( + client: Client, default_url: str, root_crl: CertificateRevocationList +) -> None: + """Fetch a full CRL where the CRL is in the cache, but not in the database (should not happen).""" + root_crl.delete() # delete the object - view still works b/c of cache + response = client.get(default_url) + assert response.status_code == HTTPStatus.OK + assert response["Content-Type"] == "application/pkix-crl" + assert response.content == root_crl.data + + +def test_force_encoding(client: Client, default_url: str, root_crl: CertificateRevocationList) -> None: + """Test that forcing a different encoding.""" + response = client.get(default_url, data={"encoding": "PEM"}) + assert response.status_code == HTTPStatus.OK + assert response["Content-Type"] == "text/plain", response.content + assert response.content == root_crl.pem + + +def test_force_encoding_with_cache_miss( + client: Client, default_url: str, root_crl: CertificateRevocationList +) -> None: + """Test that forcing a different encoding.""" + cache.clear() + response = client.get(default_url, data={"encoding": "PEM"}) + assert response.status_code == HTTPStatus.OK + assert response["Content-Type"] == "text/plain" + assert response.content == root_crl.pem + + +def test_view_configuration(client: Client, usable_root: CertificateAuthority) -> None: + """Test fetching the CRL for a manually configured view that overrides some settings.""" + response = client.get(reverse("advanced", kwargs={"serial": usable_root.serial})) + assert response.status_code == HTTPStatus.OK + assert response["Content-Type"] == "foo/bar" + assert_crl(response.content, expires=321, algorithm=hashes.SHA256(), signer=usable_root) + + +@pytest.mark.usefixtures("deprecated_scope") +def test_deprecated_full_scope(client: Client, root_crl: CertificateRevocationList) -> None: + """Test fetching deprecated `scope` parameter with value `None`.""" + response = client.get(reverse("deprecated-full-scope", kwargs={"serial": ROOT_SERIAL})) + assert response.status_code == HTTPStatus.OK + assert response.content == root_crl.data + + +@pytest.mark.usefixtures("deprecated_scope") +def test_deprecated_ca_scope(client: Client, root_ca_crl: CertificateRevocationList) -> None: + """Test fetching deprecated `scope` parameter with value `ca`.""" + response = client.get(reverse("deprecated-ca-scope", kwargs={"serial": ROOT_SERIAL})) + assert response.status_code == HTTPStatus.OK + assert response.content == root_ca_crl.data + + +@pytest.mark.usefixtures("deprecated_scope") +def test_deprecated_user_scope(client: Client, root_user_crl: CertificateRevocationList) -> None: + """Test fetching deprecated `scope` parameter with value `user`.""" + response = client.get(reverse("deprecated-user-scope", kwargs={"serial": ROOT_SERIAL})) + assert response.status_code == HTTPStatus.OK + assert response.content == root_user_crl.data + + +@pytest.mark.django_db +@pytest.mark.usefixtures("deprecated_scope") +def test_deprecated_attribute_scope(client: Client, root_attribute_crl: CertificateRevocationList) -> None: + """Test fetching deprecated `scope` parameter with value `user`.""" + response = client.get(reverse("deprecated-attribute-scope", kwargs={"serial": ROOT_SERIAL})) + assert response.status_code == HTTPStatus.OK + assert response.content == root_attribute_crl.data + + +@pytest.mark.django_db +def test_regenerate_with_unusable_ca(client: Client, default_url: str) -> None: + """Fetch CRL when it has to be regenerated but the private key is not usable.""" + response = client.get(default_url) + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + assert response["Content-Type"] == "text/plain" + assert response.content == b"Error while retrieving the CRL." + + +def test_password_with_missing_password( + client: Client, usable_pwd: CertificateAuthority, settings: SettingsWrapper +) -> None: + """Try getting the CRL for an encrypted CA where there is no password (which fails).""" + settings.CA_PASSWORDS = {} + response = client.get(reverse("default", kwargs={"serial": usable_pwd.serial})) + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + assert response["Content-Type"] == "text/plain" + assert response.content == b"Error while retrieving the CRL." + + +def test_invalid_encoding(client: Client, default_url: str) -> None: + """Test that forcing an unsupported encoding.""" + response = client.get(default_url, {"encoding": "X962"}) + assert response.status_code == HTTPStatus.BAD_REQUEST + assert response["Content-Type"] == "text/plain" + assert response.content == b"X962: Invalid encoding requested." diff --git a/ca/django_ca/typehints.py b/ca/django_ca/typehints.py index f25cc72cc..a9ee2135d 100644 --- a/ca/django_ca/typehints.py +++ b/ca/django_ca/typehints.py @@ -38,6 +38,11 @@ else: # pragma: only py>=3.12 from typing import TypeAliasType as TypeAliasType # noqa: PLC0414 +if sys.version_info < (3, 11): # pragma: only py<3.11 + from typing_extensions import Self as Self # noqa: PLC0414 +else: # pragma: only py>=3.11 + from typing import Self as Self # noqa: PLC0414 + # pylint: disable-next=invalid-name JSON = Union[dict[str, "JSON"], list["JSON"], str, int, float, bool, None] diff --git a/ca/django_ca/urls.py b/ca/django_ca/urls.py index 9c97d4c16..2d632ba26 100644 --- a/ca/django_ca/urls.py +++ b/ca/django_ca/urls.py @@ -36,8 +36,16 @@ path( "ocsp//ca/", views.GenericOCSPView.as_view(ca_ocsp=True), name="ocsp-ca-get" ), - path("crl//", views.CertificateRevocationListView.as_view(), name="crl"), - path("crl/ca//", views.CertificateRevocationListView.as_view(scope="ca"), name="ca-crl"), + path( + "crl//", + views.CertificateRevocationListView.as_view(only_contains_user_certs=True), + name="crl", + ), + path( + "crl/ca//", + views.CertificateRevocationListView.as_view(only_contains_ca_certs=True), + name="ca-crl", + ), ] if model_settings.CA_ENABLE_REST_API is True: # pragma: no branch diff --git a/ca/django_ca/utils.py b/ca/django_ca/utils.py index b04949840..5bd9bce2a 100644 --- a/ca/django_ca/utils.py +++ b/ca/django_ca/utils.py @@ -17,7 +17,7 @@ import re import shlex import typing -from collections.abc import Iterator +from collections.abc import Iterable, Iterator from datetime import datetime, timezone as tz from ipaddress import ip_address, ip_network from typing import Optional, Union @@ -959,6 +959,31 @@ def split_str(val: str, sep: str) -> Iterator[str]: yield from lex -def get_crl_cache_key(serial: str, encoding: Encoding = Encoding.DER, scope: Optional[str] = None) -> str: - """Get the cache key for a CRL with the given parameters.""" - return f"crl_{serial}_{encoding.name}_{scope}" +def get_crl_cache_key( + serial: str, + encoding: Encoding, + *, + only_contains_ca_certs: bool, + only_contains_user_certs: bool, + only_contains_attribute_certs: bool, + only_some_reasons: Optional[Iterable[x509.ReasonFlags]], +) -> str: + """Get the cache key for a CRL with the given parameters. + + Note that this function does not assert the consistency of `only_contains_ca_certs` and + `only_contains_user_certs` and `only_contains_attribute_certs`, as this is expected to already be done by + the caller. + + .. versionchanged:: 2.1.0 + + The `scope` parameter was removed. Use `only_contains_ca_certs`, `only_contains_user_certs` and + `only_contains_attribute_certs` instead. + """ + reasons = "None" + if only_some_reasons is not None: + reasons = ",".join(sorted(reason.name for reason in only_some_reasons)) + + return ( + f"crl_{serial}_{encoding.name}_{only_contains_ca_certs}_{only_contains_user_certs}_" + f"{only_contains_attribute_certs}_{reasons}" + ) diff --git a/ca/django_ca/views.py b/ca/django_ca/views.py index 548edd985..c8a00b4ea 100644 --- a/ca/django_ca/views.py +++ b/ca/django_ca/views.py @@ -52,8 +52,9 @@ from django_ca import constants from django_ca.constants import CERTIFICATE_REVOCATION_LIST_ENCODING_TYPES -from django_ca.deprecation import RemovedInDjangoCA210Warning -from django_ca.models import Certificate, CertificateAuthority +from django_ca.deprecation import RemovedInDjangoCA210Warning, RemovedInDjangoCA230Warning +from django_ca.models import Certificate, CertificateAuthority, CertificateRevocationList +from django_ca.pydantic.validators import crl_scope_validator from django_ca.typehints import CertificateRevocationListEncodings from django_ca.utils import SERIAL_RE, get_crl_cache_key, int_to_hex, parse_encoding, read_file @@ -66,6 +67,8 @@ else: SingleObjectMixinBase = SingleObjectMixin +_NOT_SET = object() + class CertificateRevocationListView(View, SingleObjectMixinBase): """Generic view that provides Certificate Revocation Lists (CRLs).""" @@ -82,19 +85,51 @@ class CertificateRevocationListView(View, SingleObjectMixinBase): type: CertificateRevocationListEncodings = Encoding.DER """Encoding for CRL.""" - scope: Optional[typing.Literal["ca", "user", "attribute"]] = "user" + scope: Any = _NOT_SET """Set to ``"user"`` to limit CRL to certificates or ``"ca"`` to certificate authorities or ``None`` to - include both.""" + include both. + + .. deprecated:: 2.1.0 + + This flag is deprecated and will be removed in django-ca 2.3.0. Use `only_contains_ca_certs` and + `only_contains_user_certs` instead. + """ - expires = 600 - """CRL not_after in this many seconds.""" + only_contains_ca_certs: bool = False + """Set to ``True`` to only include CA certificates in the CRL.""" + + only_contains_user_certs: bool = False + """Set to ``True`` to only include end-entity certificates in the CRL.""" + + only_contains_attribute_certs: bool = False + """Set to ``True`` to only include attribute certificates in the CRL.""" + + only_some_reasons: Optional[frozenset[x509.ReasonFlags]] = None + """Only include certificates revoked for one of the given :class:`~cg:cryptography.x509.ReasonFlags`. If + not set, all reasons are included.""" + + expires = 86400 + """**(deprecated)** CRL not_after in this many seconds. + + *Please note* that this value is only used if no current CRL is found in the database (or cache) and the + CRL is generated locally (which will fail if the view does not have access to the private key). + + .. versionchanged:: 2.1.0 + + The default was changed to one day (from 600) to align with the default elsewhere in the code. + """ # header used in the request content_type = None """Value of the Content-Type header used in the response. For CRLs in PEM format, use ``text/plain``.""" include_issuing_distribution_point: Optional[bool] = None - """Boolean flag to force inclusion/exclusion of IssuingDistributionPoint extension.""" + """**(deprecated)** Boolean flag to force inclusion/exclusion of IssuingDistributionPoint extension. + + .. deprecated:: 2.1.0 + + This parameter no longer has any effect and will be removed in django-ca 2.3.0. + """ def get_key_backend_options(self, ca: CertificateAuthority) -> BaseModel: """Method to get the key backend options to access the private key. @@ -112,25 +147,88 @@ def get_key_backend_options(self, ca: CertificateAuthority) -> BaseModel: def fetch_crl(self, ca: CertificateAuthority, encoding: CertificateRevocationListEncodings) -> bytes: """Actually fetch the CRL (nested function so that we can easily catch any exception).""" - cache_key = get_crl_cache_key(ca.serial, encoding=encoding, scope=self.scope) + print(self.scope) + if self.scope is not _NOT_SET: + print(2) + warnings.warn( + "The scope parameter is deprecated and will be removed in django-ca 2.3.0, use " + "`only_contains_{ca,user,attribute}_cert` instead.", + RemovedInDjangoCA230Warning, + stacklevel=2, + ) + + if self.scope == "user": + only_contains_ca_certs = False + only_contains_user_certs = True + only_contains_attribute_certs = False + elif self.scope == "ca": + only_contains_ca_certs = True + only_contains_user_certs = False + only_contains_attribute_certs = False + elif self.scope == "attribute": + only_contains_ca_certs = False + only_contains_user_certs = False + only_contains_attribute_certs = True + else: # scope is none or not set, defaults from self are fine. + only_contains_ca_certs = self.only_contains_ca_certs + only_contains_user_certs = self.only_contains_user_certs + only_contains_attribute_certs = self.only_contains_attribute_certs + + crl_scope_validator( + only_contains_ca_certs=only_contains_ca_certs, + only_contains_user_certs=only_contains_user_certs, + only_contains_attribute_certs=only_contains_attribute_certs, + only_some_reasons=self.only_some_reasons, + ) + + cache_key = get_crl_cache_key( + ca.serial, + encoding=encoding, + only_contains_ca_certs=only_contains_ca_certs, + only_contains_user_certs=only_contains_user_certs, + only_contains_attribute_certs=only_contains_attribute_certs, + only_some_reasons=self.only_some_reasons, + ) encoded_crl: Optional[bytes] = cache.get(cache_key) + + # CRL is not cached, try to retrieve it from the database. if encoded_crl is None: - # Catch this case early so that we can give a better error message - if self.include_issuing_distribution_point is True and ca.parent is None and self.scope is None: - raise ValueError( - "Cannot add IssuingDistributionPoint extension to CRLs with no scope for root CAs." + crl_obj: Optional[CertificateRevocationList] = ( + CertificateRevocationList.objects.scope( + ca=ca, + only_contains_ca_certs=only_contains_ca_certs, + only_contains_user_certs=only_contains_user_certs, + only_contains_attribute_certs=only_contains_attribute_certs, + only_some_reasons=self.only_some_reasons, ) - - key_backend_options = self.get_key_backend_options(ca) - crl = ca.get_crl( - key_backend_options, - expires=self.expires, - scope=self.scope, - include_issuing_distribution_point=self.include_issuing_distribution_point, + .filter(data__isnull=False) # only objects that have CRL data associated with it + .newest() ) - encoded_crl = crl.public_bytes(encoding) - cache.set(cache_key, encoded_crl, self.expires) + + # CRL was not found in the database either, so we try to regenerate it. + if crl_obj is None: + key_backend_options = self.get_key_backend_options(ca) + expires = datetime.now(tz=tz.utc) + timedelta(seconds=self.expires) + crl_obj = CertificateRevocationList.objects.create_certificate_revocation_list( + ca=ca, + key_backend_options=key_backend_options, + next_update=expires, + only_contains_ca_certs=only_contains_ca_certs, + only_contains_user_certs=only_contains_user_certs, + only_contains_attribute_certs=only_contains_attribute_certs, + only_some_reasons=self.only_some_reasons, + ) + + # Cache the CRL. + crl_obj.cache() + + # Get object in the right encoding. + if encoding == Encoding.PEM: + encoded_crl = crl_obj.pem + else: + encoded_crl = bytes(crl_obj.data) # type: ignore[arg-type] # None is ruled out by filter() + return encoded_crl def get(self, request: HttpRequest, serial: str) -> HttpResponse: # pylint: disable=unused-argument diff --git a/docs/source/changelog/TBR_2.1.0.rst b/docs/source/changelog/TBR_2.1.0.rst index 730589624..7e54e6fad 100644 --- a/docs/source/changelog/TBR_2.1.0.rst +++ b/docs/source/changelog/TBR_2.1.0.rst @@ -2,6 +2,35 @@ 2.1.0 (TBR) ########### +**************************** +Certificate Revocation Lists +**************************** + +* Certificate Revocation Lists (CRLs) are now stored in the database via the + :class:`~django_ca.models.CertificateRevocationList` model. This makes CRL functionality more robust, as + clearing the cache will no longer cause an error. + +********************** +Command-line utilities +********************** + +* Add the ``--only-some-reasons`` parameter to :command:`manage.py dump_crl`. +* The ``--scope`` parameter to :command:`manage.py dump_crl` is deprecated and will be removed in django-ca + 2.3.0. Use ``--only-contains-ca-certs``, ``--only-contains-user-certs`` or + ``--only-contains-attribute-certs`` instead. +* **BACKWARDS INCOMPATIBLE:** The ``--algorithm`` parameter no longer has any effect and will be removed in + django-ca 2.3.0. + +******** +Settings +******** + +* The `encodings` parameter to :ref:`settings-ca-crl-profiles` was removed. Both encodings are now always + available. +* The `scope` parameter to :ref:`settings-ca-crl-profiles` is now deprecated in favor of the + `only_contains_ca_certs`, `only_contains_user_certs` and `only_some_reasons` parameters. The old parameter + currently still takes precedence, but will be removed in django-ca 2.3.0. + ************ Dependencies ************ @@ -23,9 +52,31 @@ Python API * :func:`django_ca.managers.CertificateManager.create_cert` * :func:`django_ca.profiles.Profile.create_cert` +* :func:`~django_ca.utils.get_crl_cache_key` added the `only_contains_ca_certs`, `only_contains_user_certs`, + `only_contains_attribute_certs` and `only_some_reasons` arguments. +* **BACKWARDS INCOMPATIBLE:** The `scope` argument for :func:`~django_ca.utils.get_crl_cache_key` was removed. + Use the parameters described above instead. + *************** Database models *************** * Rename the ``valid_from`` to ``not_before`` and ``expires`` to ``not_after`` to align with the terminology used in `RFC 5280`_. The previous read-only property was removed. +* Add the :class:`~django_ca.models.CertificateRevocationList` model to store generated CRLs. +* :func:`django_ca.models.CertificateAuthority.get_crl_certs` and + :func:`django_ca.models.CertificateAuthority.get_crl` are deprecated and will be removed in django-ca 2.3.0. +* **BACKWARDS INCOMPATIBLE:** The `algorithm`, `counter`, `full_name`, `relative_name` and + `include_issuing_distribution_point` parameters for :func:`django_ca.models.CertificateAuthority.get_crl` + no longer have any effect. + +***** +Views +***** + +* The :class:`~django_ca.views.CertificateRevocationListView` has numerous updates: + + * The `expires` parameter now has a default of ``86400`` (from ``600``) to align with defaults elsewhere. + * The `scope` parameter is deprecated and will be removed in django-ca 2.3.0. Use `only_contains_ca_certs` + and `only_contains_user_certs` instead. + * The `include_issuing_distribution_point` no longer has any effect and will be removed in django-ca 2.3.0. diff --git a/docs/source/deprecation.rst b/docs/source/deprecation.rst index 5a5203109..7cf1cd1a1 100644 --- a/docs/source/deprecation.rst +++ b/docs/source/deprecation.rst @@ -6,10 +6,19 @@ Deprecation timeline 2.3.0 (TBR) *********** +* The ``--scope`` and ``--algorithm`` parameters to :command:`manage.py dump_crl` will be removed (deprecated + since django-ca 2.1.0). + +Settings +======== + +* The `scope` and `encodings` parameter to :ref:`settings-ca-crl-profiles` will be removed (deprecated since + django-ca 2.1.0). + Python API ========== -* The ``expires`` parameter to functions that create a certificate will be removed. Use ``not_after`` instead +* The `expires` parameter to functions that create a certificate will be removed. Use `not_after` instead (deprecated since 2.1.0). The following functions are affected: * :func:`django_ca.models.CertificateAuthority.sign` @@ -18,6 +27,17 @@ Python API * :func:`django_ca.managers.CertificateManager.create_cert` * :func:`django_ca.profiles.Profile.create_cert` +* The `scope` parameter to :func:`~django_ca.utils.get_crl_cache_key` will be removed (deprecated since + django-ca 2.1.0). +* :func:`django_ca.models.CertificateAuthority.get_crl_certs` and + :func:`django_ca.models.CertificateAuthority.get_crl` will be removed (deprecated since django-ca 2.1.0). + +Views +===== + +* The `scope` and `include_issuing_distribution_point` :class:`~django_ca.views.CertificateRevocationListView` + parameters will be removed (deprecated since 2.1.0). + *********** 2.2.0 (TBR) *********** diff --git a/docs/source/python/models.rst b/docs/source/python/models.rst index 6092480d2..a0a98bfb8 100644 --- a/docs/source/python/models.rst +++ b/docs/source/python/models.rst @@ -245,6 +245,17 @@ attributes. .. autoclass:: django_ca.models.X509CertMixin :members: +************************* +CertificateRevocationList +************************* + +.. autoclass:: django_ca.models.CertificateRevocationList + :members: + +.. autoclass:: django_ca.managers.CertificateRevocationListManager + :members: + + .. _models-watcher: ******** diff --git a/docs/source/settings.rst b/docs/source/settings.rst index 369be5f59..89551ce9f 100644 --- a/docs/source/settings.rst +++ b/docs/source/settings.rst @@ -60,17 +60,28 @@ CA_CRL_PROFILES 'user': { 'expires': 86400, 'scope': 'user', - 'encodings': ["DER", "PEM"], }, 'ca': { 'expires': 86400, 'scope': 'ca', - 'encodings': ["DER", "PEM"], }, } A set of CRLs to create using automated tasks. The default value is usually fine. + .. versionchanged:: 2.1.0 + + * The `only_some_reasons` parameter was added. + * The `encodings` parameter was removed. Both supported encodings are now always available. + * The `scope` parameter to :ref:`settings-ca-crl-profiles` is now deprecated in favor of the + `only_contains_ca_certs`, `only_contains_user_certs` and `only_some_reasons` parameters. The old + parameter currently still takes precedence, but will be removed in django-ca 2.3.0. + + + .. versionchanged:: 1.25.0 + + Support for specifying custom signature hash algorithms in the configuration was removed. + You may also specify an ``"OVERRIDES"`` key for a particular profile to specify custom behavior for select certificate authorities named by serial. It can set the same values as a general profile, plus the ``"skip"`` that disables the certificate authority for a particular profile. For example, to disable a @@ -86,10 +97,6 @@ CA_CRL_PROFILES } } - .. versionchanged:: 1.25.0 - - Support for specifying custom signature hash algorithms in the configuration was removed. - The hash algorithm used for signing the CRL will be the one used for signing the certificate authority itself.