diff --git a/ca/ca/test_settings.py b/ca/ca/test_settings.py
index 004915017..a419ff47d 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 = {
@@ -235,6 +236,7 @@
"user_pin": PKCS11_USER_PIN,
},
},
+ "db": {"BACKEND": "django_ca.key_backends.db.DBOCSPBackend"},
}
# Custom settings
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..cf552a349
--- /dev/null
+++ b/ca/django_ca/key_backends/db/__init__.py
@@ -0,0 +1,19 @@
+# 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
+from django_ca.key_backends.db.ocsp_backend import DBOCSPBackend
+
+__all__ = ("DBBackend", "DBOCSPBackend")
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/key_backends/db/ocsp_backend.py b/ca/django_ca/key_backends/db/ocsp_backend.py
new file mode 100644
index 000000000..a5a5e35bf
--- /dev/null
+++ b/ca/django_ca/key_backends/db/ocsp_backend.py
@@ -0,0 +1,66 @@
+# 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
+# .
+
+"""OCSP key backend storing private keys in the database."""
+
+import typing
+from typing import Optional
+
+from cryptography import x509
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric import ec
+from cryptography.hazmat.primitives.asymmetric.types import CertificateIssuerPrivateKeyTypes
+from cryptography.hazmat.primitives.serialization import Encoding, PrivateFormat
+from cryptography.x509.ocsp import OCSPResponse, OCSPResponseBuilder
+
+from django_ca.key_backends import OCSPKeyBackend
+from django_ca.models import CertificateAuthority
+from django_ca.typehints import AllowedHashTypes, ParsableKeyType
+from django_ca.utils import generate_private_key
+
+
+class DBOCSPBackend(OCSPKeyBackend):
+ """OCSP key backend storing files in the database."""
+
+ def create_private_key(
+ self,
+ ca: "CertificateAuthority",
+ key_type: ParsableKeyType,
+ key_size: Optional[int],
+ elliptic_curve: Optional[ec.EllipticCurve],
+ ) -> x509.CertificateSigningRequest:
+ # Generate the private key.
+ private_key = generate_private_key(key_size, key_type, elliptic_curve)
+
+ # Serialize and store the key
+ encryption = serialization.NoEncryption()
+ pem = private_key.private_bytes(
+ encoding=Encoding.PEM, format=PrivateFormat.PKCS8, encryption_algorithm=encryption
+ )
+
+ ca.ocsp_key_backend_options["private_key"]["pem"] = pem.decode()
+
+ # Generate the CSR to return to the caller.
+ csr_builder = x509.CertificateSigningRequestBuilder().subject_name(x509.Name([]))
+ csr_algorithm = self.get_csr_algorithm(key_type)
+ return csr_builder.sign(private_key, csr_algorithm)
+
+ def sign_ocsp_response(
+ self,
+ ca: "CertificateAuthority",
+ builder: OCSPResponseBuilder,
+ signature_hash_algorithm: Optional[AllowedHashTypes],
+ ) -> OCSPResponse:
+ pem = ca.ocsp_key_backend_options["private_key"]["pem"].encode()
+ key = typing.cast(CertificateIssuerPrivateKeyTypes, serialization.load_pem_private_key(pem, None))
+ return builder.sign(key, signature_hash_algorithm)
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..fa368b7f8 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,26 @@ 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")
+@pytest.mark.freeze_time(TIMESTAMPS["everything_valid"]) # b/c of signature validation in the end.
+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..6168fa5a6 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,40 @@ 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", "--ocsp-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])
+
+ ocsp_key: Certificate = ca.certificate_set.get()
+
+ assert ca.ocsp_key_backend_alias == "db"
+ assert "pem" in ca.ocsp_key_backend_options["private_key"]
+ assert ca.ocsp_key_backend_options["certificate"] == {"pem": ocsp_key.pub.pem, "pk": ocsp_key.pk}
+
+ # 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..1166c4cd0
--- /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): # noqa: PT011
+ 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/ca/django_ca/tests/views/test_generic_ocsp_view.py b/ca/django_ca/tests/views/test_generic_ocsp_view.py
index 802edd29a..82e959bb3 100644
--- a/ca/django_ca/tests/views/test_generic_ocsp_view.py
+++ b/ca/django_ca/tests/views/test_generic_ocsp_view.py
@@ -17,6 +17,7 @@
import logging
import shutil
+import typing
from http import HTTPStatus
from pathlib import Path
@@ -33,6 +34,7 @@
from django_ca.conf import model_settings
from django_ca.key_backends.hsm.models import HSMUsePrivateKeyOptions
+from django_ca.key_backends.storages.models import StoragesUsePrivateKeyOptions
from django_ca.models import Certificate, CertificateAuthority
from django_ca.tests.base.constants import CERT_DATA, FIXTURES_DIR, TIMESTAMPS
from django_ca.tests.views.assertions import assert_ocsp_response
@@ -69,7 +71,7 @@ def test_get_with_nonce(client: Client, child_cert: Certificate, profile_ocsp: C
@pytest.mark.usefixtures("hsm_ocsp_backend")
def test_hsm_ocsp_key(client: Client, child_cert: Certificate, usable_hsm_ca: CertificateAuthority) -> None:
- """Test key generation using the OCSP key backend."""
+ """Test fetching an OCSP response when using the HSM OCSP key backend."""
usable_hsm_ca.ocsp_key_backend_alias = "hsm"
usable_hsm_ca.save()
@@ -91,6 +93,22 @@ def test_hsm_ocsp_key(client: Client, child_cert: Certificate, usable_hsm_ca: Ce
assert_ocsp_response(response, child_cert, responder_certificate=cert, signature_hash_algorithm=algorithm)
+def test_db_ocsp_key(client: Client, root_cert: Certificate, usable_root: CertificateAuthority) -> None:
+ """Test fetching an OCSP response when using the database OCSP key backend."""
+ usable_root.ocsp_key_backend_alias = "db"
+ usable_root.save()
+
+ key_backend_options = StoragesUsePrivateKeyOptions.model_validate(
+ {}, context={"backend": usable_root.key_backend, "ca": usable_root}
+ )
+ cert = typing.cast(Certificate, usable_root.generate_ocsp_key(key_backend_options))
+
+ response = ocsp_get(client, root_cert)
+ assert_ocsp_response(
+ response, root_cert, responder_certificate=cert, signature_hash_algorithm=hashes.SHA256
+ )
+
+
def test_response_validity(client: Client, child_cert: Certificate, profile_ocsp: Certificate) -> None:
"""Test a custom OCSP response validity."""
# Reduce OCSP response validity before making request
diff --git a/docs/source/changelog/TBR_2.1.0.rst b/docs/source/changelog/TBR_2.1.0.rst
index 25c62e963..af7f029a4 100644
--- a/docs/source/changelog/TBR_2.1.0.rst
+++ b/docs/source/changelog/TBR_2.1.0.rst
@@ -16,6 +16,16 @@ 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).
+* Add a :ref:`key_backends_ocsp_db_backend` to allow storing OCSP keys in the database.
+
+************
+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/include/config/settings_db_ocsp_key_backend.py b/docs/source/include/config/settings_db_ocsp_key_backend.py
new file mode 100644
index 000000000..3ec2c3384
--- /dev/null
+++ b/docs/source/include/config/settings_db_ocsp_key_backend.py
@@ -0,0 +1,5 @@
+CA_OCSP_KEY_BACKENDS = {
+ "default": {
+ "BACKEND": "django_ca.key_backends.db.DBOCSPBackend",
+ },
+}
diff --git a/docs/source/include/config/settings_db_ocsp_key_backend.yaml b/docs/source/include/config/settings_db_ocsp_key_backend.yaml
new file mode 100644
index 000000000..9f090ab96
--- /dev/null
+++ b/docs/source/include/config/settings_db_ocsp_key_backend.yaml
@@ -0,0 +1,3 @@
+CA_OCSP_KEY_BACKENDS:
+ default:
+ BACKEND: django_ca.key_backends.db.DBOCSPBackend
\ No newline at end of file
diff --git a/docs/source/key_backends.rst b/docs/source/key_backends.rst
index f209ca4c8..aae0a79b4 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 no options and is thus very simple to configure:
+
+.. 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:
*****************
@@ -173,7 +198,7 @@ OCSP Key backends
Just like for certificate authorities, **django-ca** allows you to store private keys for OCSP responder
certificates using different backends. By default, private keys are stored on the file system, but they can
-also be stored in a Hardware Security Module.
+also be stored using any of the backends documented below.
Note that the OCSP key storage method does not have to match the method used for storing the private key of
the certificate authority.
@@ -195,7 +220,7 @@ The available key backends can be configured using the :ref:`CA_OCSP_KEY_BACKEND
The OCSP key backend that is used for a specific certificate authority can be configured using the admin
interface or with the `--ocsp-key-backend` option to :command:`manage.py init_ca`,
:command:`manage.py edit_ca` and :command:`manage.py import_ca`. Note that when you change the backend,
-you must manually regenerate OCSP keys (e.g. using :command:`manage.py regenerate_ocsp_keys`.
+you must manually regenerate OCSP keys (e.g. using :command:`manage.py regenerate_ocsp_keys`).
:spelling:word:`Storages` OCSP key backend
==========================================
@@ -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
===============================================
@@ -234,3 +261,21 @@ backend:
.. literalinclude:: /include/config/settings_hsm_ocsp_key_backend.yaml
:language: YAML
+
+.. _key_backends_ocsp_db_backend:
+
+Database OCSP key backend
+==========================
+
+The database OCSP key backend is equivalent to the :ref:`db_backend` and stores OCSP private keys in the
+database. It also takes no options:
+
+.. tab:: Python
+
+ .. literalinclude:: /include/config/settings_db_ocsp_key_backend.py
+ :language: python
+
+.. tab:: YAML
+
+ .. literalinclude:: /include/config/settings_db_ocsp_key_backend.yaml
+ :language: YAML
diff --git a/docs/source/python/key_backends.rst b/docs/source/python/key_backends.rst
index 52e237951..fb7d5fd75 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
=================
@@ -172,3 +172,5 @@ OCSP key backends
.. autoclass:: django_ca.key_backends.storages.StoragesOCSPBackend
.. autoclass:: django_ca.key_backends.hsm.HSMOCSPBackend
+
+.. autoclass:: django_ca.key_backends.db.DBOCSPBackend
diff --git a/docs/source/settings.rst b/docs/source/settings.rst
index 0d2b41bbc..6df28c489 100644
--- a/docs/source/settings.rst
+++ b/docs/source/settings.rst
@@ -420,7 +420,7 @@ CA_OCSP_KEY_BACKENDS
.. literalinclude:: /include/config/settings_default_ca_ocsp_key_backends.yaml
:language: YAML
- Configuration for storing OCSP keys. See :ref:`ocsp_key_backends` for more information.
+ Configuration for storing OCSP keys. See :ref:`ocsp_key_backends` for more information.
.. _settings-ca-ocsp-responder-certificate-renewal: