From abf6207d55c79b8e23eaaf55a9c6c8c6d83c0434 Mon Sep 17 00:00:00 2001 From: Mathias Ertl Date: Sat, 21 Dec 2024 12:09:10 +0100 Subject: [PATCH] add new oid type --- ca/django_ca/pydantic/extension_attributes.py | 62 +++++++++++-------- ca/django_ca/pydantic/general_name.py | 6 +- ca/django_ca/pydantic/name.py | 24 +++---- ca/django_ca/pydantic/type_aliases.py | 55 ++++++++++++++-- ca/django_ca/tests/pydantic/base.py | 9 +-- .../tests/pydantic/test_general_name.py | 3 +- ca/django_ca/tests/pydantic/test_name.py | 13 +++- .../tests/pydantic/test_type_aliases.py | 60 +++++++++++++++++- docs/source/python/pydantic.rst | 2 +- 9 files changed, 174 insertions(+), 60 deletions(-) diff --git a/ca/django_ca/pydantic/extension_attributes.py b/ca/django_ca/pydantic/extension_attributes.py index 298ecde3c..87e077ede 100644 --- a/ca/django_ca/pydantic/extension_attributes.py +++ b/ca/django_ca/pydantic/extension_attributes.py @@ -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 @@ -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 @@ -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, ) @@ -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=, + value='example.com' + )]), crl_issuer=[GeneralNameModel(type='URI', value='https://ca.example.com/issuer')], reasons={'key_compromise'} ) @@ -538,12 +540,16 @@ 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=, + 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 @@ -551,9 +557,7 @@ class MSCertificateTemplateValueModel(CryptographyModel[x509.MSCertificateTempla 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 ) @@ -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=, + 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`: @@ -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=, policy_qualifiers=[ 'https://ca.example.com/cps', UserNoticeModel(notice_reference=None, explicit_text='my text') @@ -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}, ) @@ -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 = [] @@ -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]): @@ -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=, value=b'123') """ - oid: OIDType + oid: ObjectIdentifierPydanticType value: Base64Bytes @model_validator(mode="before") @@ -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) diff --git a/ca/django_ca/pydantic/general_name.py b/ca/django_ca/pydantic/general_name.py index d3b7f94a0..88683aa98 100644 --- a/ca/django_ca/pydantic/general_name.py +++ b/ca/django_ca/pydantic/general_name.py @@ -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 = ( @@ -88,7 +88,7 @@ class OtherNameModel(CryptographyModel[x509.OtherName]): , value=b'\\x16\\x0bsome string')> """ - oid: OIDType + oid: ObjectIdentifierPydanticType type: Annotated[OtherNames, BeforeValidator(other_name_type_aliases)] value: Optional[Union[str, bool, datetime, int]] @@ -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]): diff --git a/ca/django_ca/pydantic/name.py b/ca/django_ca/pydantic/name.py index 3b1d1e480..8bb4c239e 100644 --- a/ca/django_ca/pydantic/name.py +++ b/ca/django_ca/pydantic/name.py @@ -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 " @@ -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}, @@ -80,14 +82,11 @@ 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 @@ -95,12 +94,11 @@ def validate_name_attribute(self) -> "NameAttributeModel": @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]): @@ -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) diff --git a/ca/django_ca/pydantic/type_aliases.py b/ca/django_ca/pydantic/type_aliases.py index dac6fb018..c5efb8343 100644 --- a/ca/django_ca/pydantic/type_aliases.py +++ b/ca/django_ca/pydantic/type_aliases.py @@ -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 @@ -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, @@ -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)] diff --git a/ca/django_ca/tests/pydantic/base.py b/ca/django_ca/tests/pydantic/base.py index 8c8867e1b..1eb99be9a 100644 --- a/ca/django_ca/tests/pydantic/base.py +++ b/ca/django_ca/tests/pydantic/base.py @@ -17,7 +17,7 @@ import typing from typing import Any, TypeVar, Union -from pydantic import ValidationError +from pydantic import BaseModel, ValidationError import pytest @@ -34,9 +34,6 @@ 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 @@ -44,7 +41,7 @@ def assert_cryptography_model( @typing.overload def assert_validation_errors( - model_class: type[CryptographyModelTypeVar], + model_class: type[BaseModel], parameters: dict[str, Any], expected_errors: ExpectedErrors, ) -> None: ... @@ -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: diff --git a/ca/django_ca/tests/pydantic/test_general_name.py b/ca/django_ca/tests/pydantic/test_general_name.py index c1cf9537e..4094337bc 100644 --- a/ca/django_ca/tests/pydantic/test_general_name.py +++ b/ca/django_ca/tests/pydantic/test_general_name.py @@ -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=, value='example.com')]: Must be an " "IPAddress/IPNetwork for type IP", ) ], diff --git a/ca/django_ca/tests/pydantic/test_name.py b/ca/django_ca/tests/pydantic/test_name.py index 28d569e61..a78e1795b 100644 --- a/ca/django_ca/tests/pydantic/test_name.py +++ b/ca/django_ca/tests/pydantic/test_name.py @@ -85,7 +85,18 @@ def test_name_attribute(parameters: dict[str, Any], name_attr: x509.NameAttribut ( ( {"oid": "foo", "value": "example.com"}, - [("value_error", ("oid",), "Value error, foo: Invalid object identifier")], + [ + ( + "value_error", + ("oid", "chain[str,function-plain[str_loader()]]"), + "Value error, foo: Not a valid dotted string.", + ), + ( + "is_instance_of", + ("oid", "is-instance[ObjectIdentifier]"), + "Input should be an instance of ObjectIdentifier", + ), + ], ), ), ) diff --git a/ca/django_ca/tests/pydantic/test_type_aliases.py b/ca/django_ca/tests/pydantic/test_type_aliases.py index f733ac21c..5b271fe10 100644 --- a/ca/django_ca/tests/pydantic/test_type_aliases.py +++ b/ca/django_ca/tests/pydantic/test_type_aliases.py @@ -13,10 +13,12 @@ """Test type aliases for Pydantic from django_ca.pydantic.type_aliases.""" -from pydantic import BaseModel +from pydantic import BaseModel, Field +from cryptography import x509 from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import NameOID import pytest @@ -25,8 +27,10 @@ Base64EncodedBytes, EllipticCurveTypeAlias, HashAlgorithmTypeAlias, + ObjectIdentifierPydanticType, Serial, ) +from django_ca.tests.pydantic.base import assert_validation_errors class EllipticCurveTypeAliasModel(BaseModel): @@ -53,6 +57,12 @@ class SerialModel(BaseModel): value: Serial +class OIDTypeModel(BaseModel): + """Test.""" + + value: ObjectIdentifierPydanticType = Field(examples=[NameOID.COMMON_NAME.dotted_string]) + + @pytest.mark.parametrize(("name", "curve_cls"), constants.ELLIPTIC_CURVE_TYPES.items()) def test_elliptic_curve(name: str, curve_cls: type[ec.EllipticCurve]) -> None: """Test EllipticCurveTypeAliasModel.""" @@ -178,3 +188,51 @@ def test_serial_errors(value: str) -> None: """Test invalid values for the Serial type alias.""" with pytest.raises(ValueError): # noqa: PT011 # pydantic controls the message SerialModel(value=value) + + +def test_oid_type() -> None: + """Test ``django_ca.pydantic.type_aliases.ObjectIdentifierPydanticType``.""" + dotted_string = "1.2.3" + oid = x509.ObjectIdentifier("1.2.3") + + obj = OIDTypeModel(value=dotted_string) + assert obj.value == oid + assert obj.model_dump() == {"value": oid} + assert obj.model_dump(context={"request": "foo"}) == {"value": dotted_string} + assert obj.model_dump(context={}) == {"value": oid} + assert obj.model_dump(mode="json") == {"value": dotted_string} + + obj = OIDTypeModel(value=oid) + assert obj.value == oid + + # Check model_validate() + assert OIDTypeModel.model_validate({"value": dotted_string}).value == oid + assert OIDTypeModel.model_validate({"value": oid}).value == oid + assert OIDTypeModel.model_validate({"value": dotted_string}, strict=True).value == oid + + # Check that we also work in strict mode + assert OIDTypeModel.model_validate({"value": dotted_string}, strict=True).value == oid + + assert_validation_errors( + OIDTypeModel, + {"value": "abc"}, + [ + ( + "value_error", + ("value", "chain[str,function-plain[str_loader()]]"), + "Value error, abc: Not a valid dotted string.", + ), + ( + "is_instance_of", + ("value", "is-instance[ObjectIdentifier]"), + "Input should be an instance of ObjectIdentifier", + ), + ], + ) + + # Test that we can generate a JSON schema: + assert OIDTypeModel.model_json_schema()["properties"]["value"] == { + "title": "Value", + "type": "string", + "examples": [NameOID.COMMON_NAME.dotted_string], + } diff --git a/docs/source/python/pydantic.rst b/docs/source/python/pydantic.rst index fe92b901a..c2f9b1a7c 100644 --- a/docs/source/python/pydantic.rst +++ b/docs/source/python/pydantic.rst @@ -23,7 +23,7 @@ instance into a cryptography instance: >>> attr = x509.NameAttribute(oid=NameOID.COMMON_NAME, value="example.com") >>> model = NameAttributeModel.model_validate(attr) >>> model - NameAttributeModel(oid='2.5.4.3', value='example.com') + NameAttributeModel(oid=, value='example.com') >>> model.cryptography == attr True