Skip to content

Commit

Permalink
adapt to Admissions extension added in cryptography 44
Browse files Browse the repository at this point in the history
  • Loading branch information
mathiasertl committed Dec 1, 2024
1 parent ac01d73 commit 471ccaa
Show file tree
Hide file tree
Showing 11 changed files with 500 additions and 56 deletions.
5 changes: 0 additions & 5 deletions ca/django_ca/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,13 +455,11 @@ def validate_ca_use_celery(cls, value: Any) -> Any:
def check_ca_key_backends(self) -> "SettingsModel":
"""Set the default key backend if not set, and validate that the default key backend is configured."""
if not self.CA_KEY_BACKENDS:
# pylint: disable-next=unsupported-assignment-operation # pylint this this is a Field()
self.CA_KEY_BACKENDS[self.CA_DEFAULT_KEY_BACKEND] = KeyBackendConfigurationModel(
BACKEND=constants.DEFAULT_STORAGE_BACKEND,
OPTIONS={"storage_alias": self.CA_DEFAULT_STORAGE_ALIAS},
)

# pylint: disable-next=unsupported-membership-test # pylint this this is a Field()
elif self.CA_DEFAULT_KEY_BACKEND not in self.CA_KEY_BACKENDS:
raise ValueError(f"{self.CA_DEFAULT_KEY_BACKEND}: The default key backend is not configured.")
return self
Expand All @@ -470,21 +468,18 @@ def check_ca_key_backends(self) -> "SettingsModel":
def check_ca_ocsp_key_backends(self) -> "SettingsModel":
"""Set the default OCSP key backend if not set, and validate that the default is configured."""
if not self.CA_OCSP_KEY_BACKENDS:
# pylint: disable-next=unsupported-assignment-operation # pylint this this is a Field()
self.CA_OCSP_KEY_BACKENDS[self.CA_DEFAULT_OCSP_KEY_BACKEND] = KeyBackendConfigurationModel(
BACKEND=constants.DEFAULT_OCSP_KEY_BACKEND,
OPTIONS={"storage_alias": self.CA_DEFAULT_STORAGE_ALIAS},
)

# pylint: disable-next=unsupported-membership-test # pylint this this is a Field()
elif self.CA_DEFAULT_OCSP_KEY_BACKEND not in self.CA_OCSP_KEY_BACKENDS:
raise ValueError(f"{self.CA_DEFAULT_KEY_BACKEND}: The default key backend is not configured.")
return self

@model_validator(mode="after")
def check_ca_default_profile(self) -> "SettingsModel":
"""Validate that the default profile is also configured."""
# pylint: disable-next=unsupported-membership-test # pylint this this is a Field()
if self.CA_DEFAULT_PROFILE not in self.CA_PROFILES:
raise ValueError(f"{self.CA_DEFAULT_PROFILE}: CA_DEFAULT_PROFILE is not defined as a profile.")
return self
Expand Down
19 changes: 18 additions & 1 deletion ca/django_ca/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@
from collections import defaultdict
from types import MappingProxyType

import packaging.version

import asn1crypto.core
import cryptography
from cryptography import x509
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import dsa, ec, ed448, ed25519, rsa
Expand All @@ -30,7 +33,7 @@
from cryptography.x509.oid import (
AuthorityInformationAccessOID,
ExtendedKeyUsageOID as _ExtendedKeyUsageOID,
ExtensionOID,
ExtensionOID as _ExtensionOID,
NameOID,
SubjectInformationAccessOID,
)
Expand All @@ -55,6 +58,16 @@
ParsableKeyType,
)

CRYPTOGRAPHY_VERSION = packaging.version.parse(cryptography.__version__).release


class ExtensionOID(_ExtensionOID):
"""Extend the ExtensionOID object with any OIDs not known to cryptography."""

if CRYPTOGRAPHY_VERSION < (44, 0): # pragma: cryptography<44 branch
ADMISSIONS = x509.ObjectIdentifier("1.3.36.8.3.3")


