From a42facded216813263938b96d8d7073a12687c71 Mon Sep 17 00:00:00 2001 From: Mathias Ertl Date: Sat, 7 Dec 2024 09:50:24 +0100 Subject: [PATCH] implement database key backend (fixes #146) --- ca/ca/test_settings.py | 1 + ca/django_ca/key_backends/base.py | 9 +- ca/django_ca/key_backends/db/__init__.py | 18 ++ ca/django_ca/key_backends/db/backend.py | 190 ++++++++++++++++++ ca/django_ca/key_backends/db/models.py | 53 +++++ ca/django_ca/tests/base/conftest_helpers.py | 1 + ca/django_ca/tests/base/fixtures.py | 14 ++ ca/django_ca/tests/base/utils.py | 6 - ca/django_ca/tests/commands/test_import_ca.py | 20 ++ ca/django_ca/tests/commands/test_init_ca.py | 28 +++ .../tests/key_backends/db/__init__.py | 0 .../tests/key_backends/db/test_backend.py | 55 +++++ .../tests/key_backends/db/test_models.py | 24 +++ ca/django_ca/tests/key_backends/test_base.py | 9 +- docs/source/changelog/TBR_2.1.0.rst | 9 + .../include/config/settings_db_backend.py | 5 + .../include/config/settings_db_backend.yaml | 3 + docs/source/key_backends.rst | 27 +++ docs/source/python/key_backends.rst | 2 +- 19 files changed, 463 insertions(+), 11 deletions(-) create mode 100644 ca/django_ca/key_backends/db/__init__.py create mode 100644 ca/django_ca/key_backends/db/backend.py create mode 100644 ca/django_ca/key_backends/db/models.py create mode 100644 ca/django_ca/tests/key_backends/db/__init__.py create mode 100644 ca/django_ca/tests/key_backends/db/test_backend.py create mode 100644 ca/django_ca/tests/key_backends/db/test_models.py create mode 100644 docs/source/include/config/settings_db_backend.py create mode 100644 docs/source/include/config/settings_db_backend.yaml diff --git a/ca/ca/test_settings.py b/ca/ca/test_settings.py index 004915017..e318ffb5c 100644 --- a/ca/ca/test_settings.py +++ b/ca/ca/test_settings.py @@ -220,6 +220,7 @@ "user_pin": PKCS11_USER_PIN, }, }, + "db": {"BACKEND": "django_ca.key_backends.db.DBBackend"}, } CA_OCSP_KEY_BACKENDS = { diff --git a/ca/django_ca/key_backends/base.py b/ca/django_ca/key_backends/base.py index fa26127c1..ff8de2cfa 100644 --- a/ca/django_ca/key_backends/base.py +++ b/ca/django_ca/key_backends/base.py @@ -177,7 +177,7 @@ def add_use_private_key_group(self, parser: CommandParser) -> Optional[ArgumentG f"Arguments for using private keys stored with the {self.alias} backend.", ) - @abc.abstractmethod + # pylint: disable-next=unused-argument # default implementation does nothing. def add_create_private_key_arguments(self, group: ArgumentGroup) -> None: """Add arguments for private key generation with this backend. @@ -185,18 +185,21 @@ def add_create_private_key_arguments(self, group: ArgumentGroup) -> None: you add here are expected to be loaded (and validated) using :py:func:`~django_ca.key_backends.KeyBackend.get_create_private_key_options`. """ + return None - @abc.abstractmethod + # pylint: disable-next=unused-argument # default implementation does nothing. def add_use_parent_private_key_arguments(self, group: ArgumentGroup) -> None: """Add arguments for loading the private key of a parent certificate authority. The arguments you add here are expected to be loaded (and validated) using :py:func:`~django_ca.key_backends.KeyBackend.get_use_parent_private_key_options`. """ + return None - @abc.abstractmethod + # pylint: disable-next=unused-argument # default implementation does nothing. def add_store_private_key_arguments(self, group: ArgumentGroup) -> None: """Add arguments for storing private keys (when importing an existing CA).""" + return None # pylint: disable=unused-argument # Method may not be overwritten, just providing default here def add_use_private_key_arguments(self, group: ArgumentGroup) -> None: diff --git a/ca/django_ca/key_backends/db/__init__.py b/ca/django_ca/key_backends/db/__init__.py new file mode 100644 index 000000000..e7324d82c --- /dev/null +++ b/ca/django_ca/key_backends/db/__init__.py @@ -0,0 +1,18 @@ +# 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 +# . + +"""DB storage backend module.""" + +from django_ca.key_backends.db.backend import DBBackend + +__all__ = ("DBBackend",) diff --git a/ca/django_ca/key_backends/db/backend.py b/ca/django_ca/key_backends/db/backend.py new file mode 100644 index 000000000..c682d5910 --- /dev/null +++ b/ca/django_ca/key_backends/db/backend.py @@ -0,0 +1,190 @@ +# 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 +# . + +"""Key backend using the Django Storages system.""" + +import typing +from collections.abc import Sequence +from datetime import datetime +from typing import Any, Optional + +from cryptography import x509 +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives._serialization import Encoding, PrivateFormat +from cryptography.hazmat.primitives.asymmetric.types import ( + CertificateIssuerPrivateKeyTypes, + CertificateIssuerPublicKeyTypes, +) +from cryptography.hazmat.primitives.serialization import load_pem_private_key + +from django.core.management import CommandParser + +from django_ca import constants +from django_ca.key_backends import KeyBackend +from django_ca.key_backends.db.models import ( + DBCreatePrivateKeyOptions, + DBStorePrivateKeyOptions, + DBUsePrivateKeyOptions, +) +from django_ca.models import CertificateAuthority +from django_ca.typehints import ( + AllowedHashTypes, + ArgumentGroup, + CertificateExtension, + EllipticCurves, + ParsableKeyType, +) +from django_ca.utils import generate_private_key, get_cert_builder + + +class DBBackend(KeyBackend[DBCreatePrivateKeyOptions, DBStorePrivateKeyOptions, DBUsePrivateKeyOptions]): + """The default storage backend that uses Django's file storage API.""" + + name = "storages" + title = "Store private keys using the Django file storage API" + description = ( + "It is most commonly used to store private keys on the file system. Custom file storage backends can " + "be used to store keys on other systems (e.g. a cloud storage system)." + ) + use_model = DBUsePrivateKeyOptions + + supported_key_types: tuple[ParsableKeyType, ...] = constants.PARSABLE_KEY_TYPES + supported_elliptic_curves: tuple[EllipticCurves, ...] = tuple(constants.ELLIPTIC_CURVE_TYPES) + + def __eq__(self, other: Any) -> bool: + return isinstance(other, DBBackend) + + def add_create_private_key_group(self, parser: CommandParser) -> Optional[ArgumentGroup]: + return None + + def add_store_private_key_group(self, parser: CommandParser) -> Optional[ArgumentGroup]: + return None + + def add_use_private_key_group(self, parser: CommandParser) -> Optional[ArgumentGroup]: + return None + + def get_create_private_key_options( + self, + key_type: ParsableKeyType, + key_size: Optional[int], + elliptic_curve: Optional[EllipticCurves], # type: ignore[override] + options: dict[str, Any], + ) -> DBCreatePrivateKeyOptions: + return DBCreatePrivateKeyOptions(key_type=key_type, key_size=key_size, elliptic_curve=elliptic_curve) + + def get_store_private_key_options(self, options: dict[str, Any]) -> DBStorePrivateKeyOptions: + return DBStorePrivateKeyOptions() + + def get_use_private_key_options( + self, ca: "CertificateAuthority", options: dict[str, Any] + ) -> DBUsePrivateKeyOptions: + return DBUsePrivateKeyOptions.model_validate({}, context={"ca": ca, "backend": self}, strict=True) + + def get_use_parent_private_key_options( + self, ca: "CertificateAuthority", options: dict[str, Any] + ) -> DBUsePrivateKeyOptions: + return DBUsePrivateKeyOptions.model_validate({}, context={"ca": ca, "backend": self}, strict=True) + + def create_private_key( + self, + ca: "CertificateAuthority", + key_type: ParsableKeyType, + options: DBCreatePrivateKeyOptions, + ) -> tuple[CertificateIssuerPublicKeyTypes, DBUsePrivateKeyOptions]: + encryption = serialization.NoEncryption() + key = generate_private_key(options.key_size, key_type, options.elliptic_curve) + pem = key.private_bytes( + encoding=Encoding.PEM, format=PrivateFormat.PKCS8, encryption_algorithm=encryption + ) + ca.key_backend_options = {"private_key": {"pem": pem.decode()}} + use_private_key_options = DBUsePrivateKeyOptions.model_validate( + {}, context={"ca": ca, "backend": self} + ) + return key.public_key(), use_private_key_options + + def store_private_key( + self, + ca: "CertificateAuthority", + key: CertificateIssuerPrivateKeyTypes, + certificate: x509.Certificate, + options: DBStorePrivateKeyOptions, + ) -> None: + encryption = serialization.NoEncryption() + pem = key.private_bytes( + encoding=Encoding.PEM, format=PrivateFormat.PKCS8, encryption_algorithm=encryption + ) + ca.key_backend_options = {"private_key": {"pem": pem.decode()}} + + def get_key( + self, + ca: "CertificateAuthority", + # pylint: disable-next=unused-argument # interface requires option + use_private_key_options: DBUsePrivateKeyOptions, + ) -> CertificateIssuerPrivateKeyTypes: + """The CAs private key as private key.""" + pem = ca.key_backend_options["private_key"]["pem"].encode() + return typing.cast( # type validated below + CertificateIssuerPrivateKeyTypes, load_pem_private_key(pem, None) + ) + + def is_usable( + self, + ca: "CertificateAuthority", + use_private_key_options: Optional[DBUsePrivateKeyOptions] = None, + ) -> bool: + # If key_backend_options is not set or path is not set, it is certainly unusable. + if not ca.key_backend_options or not ca.key_backend_options.get("private_key"): + return False + return True + + def check_usable( + self, ca: "CertificateAuthority", use_private_key_options: DBUsePrivateKeyOptions + ) -> None: + """Check if the given CA is usable, raise ValueError if not. + + The `options` are the options returned by + :py:func:`~django_ca.key_backends.base.KeyBackend.get_use_private_key_options`. It may be ``None`` in + cases where key options cannot (yet) be loaded. If ``None``, the backend should return ``False`` if it + knows for sure that it will not be usable, and ``True`` if usability cannot be determined. + """ + if not ca.key_backend_options or not ca.key_backend_options.get("private_key"): + raise ValueError(f"{ca.key_backend_options}: Private key not stored in database.") + + def sign_certificate( + self, + ca: "CertificateAuthority", + use_private_key_options: DBUsePrivateKeyOptions, + public_key: CertificateIssuerPublicKeyTypes, + serial: int, + algorithm: Optional[AllowedHashTypes], + issuer: x509.Name, + subject: x509.Name, + not_after: datetime, + extensions: Sequence[CertificateExtension], + ) -> x509.Certificate: + builder = get_cert_builder(not_after, serial=serial) + builder = builder.public_key(public_key) + builder = builder.issuer_name(issuer) + builder = builder.subject_name(subject) + for extension in extensions: + builder = builder.add_extension(extension.value, critical=extension.critical) + return builder.sign(private_key=self.get_key(ca, use_private_key_options), algorithm=algorithm) + + def sign_certificate_revocation_list( + self, + ca: "CertificateAuthority", + use_private_key_options: DBUsePrivateKeyOptions, + builder: x509.CertificateRevocationListBuilder, + algorithm: Optional[AllowedHashTypes], + ) -> x509.CertificateRevocationList: + return builder.sign(private_key=self.get_key(ca, use_private_key_options), algorithm=algorithm) diff --git a/ca/django_ca/key_backends/db/models.py b/ca/django_ca/key_backends/db/models.py new file mode 100644 index 000000000..9688b2356 --- /dev/null +++ b/ca/django_ca/key_backends/db/models.py @@ -0,0 +1,53 @@ +# 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 +# . + +"""Models for the storages backend.""" + +from typing import Optional + +from pydantic import BaseModel, ConfigDict, model_validator + +from django_ca.conf import model_settings +from django_ca.key_backends.base import CreatePrivateKeyOptionsBaseModel +from django_ca.pydantic.type_aliases import EllipticCurveTypeAlias + + +class DBCreatePrivateKeyOptions(CreatePrivateKeyOptionsBaseModel): + """Options for initializing private keys.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + elliptic_curve: Optional[EllipticCurveTypeAlias] = None + + @model_validator(mode="after") + def validate_elliptic_curve(self) -> "DBCreatePrivateKeyOptions": + """Validate that the elliptic curve is not set for invalid key types.""" + if self.key_type == "EC" and self.elliptic_curve is None: + self.elliptic_curve = model_settings.CA_DEFAULT_ELLIPTIC_CURVE + elif self.key_type != "EC" and self.elliptic_curve is not None: + raise ValueError(f"Elliptic curves are not supported for {self.key_type} keys.") + return self + + +class DBStorePrivateKeyOptions(BaseModel): + """Options for storing a private key.""" + + # NOTE: we set frozen here to prevent accidental coding mistakes. Models should be immutable. + model_config = ConfigDict(frozen=True) + + +class DBUsePrivateKeyOptions(BaseModel): + """Options for using a private key.""" + + # NOTE: we set frozen here to prevent accidental coding mistakes. Models should be immutable. + model_config = ConfigDict(frozen=True) diff --git a/ca/django_ca/tests/base/conftest_helpers.py b/ca/django_ca/tests/base/conftest_helpers.py index 5e447a2d7..e4de9356e 100644 --- a/ca/django_ca/tests/base/conftest_helpers.py +++ b/ca/django_ca/tests/base/conftest_helpers.py @@ -309,6 +309,7 @@ def load_cert( usable_ca_names = [ name for name, conf in CERT_DATA.items() if conf["type"] == "ca" and conf.get("key_filename") ] +usable_ca_names_by_type = ["dsa", "root", "ed448", "ed25519", "ec"] contrib_ca_names = [ name for name, conf in CERT_DATA.items() if conf["type"] == "ca" and conf["cat"] == "sphinx-contrib" ] diff --git a/ca/django_ca/tests/base/fixtures.py b/ca/django_ca/tests/base/fixtures.py index c3842a0d1..74cb9ee91 100644 --- a/ca/django_ca/tests/base/fixtures.py +++ b/ca/django_ca/tests/base/fixtures.py @@ -39,6 +39,7 @@ from django_ca.conf import model_settings from django_ca.key_backends import key_backends, ocsp_key_backends +from django_ca.key_backends.db.backend import DBBackend from django_ca.key_backends.hsm import HSMBackend, HSMOCSPBackend from django_ca.key_backends.hsm.models import HSMCreatePrivateKeyOptions from django_ca.key_backends.hsm.session import SessionPool @@ -53,6 +54,7 @@ signed_certificate_timestamp_cert_names, signed_certificate_timestamps_cert_names, usable_ca_names, + usable_ca_names_by_type, usable_cert_names, ) from django_ca.tests.base.constants import CERT_DATA, TIMESTAMPS @@ -414,6 +416,12 @@ def hsm_ocsp_backend(request: "SubRequest") -> Iterator[HSMOCSPBackend]: # prag ocsp_key_backends._reset() # pylint: disable=protected-access # in case we manipulated the object +@pytest.fixture +def db_backend() -> DBBackend: + """Fixture providing a DB backend.""" + return cast(DBBackend, key_backends["db"]) + + @pytest.fixture(params=HSMBackend.supported_key_types) def usable_hsm_ca( # pragma: hsm request: "SubRequest", ca_name: str, subject: x509.Name, hsm_backend: HSMBackend @@ -501,6 +509,12 @@ def usable_ca_name(request: "SubRequest") -> CertificateAuthority: return request.param # type: ignore[no-any-return] +@pytest.fixture(params=usable_ca_names_by_type) +def usable_ca_name_by_type(request: "SubRequest") -> CertificateAuthority: + """Parametrized fixture for the name of a CA of every type (``"dsa"``, ``"rsa"``, ...).""" + return request.param # type: ignore[no-any-return] + + @pytest.fixture(params=usable_ca_names) def usable_ca(request: "SubRequest") -> CertificateAuthority: """Parametrized fixture for every usable CA (with usable private key).""" diff --git a/ca/django_ca/tests/base/utils.py b/ca/django_ca/tests/base/utils.py index 91a0b89cc..9de89121d 100644 --- a/ca/django_ca/tests/base/utils.py +++ b/ca/django_ca/tests/base/utils.py @@ -72,12 +72,6 @@ class DummyBackend(KeyBackend[DummyModel, DummyModel, DummyModel]): # pragma: n def __eq__(self, other: Any) -> bool: return isinstance(other, DummyBackend) - def add_create_private_key_arguments(self, group: ArgumentGroup) -> None: - return None - - def add_store_private_key_arguments(self, group: ArgumentGroup) -> None: - return None - def get_create_private_key_options( self, key_type: ParsableKeyType, diff --git a/ca/django_ca/tests/commands/test_import_ca.py b/ca/django_ca/tests/commands/test_import_ca.py index 68a5aea18..8fea53e9e 100644 --- a/ca/django_ca/tests/commands/test_import_ca.py +++ b/ca/django_ca/tests/commands/test_import_ca.py @@ -32,6 +32,7 @@ from django_ca.conf import model_settings from django_ca.key_backends import key_backends +from django_ca.key_backends.db.models import DBUsePrivateKeyOptions from django_ca.key_backends.hsm import HSMBackend from django_ca.key_backends.hsm.keys import PKCS11EllipticCurvePrivateKey, PKCS11RSAPrivateKey from django_ca.key_backends.hsm.models import HSMUsePrivateKeyOptions @@ -325,6 +326,25 @@ def test_hsm_store_key_with_dsa_keys() -> None: import_ca("dsa", key_path, pem_path, key_backend=key_backends["hsm"], hsm_key_label="dsa") +def test_with_db_backend(usable_ca_name_by_type: str, subject: x509.Name) -> None: + """Test storing ED448/Ed25519 keys in the HSM, which is not supported.""" + cert_data = CERT_DATA[usable_ca_name_by_type] + key_path = cert_data["key_path"] + pem_path = cert_data["pub_path"] + + import_ca(usable_ca_name_by_type, key_path, pem_path, key_backend=key_backends["db"]) + + ca = CertificateAuthority.objects.get(name=usable_ca_name_by_type) + assert ca.key_backend.is_usable(ca) is True + assert ca.key_backend.check_usable(ca, DBUsePrivateKeyOptions()) is None + + # Sign a certificate to make sure that the key is actually usable + cert_data = CERT_DATA[f"{usable_ca_name_by_type}-cert"] + csr = cert_data["csr"]["parsed"] + cert = Certificate.objects.create_cert(ca, DBUsePrivateKeyOptions(), csr, subject=subject) + assert_signature([ca], cert) + + def test_bogus_public_key(ca_name: str) -> None: """Test importing a CA with a bogus public key.""" key_path = CERT_DATA["root"]["key_path"] diff --git a/ca/django_ca/tests/commands/test_init_ca.py b/ca/django_ca/tests/commands/test_init_ca.py index 2ce74a544..1f9131ded 100644 --- a/ca/django_ca/tests/commands/test_init_ca.py +++ b/ca/django_ca/tests/commands/test_init_ca.py @@ -41,6 +41,8 @@ from django_ca.conf import model_settings from django_ca.constants import ExtendedKeyUsageOID from django_ca.key_backends import key_backends +from django_ca.key_backends.db import DBBackend +from django_ca.key_backends.db.models import DBUsePrivateKeyOptions from django_ca.key_backends.hsm import HSMBackend from django_ca.key_backends.hsm.models import HSMUsePrivateKeyOptions from django_ca.key_backends.storages import StoragesBackend @@ -1076,6 +1078,32 @@ def test_hsm_backend( assert_signature([ca], cert) +@pytest.mark.django_db +@pytest.mark.usefixtures("tmpcadir") +@pytest.mark.parametrize("key_type", DBBackend.supported_key_types) +def test_db_backend( + ca_name: str, rfc4514_subject: str, key_type: ParsableKeyType, subject: x509.Name +) -> None: + """Basic test for creating a key in the database.""" + ca = init_ca_e2e(ca_name, rfc4514_subject, f"--key-type={key_type}", "--key-backend=db") + + assert ca.key_backend_alias == "db" + assert ca.key_backend.is_usable(ca, DBUsePrivateKeyOptions()) + assert ca.key_backend.check_usable(ca, DBUsePrivateKeyOptions()) is None + + assert ca.key_type == key_type + assert isinstance(ca.pub.loaded, x509.Certificate) + assert isinstance(ca.pub.loaded.public_key(), constants.PUBLIC_KEY_TYPE_MAPPING[key_type]) + + # Sign a certificate to make sure that the key is actually usable + cert_data = CERT_DATA["root-cert"] + csr = cert_data["csr"]["parsed"] + cert = Certificate.objects.create_cert( + ca, HSMUsePrivateKeyOptions(user_pin=settings.PKCS11_USER_PIN), csr, subject=subject + ) + assert_signature([ca], cert) + + @pytest.mark.django_db @pytest.mark.usefixtures("tmpcadir") @pytest.mark.usefixtures("softhsm_token") diff --git a/ca/django_ca/tests/key_backends/db/__init__.py b/ca/django_ca/tests/key_backends/db/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ca/django_ca/tests/key_backends/db/test_backend.py b/ca/django_ca/tests/key_backends/db/test_backend.py new file mode 100644 index 000000000..54d79a890 --- /dev/null +++ b/ca/django_ca/tests/key_backends/db/test_backend.py @@ -0,0 +1,55 @@ +# 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 +# . + +"""Tests for the database key backend.""" + +import pytest + +from django_ca.key_backends.db import DBBackend +from django_ca.key_backends.db.models import DBUsePrivateKeyOptions +from django_ca.models import CertificateAuthority + + +def test_eq(db_backend: DBBackend) -> None: + """Teest equality of database backends.""" + assert db_backend == DBBackend(alias="other") + + +def test_get_use_parent_private_key_options(db_backend: DBBackend, root: CertificateAuthority) -> None: + """Test getting parent private key options.""" + assert db_backend.get_use_parent_private_key_options(root, {}) == DBUsePrivateKeyOptions() + + +def test_is_not_usable_with_no_key_backend_options(db_backend: DBBackend, root: CertificateAuthority) -> None: + """Test key backend knows CA is not usable with no key backend options.""" + root.key_backend_options = {} + root.key_backend_alias = db_backend.alias + root.save() + + assert db_backend.is_usable(root) is False + match = rf"^{root.key_backend_options}: Private key not stored in database\.$" + with pytest.raises(ValueError, match=match): + db_backend.check_usable(root, DBUsePrivateKeyOptions()) + + +def test_is_not_usable_with_no_private_key(db_backend: DBBackend, root: CertificateAuthority) -> None: + """Test key backend knows CA is not usable with no key backend options.""" + root.key_backend_options = {"private_key": None} + root.key_backend_alias = db_backend.alias + root.save() + + assert db_backend.is_usable(root) is False + + match = rf"^{root.key_backend_options}: Private key not stored in database\.$" + with pytest.raises(ValueError, match=match): + db_backend.check_usable(root, DBUsePrivateKeyOptions()) diff --git a/ca/django_ca/tests/key_backends/db/test_models.py b/ca/django_ca/tests/key_backends/db/test_models.py new file mode 100644 index 000000000..112d04203 --- /dev/null +++ b/ca/django_ca/tests/key_backends/db/test_models.py @@ -0,0 +1,24 @@ +# 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 models of the db backend.""" + +import pytest + +from django_ca.key_backends.db.models import DBCreatePrivateKeyOptions + + +def test_create_with_elliptic_curve_with_no_ec_key() -> None: + """Test creating a private key options object with an EC curve and no EC key.""" + with pytest.raises(ValueError): + DBCreatePrivateKeyOptions(key_type="RSA", elliptic_curve="sect233r1") diff --git a/ca/django_ca/tests/key_backends/test_base.py b/ca/django_ca/tests/key_backends/test_base.py index 6de659a01..4399c3d36 100644 --- a/ca/django_ca/tests/key_backends/test_base.py +++ b/ca/django_ca/tests/key_backends/test_base.py @@ -13,6 +13,7 @@ """Test key backend base class.""" +import argparse from unittest.mock import patch import pytest @@ -56,6 +57,7 @@ def test_key_backends_iter(settings: SettingsWrapper) -> None: key_backends[model_settings.CA_DEFAULT_KEY_BACKEND], key_backends["secondary"], key_backends["hsm"], + key_backends["db"], ] settings.CA_KEY_BACKENDS = { @@ -113,5 +115,10 @@ def test_key_backend_overwritten_methods(settings: SettingsWrapper) -> None: }, } + parser = argparse.ArgumentParser() + group = parser.add_argument_group("group") + backend = key_backends[model_settings.CA_DEFAULT_KEY_BACKEND] - assert backend.add_use_private_key_arguments(None) is None # type: ignore[func-returns-value,arg-type] + assert backend.add_use_private_key_arguments(group) is None # type: ignore[func-returns-value] + assert backend.add_create_private_key_arguments(group) is None # type: ignore[func-returns-value] + assert backend.add_store_private_key_arguments(group) is None # type: ignore[func-returns-value] diff --git a/docs/source/changelog/TBR_2.1.0.rst b/docs/source/changelog/TBR_2.1.0.rst index 25c62e963..07045ae7d 100644 --- a/docs/source/changelog/TBR_2.1.0.rst +++ b/docs/source/changelog/TBR_2.1.0.rst @@ -16,6 +16,15 @@ OCSP responder keys * Private keys for OCSP responders are now stored using configurable backends, just like private keys for certificate authorities. See :ref:`ocsp_key_backends` for more information. +* Add a :ref:`key_backends_ocsp_hsm_backend` to allow storing OCSP keys in a HSM (Hardware Security Module). + +************ +Key backends +************ + +* Add a :ref:`db_backend` to allow storing private keys in the database. This backend makes the private key + accessible to any frontend-facing web server and is thus less secure then other backends, but is an + option if your environment has no file system available. ********************** Command-line utilities diff --git a/docs/source/include/config/settings_db_backend.py b/docs/source/include/config/settings_db_backend.py new file mode 100644 index 000000000..073da70fc --- /dev/null +++ b/docs/source/include/config/settings_db_backend.py @@ -0,0 +1,5 @@ +CA_KEY_BACKENDS = { + "default": { + "BACKEND": "django_ca.key_backends.db.DBBackend", + }, +} diff --git a/docs/source/include/config/settings_db_backend.yaml b/docs/source/include/config/settings_db_backend.yaml new file mode 100644 index 000000000..770a0ca88 --- /dev/null +++ b/docs/source/include/config/settings_db_backend.yaml @@ -0,0 +1,3 @@ +CA_KEY_BACKENDS: + default: + BACKEND: django_ca.key_backends.db.DBBackend \ No newline at end of file diff --git a/docs/source/key_backends.rst b/docs/source/key_backends.rst index f209ca4c8..f9bdb90b7 100644 --- a/docs/source/key_backends.rst +++ b/docs/source/key_backends.rst @@ -165,6 +165,31 @@ default backend): user@host:~$ python manage.py init_ca --so-pin=1234 --user-pin="" ... +.. _db_backend: + +Database backend +================ + +The database backend allows you to store private keys in the database. It is a good choice if you have no +local file system available. + +.. WARNING:: + + Using this backend negates any security benefit of using a Celery worker on a different host, as the + private key will always be accessible to the web server. + +This backend takes and requires no options: + +.. tab:: Python + + .. literalinclude:: /include/config/settings_db_backend.py + :language: python + +.. tab:: YAML + + .. literalinclude:: /include/config/settings_db_backend.yaml + :language: YAML + .. _ocsp_key_backends: ***************** @@ -219,6 +244,8 @@ to disable encryption of private keys with a random password: .. literalinclude:: /include/config/settings_storages_ocsp_key_backend.yaml :language: YAML +.. _key_backends_ocsp_hsm_backend: + HSM (Hardware Security Module) OCSP key backend =============================================== diff --git a/docs/source/python/key_backends.rst b/docs/source/python/key_backends.rst index 52e237951..85b7525e9 100644 --- a/docs/source/python/key_backends.rst +++ b/docs/source/python/key_backends.rst @@ -164,7 +164,7 @@ Implementations .. autoclass:: django_ca.key_backends.hsm.HSMBackend - +.. autoclass:: django_ca.key_backends.db.DBBackend OCSP key backends =================