Skip to content

Commit

Permalink
store certificate revocation lists in database
Browse files Browse the repository at this point in the history
  • Loading branch information
mathiasertl committed Oct 17, 2024
1 parent 88b175b commit 57690e1
Show file tree
Hide file tree
Showing 43 changed files with 2,156 additions and 1,089 deletions.
1 change: 0 additions & 1 deletion ca/django_ca/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,6 @@ class CertificateAuthorityAdmin(CertificateMixin[CertificateAuthority], Certific
{
"description": _("Information to add to newly signed certificates."),
"fields": (
"crl_number",
"sign_authority_information_access",
"sign_certificate_policies",
"sign_crl_distribution_points",
Expand Down
104 changes: 85 additions & 19 deletions ca/django_ca/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"))]
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion ca/django_ca/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 8 additions & 4 deletions ca/django_ca/management/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]):
Expand Down
118 changes: 98 additions & 20 deletions ca/django_ca/management/commands/dump_crl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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(
Expand All @@ -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
Loading

0 comments on commit 57690e1

Please sign in to comment.