ACCESS_METHOD_TYPES: MappingProxyType[AccessMethods, x509.ObjectIdentifier] = MappingProxyType(
{
"ocsp": AuthorityInformationAccessOID.OCSP,
Expand Down Expand Up @@ -176,6 +189,7 @@ class ExtendedKeyUsageOID(_ExtendedKeyUsageOID):
#: Map of ExtensionOIDs to a human-readable text describing if the extension should/must/... be critical.
EXTENSION_CRITICAL_HELP = MappingProxyType(
{
ExtensionOID.ADMISSIONS: _("may or may not be critical"),
ExtensionOID.AUTHORITY_INFORMATION_ACCESS: _("MUST be non-critical"),
ExtensionOID.AUTHORITY_KEY_IDENTIFIER: _("MUST be non-critical"),
ExtensionOID.BASIC_CONSTRAINTS: _("MUST usually be critical, but allows non-critical in some cases"),
Expand Down Expand Up @@ -212,6 +226,7 @@ class ExtendedKeyUsageOID(_ExtendedKeyUsageOID):
#: Map of ExtensionOIDs to the default critical values as defined in the RFC where they are defined.
EXTENSION_DEFAULT_CRITICAL = MappingProxyType(
{
ExtensionOID.ADMISSIONS: False, # Common PKI v2 doesn't really say
ExtensionOID.AUTHORITY_INFORMATION_ACCESS: False, # MUST mark this extension as non-critical.
ExtensionOID.AUTHORITY_KEY_IDENTIFIER: False, # MUST mark this extension as non-critical
ExtensionOID.BASIC_CONSTRAINTS: True, # RFC 5280 is more complex here, True is a good efault
Expand Down Expand Up @@ -243,6 +258,7 @@ class ExtendedKeyUsageOID(_ExtendedKeyUsageOID):
CONFIGURABLE_EXTENSION_KEYS: MappingProxyType[x509.ObjectIdentifier, ConfigurableExtensionKeys] = (
MappingProxyType(
{
ExtensionOID.ADMISSIONS: "admissions",
ExtensionOID.AUTHORITY_INFORMATION_ACCESS: "authority_information_access",
ExtensionOID.CERTIFICATE_POLICIES: "certificate_policies",
ExtensionOID.CRL_DISTRIBUTION_POINTS: "crl_distribution_points",
Expand Down Expand Up @@ -319,6 +335,7 @@ class ExtendedKeyUsageOID(_ExtendedKeyUsageOID):
#: Map of ExtensionOIDs to human-readable names as they appear in the RFC where they are defined.
EXTENSION_NAMES = MappingProxyType(
{
ExtensionOID.ADMISSIONS: "Admissions",
ExtensionOID.AUTHORITY_INFORMATION_ACCESS: "Authority Information Access",
ExtensionOID.AUTHORITY_KEY_IDENTIFIER: "Authority Key Identifier",
ExtensionOID.BASIC_CONSTRAINTS: "Basic Constraints",
Expand Down
137 changes: 135 additions & 2 deletions ca/django_ca/pydantic/extension_attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@

import base64
from datetime import datetime
from typing import Annotated, Any, Literal, NoReturn, Optional, Union
from typing import TYPE_CHECKING, Annotated, Any, Literal, NoReturn, Optional, Union

from annotated_types import MaxLen, MinLen
from pydantic import AfterValidator, Base64Bytes, BeforeValidator, ConfigDict, Field, model_validator

from cryptography import x509
Expand All @@ -28,14 +29,146 @@
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 NonEmptyOrderedSet, OIDType
from django_ca.pydantic.type_aliases import Base64EncodedBytes, NonEmptyOrderedSet, OIDType
from django_ca.typehints import DistributionPointReasons, LogEntryTypes

if TYPE_CHECKING: # pragma: only cryptography<44
# NOTE: we can use bases directly once instances are supported in every versoin
NamingAuthorityBase = CryptographyModel[x509.NamingAuthority]
ProfessionInfoBase = CryptographyModel[x509.ProfessionInfo]
AdmissionBase = CryptographyModel[x509.Admission]
AdmissionsValueModelBase = CryptographyModel[x509.Admissions]
else:
NamingAuthorityBase = ProfessionInfoBase = AdmissionBase = AdmissionsValueModelBase = CryptographyModel

_NOTICE_REFERENCE_DESCRIPTION = (
"A NoticeReferenceModel consists of an optional *organization* and an optional list of *notice_numbers*."
)


class NamingAuthorityModel(NamingAuthorityBase): # pragma: only cryptography>=44.0
"""Pydantic model wrapping :py:class:`~cg:cryptography.x509.NamingAuthority`.
.. NOTE:: This class will not be able to produce a cryptography instance when using ``cryptography<44``.
.. versionadded:: 2.1.0
"""

model_config = ConfigDict(from_attributes=True)

id: Optional[OIDType] = 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)


class ProfessionInfoModel(ProfessionInfoBase): # pragma: only cryptography>=44.0
"""Pydantic model wrapping :py:class:`~cg:cryptography.x509.ProfessionInfo`.
.. NOTE:: This class will not be able to produce a cryptography instance when using ``cryptography<44``.
.. versionadded:: 2.1.0
"""

model_config = ConfigDict(from_attributes=True)

naming_authority: Optional[NamingAuthorityModel] = None
profession_items: Annotated[list[Annotated[str, MaxLen(128)]], MinLen(1)]
profession_oids: Optional[list[OIDType]] = 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
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,
registration_number=self.registration_number,
add_profession_info=self.add_profession_info,
)

@model_validator(mode="after")
def check_consistency(self) -> "ProfessionInfoModel":
"""Verify that profession_oids has the same length as profession_items if set."""
if self.profession_oids is not None and len(self.profession_items) != len(self.profession_oids):
raise ValueError("if present, profession_oids must have the same length as profession_items.")
return self


class AdmissionModel(AdmissionBase): # pragma: only cryptography>=44.0
"""Pydantic model wrapping :py:class:`~cg:cryptography.x509.Admission`.
.. NOTE:: This class will not be able to produce a cryptography instance when using ``cryptography<44``.
.. versionadded:: 2.1.0
"""

model_config = ConfigDict(from_attributes=True)

admission_authority: Optional[GeneralNameModel] = None
naming_authority: Optional[NamingAuthorityModel] = None
profession_infos: Annotated[list[ProfessionInfoModel], MinLen(1)]

@property
def cryptography(self) -> "x509.Admission":
admission_authority = naming_authority = None
if self.admission_authority is not None:
admission_authority = self.admission_authority.cryptography
if self.naming_authority is not None:
naming_authority = self.naming_authority.cryptography

return x509.Admission(
admission_authority=admission_authority,
naming_authority=naming_authority,
profession_infos=[pi.cryptography for pi in self.profession_infos],
)


class AdmissionsValueModel(AdmissionsValueModelBase): # pragma: only cryptography>=44.0
"""Pydantic model wrapping :py:class:`~cg:cryptography.x509.Admissions`.
.. NOTE:: This class will not be able to produce a cryptography instance when using ``cryptography<44``.
.. versionadded:: 2.1.0
"""

model_config = ConfigDict(from_attributes=True)

authority: Optional[GeneralNameModel] = None
admissions: list[AdmissionModel] = Field(default_factory=list)

@property
def cryptography(self) -> "x509.Admissions":
authority = None
if self.authority is not None:
authority = self.authority.cryptography
return x509.Admissions(
authority=authority, admissions=[admission.cryptography for admission in self.admissions]
)

@model_validator(mode="before")
@classmethod
def parse_cryptography(cls, data: Any) -> Any:
"""Parse cryptography instance."""
if isinstance(data, x509.Admissions):
return {"authority": data.authority, "admissions": data._admissions} # pylint: disable=protected-access
return data


class AccessDescriptionModel(CryptographyModel[x509.AccessDescription]):
"""Pydantic model wrapping :py:class:`~cg:cryptography.x509.AccessDescription`.
Expand Down
34 changes: 32 additions & 2 deletions ca/django_ca/pydantic/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,14 @@
from pydantic_core.core_schema import ValidationInfo

from cryptography import x509
from cryptography.x509.oid import AuthorityInformationAccessOID, ExtensionOID, SubjectInformationAccessOID
from cryptography.x509.oid import AuthorityInformationAccessOID, SubjectInformationAccessOID

from django_ca.constants import EXTENSION_DEFAULT_CRITICAL, KEY_USAGE_NAMES
from django_ca.constants import EXTENSION_DEFAULT_CRITICAL, KEY_USAGE_NAMES, ExtensionOID
from django_ca.pydantic import validators
from django_ca.pydantic.base import CryptographyModel
from django_ca.pydantic.extension_attributes import (
AccessDescriptionModel,
AdmissionsValueModel,
AuthorityKeyIdentifierValueModel,
BasicConstraintsValueModel,
DistributionPointModel,
Expand Down Expand Up @@ -128,6 +129,7 @@

from pydantic.main import IncEx


###############
# Base models #
###############
Expand Down Expand Up @@ -320,6 +322,31 @@ def extension_type(self) -> NoReturn: # pragma: no cover
# ExtensionType models #
########################

if TYPE_CHECKING:
AdmissionsModelBase = ExtensionModel[x509.Admissions]
else:
AdmissionsModelBase = ExtensionModel


class AdmissionsModel(AdmissionsModelBase): # pragma: only cryptography>=44.0
"""Pydantic model for a :py:class:`~cg:cryptography.x509.Admissions` extension.
.. NOTE:: This class will not be able to produce a cryptography instance when using ``cryptography<44``.
.. versionadded:: 2.1.0
"""

model_config = ConfigDict(from_attributes=True)

type: Literal["admissions"] = Field(default="admissions", repr=False)
critical: bool = EXTENSION_DEFAULT_CRITICAL[ExtensionOID.ADMISSIONS]
value: AdmissionsValueModel

@property
def extension_type(self) -> "x509.Admissions":
"""Convert to a :py:class:`~cg:cryptography.x509.AuthorityKeyIdentifier` instance."""
return self.value.cryptography


class AuthorityInformationAccessModel(InformationAccessBaseModel[x509.AuthorityInformationAccess]):
"""Pydantic model for a :py:class:`~cg:cryptography.x509.AuthorityInformationAccess` extension.
Expand Down Expand Up @@ -924,6 +951,7 @@ def extension_type(self) -> x509.UnrecognizedExtension:

EXTENSION_MODEL_OIDS: "MappingProxyType[type[ExtensionModel[Any]], x509.ObjectIdentifier]" = MappingProxyType(
{
AdmissionsModel: ExtensionOID.ADMISSIONS,
AuthorityInformationAccessModel: ExtensionOID.AUTHORITY_INFORMATION_ACCESS,
AuthorityKeyIdentifierModel: ExtensionOID.AUTHORITY_KEY_IDENTIFIER,
BasicConstraintsModel: ExtensionOID.BASIC_CONSTRAINTS,
Expand Down Expand Up @@ -970,6 +998,7 @@ def validate_cryptography_extensions(v: Any, info: ValidationInfo) -> Any:
ConfigurableExtensionModel = Annotated[
Annotated[
Union[
AdmissionsModel,
AuthorityInformationAccessModel,
CertificatePoliciesModel,
CRLDistributionPointsModel,
Expand All @@ -992,6 +1021,7 @@ def validate_cryptography_extensions(v: Any, info: ValidationInfo) -> Any:
CertificateExtensionModel = Annotated[
Annotated[
Union[
AdmissionsModel,
AuthorityInformationAccessModel,
AuthorityKeyIdentifierModel,
BasicConstraintsModel,
Expand Down
2 changes: 0 additions & 2 deletions ca/django_ca/pydantic/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,4 @@ def get_extensions(self) -> list[ConfigurableExtension]:
if self.extensions is None:
return []
extensions = [ext.cryptography for ext in self.extensions]

# TYPEHINT NOTE: list has Extension[A] | Extension[B], but value has Extension[A | B].
return extensions
9 changes: 6 additions & 3 deletions ca/django_ca/tests/extensions/test_extension_values.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,16 @@ def pytest_generate_tests(metafunc: Any) -> None:
def assert_parsed(key: str, serialized: Any, extension_type: CertificateExtensionType, name: str) -> None:
"""Assert that the given `serialized` value parses to the given `extension_type`."""
oid = extension_type.oid
ext = x509.Extension(oid=oid, critical=EXTENSION_DEFAULT_CRITICAL[oid], value=extension_type)
ext = cast(
CertificateExtension,
x509.Extension(oid=oid, critical=EXTENSION_DEFAULT_CRITICAL[oid], value=extension_type),
)
assert parse_extension(key, {"value": serialized}) == ext, name

ext = x509.Extension(oid=oid, critical=True, value=extension_type)
ext = x509.Extension(oid=oid, critical=True, value=extension_type) # type: ignore[assignment]
assert parse_extension(key, {"value": serialized, "critical": True}) == ext, name

ext = cast(CertificateExtension, x509.Extension(oid=oid, critical=False, value=extension_type))
ext = x509.Extension(oid=oid, critical=False, value=extension_type) # type: ignore[assignment]
assert parse_extension(key, {"value": serialized, "critical": False}) == ext, name

assert parse_extension(key, ext) is ext
Expand Down
5 changes: 4 additions & 1 deletion ca/django_ca/tests/pydantic/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ def assert_cryptography_model(
"""Test that a cryptography model matches the expected value."""
model = model_class(**parameters)
assert model.cryptography == expected
assert model == model_class.model_validate(expected), (model, model_class.model_validate(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

Expand Down
Loading

0 comments on commit 471ccaa

Please sign in to comment.