Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ Changelog
Version 1.4.0 [unreleased]
--------------------------

Work in progress.
Changes
~~~~~~~

- Added schema-backed validation and a simplified admin editor for CA and
certificate extensions.

Version 1.3.0 [2025-10-23]
--------------------------
Expand Down
44 changes: 44 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,50 @@ for new end-entity certificates.

Value of the ``keyUsage`` x509 extension for new end-entity certificates.

``DJANGO_X509_CA_EXTENSIONS_SCHEMA``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

============ =================================
**type**: ``dict``
**default**: bundled CA extensions JSON schema
============ =================================

JSON schema used to validate the ``extensions`` field of CA objects and to
drive the simplified admin editor.

The default schema exposes:

- ``nsComment``
- ``nsCertType`` with CA-oriented values (``sslca``, ``emailca``,
``objca``)

``DJANGO_X509_CERT_EXTENSIONS_SCHEMA``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

============ ==========================================
**type**: ``dict``
**default**: bundled certificate extensions JSON schema
============ ==========================================

JSON schema used to validate the ``extensions`` field of end-entity
certificates and to drive the simplified admin editor.

The default schema exposes:

- ``nsComment``
- ``nsCertType`` with end-entity values (``client``, ``server``,
``email``, ``objsign``)
- ``extendedKeyUsage``

When these settings are overridden, backend validation follows the
supplied schema during field validation. The built-in editor supports
schemas that keep the same top-level ``array`` plus ``items.oneOf``
structure used by the defaults; unsupported schemas fall back to the raw
JSON textarea while backend validation still uses the configured schema.

Legacy comma-separated values for multi-value extensions are still
accepted and normalized automatically.

``DJANGO_X509_CRL_PROTECTED``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
18 changes: 18 additions & 0 deletions django_x509/base/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

from django_x509 import settings as app_settings

from .widgets import ExtensionsWidget


