Skip to content

Commit ddb5f15

Browse files
committed
[feature:ui] Make x509 extensions more flexible and user friendly #98
Closes #98
1 parent 0e05e31 commit ddb5f15

15 files changed

Lines changed: 1063 additions & 26 deletions

File tree

CHANGES.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ Changelog
44
Version 1.4.0 [unreleased]
55
--------------------------
66

7-
Work in progress.
7+
Changes
8+
~~~~~~~
9+
10+
- Added schema-backed validation and a simplified admin editor for CA and
11+
certificate extensions.
812

913
Version 1.3.0 [2025-10-23]
1014
--------------------------

README.rst

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,50 @@ for new end-entity certificates.
329329

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

332+
``DJANGO_X509_CA_EXTENSIONS_SCHEMA``
333+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
334+
335+
============ =================================
336+
**type**: ``dict``
337+
**default**: bundled CA extensions JSON schema
338+
============ =================================
339+
340+
JSON schema used to validate the ``extensions`` field of CA objects and to
341+
drive the simplified admin editor.
342+
343+
The default schema exposes:
344+
345+
- ``nsComment``
346+
- ``nsCertType`` with CA-oriented values (``sslca``, ``emailca``,
347+
``objca``)
348+
349+
``DJANGO_X509_CERT_EXTENSIONS_SCHEMA``
350+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
351+
352+
============ ==========================================
353+
**type**: ``dict``
354+
**default**: bundled certificate extensions JSON schema
355+
============ ==========================================
356+
357+
JSON schema used to validate the ``extensions`` field of end-entity
358+
certificates and to drive the simplified admin editor.
359+
360+
The default schema exposes:
361+
362+
- ``nsComment``
363+
- ``nsCertType`` with end-entity values (``client``, ``server``,
364+
``email``, ``objsign``)
365+
- ``extendedKeyUsage``
366+
367+
When these settings are overridden, backend validation follows the
368+
supplied schema during field validation. The built-in editor supports
369+
schemas that keep the same top-level ``array`` plus ``items.oneOf``
370+
structure used by the defaults; unsupported schemas fall back to the raw
371+
JSON textarea while backend validation still uses the configured schema.
372+
373+
Legacy comma-separated values for multi-value extensions are still
374+
accepted and normalized automatically.
375+
332376
``DJANGO_X509_CRL_PROTECTED``
333377
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
334378

django_x509/base/admin.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
from django_x509 import settings as app_settings
1212

13+
from .widgets import ExtensionsWidget
14+
1315

