Skip to content

Commit

Permalink
add new oid type
Browse files Browse the repository at this point in the history
  • Loading branch information
mathiasertl committed Dec 22, 2024
1 parent 6f8fcb2 commit abf6207
Show file tree
Hide file tree
Showing 9 changed files with 174 additions and 60 deletions.
62 changes: 35 additions & 27 deletions ca/django_ca/pydantic/extension_attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@
from django_ca.pydantic.base import CryptographyModel
from django_ca.pydantic.general_name import GeneralNameModel
from django_ca.pydantic.name import NameModel
from django_ca.pydantic.type_aliases import Base64EncodedBytes, NonEmptyOrderedSet, OIDType
from django_ca.pydantic.type_aliases import (
Base64EncodedBytes,
NonEmptyOrderedSet,
ObjectIdentifierPydanticType,
)
from django_ca.typehints import DistributionPointReasons, LogEntryTypes

if TYPE_CHECKING: # pragma: only cryptography<44
Expand All @@ -56,17 +60,14 @@ class NamingAuthorityModel(NamingAuthorityBase): # pragma: only cryptography>=4

model_config = ConfigDict(from_attributes=True)

id: Optional[OIDType] = None
id: Optional[ObjectIdentifierPydanticType] = None
url: Optional[Annotated[str, MaxLen(128)]] = None
text: Optional[Annotated[str, MaxLen(128)]] = None

@property
def cryptography(self) -> "x509.NamingAuthority":
"""Convert to a :py:class:`~cg:cryptography.x509.NamingAuthority` instance."""
oid = None
if self.id is not None:
oid = x509.ObjectIdentifier(self.id)
return x509.NamingAuthority(id=oid, url=self.url, text=self.text)
return x509.NamingAuthority(id=self.id, url=self.url, text=self.text)


class ProfessionInfoModel(ProfessionInfoBase): # pragma: only cryptography>=44.0
Expand All @@ -81,22 +82,20 @@ class ProfessionInfoModel(ProfessionInfoBase): # pragma: only cryptography>=44.

naming_authority: Optional[NamingAuthorityModel] = None
profession_items: Annotated[list[Annotated[str, MaxLen(128)]], MinLen(1)]
profession_oids: Optional[list[OIDType]] = None
profession_oids: Optional[list[ObjectIdentifierPydanticType]] = None
registration_number: Optional[Annotated[str, MaxLen(128)]] = None
add_profession_info: Optional[Base64EncodedBytes] = None

@property
def cryptography(self) -> "x509.ProfessionInfo":
naming_authority = profession_oids = None
naming_authority = None
if self.naming_authority is not None:
naming_authority = self.naming_authority.cryptography
if self.profession_oids is not None:
profession_oids = [x509.ObjectIdentifier(oid) for oid in self.profession_oids]

