Skip to content

Commit

Permalink
add configuration for automatic OCSP responder configuration (fixes #102
Browse files Browse the repository at this point in the history
)
  • Loading branch information
mathiasertl committed Jul 16, 2023
1 parent 9fd2d25 commit c31a4b6
Show file tree
Hide file tree
Showing 21 changed files with 322 additions and 62 deletions.
8 changes: 6 additions & 2 deletions ca/django_ca/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,10 @@ class CertificateAuthorityAdmin(CertificateMixin[CertificateAuthority], Certific
],
},
),
(
_("OCSP responder configuration"),
{"fields": ["ocsp_responder_key_validity", "ocsp_response_validity"]},
),
(
_("Certificate"),
{
Expand Down Expand Up @@ -404,7 +408,7 @@ class CertificateAuthorityAdmin(CertificateMixin[CertificateAuthority], Certific
"expires",
"hpkp_pin",
)
x509_fieldset_index = 3
x509_fieldset_index = 4

def has_add_permission(self, request: HttpRequest) -> bool:
return False
Expand All @@ -427,7 +431,7 @@ def get_fieldsets( # type: ignore[override]

if ca_settings.CA_ENABLE_ACME:
fieldsets.insert(
1,
2,
(
_("ACME"),
{
Expand Down
12 changes: 11 additions & 1 deletion ca/django_ca/management/commands/edit_ca.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def add_arguments(self, parser: CommandParser) -> None:
self.add_general_args(parser, default=None)
self.add_ca(parser, "ca", allow_disabled=True)
self.add_acme_group(parser)
self.add_ocsp_group(parser)
self.add_ca_args(parser)

group = parser.add_mutually_exclusive_group()
Expand All @@ -53,7 +54,7 @@ def add_arguments(self, parser: CommandParser) -> None:
"--disable", action="store_false", dest="enabled", help="Disable the certificate authority."
)

def handle(
def handle( # pylint: disable=too-many-arguments
self,
ca: CertificateAuthority,
sign_ca_issuer: str,
Expand All @@ -64,6 +65,9 @@ def handle(
# Certificate Policies extension
sign_certificate_policies: Optional[x509.CertificatePolicies],
sign_certificate_policies_critical: bool,
# OCSP responder configuration
ocsp_responder_key_validity: Optional[int],
ocsp_response_validity: Optional[int],
**options: Any,
) -> None:
if sign_ca_issuer:
Expand Down Expand Up @@ -104,4 +108,10 @@ def handle(
raise CommandError(f"{acme_profile}: Profile is not defined.")
ca.acme_profile = acme_profile

# Set OCSP responder options
if ocsp_responder_key_validity is not None:
ca.ocsp_responder_key_validity = ocsp_responder_key_validity
if ocsp_response_validity is not None:
ca.ocsp_response_validity = ocsp_response_validity

ca.save()
20 changes: 20 additions & 0 deletions ca/django_ca/management/commands/import_ca.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ def add_arguments(self, parser: CommandParser) -> None:
help="Password for the private key.",
)

self.add_acme_group(parser)
self.add_ocsp_group(parser)
self.add_ca_args(parser)

parser.add_argument("name", help="Human-readable name of the CA")
Expand All @@ -88,6 +90,9 @@ def handle( # pylint: disable=too-many-locals,too-many-arguments
# Certificate Policies extension
sign_certificate_policies: Optional[x509.CertificatePolicies],
sign_certificate_policies_critical: bool,
# OCSP responder configuration
ocsp_responder_key_validity: Optional[int],
ocsp_response_validity: Optional[int],
**options: Any,
) -> None:
if not os.path.exists(ca_settings.CA_DIR):
Expand Down Expand Up @@ -130,6 +135,21 @@ def handle( # pylint: disable=too-many-locals,too-many-arguments
sign_certificate_policies=sign_certificate_policies_ext,
)

# Set OCSP responder options
if ocsp_responder_key_validity is not None:
ca.ocsp_responder_key_validity = ocsp_responder_key_validity
if ocsp_response_validity is not None:
ca.ocsp_response_validity = ocsp_response_validity

# Set ACME options
if ca_settings.CA_ENABLE_ACME: # pragma: no branch; never False because parser throws error already
for param in ["acme_enabled", "acme_requires_contact"]:
if options[param] is not None:
setattr(ca, param, options[param])

if acme_profile := options["acme_profile"]:
ca.acme_profile = acme_profile

# load public key
try:
pem_loaded = x509.load_pem_x509_certificate(pem_data)
Expand Down
6 changes: 6 additions & 0 deletions ca/django_ca/management/commands/init_ca.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ def add_arguments(self, parser: CommandParser) -> None:
)

self.add_acme_group(parser)
self.add_ocsp_group(parser)

self.add_authority_information_access_group(parser, ("--ca-ocsp-url",), ("--ca-issuer-url",))
self.add_basic_constraints_group(parser)
Expand Down Expand Up @@ -296,6 +297,9 @@ def handle( # pylint: disable=too-many-arguments,too-many-locals,too-many-branc
# Certificate Policies extension
sign_certificate_policies: Optional[x509.CertificatePolicies],
sign_certificate_policies_critical: bool,
# OCSP responder configuration
ocsp_responder_key_validity: Optional[int],
ocsp_response_validity: Optional[int],
**options: Any,
) -> None:
if not os.path.exists(ca_settings.CA_DIR): # pragma: no cover
Expand Down Expand Up @@ -476,6 +480,8 @@ def handle( # pylint: disable=too-many-arguments,too-many-locals,too-many-branc
terms_of_service=tos,
extensions=extensions.values(),
sign_certificate_policies=sign_certificate_policies_ext,
ocsp_response_validity=ocsp_response_validity,
ocsp_responder_key_validity=ocsp_responder_key_validity,
**kwargs,
)
except Exception as ex: # pragma: no cover
Expand Down
22 changes: 22 additions & 0 deletions ca/django_ca/management/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from django_ca import ca_settings
from django_ca.extensions import extension_as_text, get_extension_name
from django_ca.management import actions
from django_ca.management.actions import IntegerRangeAction
from django_ca.models import CertificateAuthority, X509CertMixin
from django_ca.typehints import ActionsContainer, AllowedHashTypes, ParsableKeyType
from django_ca.utils import add_colons, validate_public_key_parameters
Expand Down Expand Up @@ -248,6 +249,27 @@ def add_acme_group(self, parser: CommandParser) -> None:
help="Require email address during ACME account registration.",
)

def add_ocsp_group(self, parser: CommandParser) -> None:
"""Add arguments for automatic OCSP configuration."""
group = parser.add_argument_group(
"OCSP responder configuration",
"Options for how the automatically configured OCSP responder behaves.",
)
group.add_argument(
"--ocsp-responder-key-validity",
action=IntegerRangeAction,
min=1,
metavar="DAYS",
help="How long (*in days*) automatically generated OCSP responder certificates are valid.",
)
group.add_argument(
"--ocsp-response-validity",
action=IntegerRangeAction,
min=600,
metavar="SECONDS",
help="How long (*in seconds*) OCSP responses are valid (default: 86400).",
)

def add_ca_args(self, parser: ActionsContainer) -> None:
"""Add CA arguments."""

Expand Down
17 changes: 16 additions & 1 deletion ca/django_ca/managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,8 @@ def init(
acme_profile: Optional[str] = None,
openssh_ca: bool = False,
sign_certificate_policies: Optional[x509.Extension[x509.CertificatePolicies]] = None,
ocsp_responder_key_validity: Optional[int] = None,
ocsp_response_validity: Optional[int] = None,
) -> "CertificateAuthority":
"""Create a new certificate authority.
Expand Down Expand Up @@ -326,6 +328,10 @@ def init(
Set to ``True`` if you want to use this to use this CA for signing OpenSSH certs.
sign_certificate_policies : :py:class:`~cg:cryptography.x509.Extension`, optional
Add the given Certificate Policies extension when signing certificates.
ocsp_responder_key_validity : int, optional
How long (in days) OCSP responder keys should be valid.
ocsp_response_validity : int, optional
How long (in seconds) OCSP responses should be valid.
Raises
------
Expand Down Expand Up @@ -440,6 +446,8 @@ def init(
acme_profile=acme_profile,
acme_requires_contact=acme_requires_contact,
sign_certificate_policies=sign_certificate_policies,
ocsp_responder_key_validity=ocsp_responder_key_validity,
ocsp_response_validity=ocsp_response_validity,
)

private_key = generate_private_key(key_size, key_type, elliptic_curve)
Expand Down Expand Up @@ -478,7 +486,7 @@ def init(
if issuer_alt_name is not None:
serialized_ian = ",".join(format_general_name(name) for name in issuer_alt_name.value)

ca = self.model(
ca: CertificateAuthority = self.model(
name=name,
issuer_url=issuer_url,
issuer_alt_name=serialized_ian,
Expand All @@ -493,6 +501,13 @@ def init(
acme_requires_contact=acme_requires_contact,
sign_certificate_policies=sign_certificate_policies,
)

# Set fields with a default value
if ocsp_responder_key_validity is not None:
ca.ocsp_responder_key_validity = ocsp_responder_key_validity
if ocsp_response_validity is not None:
ca.ocsp_response_validity = ocsp_response_validity

ca.update_certificate(certificate)

if password is None:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Generated by Django 4.2.3 on 2023-07-16 11:01

import django.core.validators
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("django_ca", "0031_certificateauthority_sign_certificate_policies"),
]

operations = [
migrations.AddField(
model_name="certificateauthority",
name="ocsp_responder_key_validity",
field=models.PositiveSmallIntegerField(
default=3,
help_text="How long <strong>(in days)</strong> OCSP responder keys may be valid.",
validators=[django.core.validators.MinValueValidator(1)],
verbose_name="OCSP responder key validity",
),
),
migrations.AddField(
model_name="certificateauthority",
name="ocsp_response_validity",
field=models.PositiveIntegerField(
default=86400,
help_text="How long <strong>(in seconds)</strong> OCSP responses may be considered valid by the client.",
validators=[django.core.validators.MinValueValidator(600)],
verbose_name="OCSP response validity",
),
),
]
26 changes: 23 additions & 3 deletions ca/django_ca/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.core.validators import URLValidator
from django.core.validators import MinValueValidator, URLValidator
from django.db import models
from django.http import HttpRequest
from django.urls import reverse
Expand Down Expand Up @@ -584,6 +584,22 @@ class CertificateAuthority(X509CertMixin):
blank=True, verbose_name="Terms of Service", help_text=_("URL to Terms of Service for this CA")
)

# OCSP configuration
ocsp_responder_key_validity = models.PositiveSmallIntegerField(
_("OCSP responder key validity"),
default=3,
validators=[MinValueValidator(1)],
help_text=_("How long <strong>(in days)</strong> OCSP responder keys may be valid."),
)
ocsp_response_validity = models.PositiveIntegerField(
_("OCSP response validity"),
default=86400,
validators=[MinValueValidator(600)],
help_text=_(
"How long <strong>(in seconds)</strong> OCSP responses may be considered valid by the client."
),
)

# ACMEv2 fields
acme_enabled = models.BooleanField(
default=False,
Expand Down Expand Up @@ -892,7 +908,7 @@ def sign(
def generate_ocsp_key( # pylint: disable=too-many-locals
self,
profile: str = "ocsp",
expires: Expires = 3,
expires: Expires = None,
algorithm: Optional[AllowedHashTypes] = None,
password: Optional[Union[str, bytes]] = None,
key_size: Optional[int] = None,
Expand Down Expand Up @@ -947,7 +963,11 @@ def generate_ocsp_key( # pylint: disable=too-many-locals
usually automatically invoked on a regular basis.
"""

expires = parse_expires(expires)
if expires is None:
expires = datetime.now(tz=tz.utc) + timedelta(days=self.ocsp_responder_key_validity)
else:
expires = parse_expires(expires)

safe_serial = self.serial.replace(":", "")

if password is None:
Expand Down
1 change: 0 additions & 1 deletion ca/django_ca/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ class can be used to create a signed certificate based on the given CA::
<Profile: example>
"""

# pylint: disable=too-many-instance-attributes
algorithm: Optional[AllowedHashTypes] = None
extensions: Dict[x509.ObjectIdentifier, Optional[x509.Extension[x509.ExtensionType]]]

Expand Down
46 changes: 38 additions & 8 deletions ca/django_ca/tests/base/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from contextlib import contextmanager
from datetime import datetime, timedelta
from http import HTTPStatus
from typing import Any, Dict, Iterable, Iterator, List, Optional, Tuple, Type, Union
from typing import Any, AnyStr, Dict, Iterable, Iterator, List, Optional, Tuple, Type, Union
from unittest import mock
from urllib.parse import quote

Expand Down Expand Up @@ -375,18 +375,48 @@ def assertCreateCertSignals( # pylint: disable=invalid-name
self.assertTrue(post_sig.called is post)

def assertE2ECommandError( # pylint: disable=invalid-name
self, cmd: typing.Sequence[str], stdout: bytes = b"", stderr: bytes = b""
self,
cmd: typing.Sequence[str],
stdout: Union[str, bytes, "re.Pattern[AnyStr]"] = "",
stderr: Union[str, bytes, "re.Pattern[AnyStr]"] = "",
) -> None:
"""Assert that the passed command raises a CommandError with the given message."""
actual_stdout = io.BytesIO()
actual_stderr = io.BytesIO()
if isinstance(stdout, str): # pragma: no cover
stdout = "CommandError: " + stdout + "\n"
elif isinstance(stdout, bytes): # pragma: no cover
stdout = b"CommandError: " + stdout + b"\n"
self.assertE2EError(cmd, stdout=stdout, stderr=stderr, code=1)

def assertE2EError( # pylint: disable=invalid-name
self,
cmd: typing.Sequence[str],
stdout: Union[str, bytes, "re.Pattern[AnyStr]"] = "",
stderr: Union[str, bytes, "re.Pattern[AnyStr]"] = "",
code: int = 2,
) -> None:
"""Assert an error was through in an e2e command."""
if isinstance(stdout, str) or (isinstance(stdout, re.Pattern) and isinstance(stdout.pattern, str)):
actual_stdout = io.StringIO()
else:
actual_stdout = io.BytesIO() # type: ignore[assignment]

stdout = b"CommandError: " + stdout + b"\n"
if isinstance(stderr, str) or (isinstance(stderr, re.Pattern) and isinstance(stderr.pattern, str)):
actual_stderr = io.StringIO()
else:
actual_stderr = io.BytesIO() # type: ignore[assignment]

with self.assertRaisesRegex(SystemExit, r"^1$"):
with self.assertSystemExit(code):
self.cmd_e2e(cmd, stdout=actual_stdout, stderr=actual_stderr)
self.assertEqual(stdout, actual_stdout.getvalue())
self.assertEqual(stderr, actual_stderr.getvalue())

if isinstance(stdout, (str, bytes)):
self.assertEqual(stdout, actual_stdout.getvalue())
else:
self.assertRegex(actual_stdout.getvalue(), stdout) # type: ignore[misc] # pragma: no cover

if isinstance(stderr, (str, bytes)):
self.assertEqual(stderr, actual_stderr.getvalue())
else:
self.assertRegex(actual_stderr.getvalue(), stderr) # type: ignore[misc]

def assertExtensions( # pylint: disable=invalid-name
self,
Expand Down
1 change: 1 addition & 0 deletions ca/django_ca/tests/commands/test_dump_crl.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ def test_include_issuing_distribution_point(self) -> None:
self.assertE2ECommandError(
["dump_crl", f"--ca={root.serial}", "--include-issuing-distribution-point"],
b"Cannot add IssuingDistributionPoint extension to CRLs with no scope for root CAs.",
b"",
)

@override_tmpcadir()
Expand Down
Loading

0 comments on commit c31a4b6

Please sign in to comment.