Skip to content

Commit 036202e

Browse files
authored
[feature] Added support for ECDSA certificates #118
Closes #118
1 parent fd5fb28 commit 036202e

File tree

6 files changed

+461
-93
lines changed

6 files changed

+461
-93
lines changed

README.rst

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ Current features
6868
- Possibility to generate and import passphrase protected x509
6969
certificates/CAs
7070
- Passphrase protected x509 content will be shown encrypted in the web UI
71+
- RSA and ECDSA support: generate or import certificates using RSA or
72+
ECDSA algorithms.
7173

7274
Project goals
7375
-------------
@@ -239,10 +241,8 @@ Default key length for new CAs and new certificates.
239241

240242
Must be one of the following values:
241243

242-
- ``512``
243-
- ``1024``
244-
- ``2048``
245-
- ``4096``
244+
- RSA: ``1024``, ``2048``, ``4096``
245+
- ECDSA: ``256`` (P-256), ``384`` (P-384), ``521`` (P-521)
246246

247247
``DJANGO_X509_DEFAULT_DIGEST_ALGORITHM``
248248
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

django_x509/base/models.py

Lines changed: 76 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,17 @@
1919
from .. import settings as app_settings
2020

2121
KEY_LENGTH_CHOICES = (
22-
("512", "512"),
23-
("1024", "1024"),
24-
("2048", "2048"),
25-
("4096", "4096"),
22+
("256", "256 (ECDSA)"),
23+
("384", "384 (ECDSA)"),
24+
("521", "521 (ECDSA)"),
25+
("1024", "1024 (RSA)"),
26+
("2048", "2048 (RSA)"),
27+
("4096", "4096 (RSA)"),
2628
)
2729

30+
RSA_KEY_LENGTHS = ("1024", "2048", "4096")
31+
EC_KEY_LENGTHS = ("256", "384", "521")
32+
2833
DIGEST_CHOICES = (
2934
("sha1", "SHA1"),
3035
("sha224", "SHA224"),
@@ -165,6 +170,9 @@ def clean_fields(self, *args, **kwargs):
165170
super().clean_fields(*args, **kwargs)
166171

167172
def clean(self):
173+
if self.serial_number:
174+
self._validate_serial_number()
175+
self._verify_extension_format()
168176
# when importing, both public and private must be present
169177
if (self.certificate and not self.private_key) or (
170178
self.private_key and not self.certificate
@@ -175,9 +183,11 @@ def clean(self):
175183
"keys (private and public) must be present"
176184
)
177185
)
178-
if self.serial_number:
179-
self._validate_serial_number()
180-
self._verify_extension_format()
186+
all_supported = list(RSA_KEY_LENGTHS) + list(EC_KEY_LENGTHS)
187+
if self.key_length not in all_supported:
188+
raise ValidationError(
189+
{"key_length": _("Unsupported key length: %s") % self.key_length}
190+
)
181191