class X509Form(forms.ModelForm):
OPERATION_CHOICES = (
Expand Down Expand Up @@ -79,6 +81,13 @@ def get_fields(self, request, obj=None):
fields.remove("passphrase")
return fields

def get_form(self, request, obj=None, change=False, **kwargs):
form = super().get_form(request, obj, change=change, **kwargs)
extensions = form.base_fields.get("extensions")
if extensions:
extensions.widget = ExtensionsWidget(schema=self.get_extensions_schema())
return form

def get_context(self, data, ca_count=0, cert_count=0):
context = dict()
if ca_count:
Expand All @@ -103,6 +112,9 @@ def get_context(self, data, ca_count=0, cert_count=0):
context.update({"opts": self.model._meta, "data": data})
return context

def get_extensions_schema(self):
return []


class AbstractCaAdmin(BaseAdmin):
list_filter = ["key_length", "digest", "created"]
Expand Down Expand Up @@ -134,6 +146,9 @@ class AbstractCaAdmin(BaseAdmin):
class Media:
js = ("admin/js/jquery.init.js", "django-x509/js/x509-admin.js")

def get_extensions_schema(self):
return app_settings.get_ca_extensions_schema()

def get_urls(self):
return [
path("<int:pk>.crl", self.crl_view, name="crl"),
Expand Down Expand Up @@ -224,6 +239,9 @@ class AbstractCertAdmin(BaseAdmin):
class Media:
js = ("admin/js/jquery.init.js", "django-x509/js/x509-admin.js")

def get_extensions_schema(self):
return app_settings.get_cert_extensions_schema()

def ca_url(self, obj):
url = reverse(
"admin:{0}_ca_change".format(self.opts.app_label), args=[obj.ca.pk]
Expand Down
198 changes: 183 additions & 15 deletions django_x509/base/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import uuid
from datetime import datetime, timedelta

import jsonschema
import swapper
from cryptography import x509
from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm
Expand All @@ -16,6 +17,7 @@
from OpenSSL import crypto

from .. import settings as app_settings
from ..schemas import get_schema_item_options

KEY_LENGTH_CHOICES = (
("256", "256 (ECDSA)"),
Expand All @@ -37,6 +39,23 @@
("sha512", "SHA512"),
)

SUPPORTED_EXTENDED_KEY_USAGE_VALUES = {
"clientauth",
"serverauth",
"codesigning",
"emailprotection",
}

SUPPORTED_NS_CERT_TYPE_VALUES = {
"client",
"server",
"email",
"objsign",
"sslca",
"emailca",
"objca",
}


def default_validity_start():
"""
Expand Down Expand Up @@ -157,19 +176,20 @@ class Meta:
def __str__(self):
return self.name

def clean_fields(self, *args, **kwargs):
def clean_fields(self, exclude=None):
# importing existing certificate
# must be done here in order to validate imported fields
# and fill private and public key before validation fails
if self._state.adding and self.certificate and self.private_key:
self._validate_pem()
self._import()
super().clean_fields(*args, **kwargs)
super().clean_fields(exclude=exclude)
if not exclude or "extensions" not in exclude:
self._validate_extensions()

def clean(self):
if self.serial_number:
self._validate_serial_number()
self._verify_extension_format()
# when importing, both public and private must be present
if (self.certificate and not self.private_key) or (
self.private_key and not self.certificate
Expand Down Expand Up @@ -505,19 +525,165 @@ def _verify_ca(self):
_("Cryptographic signature verification failed: CA does not match.")
)

def _verify_extension_format(self):
"""
(internal use only)
verifies the format of ``self.extension`` is correct
"""
msg = "Extension format invalid"
def _get_extensions_schema(self):
if hasattr(self, "ca"):
return app_settings.get_cert_extensions_schema()
return app_settings.get_ca_extensions_schema()

def _normalize_extensions(self, schema):
if self.extensions is None:
self.extensions = []
return
if not isinstance(self.extensions, list):
raise ValidationError(msg)
return
options = get_schema_item_options(schema)
normalized = []
for ext in self.extensions:
if not isinstance(ext, dict):
raise ValidationError(msg)
if not ("name" in ext and "critical" in ext and "value" in ext):
raise ValidationError(msg)
normalized.append(ext)
continue
normalized_ext = ext.copy()
branch = options.get(normalized_ext.get("name"), {})
value_schema = branch.get("properties", {}).get("value", {})
if value_schema.get("type") == "array" and isinstance(
normalized_ext.get("value"), str
):
normalized_ext["value"] = [
value.strip()
for value in normalized_ext["value"].split(",")
if value.strip()
]
normalized.append(normalized_ext)
self.extensions = normalized

def _get_best_extensions_error(self, errors):
error = jsonschema.exceptions.best_match(errors)
while error.context:
nested_error = jsonschema.exceptions.best_match(error.context)
if nested_error is error:
break
error = nested_error
return error

def _format_extensions_error(self, error):
path = []
for segment in error.absolute_path:
if isinstance(segment, int):
path.append(f"[{segment}]")
elif path:
path.append(f".{segment}")
else:
path.append(str(segment))
message = error.message
if path:
return _("Extensions data at %(path)s: %(message)s") % {
"path": "".join(path),
"message": message,
}
return _("Extensions data: %(message)s") % {"message": message}

def _raise_extensions_validation_error(self, path, message):
if path:
raise ValidationError(
{
"extensions": _("Extensions data at %(path)s: %(message)s")
% {"path": path, "message": message}
}
)
raise ValidationError(
{"extensions": _("Extensions data: %(message)s") % {"message": message}}
)

def _get_extension_values(self, value):
if isinstance(value, list):
return value
if isinstance(value, str):
return value.split(",")
return []

def _validate_supported_extensions(self):
for index, ext in enumerate(self.extensions or []):
if not isinstance(ext, dict):
continue

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Custom schemas can currently validate shapes that the backend still cannot generate. Since non-dict entries are skipped here, a custom schema like {"type": "array", "items": {"type": "string"}} lets ["not-an-extension"] pass _validate_extensions(). The generator later expects dictionaries and fails in _add_extensions() when it calls ext_data.get(...).

Please keep the old invariant independent from the configured schema: extensions must be a list of extension objects with at least name and value; critical can default to False. Add a test with a custom schema that validates a non-object item and make sure this is rejected as an extensions field validation error.

path = f"[{index}]"
name = ext.get("name")
critical = ext.get("critical", False)
value = ext.get("value", "")
if not isinstance(critical, bool):
self._raise_extensions_validation_error(
f"{path}.critical", _("Critical flag must be a boolean value.")
)
if name == "nsComment":
if not isinstance(value, str):
self._raise_extensions_validation_error(
f"{path}.value",
_("nsComment extension requires a string value."),
)
if not value:
self._raise_extensions_validation_error(
f"{path}.value", _("nsComment extension requires a value.")
)
if len(value) > 255:
self._raise_extensions_validation_error(
f"{path}.value",
_("nsComment value exceeds maximum length of 255 bytes"),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
continue
if name == "extendedKeyUsage":
values = self._get_extension_values(value)
if not values:
self._raise_extensions_validation_error(
f"{path}.value",
_(
"extendedKeyUsage extension requires at least "
"one valid value."
),
)
for raw_value in values:
cleaned_value = (
raw_value.strip().lower()
if isinstance(raw_value, str)
else str(raw_value).strip().lower()
)
if cleaned_value not in SUPPORTED_EXTENDED_KEY_USAGE_VALUES:
self._raise_extensions_validation_error(
f"{path}.value",
_("Unsupported extendedKeyUsage value: %s") % cleaned_value,
)
continue
if name == "nsCertType":
values = self._get_extension_values(value)
if not values:
self._raise_extensions_validation_error(
f"{path}.value",
_("nsCertType extension requires at least one valid type."),
)
for raw_value in values:
cleaned_value = (
raw_value.strip().lower()
if isinstance(raw_value, str)
else str(raw_value).strip().lower()
)
if cleaned_value not in SUPPORTED_NS_CERT_TYPE_VALUES:
self._raise_extensions_validation_error(
f"{path}.value",
_("Unsupported nsCertType value: %s") % cleaned_value,
)
continue
self._raise_extensions_validation_error(
f"{path}.name", _("Unsupported extension: %s") % name
)

def _validate_extensions(self):
schema = self._get_extensions_schema()
self._normalize_extensions(schema)
validator_cls = jsonschema.validators.validator_for(schema)
validator = validator_cls(schema)
errors = list(validator.iter_errors(self.extensions))
if errors:
error = self._get_best_extensions_error(errors)
raise ValidationError({"extensions": self._format_extensions_error(error)})
self._validate_supported_extensions()

def _add_extensions(self, builder, public_key):
"""
Expand Down Expand Up @@ -594,7 +760,8 @@ def _add_extensions(self, builder, public_key):
"emailprotection": ExtendedKeyUsageOID.EMAIL_PROTECTION,
}
oids = []
for v in val.split(","):
values = val if isinstance(val, list) else val.split(",")
for v in values:
v_clean = v.strip().lower()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
if v_clean not in eku_map:
raise ValidationError(
Expand Down Expand Up @@ -634,7 +801,8 @@ def _add_extensions(self, builder, public_key):
"objca": 0x01,
}
bits = 0
for v in val.split(","):
values = val if isinstance(val, list) else val.split(",")
for v in values:
v_clean = v.strip().lower()
if v_clean not in ns_cert_type_map:
raise ValidationError(
Expand Down
Loading
Loading