Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow using DOMAIN COMPONENT for AD DS certificates #75

Closed
wants to merge 13 commits into from
1 change: 1 addition & 0 deletions ca/django_ca/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ca/django_ca/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,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)
mathiasertl marked this conversation as resolved.
Show resolved Hide resolved
subject_alternative_name = SubjectAltNameField(
label="subjectAltName",
required=False,
Expand Down
53 changes: 53 additions & 0 deletions ca/django_ca/migrations/0027_auto_20210927_1012.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
12 changes: 10 additions & 2 deletions ca/django_ca/tests/tests_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,14 @@ def test_multiple(self) -> None:
[("C", "AT"), ("OU", "foo"), ("OU", "bar"), ("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(
Expand Down Expand Up @@ -573,8 +581,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:
Expand Down
21 changes: 15 additions & 6 deletions ca/django_ca/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,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
Expand All @@ -34,7 +35,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
Expand Down Expand Up @@ -69,6 +70,7 @@
"ST",
"L",
"O",
"DC",
"OU",
"CN",
"emailAddress",
Expand Down Expand Up @@ -108,6 +110,7 @@
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",
Expand All @@ -124,6 +127,7 @@

# Some OIDs can occur multiple times
MULTIPLE_OIDS = (
NameOID.DOMAIN_COMPONENT,
NameOID.ORGANIZATIONAL_UNIT_NAME,
NameOID.STREET_ADDRESS,
)
Expand Down Expand Up @@ -209,8 +213,11 @@ 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."""
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]))
Copy link
Owner

Choose a reason for hiding this comment

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

This function here: I think I understand what your updated comment means to express, but the code is far from obvious. And I'm frankly surprised that the old code does not respect sort stability.

  1. I think subject[::-1] is just reversing the list. At least a simple test did that. If yes, then use the more obvious reversed() instead.
  2. What does that if check do?
  3. Definitely add tests for an updated version of this function.

Copy link
Contributor Author

@alfonsrv alfonsrv Sep 26, 2021

Choose a reason for hiding this comment

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

See comment below. I'm not sure how it does its magic exactly anymore, I just know it reliably yields the desired results.

Exemplary test cases as mentioned below:

>>> subject = [('CN', 'John Doe'), ('OU', 'Users'), ('DC', 'contoso'), ('DC', 'loc')] 
>>> sort_name(subject)
[('DC', 'loc'), ('DC', 'contoso'), ('OU', 'Users'), ('CN', 'John Doe')]

>>> subject = [('CN', 'John Doe'), ('OU', 'Marketing'), ('OU', 'Users'), ('OU', 'OtherUnit'), ('DC', 'contoso'), ('DC', 'loc')]
>>> sort_name(subject)
[('DC', 'loc'), ('DC', 'contoso'), ('OU', 'OtherUnit'), ('OU', 'Users'), ('OU', 'Marketing'), ('CN', 'John Doe')]

>>> subject = [('DC', 'loc'), ('DC', 'contoso'), ('DC', 'ad'), ('OU', 'Users'), ('CN', 'John Doe')]
>>> sort_name(subject)
[('DC', 'loc'), ('DC', 'contoso'), ('DC', 'ad'), ('OU', 'Users'), ('CN', 'John Doe')]

Let me know if you feel comfortable with the sort function so we can move forward. Halting any further effort until then.

Copy link
Owner

@mathiasertl mathiasertl Sep 28, 2021

Choose a reason for hiding this comment

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

Now that's behavior that I don't understand: In testcase two, loc is the last DC in the input and ends up as first element.

In the third test case, loc is the first DC, and still ends up as last. Is that supposed to be?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes. In the first and second test case, the user input the subject with CN being the first element – this is a classic case of assuming the hierarchy goes from left-to-right with CN being the lowest weighted element.

In test case three, we're looking at the opposite. The user's hierarchical input is correct as per X509 with CN being the last element.

In all three test cases the user respect the general hierarchical-structure, albeit in a reversed manner.



def encode_url(url: str) -> str:
Expand Down Expand Up @@ -859,7 +866,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).dump()
elif asn_typ == "OctetString":
parsed_value = OctetString(bytes(bytearray.fromhex(val))).dump()
else:
Expand Down Expand Up @@ -981,8 +988,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 hasattr(expires, 'tzinfo') and not expires.tzinfo:
expires = expires.replace(tzinfo=timezone.utc)
if isinstance(expires, int):
return now + timedelta(days=expires)
if isinstance(expires, timedelta):
Expand Down Expand Up @@ -1062,7 +1071,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.
Expand Down
8 changes: 7 additions & 1 deletion ca/django_ca/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ def __init__(self, attrs: typing.Optional[typing.Dict[str, str]] = None) -> None
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")),
Expand All @@ -118,7 +119,11 @@ def decompress(
) -> typing.List[typing.Union[str, typing.List[str]]]:
if value is None: # pragma: no cover
return ["", "", "", "", "", ""]


# 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]
# Multiple OUs are not supported in webinterface
org_unit = value.get("OU", "")
if isinstance(org_unit, list) and org_unit:
Expand All @@ -130,6 +135,7 @@ def decompress(
value.get("ST", ""),
value.get("L", ""),
value.get("O", ""),
domain_component,
org_unit,
value.get("CN", ""),
value.get("emailAddress", ""),
Expand Down
19 changes: 19 additions & 0 deletions docs/source/cli/certs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:<oid>;<encoding>:<value>`

.. 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
==============

Expand Down