182192
def save(self, *args, **kwargs):
183193
if self._state.adding and not self.certificate and not self.private_key:
@@ -280,10 +290,24 @@ def _generate(self):
280290
for attr in ["x509", "pkey"]:
281291
if attr in self.__dict__:
282292
del self.__dict__[attr]
283-
key = rsa.generate_private_key(
284-
public_exponent=65537,
285-
key_size=int(self.key_length),
286-
)
293+
is_ec = self.key_length in EC_KEY_LENGTHS
294+
if is_ec:
295+
curves = {
296+
"256": ec.SECP256R1(),
297+
"384": ec.SECP384R1(),
298+
"521": ec.SECP521R1(),
299+
}
300+
curve = curves.get(self.key_length)
301+
if not curve:
302+
raise ValidationError(
303+
_("Unsupported EC key length: %s") % self.key_length
304+
)
305+
key = ec.generate_private_key(curve)
306+
else:
307+
key = rsa.generate_private_key(
308+
public_exponent=65537,
309+
key_size=int(self.key_length),
310+
)
287311
if hasattr(self, "ca"):
288312
signing_key = self.ca.pkey
289313
issuer_name = self.ca.x509.subject
@@ -307,7 +331,12 @@ def _generate(self):
307331
"sha384": hashes.SHA384,
308332
"sha512": hashes.SHA512,
309333
}
310-
digest_name = self.digest.lower().replace("withrsaencryption", "")
334+
digest_name = (
335+
self.digest.lower()
336+
.replace("withrsaencryption", "")
337+
.replace("ecdsa-with-", "")
338+
.replace("withsha", "sha")
339+
)
311340
digest_alg = HASH_MAP.get(digest_name, hashes.SHA256)()
312341
cert = builder.sign(signing_key, digest_alg)
313342
self.certificate = cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
@@ -318,7 +347,11 @@ def _generate(self):
318347
)
319348
self.private_key = key.private_bytes(
320349
encoding=serialization.Encoding.PEM,
321-
format=serialization.PrivateFormat.TraditionalOpenSSL,
350+
format=(
351+
serialization.PrivateFormat.PKCS8
352+
if is_ec
353+
else serialization.PrivateFormat.TraditionalOpenSSL
354+
),
322355
encryption_algorithm=encryption,
323356
).decode("utf-8")
324357