return x509.ProfessionInfo(
naming_authority=naming_authority,
profession_items=self.profession_items,
profession_oids=profession_oids,
profession_oids=self.profession_oids,
registration_number=self.registration_number,
add_profession_info=self.add_profession_info,
)
Expand Down Expand Up @@ -337,7 +336,10 @@ class DistributionPointModel(CryptographyModel[x509.DistributionPoint]):
... ) # doctest: +STRIP_WHITESPACE
DistributionPointModel(
full_name=None,
relative_name=NameModel(root=[NameAttributeModel(oid='2.5.4.3', value='example.com')]),
relative_name=NameModel(root=[NameAttributeModel(
oid=<ObjectIdentifier(oid=2.5.4.3, name=commonName)>,
value='example.com'
)]),
crl_issuer=[GeneralNameModel(type='URI', value='https://ca.example.com/issuer')],
reasons={'key_compromise'}
)
Expand Down Expand Up @@ -538,22 +540,24 @@ class MSCertificateTemplateValueModel(CryptographyModel[x509.MSCertificateTempla
The `template_id` parameter is a dotted-string object identifier, while `major_version` and
`minor_version` are optional integers:
>>> MSCertificateTemplateValueModel(template_id="1.2.3", major_version=1)
MSCertificateTemplateValueModel(template_id='1.2.3', major_version=1, minor_version=None)
>>> MSCertificateTemplateValueModel(template_id="1.2.3", major_version=1) # doctest: +STRIP_WHITESPACE
MSCertificateTemplateValueModel(
template_id=<ObjectIdentifier(oid=1.2.3, name=Unknown OID)>,
major_version=1,
minor_version=None
)
"""

model_config = ConfigDict(from_attributes=True)
template_id: OIDType
template_id: ObjectIdentifierPydanticType
major_version: Optional[int] = None
minor_version: Optional[int] = None

@property
def cryptography(self) -> x509.MSCertificateTemplate:
"""Convert to a :py:class:`~cg:cryptography.x509.MSCertificateTemplate` instance."""
return x509.MSCertificateTemplate(
template_id=x509.ObjectIdentifier(self.template_id),
major_version=self.major_version,
minor_version=self.minor_version,
template_id=self.template_id, major_version=self.major_version, minor_version=self.minor_version
)


Expand Down Expand Up @@ -691,8 +695,11 @@ class PolicyInformationModel(CryptographyModel[x509.PolicyInformation]):
In its simplest for, this model requires only a `policy_identifier`:
>>> PolicyInformationModel(policy_identifier="2.5.29.32.0")
PolicyInformationModel(policy_identifier='2.5.29.32.0', policy_qualifiers=None)
>>> PolicyInformationModel(policy_identifier="1.3.6.1.5.5.7.2.1") # doctest: +STRIP_WHITESPACE
PolicyInformationModel(
policy_identifier=<ObjectIdentifier(oid=1.3.6.1.5.5.7.2.1, name=id-qt-cps)>,
policy_qualifiers=None
)
A list of `policy_qualifiers` may also be passed, with elements being either a ``str`` or a
:py:class:`~django_ca.pydantic.extension_attributes.UserNoticeModel`:
Expand All @@ -703,7 +710,7 @@ class PolicyInformationModel(CryptographyModel[x509.PolicyInformation]):
... policy_qualifiers=["https://ca.example.com/cps", notice]
... ) # doctest: +STRIP_WHITESPACE
PolicyInformationModel(
policy_identifier='1.3.6.1.5.5.7.2.1',
policy_identifier=<ObjectIdentifier(oid=1.3.6.1.5.5.7.2.1, name=id-qt-cps)>,
policy_qualifiers=[
'https://ca.example.com/cps',
UserNoticeModel(notice_reference=None, explicit_text='my text')
Expand All @@ -719,7 +726,7 @@ class PolicyInformationModel(CryptographyModel[x509.PolicyInformation]):
},
)

policy_identifier: OIDType = Field(
policy_identifier: ObjectIdentifierPydanticType = Field(
description="An object identifier (OID) as dotted string.",
json_schema_extra={"example": CertificatePoliciesOID.ANY_POLICY.dotted_string},
)
Expand All @@ -732,7 +739,6 @@ class PolicyInformationModel(CryptographyModel[x509.PolicyInformation]):
@property
def cryptography(self) -> x509.PolicyInformation:
"""Convert to a :py:class:`~cg:cryptography.x509.PolicyInformation` instance."""
oid = x509.ObjectIdentifier(self.policy_identifier)
policy_qualifiers: Optional[list[Union[str, x509.UserNotice]]] = None
if self.policy_qualifiers is not None:
policy_qualifiers = []
Expand All @@ -741,7 +747,9 @@ def cryptography(self) -> x509.PolicyInformation:
policy_qualifiers.append(qualifier)
else:
policy_qualifiers.append(qualifier.cryptography)
return x509.PolicyInformation(policy_identifier=oid, policy_qualifiers=policy_qualifiers)
return x509.PolicyInformation(
policy_identifier=self.policy_identifier, policy_qualifiers=policy_qualifiers
)


class UnrecognizedExtensionValueModel(CryptographyModel[x509.UnrecognizedExtension]):
Expand All @@ -750,10 +758,10 @@ class UnrecognizedExtensionValueModel(CryptographyModel[x509.UnrecognizedExtensi
The `value` a base64 encoded bytes value, and the `oid` is any dotted string:
>>> UnrecognizedExtensionValueModel(value=b"MTIz", oid="1.2.3")
UnrecognizedExtensionValueModel(oid='1.2.3', value=b'123')
UnrecognizedExtensionValueModel(oid=<ObjectIdentifier(oid=1.2.3, name=Unknown OID)>, value=b'123')
"""

oid: OIDType
oid: ObjectIdentifierPydanticType
value: Base64Bytes

@model_validator(mode="before")
Expand All @@ -767,4 +775,4 @@ def parse_cryptography(cls, data: Any) -> Any:
@property
def cryptography(self) -> x509.UnrecognizedExtension:
"""The :py:class:`~cg:cryptography.x509.UnrecognizedExtension` instance."""
return x509.UnrecognizedExtension(value=self.value, oid=x509.ObjectIdentifier(self.oid))
return x509.UnrecognizedExtension(value=self.value, oid=self.oid)
6 changes: 3 additions & 3 deletions ca/django_ca/pydantic/general_name.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
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.pydantic.type_aliases import ObjectIdentifierPydanticType
from django_ca.typehints import GeneralNames, IPAddressType, OtherNames

ip_address_classes = (
Expand Down Expand Up @@ -88,7 +88,7 @@ class OtherNameModel(CryptographyModel[x509.OtherName]):
<OtherName(type_id=<ObjectIdentifier(oid=1.2.3, name=Unknown OID)>, value=b'\\x16\\x0bsome string')>
"""

oid: OIDType
oid: ObjectIdentifierPydanticType
type: Annotated[OtherNames, BeforeValidator(other_name_type_aliases)]
value: Optional[Union[str, bool, datetime, int]]

Expand Down Expand Up @@ -160,7 +160,7 @@ def cryptography(self) -> x509.OtherName:
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)
return x509.OtherName(type_id=self.oid, value=value)


class GeneralNameModel(CryptographyModel[x509.GeneralName]):
Expand Down
24 changes: 10 additions & 14 deletions ca/django_ca/pydantic/name.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from django_ca import constants
from django_ca.pydantic import validators
from django_ca.pydantic.base import CryptographyModel, CryptographyRootModel
from django_ca.pydantic.type_aliases import OIDType
from django_ca.pydantic.type_aliases import ObjectIdentifierPydanticType

_NAME_ATTRIBUTE_OID_DESCRIPTION = (
"A dotted string representing the OID or a known alias as described in "
Expand Down Expand Up @@ -58,7 +58,9 @@ class NameAttributeModel(CryptographyModel[x509.NameAttribute]):
},
)

oid: Annotated[OIDType, BeforeValidator(validators.name_oid_dotted_string_parser)] = Field(
oid: Annotated[
ObjectIdentifierPydanticType, BeforeValidator(validators.name_oid_dotted_string_parser)
] = Field(
title="Object identifier",
description=_NAME_ATTRIBUTE_OID_DESCRIPTION,
json_schema_extra={"example": NameOID.COMMON_NAME.dotted_string},
Expand All @@ -80,27 +82,23 @@ def parse_cryptography(cls, data: Any) -> Any:
@model_validator(mode="after")
def validate_name_attribute(self) -> "NameAttributeModel":
"""Validate that country code OIDs have exactly two characters."""
country_code_oids = (
NameOID.COUNTRY_NAME.dotted_string,
NameOID.JURISDICTION_COUNTRY_NAME.dotted_string,
)
country_code_oids = (NameOID.COUNTRY_NAME, NameOID.JURISDICTION_COUNTRY_NAME)
if self.oid in country_code_oids and len(self.value) != 2:
raise ValueError(f"{self.value}: Must have exactly two characters")

if self.oid == NameOID.COMMON_NAME.dotted_string and not self.value:
if self.oid == NameOID.COMMON_NAME and not self.value:
name = constants.NAME_OID_NAMES[NameOID.COMMON_NAME]
raise ValueError(f"{name} must not be an empty value")
return self

@property
def cryptography(self) -> x509.NameAttribute:
"""The :py:class:`~cg:cryptography.x509.NameAttribute` instance for this model."""
oid = x509.ObjectIdentifier(self.oid)
if oid == NameOID.X500_UNIQUE_IDENTIFIER:
if self.oid == NameOID.X500_UNIQUE_IDENTIFIER:
value = base64.b64decode(self.value)
return x509.NameAttribute(oid=oid, value=value, _type=_ASN1Type.BitString)
return x509.NameAttribute(oid=self.oid, value=value, _type=_ASN1Type.BitString)

return x509.NameAttribute(oid=oid, value=self.value)
return x509.NameAttribute(oid=self.oid, value=self.value)


class NameModel(CryptographyRootModel[list[NameAttributeModel], x509.Name]):
Expand Down Expand Up @@ -137,9 +135,7 @@ def validate_duplicates(self) -> "NameModel":
seen = set()

# for oid in set(oids):
for attr in self.root:
oid = x509.ObjectIdentifier(attr.oid)

for oid in [attr.oid for attr in self.root]:
# Check if any fields are duplicate where this is not allowed (e.g. multiple CommonName fields)
if oid in seen and oid not in constants.MULTIPLE_OIDS:
name = constants.NAME_OID_NAMES.get(oid, oid.dotted_string)
Expand Down
55 changes: 49 additions & 6 deletions ca/django_ca/pydantic/type_aliases.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,14 @@
from collections.abc import Hashable
from typing import Annotated, Any, Callable, Optional, TypeVar, Union

from pydantic import AfterValidator, BeforeValidator, Field, GetPydanticSchema, PlainSerializer
from pydantic import (
AfterValidator,
BeforeValidator,
Field,
GetPydanticSchema,
PlainSerializer,
SerializationInfo,
)
from pydantic_core import core_schema
from pydantic_core.core_schema import IsInstanceSchema, LiteralSchema

Expand All @@ -31,8 +38,6 @@
int_to_hex_parser,
is_power_two_validator,
non_empty_validator,
oid_parser,
oid_validator,
reason_flag_crl_scope_validator,
reason_flag_validator,
serial_validator,
Expand Down Expand Up @@ -143,10 +148,48 @@ def str_loader(value: str) -> T:

NonEmptyOrderedSetTypeVar = TypeVar("NonEmptyOrderedSetTypeVar", bound=list[Any])

#: A string that will convert :py:class:`~cg:cryptography.x509.ObjectIdentifier` objects.

def _get_oid_schema() -> GetPydanticSchema:
def serializer(
value: x509.ObjectIdentifier, info: SerializationInfo
) -> Union[str, x509.ObjectIdentifier]:
if info.mode == "json":
return value.dotted_string

context = info.context
if context is not None:
if "request" in context:
return value.dotted_string

return value

def str_loader(value: str) -> x509.ObjectIdentifier:
try:
return x509.ObjectIdentifier(value)
except ValueError as ex:
raise ValueError(f"{value}: Not a valid dotted string.") from ex

json_schema = core_schema.chain_schema(
[
core_schema.str_schema(),
core_schema.no_info_plain_validator_function(str_loader),
]
)
python_schema = core_schema.is_instance_schema(x509.ObjectIdentifier)

return GetPydanticSchema(
lambda tp, handler: core_schema.json_or_python_schema(
json_schema=json_schema,
python_schema=core_schema.union_schema([json_schema, python_schema]),
serialization=core_schema.plain_serializer_function_ser_schema(serializer, info_arg=True),
)
)


#: Annotated type for :py:class:`~cg:cryptography.x509.ObjectIdentifier`.
#:
#: This type alias will also validate the x509 dotted string format.
OIDType = Annotated[str, BeforeValidator(oid_parser), AfterValidator(oid_validator)]
#: This annotated type will accept dotted strings as input and will always serialize to a dotted string.
ObjectIdentifierPydanticType = Annotated[x509.ObjectIdentifier, _get_oid_schema()]

UniqueTupleTypeVar = TypeVar("UniqueTupleTypeVar", bound=tuple[Hashable, ...])
UniqueElementsTuple = Annotated[UniqueTupleTypeVar, AfterValidator(unique_validator)]
Expand Down
9 changes: 3 additions & 6 deletions ca/django_ca/tests/pydantic/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import typing
from typing import Any, TypeVar, Union

from pydantic import ValidationError
from pydantic import BaseModel, ValidationError

import pytest

Expand All @@ -34,17 +34,14 @@ def assert_cryptography_model(
"""Test that a cryptography model matches the expected value."""
model = model_class(**parameters)
assert model.cryptography == expected
print(1, expected)
print(2, model)
print(3, model_class.model_validate(expected))
assert model == model_class.model_validate(expected), (model, expected)
assert model == model_class.model_validate_json(model.model_dump_json()) # test JSON serialization
return model # for any further tests on the model


@typing.overload
def assert_validation_errors(
model_class: type[CryptographyModelTypeVar],
model_class: type[BaseModel],
parameters: dict[str, Any],
expected_errors: ExpectedErrors,
) -> None: ...
Expand All @@ -59,7 +56,7 @@ def assert_validation_errors(


def assert_validation_errors(
model_class: Union[type[CryptographyModelTypeVar], type[CryptographyRootModelTypeVar]],
model_class: type[BaseModel],
parameters: Union[list[dict[str, Any]], dict[str, Any]],
expected_errors: ExpectedErrors,
) -> None:
Expand Down
3 changes: 2 additions & 1 deletion ca/django_ca/tests/pydantic/test_general_name.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,8 @@ def test_general_name(parameters: dict[str, Any], name: x509.GeneralName, discri
(
"value_error",
(),
"Value error, root=[NameAttributeModel(oid='1.2.3', value='example.com')]: Must be an "
"Value error, root=[NameAttributeModel(oid=<ObjectIdentifier(oid=1.2.3, "
"name=Unknown OID)>, value='example.com')]: Must be an "
"IPAddress/IPNetwork for type IP",
)
],
Expand Down
Loading

0 comments on commit abf6207

Please sign in to comment.