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
=================