@@ -352,6 +385,36 @@ def _import(self):
352385
imports existing x509 certificates
353386
"""
354387
cert = self.x509
388+
public_key = cert.public_key()
389+
if isinstance(public_key, rsa.RSAPublicKey):
390+
actual_length = str(public_key.key_size)
391+
actual_is_ec = False
392+
elif isinstance(public_key, ec.EllipticCurvePublicKey):
393+
actual_length = str(public_key.curve.key_size)
394+
actual_is_ec = True
395+
else:
396+
raise ValidationError(
397+
_(
398+
"Unsupported key type in certificate. "
399+
"Only RSA and EC keys are supported."
400+
)
401+
)
402+
selected_is_ec = self.key_length in EC_KEY_LENGTHS
403+
if selected_is_ec != actual_is_ec:
404+
algorithm_expected = "ECDSA" if selected_is_ec else "RSA"
405+
algorithm_provided = "ECDSA" if actual_is_ec else "RSA"
406+
raise ValidationError(
407+
{
408+
"key_length": _(
409+
"Algorithm mismatch: You selected a length for %s, "
410+
"but the provided certificate contains an %s key."
411+
)
412+
% (algorithm_expected, algorithm_provided)
413+
}
414+
)
415+
if actual_is_ec and actual_length not in EC_KEY_LENGTHS:
416+
raise ValidationError(_("Unsupported EC curve size: %s") % actual_length)
417+
self.key_length = actual_length
355418
# when importing an end entity certificate
356419
if hasattr(self, "ca"):
357420
self._verify_ca()
@@ -366,7 +429,6 @@ def _import(self):
366429
email = str(attrs[0].value) if attrs else ""
367430

368431
self.email = email
369-
self.key_length = str(cert.public_key().key_size)
370432
self.digest = cert.signature_hash_algorithm.name.lower()
371433
self.validity_start = cert.not_valid_before_utc
372434
self.validity_end = cert.not_valid_after_utc
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Generated by Django 6.0.1 on 2026-01-30 18:18
2+
3+
import django_x509.base.models
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("django_x509", "0009_alter_ca_digest_alter_ca_key_length_and_more"),
11+
]
12+
13+
operations = [
14+
migrations.AlterField(
15+
model_name="ca",
16+
name="key_length",
17+
field=models.CharField(
18+
choices=[
19+
("256", "256 (ECDSA)"),
20+
("384", "384 (ECDSA)"),
21+
("521", "521 (ECDSA)"),
22+
("1024", "1024 (RSA)"),
23+
("2048", "2048 (RSA)"),
24+
("4096", "4096 (RSA)"),
25+
],
26+
default=django_x509.base.models.default_key_length,
27+
help_text="bits",
28+
max_length=6,
29+
verbose_name="key length",
30+
),
31+
),
32+
migrations.AlterField(
33+
model_name="cert",
34+
name="key_length",
35+
field=models.CharField(
36+
choices=[
37+
("256", "256 (ECDSA)"),
38+
("384", "384 (ECDSA)"),
39+
("521", "521 (ECDSA)"),
40+
("1024", "1024 (RSA)"),
41+
("2048", "2048 (RSA)"),
42+
("4096", "4096 (RSA)"),
43+
],
44+
default=django_x509.base.models.default_key_length,
45+
help_text="bits",
46+
max_length=6,
47+
verbose_name="key length",
48+
),
49+
),
50+
]

django_x509/tests/test_ca.py

Lines changed: 110 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
from datetime import timezone as dt_timezone
33

44
from cryptography import x509
5-
from cryptography.hazmat.primitives.asymmetric import rsa
5+
from cryptography.hazmat.primitives import hashes, serialization
6+
from cryptography.hazmat.primitives.asymmetric import ec, rsa
67
from cryptography.x509.oid import NameOID
78
from django.core.exceptions import ValidationError
89
from django.test import TestCase
@@ -34,29 +35,56 @@ def _prepare_revoked(self):
3435
return (ca, cert)
3536

3637
import_certificate = """-----BEGIN CERTIFICATE-----
37-
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
58+
EQZrw8bbikXRSH3D31NVroN7fw==
4959
-----END CERTIFICATE-----"""
5060

5161
import_private_key = """-----BEGIN PRIVATE KEY-----
52-
MIIBVgIBADANBgkqhkiG9w0BAQEFAASCAUAwggE8AgEAAkEAxwD5toX4+4ZG61wM
53-
vgsNl28lFxar1KT+3yT3E1yAU6ipJ04hbJX+F2MzJDCuL/sU/2h6yACgf6Dfqe7A
54-
2uOHRQIDAQABAkEAwQApLuPv/cDUtx6nHQkLPXsFtca/D5SVu0TWe2iS7I5IQuXN
55-
XIwUScvp9rd/GnSuphyMXrgE8XI3nn1baTS7AQIhAO6eXAYz4cQHWPWoMS75S6O/
56-
uE/ZJVCfBUonFfmS2ELpAiEA1X/mNy4pacJSJLpGv1vajOIzXR76W9ud5lHh23U3
57-
z/0CIBj7ES1BDzijgEevhP6i8K1C6/vIAuUO0NHzh5RqMCPJAiEAzBJjyCzMkvWW
58-
NNsE0taGsZFpjUIWBoWGiWeNHosNnTUCIQC1wzMjWku4OXk030WC4fFeJwU060WT
59-
9iuP3scMQKQZdg==
62+
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCTI2xoZlq0VO+U
63+
9DZKiPiDmTNqU+asuQoRfFiQ28gInjfQ+hvUnQ35HlV2E27C5JoJzhuPmXPIcK9J
64+
xJkFpPJOFdfp15FRbW6TxKzx8b4TwkKsAfMpZBVR7u5klOAq0jqE0F83OZUdVe9a
65+
IUFBEw7g5c5b3aEoo+E4hxxP31Bx6U2s7VMpP3ftyeAppJv7RlygCykpZ8VvTLrZ
66+
fHECscL9/mc3anUjz7qH2BDbr4xFebHCW9P2pfZMxEEKPfoPSOCwKAnXQLuJCNm7
67+
d3kW2ZtTt1LgO+UKPyk1dydLhHFceuZkp5jdRWT1M1FjIw53ZwebsVB2sZhY7jFP
68+
7ku0+QtxAgMBAAECggEANH9kE4/JdyQC41uK72cVfCayMJLE8AWJcRmzo+O26FRD
69+
R/2k5mQu8x5+kYV3dHQJ/cubC85NgEusTx6lFl120qN6iQWP5MStum1m42BEWFps
70+
XWDIuJDsBnLAfgScQssFdBPAlTynVnMt1jOdS7GYEmgMC7z03kIyfm++i0T7N9ji
71+
fyN2CFOXgevgHK5EtTSrBTzg8JkFnhNZKjHPU9IkRyaN8KtOwKrEgxh0glvNM6yp
72+
cmU8PE+DPK4TSQGsIO4X4Z19wKv7O8x6CYLos8w2Yh9jwMHaGeDnv68RVFoY1vgH
73+
q/PJcWylRanDeyoShIm3v2qBCQcBtUqDUqdTotww7QKBgQDH2LeZ4rn7SUuRkt7l
74+
YeM4YtlbWSlycTh1KphqkM3UW0MQA/HcJKgFacbxdEj9hV0Ol/l76qQv39V1Ts3X
75+
Zsf1eSGLdrvi2JYrlh0WYhFco/g7Cqt43dNfqSAnNKb8ihRq3X8zsQ7nTDU3wWoK
76+
fYjeHyYRFYPi7jQF3sYcmrM5OwKBgQC8e1KPKQ2eIJNC5rYbolxRgrSOMxr+AxgD
77+
iiNdhPT0lpeYEPW65z/pbdiiehXt8zrrJhtfDDe7f0dHaYkbSJWq5dzjAqnfwNGO
78+
+gr5+skTL5nkimjbinGHN8+2134eMxh4WmsyQvkY23wk8SJlC9/57/TQvjRNZUAw
79+
nmBmQdojQwKBgQCWFXl9RjqqLxdjkkt3NRZxyDq4UbPA0Kq3w2+HyIvryUYKBwxi
80+
ad0Ng6z2tIAEdV23kga5OzRnB9DFMpOACx5sibXZiSf9au8MeMYLg0bKrhHENXUl
81+
ZmJR2y/cgbxOuFwxDXt0FKq+pgrpfoXmrvRU7EuoVOIhUQccyXs7DCtA9QKBgEoS
82+
vVN98tgePUGhohgiKt3t3D+2XflOBfX+J//s7MfjFxiwMaKOl1OJ1AWmrU+is5kO
83+
lNs51f1d/AlYtIWAdTGAvNqKhXBmOvVR11Z+9N8Rag2jR6pgMlXN3VgiQHJl6kwC
84+
XPaX04WtXJC4I6hKjm+PmksfNTblf+CbnY8SekQ5AoGBALvK6n2UGG+0fKtUFM+M
85+
0enYrqW065OsDOIXo7roOVZjBhul+vilk7xJ85fhtDD0zll/4OoJ1BG9IN0rUUJh
86+
eMIi3AQRO060kYejKGC2ls5hMxjdhxB/bUGrwXkMxZQPfjBkcP3JqaZkwcFBKq5N
87+
tsND+97h9r73S+UTOhepQTDB
6088
-----END PRIVATE KEY-----"""
6189

6290
def test_new(self):
@@ -132,7 +160,8 @@ def test_import_ca(self):
132160
ca.save()
133161
cert = ca.x509
134162
# verify attributes
135-
self.assertEqual(cert.serial_number, 123456)
163+
serial = 371904255628934431598705194442539630076148098375
164+
self.assertEqual(cert.serial_number, serial)
136165
subject = cert.subject
137166
self.assertEqual(
138167
subject.get_attributes_for_oid(NameOID.COUNTRY_NAME)[0].value, "US"
@@ -157,18 +186,18 @@ def test_import_ca(self):
157186
)
158187
self.assertEqual(cert.issuer, cert.subject)
159188
# verify field attribtues
160-
self.assertEqual(ca.key_length, "512")
161-
self.assertEqual(ca.digest, "sha1")
189+
self.assertEqual(ca.key_length, "2048")
190+
self.assertEqual(ca.digest, "sha256")
162191
self.assertEqual(ca.country_code, "US")
163192
self.assertEqual(ca.state, "CA")
164193
self.assertEqual(ca.city, "San Francisco")
165194
self.assertEqual(ca.organization_name, "ACME")
166195
self.assertEqual(ca.email, "[email protected]")
167196
self.assertEqual(ca.common_name, "importtest")
168-
self.assertEqual(int(ca.serial_number), 123456)
197+
self.assertEqual(int(ca.serial_number), serial)
169198
self.assertEqual(ca.name, "ImportTest")
170-
start = datetime(2025, 12, 25, 9, 20, 56, tzinfo=dt_timezone.utc)
171-
end = datetime(2026, 12, 25, 9, 20, 56, tzinfo=dt_timezone.utc)
199+
start = datetime(2026, 1, 17, 7, 41, 5, tzinfo=dt_timezone.utc)
200+
end = datetime(2036, 1, 15, 7, 41, 5, tzinfo=dt_timezone.utc)
172201
self.assertEqual(ca.validity_start, start)
173202
self.assertEqual(ca.validity_end, end)
174203
# ensure version is 3
@@ -684,3 +713,57 @@ def test_renewal_serial_sync(self):
684713
cert_obj = x509.load_pem_x509_certificate(cert.certificate.encode())
685714
pem_serial = cert_obj.serial_number
686715
self.assertEqual(int(cert.serial_number), pem_serial)
716+
717+
def test_ca_ecdsa_full_lifecycle(self):
718+
curves_to_test = [
719+
("256", ec.SECP256R1, hashes.SHA256()),
720+
("384", ec.SECP384R1, hashes.SHA384()),
721+
("521", ec.SECP521R1, hashes.SHA512()),
722+
]
723+
for length, curve_class, digest in curves_to_test:
724+
with self.subTest(key_length=length):
725+
priv_key = ec.generate_private_key(curve_class())
726+
key_pem = priv_key.private_bytes(
727+
encoding=serialization.Encoding.PEM,
728+
format=serialization.PrivateFormat.PKCS8,
729+
encryption_algorithm=serialization.NoEncryption(),
730+
).decode("utf-8")
731+
now = datetime.now(dt_timezone.utc)
732+
subject = issuer = x509.Name(
733+
[x509.NameAttribute(NameOID.COMMON_NAME, "test")]
734+
)
735+
cert = (
736+
x509.CertificateBuilder()
737+
.subject_name(subject)
738+
.issuer_name(issuer)
739+
.public_key(priv_key.public_key())
740+
.serial_number(x509.random_serial_number())
741+
.not_valid_before(now)
742+
.not_valid_after(now + timedelta(days=10))
743+
.sign(priv_key, digest)
744+
)
745+
cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
746+
ca = Ca(
747+
name=f"EC-{length}",
748+
certificate=cert_pem,
749+
private_key=key_pem,
750+
key_length=length,
751+
)
752+
ca.full_clean()
753+
ca.save()
754+
self.assertEqual(ca.key_length, length)
755+
self.assertIsInstance(ca.pkey, ec.EllipticCurvePrivateKey)
756+
gen_ca = Ca(
757+
name=f"Gen-EC-{length}",
758+
key_length=length,
759+
)
760+
gen_ca.full_clean()
761+
gen_ca.save()
762+
self.assertIsInstance(gen_ca.pkey, ec.EllipticCurvePrivateKey)
763+
original_cert = gen_ca.certificate
764+
original_key = gen_ca.private_key
765+
gen_ca.renew()
766+
gen_ca.refresh_from_db()
767+
self.assertEqual(gen_ca.key_length, length)
768+
self.assertNotEqual(gen_ca.private_key, original_key)
769+
self.assertNotEqual(original_cert, gen_ca.certificate)

0 commit comments

Comments
 (0)