Source code for django_ca.pydantic.general_name

# 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
# <http://www.gnu.org/licenses/>.

"""Model for GeneralName subclasses."""

import binascii
import ipaddress
import typing
from datetime import datetime
from ipaddress import ip_address, ip_network
from typing import Any, List, Optional, Union

import idna
from pydantic import BeforeValidator, Discriminator, Tag, TypeAdapter, model_validator

import asn1crypto.core
from cryptography import x509

from django_ca import constants
from django_ca.pydantic import validators
from django_ca.pydantic.base import CryptographyModel
from django_ca.pydantic.name import NameModel
from django_ca.pydantic.type_aliases import OIDType
from django_ca.typehints import Annotated, GeneralNames, IPAddressType, OtherNames
from django_ca.utils import encode_dns, encode_url, validate_email

ip_address_classes = (
    ipaddress.IPv4Address,
    ipaddress.IPv6Address,
    ipaddress.IPv4Network,
    ipaddress.IPv6Network,
)


def general_name_discriminator(value: Any) -> Optional[str]:
    """Decide on the discriminated type for a GeneralName value."""
    if isinstance(value, ip_address_classes):
        return "ipaddress"
    if isinstance(value, str):
        return "str"
    if isinstance(value, (list, NameModel)):
        return "name"
    if isinstance(value, (dict, OtherNameModel)):
        return "othername"
    return "str"


def other_name_type_aliases(value: Any) -> Any:
    """Validator to convert OtherName aliases."""
    return constants.OTHER_NAME_ALIASES.get(value, value)