1416
class X509Form(forms.ModelForm):
1517
OPERATION_CHOICES = (
@@ -79,6 +81,13 @@ def get_fields(self, request, obj=None):
7981
fields.remove("passphrase")
8082
return fields
8183

84+
def get_form(self, request, obj=None, change=False, **kwargs):
85+
form = super().get_form(request, obj, change=change, **kwargs)
86+
extensions = form.base_fields.get("extensions")
87+
if extensions:
88+
extensions.widget = ExtensionsWidget(schema=self.get_extensions_schema())
89+
return form
90+
8291
def get_context(self, data, ca_count=0, cert_count=0):
8392
context = dict()
8493
if ca_count:
@@ -103,6 +112,9 @@ def get_context(self, data, ca_count=0, cert_count=0):
103112
context.update({"opts": self.model._meta, "data": data})
104113
return context
105114

115+
def get_extensions_schema(self):
116+
return []
117+
106118

107119
class AbstractCaAdmin(BaseAdmin):
108120
list_filter = ["key_length", "digest", "created"]
@@ -134,6 +146,9 @@ class AbstractCaAdmin(BaseAdmin):
134146
class Media:
135147
js = ("admin/js/jquery.init.js", "django-x509/js/x509-admin.js")
136148

149+
def get_extensions_schema(self):
150+
return app_settings.get_ca_extensions_schema()
151+
137152
def get_urls(self):
138153
return [
139154
path("<int:pk>.crl", self.crl_view, name="crl"),
@@ -224,6 +239,9 @@ class AbstractCertAdmin(BaseAdmin):
224239
class Media:
225240
js = ("admin/js/jquery.init.js", "django-x509/js/x509-admin.js")
226241

242+
def get_extensions_schema(self):
243+
return app_settings.get_cert_extensions_schema()
244+
227245
def ca_url(self, obj):
228246
url = reverse(
229247
"admin:{0}_ca_change".format(self.opts.app_label), args=[obj.ca.pk]

django_x509/base/models.py

Lines changed: 183 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import uuid
22
from datetime import datetime, timedelta
33

4+
import jsonschema
45
import swapper
56
from cryptography import x509
67
from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm
@@ -16,6 +17,7 @@
1617
from OpenSSL import crypto
1718

1819
from .. import settings as app_settings
20+
from ..schemas import get_schema_item_options
1921

2022
KEY_LENGTH_CHOICES = (
2123
("256", "256 (ECDSA)"),
@@ -37,6 +39,23 @@
3739
("sha512", "SHA512"),
3840
)
3941

42+
SUPPORTED_EXTENDED_KEY_USAGE_VALUES = {
43+
"clientauth",
44+
"serverauth",
45+
"codesigning",
46+
"emailprotection",
47+
}
48+
49+
SUPPORTED_NS_CERT_TYPE_VALUES = {
50+
"client",
51+
"server",
52+
"email",
53+
"objsign",
54+
"sslca",
55+
"emailca",
56+
"objca",
57+
}
58+
4059

4160
def default_validity_start():
4261
"""
@@ -157,19 +176,20 @@ class Meta:
157176
def __str__(self):
158177
return self.name
159178

160-
def clean_fields(self, *args, **kwargs):
179+
def clean_fields(self, exclude=None):
161180
# importing existing certificate
162181
# must be done here in order to validate imported fields
163182
# and fill private and public key before validation fails
164183
if self._state.adding and self.certificate and self.private_key:
165184
self._validate_pem()
166185
self._import()
167-
super().clean_fields(*args, **kwargs)
186+
super().clean_fields(exclude=exclude)
187+
if not exclude or "extensions" not in exclude:
188+
self._validate_extensions()
168189

169190
def clean(self):
170191
if self.serial_number:
171192
self._validate_serial_number()
172-
self._verify_extension_format()
173193
# when importing, both public and private must be present
174194
if (self.certificate and not self.private_key) or (
175195
self.private_key and not self.certificate
@@ -505,19 +525,165 @@ def _verify_ca(self):
505525
_("Cryptographic signature verification failed: CA does not match.")
506526
)
507527

508-
def _verify_extension_format(self):
509-
"""
510-
(internal use only)
511-
verifies the format of ``self.extension`` is correct
512-
"""
513-
msg = "Extension format invalid"
528+
def _get_extensions_schema(self):
529+
if hasattr(self, "ca"):
530+
return app_settings.get_cert_extensions_schema()
531+
return app_settings.get_ca_extensions_schema()
532+
533+
def _normalize_extensions(self, schema):
534+
if self.extensions is None:
535+
self.extensions = []
536+
return
514537
if not isinstance(self.extensions, list):
515-
raise ValidationError(msg)
538+
return
539+
options = get_schema_item_options(schema)
540+
normalized = []
516541
for ext in self.extensions:
517542
if not isinstance(ext, dict):
518-
raise ValidationError(msg)
519-
if not ("name" in ext and "critical" in ext and "value" in ext):
520-
raise ValidationError(msg)
543+
normalized.append(ext)
544+
continue
545+
normalized_ext = ext.copy()
546+
branch = options.get(normalized_ext.get("name"), {})
547+
value_schema = branch.get("properties", {}).get("value", {})
548+
if value_schema.get("type") == "array" and isinstance(
549+
normalized_ext.get("value"), str
550+
):
551+
normalized_ext["value"] = [
552+
value.strip()
553+
for value in normalized_ext["value"].split(",")
554+
if value.strip()
555+
]
556+
normalized.append(normalized_ext)
557+
self.extensions = normalized
558+
559+
def _get_best_extensions_error(self, errors):
560+
error = jsonschema.exceptions.best_match(errors)
561+
while error.context:
562+
nested_error = jsonschema.exceptions.best_match(error.context)
563+
if nested_error is error:
564+
break
565+
error = nested_error
566+
return error
567+
568+
def _format_extensions_error(self, error):
569+
path = []
570+
for segment in error.absolute_path:
571+
if isinstance(segment, int):
572+
path.append(f"[{segment}]")
573+
elif path:
574+
path.append(f".{segment}")
575+
else:
576+
path.append(str(segment))
577+
message = error.message
578+
if path:
579+
return _("Extensions data at %(path)s: %(message)s") % {
580+
"path": "".join(path),
581+
"message": message,
582+
}
583+
return _("Extensions data: %(message)s") % {"message": message}
584+
585+
def _raise_extensions_validation_error(self, path, message):
586+
if path:
587+
raise ValidationError(
588+
{
589+
"extensions": _("Extensions data at %(path)s: %(message)s")
590+
% {"path": path, "message": message}
591+
}
592+
)
593+
raise ValidationError(
594+
{"extensions": _("Extensions data: %(message)s") % {"message": message}}
595+
)
596+
597+
def _get_extension_values(self, value):
598+
if isinstance(value, list):
599+
return value
600+
if isinstance(value, str):
601+
return value.split(",")
602+
return []
603+
604+
def _validate_supported_extensions(self):
605+
for index, ext in enumerate(self.extensions or []):
606+
if not isinstance(ext, dict):
607+
continue
608+
path = f"[{index}]"
609+
name = ext.get("name")
610+
critical = ext.get("critical", False)
611+
value = ext.get("value", "")
612+
if not isinstance(critical, bool):
613+
self._raise_extensions_validation_error(
614+
f"{path}.critical", _("Critical flag must be a boolean value.")
615+
)
616+
if name == "nsComment":
617+
if not isinstance(value, str):
618+
self._raise_extensions_validation_error(
619+
f"{path}.value",
620+
_("nsComment extension requires a string value."),
621+
)
622+
if not value:
623+
self._raise_extensions_validation_error(
624+
f"{path}.value", _("nsComment extension requires a value.")
625+
)
626+
if len(value) > 255:
627+
self._raise_extensions_validation_error(
628+
f"{path}.value",
629+
_("nsComment value exceeds maximum length of 255 bytes"),
630+
)
631+
continue
632+
if name == "extendedKeyUsage":
633+
values = self._get_extension_values(value)
634+
if not values:
635+
self._raise_extensions_validation_error(
636+
f"{path}.value",
637+
_(
638+
"extendedKeyUsage extension requires at least "
639+
"one valid value."
640+
),
641+
)
642+
for raw_value in values:
643+
cleaned_value = (
644+
raw_value.strip().lower()
645+
if isinstance(raw_value, str)
646+
else str(raw_value).strip().lower()
647+
)
648+
if cleaned_value not in SUPPORTED_EXTENDED_KEY_USAGE_VALUES:
649+
self._raise_extensions_validation_error(
650+
f"{path}.value",
651+
_("Unsupported extendedKeyUsage value: %s") % cleaned_value,
652+
)
653+
continue
654+
if name == "nsCertType":
655+
values = self._get_extension_values(value)
656+
if not values:
657+
self._raise_extensions_validation_error(
658+
f"{path}.value",
659+
_("nsCertType extension requires at least one valid type."),
660+
)
661+
for raw_value in values:
662+
cleaned_value = (
663+
raw_value.strip().lower()
664+
if isinstance(raw_value, str)
665+
else str(raw_value).strip().lower()
666+
)
667+
if cleaned_value not in SUPPORTED_NS_CERT_TYPE_VALUES:
668+
self._raise_extensions_validation_error(
669+
f"{path}.value",
670+
_("Unsupported nsCertType value: %s") % cleaned_value,
671+
)
672+
continue
673+
self._raise_extensions_validation_error(
674+
f"{path}.name", _("Unsupported extension: %s") % name
675+
)
676+
677+
def _validate_extensions(self):
678+
schema = self._get_extensions_schema()
679+
self._normalize_extensions(schema)
680+
validator_cls = jsonschema.validators.validator_for(schema)
681+
validator = validator_cls(schema)
682+
errors = list(validator.iter_errors(self.extensions))
683+
if errors:
684+
error = self._get_best_extensions_error(errors)
685+
raise ValidationError({"extensions": self._format_extensions_error(error)})
686+
self._validate_supported_extensions()
521687

522688
def _add_extensions(self, builder, public_key):
523689
"""
@@ -594,7 +760,8 @@ def _add_extensions(self, builder, public_key):
594760
"emailprotection": ExtendedKeyUsageOID.EMAIL_PROTECTION,
595761
}
596762
oids = []
597-
for v in val.split(","):
763+
values = val if isinstance(val, list) else val.split(",")
764+
for v in values:
598765
v_clean = v.strip().lower()
599766
if v_clean not in eku_map:
600767
raise ValidationError(
@@ -634,7 +801,8 @@ def _add_extensions(self, builder, public_key):
634801
"objca": 0x01,
635802
}
636803
bits = 0
637-
for v in val.split(","):
804+
values = val if isinstance(val, list) else val.split(",")
805+
for v in values:
638806
v_clean = v.strip().lower()
639807
if v_clean not in ns_cert_type_map:
640808
raise ValidationError(

0 commit comments

Comments
 (0)