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
from datetime import datetime
from ipaddress import ip_address, ip_network
from typing import Annotated, Any, Optional, Union, cast

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 GeneralNames, IPAddressType, OtherNames

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: .. pydantic-model:: othername For datetime variants (``UTCTIME`` and ``GENERALIZEDTIME``), you must pass a timezone-aware object: .. pydantic-model:: othername_utctime For ``INTEGER``, you can pass an ``int`` or a ``str`` for a base 16 integer: .. pydantic-model:: othername_integer Finally, for an ``OctetString``, pass the raw bytes or as a hex-encoded string: .. pydantic-model:: othername_octetstring 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") @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 @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 = 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``: .. pydantic-model:: general_name For directory names, you have to pass a :py:class:`~django_ca.pydantic.name.NameModel` instead: .. pydantic-model:: general_name_name For :py:class:`~cg:cryptography.x509.OtherName` instances, pass a :py:class:`~django_ca.pydantic.general_name.OtherNameModel` instead: .. pydantic-model:: general_name_othername :cryptography-prefix: othername """ 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), ] @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 @model_validator(mode="after") def validate_value(self) -> "GeneralNameModel": """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}") self.value = validators.url_validator(self.value) 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 = validators.email_validator(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 = validators.dns_validator(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])