[docs] class OtherNameModel(CryptographyModel[x509.OtherName]): """Pydantic model wrapping :py:class:`~cg:cryptography.x509.OtherName`. The `oid` argument may be any valid object identifier as dotted string (e.g. ``"1.2.3"``). The `type` argument may be any type in :py:attr:`~django_ca.constants.OTHER_NAME_TYPES` or :py:class:`~django_ca.constants.OTHER_NAME_ALIASES`. The type of the `value` argument depends on the `type` value. String variants (``UTFString``, etc.) require a ``str``, boolean requires a ``bool`` value and so on: >>> OtherNameModel(oid="1.2.3", type="BOOLEAN", value=True) OtherNameModel(oid='1.2.3', type='BOOLEAN', value=True) >>> OtherNameModel(oid="1.2.3", type="NULL", value=None) OtherNameModel(oid='1.2.3', type='NULL', value=None) For datetime variants (``UTCTIME`` and ``GENERALIZEDTIME``), you must pass a timezone-aware object: >>> from datetime import timezone >>> dt = datetime(2021, 10, 5, 22, 1, 4, tzinfo=timezone.utc) >>> OtherNameModel(oid="1.2.3", type="UTCTIME", value=dt) # doctest: +NORMALIZE_WHITESPACE OtherNameModel(oid='1.2.3', type='UTCTIME', value=datetime.datetime(2021, 10, 5, 22, 1, 4, tzinfo=datetime.timezone.utc)) >>> OtherNameModel(oid="1.2.3", type="GENERALIZEDTIME", value=dt) # doctest: +NORMALIZE_WHITESPACE OtherNameModel(oid='1.2.3', type='GENERALIZEDTIME', value=datetime.datetime(2021, 10, 5, 22, 1, 4, tzinfo=datetime.timezone.utc)) For ``INTEGER``, you can pass an ``int`` or a ``str`` for a base 16 integer: >>> OtherNameModel(oid="1.2.3", type="INTEGER", value=12) OtherNameModel(oid='1.2.3', type='INTEGER', value=12) >>> OtherNameModel(oid="1.2.3", type="INTEGER", value="0x123") # 0x123 is 291 in decimal OtherNameModel(oid='1.2.3', type='INTEGER', value=291) Finally, for an ``OctetString``, pass the raw bytes or as a hex-encoded string: >>> OtherNameModel(oid="1.2.3", type="OctetString", value=b"\\x61\\x62\\x63") OtherNameModel(oid='1.2.3', type='OctetString', value='616263') >>> OtherNameModel(oid="1.2.3", type="OctetString", value="09CFF1A") OtherNameModel(oid='1.2.3', type='OctetString', value='09CFF1A') As usual, the ``cryptography`` property will return the cryptography variant of the model: >>> OtherNameModel(oid="1.2.3", type="IA5STRING", value="some string").cryptography <OtherName(type_id=<ObjectIdentifier(oid=1.2.3, name=Unknown OID)>, value=b'\\x16\\x0bsome string')> """ oid: OIDType type: Annotated[OtherNames, BeforeValidator(other_name_type_aliases)] value: Optional[Union[str, bool, datetime, int]] @classmethod def _parse_bytes(cls, value: bytes) -> str: return binascii.hexlify(value).upper().decode("ascii")
[docs] @model_validator(mode="before") @classmethod def parse_cryptography(cls, data: Any) -> Any: """Parse cryptography instances.""" if isinstance(data, x509.OtherName): try: value = asn1crypto.core.load(data.value) except ValueError as ex: raise ValueError(f"could not parse asn1 data: {ex}") from ex if name_type := constants.OTHER_NAME_NAMES.get(type(value)): name_value = value.native if isinstance(value, asn1crypto.core.OctetString): name_value = cls._parse_bytes(name_value) return {"oid": data.type_id.dotted_string, "type": name_type, "value": name_value} raise ValueError(f"{value.tag}: Unknown otherName type found.") if isinstance(data, dict) and data.get("type") == "OctetString": if isinstance(data.get("value"), bytes): data["value"] = cls._parse_bytes(data["value"]) return data
[docs] @model_validator(mode="after") def check_consistency(self) -> "OtherNameModel": """Validator to check that the `type` matches the type of `value`.""" if self.type in ("UTF8String", "UNIVERSALSTRING", "IA5STRING") and not isinstance(self.value, str): raise ValueError(f"{self.type}: Value must be a str object.") if self.type == "BOOLEAN" and not isinstance(self.value, bool): raise ValueError(f"{self.type}: Value must be a boolean.") if self.type in ("UTCTIME", "GENERALIZEDTIME") and not isinstance(self.value, datetime): raise ValueError(f"{self.type}: Value must be a datetime object.") if self.type == "INTEGER": if isinstance(self.value, str): if self.value.startswith("0x"): self.value = int(self.value, 16) else: try: self.value = int(self.value) except ValueError: pass if not isinstance(self.value, int): raise ValueError(f"{self.type}: Value must be an int.") if self.type == "NULL" and self.value is not None: raise ValueError(f"{self.type}: Value must be None.") if self.type == "OctetString" and not isinstance(self.value, str): raise ValueError(f"{self.type}: Value must be a str object.") return self
@property def cryptography(self) -> x509.OtherName: """Convert to a :py:class:`~cg:cryptography.x509.OtherName` instance.""" if self.type == "OctetString": hex_value = typing.cast(str, self.value) # asserted by the validator value = asn1crypto.core.OctetString(bytes(bytearray.fromhex(hex_value))).dump() elif asn1_cls := constants.OTHER_NAME_TYPES.get(self.type): value = asn1_cls(self.value).dump() else: # pragma: no cover # we cover all cases raise ValueError(f"{self.type}: Unknown type") return x509.OtherName(type_id=x509.ObjectIdentifier(self.oid), value=value)
[docs] class GeneralNameModel(CryptographyModel[x509.GeneralName]): """Pydantic model wrapping :py:class:`~cg:cryptography.x509.NameAttribute`. This model takes a `type` named in :py:attr:`~django_ca.constants.GENERAL_NAME_TYPES` and a `value` that is usually a ``str``: >>> GeneralNameModel(type="DNS", value="example.com") GeneralNameModel(type='DNS', value='example.com') For directory names, you have to pass a :py:class:`~django_ca.pydantic.name.NameModel` instead: >>> GeneralNameModel( ... type="dirName", value=[{"oid": "2.5.4.3", "value": "example.com"}] ... ) # doctest: +NORMALIZE_WHITESPACE GeneralNameModel(type='dirName', value=NameModel(root=[NameAttributeModel(oid='2.5.4.3', value='example.com')])) For :py:class:`~cg:cryptography.x509.OtherName` instances, pass a :py:class:`~django_ca.pydantic.general_name.OtherNameModel` instead: >>> GeneralNameModel( ... type="otherName", value={"oid": "2.5.4.3", "type": "BOOLEAN", 'value': True} ... ) # doctest: +NORMALIZE_WHITESPACE GeneralNameModel(type='otherName', value=OtherNameModel(oid='2.5.4.3', type='BOOLEAN', value=True)) """ type: GeneralNames # Use a discriminated Union so that pydantic can more efficiently determine the type. Without # discrimination, passing large IPv4/IPv6 networks (which are iterable, just like a list of str intended # for NameModel) would invoke the NameAttribute validation for every address in the network, making this # extremely slow for large network segments. value: Annotated[ Union[ Annotated[str, Tag("str")], Annotated[NameModel, Tag("name")], Annotated[OtherNameModel, Tag("othername")], Annotated[IPAddressType, Tag("ipaddress")], ], Discriminator(general_name_discriminator), ]
[docs] @model_validator(mode="before") @classmethod def parse_cryptography(cls, data: Any) -> Any: """Validator to parse cryptography values.""" if isinstance(data, x509.RegisteredID): return {"type": "RID", "value": data.value.dotted_string} if isinstance(data, x509.OtherName): return {"type": "otherName", "value": OtherNameModel.model_validate(data)} if isinstance(data, x509.DirectoryName): return {"type": "dirName", "value": list(data.value)} if isinstance(data, x509.GeneralName): # email, URI, DNS, IPAddress type_value = constants.GENERAL_NAME_NAMES[type(data)] return {"type": type_value, "value": data.value} return data
[docs] @model_validator(mode="after") def validate_value(self) -> "GeneralNameModel": # noqa: PLR0912 """Validator to make sure that `value` is of the right type.""" if self.type == "URI": if not isinstance(self.value, str): raise ValueError(f"{self.value}: Must be a str for type {self.type}") try: self.value = encode_url(self.value) except idna.IDNAError as ex: raise ValueError(f"Could not parse DNS name in URL: {self.value}") from ex elif self.type == "email": if not isinstance(self.value, str): raise ValueError(f"{self.value}: Must be a str for type {self.type}") self.value = validate_email(self.value) elif self.type == "IP": if isinstance(self.value, str): try: self.value = ip_address(self.value) except ValueError: try: self.value = ip_network(self.value) except ValueError as ex: raise ValueError(f"{self.value}: Could not parse IP address") from ex elif not isinstance(self.value, ip_address_classes): raise ValueError(f"{self.value}: Must be an IPAddress/IPNetwork for type {self.type}") elif self.type == "RID": if not isinstance(self.value, str): raise ValueError(f"{self.value}: Must be a str for type {self.type}") validators.oid_validator(self.value) elif self.type == "otherName": if not isinstance(self.value, OtherNameModel): raise ValueError(f"{self.value}: Must be OtherNameModel for type {self.type}") elif self.type == "DNS": if not isinstance(self.value, str): raise ValueError(f"{self.value}: Must be a str for type {self.type}") self.value = encode_dns(self.value) elif self.type == "dirName": pass else: raise ValueError(f"{self.type}: Unknown type") # pragma: no cover return self
@property def cryptography(self) -> x509.GeneralName: """Convert to a :py:class:`~cg:cryptography.x509.GeneralName` instance.""" if self.type == "RID": if not isinstance(self.value, str): # pragma: no cover # just to make mypy happy raise ValueError(f"{self.value}: Must be a str for type {self.type}") return x509.RegisteredID(x509.ObjectIdentifier(self.value)) if self.type == "dirName": if not isinstance(self.value, NameModel): # pragma: no cover # just to make mypy happy raise ValueError(f"{self.value}: Must be a str for type {self.type}") return x509.DirectoryName(self.value.cryptography) if self.type == "otherName": if not isinstance(self.value, OtherNameModel): # pragma: no cover # just to make mypy happy raise ValueError(f"{self.value}: Must be a OtherNameModel for type {self.type}") return self.value.cryptography # TYPEHINT NOTE: constant has type GeneralName, abstract constructor does not take arguments return constants.GENERAL_NAME_TYPES[self.type](self.value) # type: ignore[call-arg]
GeneralNameModelList = TypeAdapter(List[GeneralNameModel])