Skip to content

Commit 0c2a2f2

Browse files
committed
do not always renew CRLs, similar to OCSP keys
1 parent 7cd6fb4 commit 0c2a2f2

File tree

12 files changed

+136
-116
lines changed

12 files changed

+136
-116
lines changed

ca/ca/settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@
194194
CELERY_BEAT_SCHEDULE = {
195195
"generate-crls": {
196196
"task": "django_ca.tasks.generate_crls",
197-
"schedule": 86100,
197+
"schedule": 3600,
198198
},
199199
"generate-ocsp-keys": {
200200
# Attempt to regenerate OCSP responder certificates every hour. Certificates are only regenerated if

ca/django_ca/conf.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
AnnotatedEllipticCurveName,
4040
AnnotatedSignatureHashAlgorithmName,
4141
CertificateRevocationListReasonCode,
42+
PositiveTimedelta,
4243
PowerOfTwoInt,
4344
Serial,
4445
UniqueElementsTuple,
@@ -56,8 +57,7 @@
5657
# https://github.com/pydantic/pydantic/issues/10459
5758
# TimedeltaAsDays = Annotated[timedelta, BeforeValidator(timedelta_as_number_parser("days"))]
5859
DayValidator = BeforeValidator(timedelta_as_number_parser("days"))
59-
PositiveTimedelta = Annotated[timedelta, Ge(timedelta(days=1))]
60-
AcmeCertValidity = Annotated[PositiveTimedelta, Le(timedelta(days=365)), DayValidator]
60+
AcmeCertValidity = Annotated[PositiveTimedelta, Ge(timedelta(days=1)), Le(timedelta(days=365)), DayValidator]
6161

6262
_KT = TypeVar("_KT")
6363
_KV = TypeVar("_KV")
@@ -205,14 +205,16 @@ def validate_scope(self) -> Self:
205205
class CertificateRevocationListProfileOverride(CertificateRevocationListBaseModel):
206206
"""Model for overriding fields of a CRL Profile."""
207207

208-
expires: timedelta | None = None
208+
expires: Annotated[PositiveTimedelta, Ge(timedelta(seconds=600))] | None = None
209+
renewal: Annotated[PositiveTimedelta, Ge(timedelta(seconds=300))] | None = None
209210
skip: bool = False
210211

211212

212213
class CertificateRevocationListProfile(CertificateRevocationListBaseModel):
213214
"""Model for profiles for CRL generation."""
214215

215-
expires: timedelta = timedelta(days=1)
216+
expires: Annotated[PositiveTimedelta, Ge(timedelta(seconds=600))] = timedelta(days=1)
217+
renewal: Annotated[PositiveTimedelta, Ge(timedelta(seconds=300))] = timedelta(hours=12)
216218
OVERRIDES: dict[Serial, CertificateRevocationListProfileOverride] = Field(default_factory=dict)
217219

218220
@model_validator(mode="after")
@@ -279,7 +281,7 @@ class SettingsModel(BaseModel):
279281
CA_DEFAULT_ELLIPTIC_CURVE: AnnotatedEllipticCurveName = Field(
280282
default="secp256r1", description="The default elliptic curve for EC based CAs."
281283
)
282-
CA_DEFAULT_EXPIRES: Annotated[PositiveTimedelta, DayValidator] = Field(
284+
CA_DEFAULT_EXPIRES: Annotated[PositiveTimedelta, Ge(timedelta(days=1)), DayValidator] = Field(
283285
default=timedelta(days=100),
284286
description="The default validity time for a new certificate.",
285287
examples=[timedelta(days=45), 45],

ca/django_ca/managers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,8 @@ def scope(
736736
only_some_reasons: frozenset[x509.ReasonFlags] | None = None,
737737
) -> "CertificateRevocationListQuerySet": ...
738738

739+
def valid(self, now: datetime | None = None) -> "CertificateRevocationListQuerySet": ...
740+
739741
def _add_issuing_distribution_point_extension(
740742
self,
741743
builder: x509.CertificateRevocationListBuilder,

ca/django_ca/models.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ def __str__(self) -> str:
212212
return self.mail
213213

214214
@classmethod
215-
def from_addr(cls, addr: str) -> "Self":
215+
def from_addr(cls, addr: str) -> Self:
216216
"""Class constructor that creates an instance from an email address."""
217217
name = ""
218218
match = re.match(r"(.*?)\s*<(.*)>", addr)
@@ -648,7 +648,7 @@ def key_type(self) -> ParsableKeyType:
648648
def cache_crls(self, key_backend_options: BaseModel) -> None: # pylint: disable=C0116
649649
self.generate_crls(key_backend_options)
650650

651-
def generate_crls(self, key_backend_options: BaseModel) -> None:
651+
def generate_crls(self, key_backend_options: BaseModel, force: bool = False) -> None:
652652
"""Generate certificate revocation lists (CRLs) for this certificate authority.
653653
654654
.. versionchanged:: 3.0.0
@@ -659,9 +659,17 @@ def generate_crls(self, key_backend_options: BaseModel) -> None:
659659
.. versionchanged:: 1.25.0
660660
661661
Support for passing a custom hash algorithm to this function was removed.
662+
663+
Parameters
664+
----------
665+
key_backend_options : BaseModel
666+
Options required for using the private key of the certificate authority.
667+
force : bool, optional
668+
Set to ``True`` to force regeneration of CRLs. By default, keys are only regenerated if they
669+
expire within the renewal period of the respective :ref:settings-ca-crl-profiles`.
662670
"""
663-
for crl_profile in model_settings.CA_CRL_PROFILES.values():
664-
now = datetime.now(tz=UTC)
671+
for name, crl_profile in model_settings.CA_CRL_PROFILES.items():
672+
now = timezone.now()
665673

666674
# If there is an override for the current CA, create a new profile model with values updated from
667675
# the override.
@@ -673,6 +681,19 @@ def generate_crls(self, key_backend_options: BaseModel) -> None:
673681
config.update(crl_profile_override.model_dump(exclude_unset=True))
674682
crl_profile = CertificateRevocationListProfile.model_validate(config)
675683

684+
if force is False:
685+
crl_qs = CertificateRevocationList.objects.scope(
686+
serial=self.serial,
687+
only_contains_ca_certs=crl_profile.only_contains_ca_certs,
688+
only_contains_user_certs=crl_profile.only_contains_user_certs,
689+
only_contains_attribute_certs=crl_profile.only_contains_attribute_certs,
690+
only_some_reasons=crl_profile.only_some_reasons,
691+
)
692+
current_crl = crl_qs.valid(now=now).newest()
693+
if current_crl is not None and current_crl.next_update > now + crl_profile.renewal:
694+
log.info("%s: %s CRL profile is not yet scheduled for renewal.", self.serial, name)
695+
continue
696+
676697
crl = CertificateRevocationList.objects.create_certificate_revocation_list(
677698
ca=self,
678699
key_backend_options=key_backend_options,

ca/django_ca/pydantic/type_aliases.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from datetime import timedelta
1818
from typing import Annotated, Any, TypeVar
1919

20-
from annotated_types import Ge
20+
from annotated_types import Gt
2121
from pydantic import AfterValidator, AwareDatetime, BeforeValidator, Field, PlainSerializer
2222

2323
from cryptography import x509
@@ -134,7 +134,7 @@
134134
"""A datetime that is timezone-aware and in the future."""
135135

136136
DayValidator = BeforeValidator(timedelta_as_number_parser("days"))
137-
PositiveTimedelta = Annotated[timedelta, Ge(timedelta(seconds=0))]
137+
PositiveTimedelta = Annotated[timedelta, Gt(timedelta(seconds=0))]
138138

139139
UniqueTupleTypeVar = TypeVar("UniqueTupleTypeVar", bound=tuple[Hashable, ...])
140140
UniqueElementsTuple = Annotated[UniqueTupleTypeVar, AfterValidator(unique_validator)]

ca/django_ca/querysets.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,11 @@ def scope(
290290
only_contains_attribute_certs=only_contains_attribute_certs,
291291
).reasons(only_some_reasons)
292292

293+
def valid(self, now: datetime | None = None) -> "CertificateRevocationListQuerySet":
294+
if now is None: # pragma: no cover
295+
now = timezone.now()
296+
return self.exclude(next_update__lt=now).exclude(last_update__gt=now)
297+
293298

294299
class AcmeAccountQuerySet(AcmeAccountQuerySetBase):
295300
"""QuerySet for :py:class:`~django_ca.models.AcmeAccount`."""

ca/django_ca/tests/base/assertions.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from cryptography.x509.oid import ExtensionOID
3232
from OpenSSL.crypto import FILETYPE_PEM, X509Store, X509StoreContext, load_certificate
3333

34+
from django.core.cache import cache
3435
from django.core.exceptions import ImproperlyConfigured, ValidationError
3536
from django.core.management import CommandError
3637

@@ -40,15 +41,17 @@
4041
from django_ca.constants import ReasonFlags
4142
from django_ca.deprecation import RemovedInDjangoCA310Warning, RemovedInDjangoCA320Warning
4243
from django_ca.key_backends.storages.models import StoragesUsePrivateKeyOptions
43-
from django_ca.models import Certificate, CertificateAuthority, X509CertMixin
44+
from django_ca.models import Certificate, CertificateAuthority, CertificateRevocationList, X509CertMixin
4445
from django_ca.signals import post_create_ca, post_issue_cert, post_sign_cert, pre_create_ca, pre_sign_cert
4546
from django_ca.tests.base.mocks import mock_signal
4647
from django_ca.tests.base.utils import (
4748
authority_information_access,
4849
basic_constraints,
4950
cmd_e2e,
51+
crl_cache_key,
5052
crl_distribution_points,
5153
distribution_point,
54+
get_idp,
5255
uri,
5356
)
5457

@@ -278,6 +281,27 @@ def ext_sorter(ext: x509.Extension[x509.ExtensionType]) -> str:
278281
assert not list(entry.extensions)
279282

280283

284+
def assert_crls(ca: CertificateAuthority, number: int = 0) -> None:
285+
"""Test the CRLs for the given certificate authority."""
286+
algorithm = ca.algorithm
287+
for kwargs in [{"only_contains_ca_certs": True}, {"only_contains_user_certs": True}]:
288+
der_key = crl_cache_key(ca.serial, **kwargs)
289+
pem_key = crl_cache_key(ca.serial, Encoding.PEM, **kwargs)
290+
idp = get_idp(full_name=None, **kwargs)
291+
292+
# Fetch and test CRLs from the cache
293+
der_crl = cache.get(der_key)
294+
pem_crl = cache.get(pem_key)
295+
assert_crl(der_crl, crl_number=number, idp=idp, encoding=Encoding.DER, signer=ca, algorithm=algorithm)
296+
assert_crl(pem_crl, crl_number=number, idp=idp, encoding=Encoding.PEM, signer=ca, algorithm=algorithm)
297+
298+
# Fetch from the database and verify
299+
db_crl = CertificateRevocationList.objects.scope(serial=ca.serial, **kwargs).newest()
300+
assert db_crl is not None
301+
assert db_crl.data == der_crl
302+
assert db_crl.number == number
303+
304+
281305
def assert_e2e_error(
282306
cmd: typing.Sequence[str],
283307
stdout: str | re.Pattern[str] = "",

ca/django_ca/tests/models/test_certificate_authority.py

Lines changed: 58 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040

4141
import pytest
4242
from freezegun import freeze_time
43+
from freezegun.api import FrozenDateTimeFactory
4344
from pytest_django.fixtures import SettingsWrapper
4445

4546
from django_ca.conf import model_settings
@@ -50,6 +51,7 @@
5051
from django_ca.tests.base.assertions import (
5152
assert_certificate,
5253
assert_crl,
54+
assert_crls,
5355
assert_removed_in_310,
5456
assert_sign_cert_signals,
5557
)
@@ -124,112 +126,48 @@ def test_root(root: CertificateAuthority, child: CertificateAuthority) -> None:
124126
@pytest.mark.freeze_time(TIMESTAMPS["everything_valid"])
125127
def test_generate_crls(settings: SettingsWrapper, usable_ca: CertificateAuthority) -> None:
126128
"""Test generation of CRLs."""
127-
ca_private_key_options = StoragesUsePrivateKeyOptions(password=CERT_DATA[usable_ca.name].get("password"))
128-
der_user_key = crl_cache_key(usable_ca.serial, only_contains_user_certs=True)
129-
pem_user_key = crl_cache_key(usable_ca.serial, Encoding.PEM, only_contains_user_certs=True)
130-
der_ca_key = crl_cache_key(usable_ca.serial, only_contains_ca_certs=True)
131-
pem_ca_key = crl_cache_key(usable_ca.serial, Encoding.PEM, only_contains_ca_certs=True)
132-
user_idp = get_idp(full_name=None, only_contains_user_certs=True)
133-
ca_idp = get_idp(full_name=None, only_contains_ca_certs=True)
129+
key_backend_options = StoragesUsePrivateKeyOptions(password=CERT_DATA[usable_ca.name].get("password"))
134130

135-
assert cache.get(der_ca_key) is None
136-
assert cache.get(pem_ca_key) is None
137-
assert cache.get(der_user_key) is None
138-
assert cache.get(pem_user_key) is None
131+
usable_ca.generate_crls(key_backend_options)
132+
assert_crls(usable_ca)
139133

140-
usable_ca.generate_crls(ca_private_key_options)
141134

142-
der_user_crl = cache.get(der_user_key)
143-
pem_user_crl = cache.get(pem_user_key)
144-
assert_crl(
145-
der_user_crl,
146-
idp=user_idp,
147-
encoding=Encoding.DER,
148-
signer=usable_ca,
149-
algorithm=usable_ca.algorithm,
150-
)
151-
assert_crl(
152-
pem_user_crl,
153-
idp=user_idp,
154-
encoding=Encoding.PEM,
155-
signer=usable_ca,
156-
algorithm=usable_ca.algorithm,
157-
)
135+
@pytest.mark.usefixtures("clear_cache")
136+
@pytest.mark.freeze_time
137+
def test_generate_crls_with_regeneration(
138+
settings: SettingsWrapper, freezer: FrozenDateTimeFactory, usable_root: CertificateAuthority
139+
) -> None:
140+
"""Test generation of CRLs."""
141+
print(TIMESTAMPS["everything_valid"])
142+
key_backend_options = StoragesUsePrivateKeyOptions()
158143

159-
der_ca_crl = cache.get(der_ca_key)
160-
pem_ca_crl = cache.get(pem_ca_key)
161-
assert_crl(
162-
der_ca_crl,
163-
idp=ca_idp,
164-
encoding=Encoding.DER,
165-
signer=usable_ca,
166-
algorithm=usable_ca.algorithm,
167-
)
168-
assert_crl(
169-
pem_ca_crl,
170-
idp=ca_idp,
171-
encoding=Encoding.PEM,
172-
signer=usable_ca,
173-
algorithm=usable_ca.algorithm,
174-
)
144+
# Generate CRLs and test contents (just to be sure)
145+
usable_root.generate_crls(key_backend_options)
146+
assert_crls(usable_root)
175147

176-
# cache again - which will force triggering a new computation
177-
usable_ca.generate_crls(ca_private_key_options)
178-
179-
# Get CRLs from cache - we have a new CRLNumber
180-
der_user_crl = cache.get(der_user_key)
181-
pem_user_crl = cache.get(pem_user_key)
182-
assert_crl(
183-
der_user_crl,
184-
idp=user_idp,
185-
crl_number=1,
186-
encoding=Encoding.DER,
187-
signer=usable_ca,
188-
algorithm=usable_ca.algorithm,
189-
)
190-
assert_crl(
191-
pem_user_crl,
192-
idp=user_idp,
193-
crl_number=1,
194-
encoding=Encoding.PEM,
195-
signer=usable_ca,
196-
algorithm=usable_ca.algorithm,
197-
)
148+
# Generate again - should not do anything, b/c values are cached
149+
usable_root.generate_crls(key_backend_options)
150+
assert_crls(usable_root)
198151

199-
der_ca_crl = cache.get(der_ca_key)
200-
pem_ca_crl = cache.get(pem_ca_key)
201-
assert_crl(
202-
der_ca_crl,
203-
idp=ca_idp,
204-
crl_number=1,
205-
encoding=Encoding.DER,
206-
signer=usable_ca,
207-
algorithm=usable_ca.algorithm,
208-
)
209-
assert_crl(
210-
pem_ca_crl,
211-
idp=ca_idp,
212-
crl_number=1,
213-
encoding=Encoding.PEM,
214-
signer=usable_ca,
215-
algorithm=usable_ca.algorithm,
216-
)
152+
# move time ahead so that we regenerate
153+
freezer.tick(timedelta(hours=12))
217154

218-
# clear caches and skip generation
219-
cache.clear()
220-
crl_profiles = {
221-
k: v.model_dump(exclude={"encodings", "scope"}) for k, v in model_settings.CA_CRL_PROFILES.items()
222-
}
223-
crl_profiles["ca"]["OVERRIDES"][usable_ca.serial] = {"skip": True}
224-
crl_profiles["user"]["OVERRIDES"][usable_ca.serial] = {"skip": True}
155+
# generate again - values should be cached and no new CRLs are created
156+
usable_root.generate_crls(key_backend_options)
157+
assert_crls(usable_root, number=1)
225158

226-
settings.CA_CRL_PROFILES = crl_profiles
227-
usable_ca.generate_crls(ca_private_key_options)
228159

229-
assert cache.get(der_ca_key) is None
230-
assert cache.get(pem_ca_key) is None
231-
assert cache.get(der_user_key) is None
232-
assert cache.get(pem_user_key) is None
160+
@pytest.mark.usefixtures("clear_cache")
161+
@pytest.mark.freeze_time(TIMESTAMPS["everything_valid"])
162+
def test_generate_crls_with_force(settings: SettingsWrapper, usable_root: CertificateAuthority) -> None:
163+
"""Test the force option."""
164+
# Initially generate CRLs
165+
usable_root.generate_crls(KEY_BACKEND_OPTIONS)
166+
assert_crls(usable_root)
167+
168+
# Generate CRLs again, forcing regeneration
169+
usable_root.generate_crls(KEY_BACKEND_OPTIONS, force=True)
170+
assert_crls(usable_root, number=1)
233171

234172

235173
@pytest.mark.usefixtures("clear_cache")
@@ -280,6 +218,29 @@ def test_generate_crls_with_overrides(settings: SettingsWrapper, usable_root: Ce
280218
assert pem_user_crl.next_update_utc == TIMESTAMPS["everything_valid"] + timedelta(days=3)
281219

282220

221+
@pytest.mark.usefixtures("clear_cache")
222+
@pytest.mark.freeze_time(TIMESTAMPS["everything_valid"])
223+
def test_generate_crls_with_skip(settings: SettingsWrapper, usable_root: CertificateAuthority) -> None:
224+
"""Test the skip option in CRL profiles."""
225+
crl_profiles = {
226+
k: v.model_dump(exclude={"encodings", "scope"}) for k, v in model_settings.CA_CRL_PROFILES.items()
227+
}
228+
crl_profiles["ca"]["OVERRIDES"][usable_root.serial] = {"skip": True}
229+
crl_profiles["user"]["OVERRIDES"][usable_root.serial] = {"skip": True}
230+
231+
settings.CA_CRL_PROFILES = crl_profiles
232+
usable_root.generate_crls(StoragesUsePrivateKeyOptions())
233+
234+
der_user_key = crl_cache_key(usable_root.serial, only_contains_user_certs=True)
235+
pem_user_key = crl_cache_key(usable_root.serial, Encoding.PEM, only_contains_user_certs=True)
236+
der_ca_key = crl_cache_key(usable_root.serial, only_contains_ca_certs=True)
237+
pem_ca_key = crl_cache_key(usable_root.serial, Encoding.PEM, only_contains_ca_certs=True)
238+
assert cache.get(der_ca_key) is None
239+
assert cache.get(pem_ca_key) is None
240+
assert cache.get(der_user_key) is None
241+
assert cache.get(pem_user_key) is None
242+
243+
283244
@pytest.mark.usefixtures("clear_cache")
284245
@pytest.mark.freeze_time(TIMESTAMPS["everything_valid"])
285246
def test_deprecated_cache_crls(usable_root: CertificateAuthority) -> None:

0 commit comments

Comments
 (0)