From 6080ea5fb0d91edfb31b9c2a38f296a5552d0fa4 Mon Sep 17 00:00:00 2001 From: alfonsrv Date: Tue, 21 Sep 2021 11:36:00 +0200 Subject: [PATCH 01/11] Create pull.yml --- .github/workflows/pull.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .github/workflows/pull.yml diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml new file mode 100644 index 000000000..a668c7da4 --- /dev/null +++ b/.github/workflows/pull.yml @@ -0,0 +1,5 @@ +version: "1" +rules: + - base: main + upstream: mathiasertl:main + mergeMethod: merge From 7e955fa21934d30fe03dd4f0604d365c3a82201e Mon Sep 17 00:00:00 2001 From: alfonsrv Date: Tue, 21 Sep 2021 19:48:52 +0200 Subject: [PATCH 02/11] Allow using DOMAIN COMPONENT for AD DS certificates --- .github/workflows/pull.yml | 5 ----- ca/django_ca/utils.py | 13 +++++++++++-- 2 files changed, 11 insertions(+), 7 deletions(-) delete mode 100644 .github/workflows/pull.yml diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml deleted file mode 100644 index a668c7da4..000000000 --- a/.github/workflows/pull.yml +++ /dev/null @@ -1,5 +0,0 @@ -version: "1" -rules: - - base: main - upstream: mathiasertl:main - mergeMethod: merge diff --git a/ca/django_ca/utils.py b/ca/django_ca/utils.py index 1700c2bf7..0f3d80f5f 100644 --- a/ca/django_ca/utils.py +++ b/ca/django_ca/utils.py @@ -65,6 +65,7 @@ # List of possible subject fields, in order SUBJECT_FIELDS = [ + "DC", "C", "ST", "L", @@ -104,6 +105,7 @@ #: Map OID objects to IDs used in subject strings OID_NAME_MAPPINGS: Dict[x509.ObjectIdentifier, str] = { + NameOID.DOMAIN_COMPONENT: "DC", NameOID.COUNTRY_NAME: "C", NameOID.STATE_OR_PROVINCE_NAME: "ST", NameOID.LOCALITY_NAME: "L", @@ -124,6 +126,7 @@ # Some OIDs can occur multiple times MULTIPLE_OIDS = ( + NameOID.DOMAIN_COMPONENT, NameOID.ORGANIZATIONAL_UNIT_NAME, NameOID.STREET_ADDRESS, ) @@ -209,8 +212,14 @@ def getter(self, method): # type: ignore def sort_name(subject: List[Tuple[str, str]]) -> List[Tuple[str, str]]: - """Returns the subject in the correct order for a x509 subject.""" - return sorted(subject, key=lambda e: SUBJECT_FIELDS.index(e[0])) + """Returns the subject in the correct order for a x509 subject, while respecting + the original list order for possible subject fields allowing for MULTIPLE_OIDS.""" + half_index = len(subject) // 2 + relative_index = lambda x: len(subject) - subject.index(x) \ + if subject.index(x)+1 > half_index \ + else subject.index(x) - len(subject) + sorted_fields = sorted(subject, key=lambda e: (SUBJECT_FIELDS.index(e[0]), relative_index(e))) + return sorted(subject, key=lambda e: (SUBJECT_FIELDS.index(e[0]), relative_index(e))) def encode_url(url: str) -> str: From defd072a3ae61f1c953c8e1ab302eb0c69dacb78 Mon Sep 17 00:00:00 2001 From: alfonsrv Date: Tue, 21 Sep 2021 22:28:36 +0200 Subject: [PATCH 03/11] Improved sorting for more complicated patterns --- ca/django_ca/utils.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/ca/django_ca/utils.py b/ca/django_ca/utils.py index 0f3d80f5f..ce4fb22b0 100644 --- a/ca/django_ca/utils.py +++ b/ca/django_ca/utils.py @@ -214,12 +214,9 @@ def getter(self, method): # type: ignore def sort_name(subject: List[Tuple[str, str]]) -> List[Tuple[str, str]]: """Returns the subject in the correct order for a x509 subject, while respecting the original list order for possible subject fields allowing for MULTIPLE_OIDS.""" - half_index = len(subject) // 2 - relative_index = lambda x: len(subject) - subject.index(x) \ - if subject.index(x)+1 > half_index \ - else subject.index(x) - len(subject) - sorted_fields = sorted(subject, key=lambda e: (SUBJECT_FIELDS.index(e[0]), relative_index(e))) - return sorted(subject, key=lambda e: (SUBJECT_FIELDS.index(e[0]), relative_index(e))) + if SUBJECT_FIELDS.index(subject[0][0]) > SUBJECT_FIELDS.index(subject[-1][0]): + return sorted(subject[::-1], key=lambda k: SUBJECT_FIELDS.index(k[0])) + return sorted(subject, key=lambda k: SUBJECT_FIELDS.index(k[0])) def encode_url(url: str) -> str: From b6a176cbd8af04d9ea9bb0390262f489bea2e4be Mon Sep 17 00:00:00 2001 From: alfonsrv Date: Wed, 22 Sep 2021 12:51:12 +0200 Subject: [PATCH 04/11] USE_TZ compatibility --- ca/django_ca/fields.py | 1 + ca/django_ca/utils.py | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/ca/django_ca/fields.py b/ca/django_ca/fields.py index 22a3ae064..d50e2ef2e 100644 --- a/ca/django_ca/fields.py +++ b/ca/django_ca/fields.py @@ -96,6 +96,7 @@ class SubjectField(forms.MultiValueField): def __init__(self, **kwargs: typing.Any) -> None: fields = ( + forms.CharField(required=False), # DC forms.CharField(required=False), # C forms.CharField(required=False), # ST forms.CharField(required=False), # L diff --git a/ca/django_ca/utils.py b/ca/django_ca/utils.py index ce4fb22b0..c0524e394 100644 --- a/ca/django_ca/utils.py +++ b/ca/django_ca/utils.py @@ -20,6 +20,7 @@ import typing from collections import abc from datetime import datetime +from datetime import timezone from datetime import timedelta from ipaddress import ip_address from ipaddress import ip_network @@ -49,6 +50,7 @@ from django.core.validators import URLValidator from django.utils.encoding import force_bytes from django.utils.encoding import force_str +from django.utils.timezone import get_current_timezone from django.utils.translation import gettext_lazy as _ from . import ca_settings @@ -965,8 +967,10 @@ def parse_encoding(value: Optional[Union[str, Encoding]] = None) -> Encoding: def parse_expires(expires: Expires = None) -> datetime: """Parse a value specifying an expiry into a concrete datetime.""" - now = datetime.utcnow().replace(second=0, microsecond=0) + now = datetime.now(timezone.utc).replace(second=0, microsecond=0) + if not expires.tzinfo: + expires = expires.replace(tzinfo=get_current_timezone()) if isinstance(expires, int): return now + timedelta(days=expires) if isinstance(expires, timedelta): @@ -1046,7 +1050,7 @@ def get_cert_builder(expires: datetime, serial: Optional[int] = None) -> x509.Ce to generate such a value. By default, a value will be generated. """ - now = datetime.utcnow().replace(second=0, microsecond=0) + now = datetime.now(timezone.utc).replace(second=0, microsecond=0) # NOTE: Explicitly passing a serial is used when creating a CA, where we want to add extensions where the # value references the serial. From 38c6e4f4e7badf0dea94389d47519796ebd2b7a5 Mon Sep 17 00:00:00 2001 From: alfonsrv Date: Wed, 22 Sep 2021 12:58:35 +0200 Subject: [PATCH 05/11] Fix overly complex Django admin widget --- ca/django_ca/forms.py | 2 +- ca/django_ca/widgets.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/ca/django_ca/forms.py b/ca/django_ca/forms.py index 2a2dc7305..18a788e47 100644 --- a/ca/django_ca/forms.py +++ b/ca/django_ca/forms.py @@ -113,7 +113,7 @@ def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: help_text=_("Password for the private key. If not given, the private key must be unencrypted."), ) expires = forms.DateField(initial=_initial_expires, widget=AdminDateWidget()) - subject = SubjectField(label="Subject", required=True) + subject = SubjectField(label="Subject", required=False) subject_alternative_name = SubjectAltNameField( label="subjectAltName", required=False, diff --git a/ca/django_ca/widgets.py b/ca/django_ca/widgets.py index 009cfde23..49e3e2b56 100644 --- a/ca/django_ca/widgets.py +++ b/ca/django_ca/widgets.py @@ -103,6 +103,7 @@ class SubjectWidget(CustomMultiWidget): def __init__(self, attrs: typing.Optional[typing.Dict[str, str]] = None) -> None: _widgets = ( + SubjectTextInput(label=_("Domain Component")), SubjectTextInput(label=_("Country"), attrs={"placeholder": "2 character country code"}), SubjectTextInput(label=_("State")), SubjectTextInput(label=_("Location")), @@ -118,7 +119,11 @@ def decompress( ) -> typing.List[typing.Union[str, typing.List[str]]]: if value is None: # pragma: no cover return ["", "", "", "", "", ""] - + + # Multiple OUs are not supported in webinterface + domain_component = value.get("DC", "") + if isinstance(domain_component, list) and domain_component: + domain_component = domain_component[0] # Multiple OUs are not supported in webinterface org_unit = value.get("OU", "") if isinstance(org_unit, list) and org_unit: @@ -126,6 +131,7 @@ def decompress( # Used e.g. for initial form data (e.g. resigning a cert) return [ + domain_component, value.get("C", ""), value.get("ST", ""), value.get("L", ""), From be00b5897bf722068c60f5f422349cdeb536ba52 Mon Sep 17 00:00:00 2001 From: alfonsrv Date: Fri, 24 Sep 2021 12:15:02 +0200 Subject: [PATCH 06/11] sign_cert management command different expiring logic --- ca/django_ca/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ca/django_ca/utils.py b/ca/django_ca/utils.py index c0524e394..c50e4dc02 100644 --- a/ca/django_ca/utils.py +++ b/ca/django_ca/utils.py @@ -969,7 +969,7 @@ def parse_expires(expires: Expires = None) -> datetime: now = datetime.now(timezone.utc).replace(second=0, microsecond=0) - if not expires.tzinfo: + if hasattr(expires, 'tzinfo') and not expires.tzinfo: expires = expires.replace(tzinfo=get_current_timezone()) if isinstance(expires, int): return now + timedelta(days=expires) From bad161dacd9ef097b262285366497df9d220be56 Mon Sep 17 00:00:00 2001 From: alfonsrv Date: Sat, 25 Sep 2021 12:18:01 +0200 Subject: [PATCH 07/11] Fixing otherName UTF8 encoding --- ca/django_ca/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ca/django_ca/utils.py b/ca/django_ca/utils.py index c50e4dc02..f3fa25bdb 100644 --- a/ca/django_ca/utils.py +++ b/ca/django_ca/utils.py @@ -36,7 +36,7 @@ import idna -from asn1crypto.core import OctetString +from asn1crypto.core import OctetString, UTF8String from cryptography import x509 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes @@ -845,7 +845,7 @@ def parse_general_name(name: ParsableGeneralName) -> x509.GeneralName: if match is not None: oid, asn_typ, val = match.groups() if asn_typ == "UTF8": - parsed_value = val.encode("utf-8") + parsed_value = UTF8String(val) elif asn_typ == "OctetString": parsed_value = OctetString(bytes(bytearray.fromhex(val))).dump() else: From 082ccd3270c4b07110811e25a66c9462d61e0c06 Mon Sep 17 00:00:00 2001 From: alfonsrv <48770755+alfonsrv@users.noreply.github.com> Date: Sat, 25 Sep 2021 12:56:11 +0200 Subject: [PATCH 08/11] Add documentation on custom extensions --- docs/source/cli/certs.rst | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/source/cli/certs.rst b/docs/source/cli/certs.rst index 3c059e163..4089ab893 100644 --- a/docs/source/cli/certs.rst +++ b/docs/source/cli/certs.rst @@ -106,6 +106,25 @@ You can also disable adding the CommonName as ``subjectAltName``: ... this will only have "example.net" but not example.com as ``subjectAltName``. +Advanced subject alternative names +============================= + +`django-ca` supports storing custom OID fields in the Subject alternative name extension – e.g. Microsoft's +`User Principal Name` (`UPN`). + +To add a custom field you must specify the corresponding `OID`, `encoding` and `value`. + +Syntax: `otherName:;:` + +.. code-block:: console + + $ python manage.py sign_cert --subject /C=AT/.../CN=example.com --alt="otherName:1.3.6.1.4.1.311.20.2.3;UTF8:dummy@domain.tld" + +To easily dissect a reference certificate's custom SAN fields it is recommended to use a tool +like [ASN.1 Editor](https://www.codeproject.com/Articles/4910/ASN-1-Editor). + + + Using profiles ============== From 83619b2f00b07fd5ca3130337cdeddb01cdcd3f5 Mon Sep 17 00:00:00 2001 From: alfonsrv <48770755+alfonsrv@users.noreply.github.com> Date: Sun, 26 Sep 2021 13:56:27 +0200 Subject: [PATCH 09/11] Adding tests x minor code-base alignments --- ca/django_ca/tests/tests_utils.py | 12 ++++++++++-- ca/django_ca/utils.py | 5 ++--- ca/django_ca/widgets.py | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/ca/django_ca/tests/tests_utils.py b/ca/django_ca/tests/tests_utils.py index 42ca92a89..dc3b077a4 100644 --- a/ca/django_ca/tests/tests_utils.py +++ b/ca/django_ca/tests/tests_utils.py @@ -309,6 +309,14 @@ def test_multiple(self) -> None: """Test subject with multiple tokens.""" self.assertSubject("/C=AT/OU=foo/CN=example.com", [("C", "AT"), ("OU", "foo"), ("CN", "example.com")]) + def test_multiple_sorting(self) -> None: + """Test subject with multiple tokens of the same OID - especially focusing + on sorting here, keeping the original subject's ordering integrity """ + self.assertSubject( + "/CN=example.com/OU=foo/OU=bar/DC=domain/DC=tld", + [("DC", "tld"), ("DC", "domain"), ("OU", "bar"), ("OU", "foo"), ("CN", "example.com")] + ) + def test_case(self) -> None: """Test that case doesn't matter.""" self.assertSubject( @@ -563,8 +571,8 @@ def test_rid(self) -> None: def test_othername(self) -> None: """Test parsing an otherName name.""" self.assertEqual( - parse_general_name("otherName:2.5.4.3;UTF8:example.com"), - x509.OtherName(NameOID.COMMON_NAME, b"example.com"), + parse_general_name("otherName:2.5.4.3;UTF8:dummy@domain.tld"), + x509.OtherName(NameOID.COMMON_NAME, b'\x0c\x10dummy@domain.tld'), ) def test_unicode_domains(self) -> None: diff --git a/ca/django_ca/utils.py b/ca/django_ca/utils.py index f3fa25bdb..075ff7e52 100644 --- a/ca/django_ca/utils.py +++ b/ca/django_ca/utils.py @@ -50,7 +50,6 @@ from django.core.validators import URLValidator from django.utils.encoding import force_bytes from django.utils.encoding import force_str -from django.utils.timezone import get_current_timezone from django.utils.translation import gettext_lazy as _ from . import ca_settings @@ -845,7 +844,7 @@ def parse_general_name(name: ParsableGeneralName) -> x509.GeneralName: if match is not None: oid, asn_typ, val = match.groups() if asn_typ == "UTF8": - parsed_value = UTF8String(val) + parsed_value = UTF8String(val).dump() elif asn_typ == "OctetString": parsed_value = OctetString(bytes(bytearray.fromhex(val))).dump() else: @@ -970,7 +969,7 @@ def parse_expires(expires: Expires = None) -> datetime: now = datetime.now(timezone.utc).replace(second=0, microsecond=0) if hasattr(expires, 'tzinfo') and not expires.tzinfo: - expires = expires.replace(tzinfo=get_current_timezone()) + expires = expires.replace(tzinfo=timezone.utc) if isinstance(expires, int): return now + timedelta(days=expires) if isinstance(expires, timedelta): diff --git a/ca/django_ca/widgets.py b/ca/django_ca/widgets.py index 49e3e2b56..ee2ddb25d 100644 --- a/ca/django_ca/widgets.py +++ b/ca/django_ca/widgets.py @@ -120,7 +120,7 @@ def decompress( if value is None: # pragma: no cover return ["", "", "", "", "", ""] - # Multiple OUs are not supported in webinterface + # Multiple DCs are not supported in webinterface domain_component = value.get("DC", "") if isinstance(domain_component, list) and domain_component: domain_component = domain_component[0] From 0c9dec86defcf73eb7e9dbc2b2a324f1d45ccd79 Mon Sep 17 00:00:00 2001 From: alfonsrv <48770755+alfonsrv@users.noreply.github.com> Date: Mon, 27 Sep 2021 10:15:06 +0200 Subject: [PATCH 10/11] Migrations --- .../migrations/0027_auto_20210927_1012.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 ca/django_ca/migrations/0027_auto_20210927_1012.py diff --git a/ca/django_ca/migrations/0027_auto_20210927_1012.py b/ca/django_ca/migrations/0027_auto_20210927_1012.py new file mode 100644 index 000000000..b121294b5 --- /dev/null +++ b/ca/django_ca/migrations/0027_auto_20210927_1012.py @@ -0,0 +1,53 @@ +# Generated by Django 3.2.7 on 2021-09-27 08:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_ca', '0026_auto_20210501_1258'), + ] + + operations = [ + migrations.AlterField( + model_name='acmeaccount', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='acmeauthorization', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='acmecertificate', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='acmechallenge', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='acmeorder', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='certificate', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='certificateauthority', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='watcher', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ] From a0b8bc4cc3f38b0d11ef88fa8528748a91855d4b Mon Sep 17 00:00:00 2001 From: alfonsrv Date: Sun, 3 Oct 2021 13:15:04 +0200 Subject: [PATCH 11/11] Move DC above OU, docs header fix --- ca/django_ca/utils.py | 4 ++-- ca/django_ca/widgets.py | 4 ++-- docs/source/cli/certs.rst | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ca/django_ca/utils.py b/ca/django_ca/utils.py index c20031a02..897a001c2 100644 --- a/ca/django_ca/utils.py +++ b/ca/django_ca/utils.py @@ -66,11 +66,11 @@ # List of possible subject fields, in order SUBJECT_FIELDS = [ - "DC", "C", "ST", "L", "O", + "DC", "OU", "CN", "emailAddress", @@ -106,11 +106,11 @@ #: Map OID objects to IDs used in subject strings OID_NAME_MAPPINGS: Dict[x509.ObjectIdentifier, str] = { - NameOID.DOMAIN_COMPONENT: "DC", NameOID.COUNTRY_NAME: "C", NameOID.STATE_OR_PROVINCE_NAME: "ST", NameOID.LOCALITY_NAME: "L", NameOID.ORGANIZATION_NAME: "O", + NameOID.DOMAIN_COMPONENT: "DC", NameOID.ORGANIZATIONAL_UNIT_NAME: "OU", NameOID.COMMON_NAME: "CN", NameOID.EMAIL_ADDRESS: "emailAddress", diff --git a/ca/django_ca/widgets.py b/ca/django_ca/widgets.py index ee2ddb25d..320a02a0f 100644 --- a/ca/django_ca/widgets.py +++ b/ca/django_ca/widgets.py @@ -103,11 +103,11 @@ class SubjectWidget(CustomMultiWidget): def __init__(self, attrs: typing.Optional[typing.Dict[str, str]] = None) -> None: _widgets = ( - SubjectTextInput(label=_("Domain Component")), SubjectTextInput(label=_("Country"), attrs={"placeholder": "2 character country code"}), SubjectTextInput(label=_("State")), SubjectTextInput(label=_("Location")), SubjectTextInput(label=_("Organization")), + SubjectTextInput(label=_("Domain Component")), SubjectTextInput(label=_("Organizational Unit")), SubjectTextInput(label=_("CommonName"), attrs={"required": True}), SubjectTextInput(label=_("E-Mail")), @@ -131,11 +131,11 @@ def decompress( # Used e.g. for initial form data (e.g. resigning a cert) return [ - domain_component, value.get("C", ""), value.get("ST", ""), value.get("L", ""), value.get("O", ""), + domain_component, org_unit, value.get("CN", ""), value.get("emailAddress", ""), diff --git a/docs/source/cli/certs.rst b/docs/source/cli/certs.rst index 4089ab893..0229c9340 100644 --- a/docs/source/cli/certs.rst +++ b/docs/source/cli/certs.rst @@ -107,7 +107,7 @@ You can also disable adding the CommonName as ``subjectAltName``: ... this will only have "example.net" but not example.com as ``subjectAltName``. Advanced subject alternative names -============================= +================================== `django-ca` supports storing custom OID fields in the Subject alternative name extension – e.g. Microsoft's `User Principal Name` (`UPN`).