diff --git a/ca/django_ca/migration_helpers.py b/ca/django_ca/migration_helpers.py index a93f58394..4d46d1581 100644 --- a/ca/django_ca/migration_helpers.py +++ b/ca/django_ca/migration_helpers.py @@ -17,14 +17,29 @@ that they are tested properly. """ +import shlex import typing import warnings +from collections.abc import Iterator from typing import Optional from cryptography import x509 from cryptography.x509.oid import AuthorityInformationAccessOID, ExtensionOID -from django_ca.utils import format_general_name, parse_general_name, split_str +from django_ca.utils import format_general_name, parse_general_name + + +def split_str(val: str, sep: str) -> Iterator[str]: + """Split a character on the given set of characters. + + This function was originally in ``django_ca.utils`` but has since been deprecated/removed. We keep a copy + here so that the function keeps working. + """ + lex = shlex.shlex(val, posix=True) + lex.commenters = "" + lex.whitespace = sep + lex.whitespace_split = True + yield from lex class Migration0040Helper: diff --git a/ca/django_ca/tests/test_utils.py b/ca/django_ca/tests/test_utils.py index 2f781b667..c4c104409 100644 --- a/ca/django_ca/tests/test_utils.py +++ b/ca/django_ca/tests/test_utils.py @@ -17,7 +17,6 @@ import itertools import os import typing -import unittest from collections.abc import Iterable from datetime import datetime, timedelta, timezone as tz from pathlib import Path @@ -37,7 +36,7 @@ from django_ca import utils from django_ca.conf import model_settings -from django_ca.tests.base.constants import CRYPTOGRAPHY_VERSION +from django_ca.tests.base.assertions import assert_removed_in_230 from django_ca.tests.base.doctest import doctest_module from django_ca.tests.base.utils import cn, country, dns from django_ca.typehints import SerializedObjectIdentifier @@ -98,7 +97,8 @@ def test_parse_serialized_name_attributes( serialized: list[SerializedObjectIdentifier] = [ {"oid": attr[0].dotted_string, "value": attr[1]} for attr in attributes ] - assert parse_serialized_name_attributes(serialized) == expected + with assert_removed_in_230(): + assert parse_serialized_name_attributes(serialized) == expected class GeneratePrivateKeyTestCase(TestCase): @@ -147,19 +147,23 @@ class SerializeName(TestCase): def test_name(self) -> None: """Test passing a standard Name.""" - assert serialize_name(x509.Name([cn("example.com")])) == [{"oid": "2.5.4.3", "value": "example.com"}] - assert serialize_name(x509.Name([country("AT"), cn("example.com")])) == [ - {"oid": "2.5.4.6", "value": "AT"}, - {"oid": "2.5.4.3", "value": "example.com"}, - ] + with assert_removed_in_230(): + assert serialize_name(x509.Name([cn("example.com")])) == [ + {"oid": "2.5.4.3", "value": "example.com"} + ] + with assert_removed_in_230(): + assert serialize_name(x509.Name([country("AT"), cn("example.com")])) == [ + {"oid": "2.5.4.6", "value": "AT"}, + {"oid": "2.5.4.3", "value": "example.com"}, + ] - @unittest.skipIf(CRYPTOGRAPHY_VERSION < (37, 0), "cg<36 does not yet have bytes.") def test_bytes(self) -> None: """Test names with byte values - probably never happens.""" name = x509.Name( [x509.NameAttribute(NameOID.X500_UNIQUE_IDENTIFIER, b"example.com", _type=_ASN1Type.BitString)] ) - assert serialize_name(name) == [{"oid": "2.5.4.45", "value": "65:78:61:6D:70:6C:65:2E:63:6F:6D"}] + with assert_removed_in_230(): + assert serialize_name(name) == [{"oid": "2.5.4.45", "value": "65:78:61:6D:70:6C:65:2E:63:6F:6D"}] @pytest.mark.parametrize( @@ -354,13 +358,20 @@ def test_str(self) -> None: ("CN", "example.com"), ("emailAddress", "user@example.com"), ] - assert x509_name(subject) == self.name + with assert_removed_in_230(): + assert x509_name(subject) == self.name def test_multiple_other(self) -> None: """Test multiple other tokens (only OUs work).""" - with pytest.raises(ValueError, match='^Subject contains multiple "countryName" fields$'): + with ( + assert_removed_in_230(), + pytest.raises(ValueError, match='^Subject contains multiple "countryName" fields$'), + ): x509_name([("C", "AT"), ("C", "DE")]) - with pytest.raises(ValueError, match='^Subject contains multiple "commonName" fields$'): + with ( + assert_removed_in_230(), + pytest.raises(ValueError, match='^Subject contains multiple "commonName" fields$'), + ): x509_name([("CN", "AT"), ("CN", "FOO")]) diff --git a/ca/django_ca/tests/utils/test_name_for_display.py b/ca/django_ca/tests/utils/test_name_for_display.py new file mode 100644 index 000000000..a52b094ba --- /dev/null +++ b/ca/django_ca/tests/utils/test_name_for_display.py @@ -0,0 +1,48 @@ +# This file is part of django-ca (https://github.com/mathiasertl/django-ca). +# +# django-ca is free software: you can redistribute it and/or modify it under the terms of the GNU General +# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# django-ca is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the +# implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License along with django-ca. If not, see +# . + +"""Test ``django_ca.utils.name_for_display``.""" + +from cryptography import x509 +from cryptography.x509 import NameOID +from cryptography.x509.name import _ASN1Type + +import pytest + +from django_ca.tests.base.utils import cn +from django_ca.utils import name_for_display + + +@pytest.mark.parametrize( + ("value", "expected"), + ( + (x509.Name([cn("example.com")]), [("commonName (CN)", "example.com")]), + ( + x509.Name([cn("example.net"), cn("example.com")]), + [("commonName (CN)", "example.net"), ("commonName (CN)", "example.com")], + ), + ( + x509.Name( + [ + x509.NameAttribute( + oid=NameOID.X500_UNIQUE_IDENTIFIER, value=b"example.com", _type=_ASN1Type.BitString + ) + ] + ), + [("x500UniqueIdentifier", "65:78:61:6D:70:6C:65:2E:63:6F:6D")], + ), + ), +) +def test_name_for_display(value: x509.Name, expected: list[tuple[str, str]]) -> None: + """Test the function.""" + assert name_for_display(value) == expected diff --git a/ca/django_ca/tests/utils/test_parse_name_x509.py b/ca/django_ca/tests/utils/test_parse_name_x509.py index dd6353dc3..4f1f5fc49 100644 --- a/ca/django_ca/tests/utils/test_parse_name_x509.py +++ b/ca/django_ca/tests/utils/test_parse_name_x509.py @@ -18,6 +18,7 @@ import pytest +from django_ca.tests.base.assertions import assert_removed_in_230 from django_ca.utils import parse_name_x509 @@ -152,10 +153,11 @@ ) def test_parse_name_x509(value: str, expected: list[tuple[x509.ObjectIdentifier, str]]) -> None: """Some basic tests.""" - assert parse_name_x509(value) == tuple(x509.NameAttribute(oid, value) for oid, value in expected) + with assert_removed_in_230(): + assert parse_name_x509(value) == tuple(x509.NameAttribute(oid, value) for oid, value in expected) def test_unknown() -> None: """Test unknown field.""" - with pytest.raises(ValueError, match=r"^Unknown x509 name field: ABC$"): + with assert_removed_in_230(), pytest.raises(ValueError, match=r"^Unknown x509 name field: ABC$"): parse_name_x509("/ABC=example.com") diff --git a/ca/django_ca/tests/utils/test_split_str.py b/ca/django_ca/tests/utils/test_split_str.py index ed4dcecc0..eaf7c09da 100644 --- a/ca/django_ca/tests/utils/test_split_str.py +++ b/ca/django_ca/tests/utils/test_split_str.py @@ -15,6 +15,7 @@ import pytest +from django_ca.tests.base.assertions import assert_removed_in_230 from django_ca.utils import split_str @@ -85,7 +86,8 @@ ) def test_basic(value: str, seperator: str, expected: list[str]) -> None: """Some basic split_str() test cases.""" - assert list(split_str(value, seperator)) == expected + with assert_removed_in_230(): + assert list(split_str(value, seperator)) == expected @pytest.mark.parametrize( @@ -100,5 +102,5 @@ def test_basic(value: str, seperator: str, expected: list[str]) -> None: ) def test_quotation_errors(value: str, match: str) -> None: """Test quoting.""" - with pytest.raises(ValueError, match=match): + with assert_removed_in_230(), pytest.raises(ValueError, match=match): list(split_str(value, "/")) diff --git a/ca/django_ca/typehints.py b/ca/django_ca/typehints.py index 0caa2d2e2..39e10483a 100644 --- a/ca/django_ca/typehints.py +++ b/ca/django_ca/typehints.py @@ -73,7 +73,7 @@ class OCSPKeyBackendDict(TypedDict): hashes.SHA3_512, ] -ParsableName = Union[str, Iterable[tuple[str, str]]] +ParsableName = Union[str, Iterable[tuple[str, str]]] # TODO: remove? ParsableKeyType = Literal["RSA", "DSA", "EC", "Ed25519", "Ed448"] ParsableSubject = Union[ diff --git a/ca/django_ca/utils.py b/ca/django_ca/utils.py index be5e8ef65..3dc7424df 100644 --- a/ca/django_ca/utils.py +++ b/ca/django_ca/utils.py @@ -39,19 +39,14 @@ from django_ca import constants from django_ca.conf import model_settings from django_ca.constants import MULTIPLE_OIDS, NAME_OID_DISPLAY_NAMES +from django_ca.deprecation import RemovedInDjangoCA230Warning, deprecate_function from django_ca.pydantic.validators import ( dns_validator, email_validator, is_power_two_validator, url_validator, ) -from django_ca.typehints import ( - AllowedHashTypes, - ParsableGeneralName, - ParsableKeyType, - ParsableName, - SerializedName, -) +from django_ca.typehints import AllowedHashTypes, ParsableGeneralName, ParsableKeyType, SerializedName #: Regular expression to match general names. GENERAL_NAME_RE = re.compile("^(email|URI|IP|DNS|RID|dirName|otherName):(.*)", flags=re.I) @@ -130,23 +125,18 @@ def _serialize_name_attribute_value(name_attribute: x509.NameAttribute) -> str: return name_attribute.value +@deprecate_function(RemovedInDjangoCA230Warning) def serialize_name(name: Union[x509.Name, x509.RelativeDistinguishedName]) -> SerializedName: """Serialize a :py:class:`~cg:cryptography.x509.Name`. + .. deprecated:: 2.2.0 + + This function is deprecated and will be removed in ``django-ca==2.3.0``. Use Pydantic models instead. + The value also accepts a :py:class:`~cg:cryptography.x509.RelativeDistinguishedName`. The returned value is a list of tuples, each consisting of two strings. If an attribute contains ``bytes``, it is converted using :py:func:`~django_ca.utils.bytes_to_hex`. - - Examples:: - - >>> serialize_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'example.com')])) - [{'oid': '2.5.4.3', 'value': 'example.com'}] - >>> serialize_name(x509.RelativeDistinguishedName([ - ... x509.NameAttribute(NameOID.COUNTRY_NAME, 'AT'), - ... x509.NameAttribute(NameOID.COMMON_NAME, 'example.com'), - ... ])) - [{'oid': '2.5.4.6', 'value': 'AT'}, {'oid': '2.5.4.3', 'value': 'example.com'}] """ return [{"oid": attr.oid.dotted_string, "value": _serialize_name_attribute_value(attr)} for attr in name] @@ -169,16 +159,18 @@ def name_for_display(name: Union[x509.Name, x509.RelativeDistinguishedName]) -> ] +@deprecate_function(RemovedInDjangoCA230Warning) def parse_serialized_name_attributes(name: SerializedName) -> list[x509.NameAttribute]: """Parse a serialized list of name attributes into a list of NameAttributes. + .. deprecated:: 2.2.0 + + This function is deprecated and will be removed in ``django-ca==2.3.0``. Use Pydantic models instead. + This function takes care of parsing hex-encoded byte values name attributes that are known to use bytes (currently only :py:attr:`NameOID.X500_UNIQUE_IDENTIFIER `). - >>> parse_serialized_name_attributes([{"oid": "2.5.4.3", "value": "example.com"}]) - [, value='example.com')>] - This function is more or less the inverse of :py:func:`~django_ca.utils.serialize_name`, except that it returns a list of :py:class:`~cg:cryptography.x509.NameAttribute` instances (``serialize_name()`` takes a :py:class:`~cg:cryptography.x509.Name` or :py:class:`~cg:cryptography.x509.RelativeDistinguishedName`) @@ -308,10 +300,14 @@ def sanitize_serial(value: str) -> str: return serial -# @deprecate_function(RemovedInDjangoCA200Warning) -def parse_name_x509(name: ParsableName) -> tuple[x509.NameAttribute, ...]: +@deprecate_function(RemovedInDjangoCA230Warning) +def parse_name_x509(name: Union[str, Iterable[tuple[str, str]]]) -> tuple[x509.NameAttribute, ...]: """Parses a subject string as used in OpenSSLs command line utilities. + .. deprecated:: 2.2.0 + + This function is deprecated and will be removed in ``django-ca==2.3.0``. Use Pydantic models instead. + .. versionchanged:: 1.20.0 This function no longer returns the subject in pseudo-sorted order. @@ -320,16 +316,6 @@ def parse_name_x509(name: ParsableName) -> tuple[x509.NameAttribute, ...]: ``/C=AT/L=Vienna/CN=example.com/emailAddress=user@example.com``. The function does its best to be lenient on deviations from the format, object identifiers are case-insensitive, whitespace at the start and end is stripped and the subject does not have to start with a slash (``/``). - - >>> parse_name_x509([("CN", "example.com")]) - (, value='example.com')>,) - >>> parse_name_x509( - ... [("c", "AT"), ("l", "Vienna"), ("o", "quoting/works"), ("CN", "example.com")] - ... ) # doctest: +NORMALIZE_WHITESPACE - (, value='AT')>, - , value='Vienna')>, - , value='quoting/works')>, - , value='example.com')>) """ if isinstance(name, str): # TYPE NOTE: mypy detects t.split() as Tuple[str, ...] and does not recognize the maxsplit parameter @@ -344,12 +330,13 @@ def parse_name_x509(name: ParsableName) -> tuple[x509.NameAttribute, ...]: return tuple(x509.NameAttribute(oid, value) for oid, value in items) -# @deprecate_function(RemovedInDjangoCA200Warning) -def x509_name(name: ParsableName) -> x509.Name: +@deprecate_function(RemovedInDjangoCA230Warning) +def x509_name(name: Union[str, Iterable[tuple[str, str]]]) -> x509.Name: """Parses a string or iterable of two-tuples into a :py:class:`x509.Name `. - >>> x509_name([('C', 'AT'), ('CN', 'example.com')]) - + .. deprecated:: 2.2.0 + + This function is deprecated and will be removed in ``django-ca==2.3.0``. Use Pydantic models instead. """ return check_name(x509.Name(parse_name_x509(name))) @@ -938,6 +925,7 @@ def read_file(path: str) -> bytes: stream.close() +@deprecate_function(RemovedInDjangoCA230Warning) def split_str(val: str, sep: str) -> Iterator[str]: """Split a character on the given set of characters.""" lex = shlex.shlex(val, posix=True) diff --git a/devscripts/recreate_fixtures_helpers.py b/devscripts/recreate_fixtures_helpers.py index 6d784d016..d4e888505 100644 --- a/devscripts/recreate_fixtures_helpers.py +++ b/devscripts/recreate_fixtures_helpers.py @@ -51,6 +51,7 @@ ) from django_ca.models import Certificate, CertificateAuthority from django_ca.profiles import profiles +from django_ca.pydantic import NameModel from django_ca.pydantic.extensions import ( EXTENSION_MODELS, AuthorityInformationAccessModel, @@ -60,7 +61,7 @@ ) from django_ca.tests.base.typehints import CertFixtureData, OcspFixtureData from django_ca.typehints import ParsableKeyType -from django_ca.utils import bytes_to_hex, parse_serialized_name_attributes, serialize_name +from django_ca.utils import bytes_to_hex DEFAULT_KEY_SIZE = 2048 # Size for private keys TIMEFORMAT = "%Y-%m-%d %H:%M:%S" @@ -174,7 +175,7 @@ def _copy_cert(dest: Path, cert: Certificate, data: CertFixtureData, key_path: P with open(dest / data["pub_filename"], "wb") as stream: stream.write(cert.pub.der) - data["subject"] = serialize_name(cert.subject) + data["subject"] = [{"oid": attr.oid.dotted_string, "value": attr.value} for attr in cert.subject] data["parsed_cert"] = cert _update_cert_data(cert, data) @@ -196,7 +197,7 @@ def _update_contrib( "key_filename": False, "csr_filename": False, "serial": cert.serial, - "subject": serialize_name(cert.subject), + "subject": [{"oid": attr.oid.dotted_string, "value": attr.value} for attr in cert.subject], "md5": cert.get_fingerprint(hashes.MD5()), "sha1": cert.get_fingerprint(hashes.SHA1()), "sha256": cert.get_fingerprint(hashes.SHA256()), @@ -295,7 +296,7 @@ def create_cas(dest: Path, now: datetime, delay: bool, data: CertFixtureData) -> data[name]["name"], key_backend, key_backend_options, - subject=x509.Name(parse_serialized_name_attributes(data[name]["subject"])), + subject=NameModel.model_validate(data[name]["subject"]).cryptography, expires=datetime.now(tz=tz.utc) + data[name]["not_after"], key_type=data[name]["key_type"], algorithm=data[name].get("algorithm"), @@ -322,7 +323,7 @@ def create_certs( name = f"{ca.name}-cert" key_path = Path(os.path.join(settings.CA_DIR, f"{name}.key")) csr_path = Path(os.path.join(settings.CA_DIR, f"{name}.csr")) - csr_subject = x509.Name(parse_serialized_name_attributes(data[name]["csr_subject"])) + csr_subject = NameModel.model_validate(data[name]["csr_subject"]).cryptography csr = _create_csr( key_path, csr_path, @@ -355,7 +356,7 @@ def create_certs( key_path = Path(os.path.join(settings.CA_DIR, f"{name}.key")) csr_path = Path(os.path.join(settings.CA_DIR, f"{name}.csr")) - csr_subject = x509.Name(parse_serialized_name_attributes(data[name]["csr_subject"])) + csr_subject = NameModel.model_validate(data[name]["csr_subject"]).cryptography csr = _create_csr( key_path, csr_path, @@ -393,7 +394,7 @@ def create_special_certs( # noqa: PLR0915 ca = CertificateAuthority.objects.get(name=data[name]["ca"]) key_path = Path(os.path.join(settings.CA_DIR, f"{name}.key")) csr_path = Path(os.path.join(settings.CA_DIR, f"{name}.csr")) - csr_subject = x509.Name(parse_serialized_name_attributes(data[name]["csr_subject"])) + csr_subject = NameModel.model_validate(data[name]["csr_subject"]).cryptography csr = _create_csr(key_path, csr_path, subject=csr_subject) freeze_now = now @@ -402,7 +403,7 @@ def create_special_certs( # noqa: PLR0915 with freeze_time(freeze_now): no_ext_now = datetime.now(tz=tz.utc).replace(tzinfo=None) pwd = data[ca.name].get("password") - subject = x509.Name(parse_serialized_name_attributes(data[name]["subject"])) + subject = NameModel.model_validate(data[name]["subject"]).cryptography builder = x509.CertificateBuilder() builder = builder.not_valid_before(no_ext_now) @@ -435,7 +436,7 @@ def create_special_certs( # noqa: PLR0915 ca = CertificateAuthority.objects.get(name=data[name]["ca"]) key_path = Path(os.path.join(settings.CA_DIR, f"{name}.key")) csr_path = Path(os.path.join(settings.CA_DIR, f"{name}.csr")) - csr_subject = x509.Name(parse_serialized_name_attributes(data[name]["csr_subject"])) + csr_subject = NameModel.model_validate(data[name]["csr_subject"]).cryptography csr = _create_csr(key_path, csr_path, subject=csr_subject) with freeze_time(now + data[name]["delta"]): @@ -445,7 +446,7 @@ def create_special_certs( # noqa: PLR0915 csr=csr, profile=profiles["webserver"], algorithm=data[name].get("algorithm"), - subject=x509.Name(parse_serialized_name_attributes(data[name]["subject"])), + subject=NameModel.model_validate(data[name]["subject"]).cryptography, expires=data[name]["not_after"], extensions=data[name]["extensions"].values(), ) @@ -457,7 +458,7 @@ def create_special_certs( # noqa: PLR0915 ca = CertificateAuthority.objects.get(name=data[name]["ca"]) key_path = Path(os.path.join(settings.CA_DIR, f"{name}.key")) csr_path = Path(os.path.join(settings.CA_DIR, f"{name}.csr")) - csr_subject = x509.Name(parse_serialized_name_attributes(data[name]["csr_subject"])) + csr_subject = NameModel.model_validate(data[name]["csr_subject"]).cryptography csr = _create_csr(key_path, csr_path, subject=csr_subject) with freeze_time(now + data[name]["delta"]): @@ -467,7 +468,7 @@ def create_special_certs( # noqa: PLR0915 csr=csr, profile=profiles["webserver"], algorithm=data[name].get("algorithm"), - subject=x509.Name(parse_serialized_name_attributes(data[name]["subject"])), + subject=NameModel.model_validate(data[name]["subject"]).cryptography, expires=data[name]["not_after"], extensions=data[name]["extensions"].values(), ) @@ -478,7 +479,7 @@ def create_special_certs( # noqa: PLR0915 ca = CertificateAuthority.objects.get(name=data[name]["ca"]) key_path = Path(os.path.join(settings.CA_DIR, f"{name}.key")) csr_path = Path(os.path.join(settings.CA_DIR, f"{name}.csr")) - csr_subject = x509.Name(parse_serialized_name_attributes(data[name]["csr_subject"])) + csr_subject = NameModel.model_validate(data[name]["csr_subject"]).cryptography csr = _create_csr(key_path, csr_path, subject=csr_subject) freeze_now = now diff --git a/docs/source/changelog/TBR_2.2.0.rst b/docs/source/changelog/TBR_2.2.0.rst index 516518667..03dec1684 100644 --- a/docs/source/changelog/TBR_2.2.0.rst +++ b/docs/source/changelog/TBR_2.2.0.rst @@ -36,3 +36,11 @@ Deprecation notices * ``django_ca.extensions.parse_extension()`` is deprecated and will be removed in ``django-ca==2.3.0``. Use :doc:`Pydantic models ` instead. +* Functions related to the old OpenSSL style subject format are deprecated and will be removed in + ``django_ca==2.3.0``: + + * ``django_ca.utils.parse_name_x509()`` + * ``django_ca.utils.parse_serialized_name_attributes()`` + * ``django_ca.utils.serialize_name()`` + * ``django_ca.utils.split_str()`` + * ``django_ca.utils.x509_name()`` diff --git a/docs/source/python/intro.rst b/docs/source/python/intro.rst index cda575a31..b4f6fb880 100644 --- a/docs/source/python/intro.rst +++ b/docs/source/python/intro.rst @@ -49,7 +49,6 @@ creates a minimal CA using the file system storage backend:: ... StoragesUsePrivateKeyOptions, ... ) >>> from django_ca.models import CertificateAuthority - >>> from django_ca.utils import x509_name >>> key_backend = key_backends["default"] >>> key_backend_options = StoragesCreatePrivateKeyOptions( ... key_type="RSA", key_size=1024, password=None, path="ca" @@ -80,7 +79,6 @@ Django model:: Much like with certificate authorities, creating a new certificate requires a manager method, :py:func:`Certificate.objects.create_cert() `:: - >>> from django_ca.utils import x509_name >>> Certificate.objects.create_cert( ... ca, ... StoragesUsePrivateKeyOptions(password=None),