diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 81711a4e5134..02385b4d64e6 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,6 +8,8 @@ Changelog .. note:: This version is not yet released and is under active development. +* Added support for the :class:`~cryptography.x509.PolicyMappings` extension. + .. _v50-0-0: 50.0.0 - 2026-07-31 diff --git a/docs/x509/reference.rst b/docs/x509/reference.rst index 1dfc9975af8e..9c12a5ef0c0f 100644 --- a/docs/x509/reference.rst +++ b/docs/x509/reference.rst @@ -2986,6 +2986,26 @@ X.509 Extensions mapping may be processed in certificates issued by the subject of this certificate, but not in additional certificates in the chain. +.. class:: PolicyMappings(mappings) + :canonical: cryptography.x509.extensions.PolicyMappings + + .. versionadded:: 51.0.0 + + The policy mappings extension is used in CA certificates to map policy + identifiers in the issuer's policy domain to policy identifiers in the + subject's policy domain. For more information see :rfc:`5280`. + + :param mappings: A non-empty iterable of 2-tuples. Each tuple contains an + issuer domain policy :class:`ObjectIdentifier` followed by a subject + domain policy :class:`ObjectIdentifier`. + + .. attribute:: oid + + :type: :class:`ObjectIdentifier` + + Returns :attr:`~cryptography.x509.oid.ExtensionOID.POLICY_MAPPINGS`. + + .. class:: CRLNumber(crl_number) :canonical: cryptography.x509.extensions.CRLNumber diff --git a/src/cryptography/x509/__init__.py b/src/cryptography/x509/__init__.py index 6442b0096c1c..a9f4efdd01d2 100644 --- a/src/cryptography/x509/__init__.py +++ b/src/cryptography/x509/__init__.py @@ -64,6 +64,7 @@ OCSPNonce, PolicyConstraints, PolicyInformation, + PolicyMappings, PrecertificateSignedCertificateTimestamps, PrecertPoison, PrivateKeyUsagePeriod, @@ -233,6 +234,7 @@ "OtherName", "PolicyConstraints", "PolicyInformation", + "PolicyMappings", "PrecertPoison", "PrecertificateSignedCertificateTimestamps", "PrivateKeyUsagePeriod", diff --git a/src/cryptography/x509/extensions.py b/src/cryptography/x509/extensions.py index dc7728e23e67..f420f4859a98 100644 --- a/src/cryptography/x509/extensions.py +++ b/src/cryptography/x509/extensions.py @@ -37,6 +37,7 @@ ) from cryptography.x509.name import Name, RelativeDistinguishedName from cryptography.x509.oid import ( + CertificatePoliciesOID, CRLEntryExtensionOID, ExtensionOID, ObjectIdentifier, @@ -815,6 +816,52 @@ def public_bytes(self) -> bytes: return rust_x509.encode_extension_value(self) +class PolicyMappings(ExtensionType): + oid = ExtensionOID.POLICY_MAPPINGS + + def __init__( + self, + mappings: Iterable[tuple[ObjectIdentifier, ObjectIdentifier]], + ) -> None: + mappings = list(mappings) + if not mappings: + raise ValueError("mappings must be a non-empty list") + if not all( + isinstance(mapping, tuple) + and len(mapping) == 2 + and all(isinstance(oid, ObjectIdentifier) for oid in mapping) + for mapping in mappings + ): + raise TypeError( + "Every item in the mappings list must be a 2-tuple of " + "ObjectIdentifier" + ) + if any( + CertificatePoliciesOID.ANY_POLICY in mapping + for mapping in mappings + ): + raise ValueError("Policy mappings must not contain anyPolicy") + + self._mappings = mappings + + __len__, __iter__, __getitem__ = _make_sequence_methods("_mappings") + + def __repr__(self) -> str: + return f"" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, PolicyMappings): + return NotImplemented + + return self._mappings == other._mappings + + def __hash__(self) -> int: + return hash(tuple(self._mappings)) + + def public_bytes(self) -> bytes: + return rust_x509.encode_extension_value(self) + + class CertificatePolicies(ExtensionType): oid = ExtensionOID.CERTIFICATE_POLICIES diff --git a/src/rust/cryptography-x509/src/extensions.rs b/src/rust/cryptography-x509/src/extensions.rs index 8a9df2a16482..03934b3890da 100644 --- a/src/rust/cryptography-x509/src/extensions.rs +++ b/src/rust/cryptography-x509/src/extensions.rs @@ -87,6 +87,14 @@ pub struct PolicyConstraints { pub inhibit_policy_mapping: Option, } +#[derive(asn1::Asn1Read, asn1::Asn1Write)] +pub struct PolicyMapping { + pub issuer_domain_policy: asn1::ObjectIdentifier, + pub subject_domain_policy: asn1::ObjectIdentifier, +} + +pub type PolicyMappings<'a, Op> = ::SequenceOfVec<'a, PolicyMapping>; + #[derive(asn1::Asn1Read, asn1::Asn1Write)] pub struct AccessDescription<'a> { pub access_method: asn1::ObjectIdentifier, diff --git a/src/rust/cryptography-x509/src/oid.rs b/src/rust/cryptography-x509/src/oid.rs index 9f54feab0c62..273b6ff4891f 100644 --- a/src/rust/cryptography-x509/src/oid.rs +++ b/src/rust/cryptography-x509/src/oid.rs @@ -38,6 +38,7 @@ pub const CERTIFICATE_ISSUER_OID: asn1::ObjectIdentifier = asn1::oid!(2, 5, 29, pub const NAME_CONSTRAINTS_OID: asn1::ObjectIdentifier = asn1::oid!(2, 5, 29, 30); pub const CRL_DISTRIBUTION_POINTS_OID: asn1::ObjectIdentifier = asn1::oid!(2, 5, 29, 31); pub const CERTIFICATE_POLICIES_OID: asn1::ObjectIdentifier = asn1::oid!(2, 5, 29, 32); +pub const POLICY_MAPPINGS_OID: asn1::ObjectIdentifier = asn1::oid!(2, 5, 29, 33); pub const AUTHORITY_KEY_IDENTIFIER_OID: asn1::ObjectIdentifier = asn1::oid!(2, 5, 29, 35); pub const POLICY_CONSTRAINTS_OID: asn1::ObjectIdentifier = asn1::oid!(2, 5, 29, 36); pub const EXTENDED_KEY_USAGE_OID: asn1::ObjectIdentifier = asn1::oid!(2, 5, 29, 37); diff --git a/src/rust/src/types.rs b/src/rust/src/types.rs index 74c6c3ec9b9e..9363b7eaa9bc 100644 --- a/src/rust/src/types.rs +++ b/src/rust/src/types.rs @@ -153,6 +153,8 @@ pub static INHIBIT_ANY_POLICY: LazyPyImport = pub static OCSP_NO_CHECK: LazyPyImport = LazyPyImport::new("cryptography.x509", &["OCSPNoCheck"]); pub static POLICY_CONSTRAINTS: LazyPyImport = LazyPyImport::new("cryptography.x509", &["PolicyConstraints"]); +pub static POLICY_MAPPINGS: LazyPyImport = + LazyPyImport::new("cryptography.x509", &["PolicyMappings"]); pub static CERTIFICATE_POLICIES: LazyPyImport = LazyPyImport::new("cryptography.x509", &["CertificatePolicies"]); pub static SUBJECT_INFORMATION_ACCESS: LazyPyImport = diff --git a/src/rust/src/x509/certificate.rs b/src/rust/src/x509/certificate.rs index af5b6fe499f9..6f97c23d660c 100644 --- a/src/rust/src/x509/certificate.rs +++ b/src/rust/src/x509/certificate.rs @@ -11,7 +11,7 @@ use cryptography_x509::extensions::{ Admission, Admissions, AuthorityKeyIdentifier, BasicConstraints, DisplayText, DistributionPoint, DistributionPointName, DuplicateExtensionsError, ExtendedKeyUsage, Extension, IssuerAlternativeName, KeyUsage, MSCertificateTemplate, NameConstraints, - NamingAuthority, PolicyConstraints, PolicyInformation, PolicyQualifierInfo, + NamingAuthority, PolicyConstraints, PolicyInformation, PolicyMappings, PolicyQualifierInfo, PrivateKeyUsagePeriod, ProfessionInfo, Qualifier, RawExtensions, SequenceOfAccessDescriptions, SequenceOfSubtrees, SubjectAlternativeName, UserNotice, }; @@ -889,6 +889,16 @@ pub fn parse_cert_ext<'p>( pc.inhibit_policy_mapping, ))?)) } + oid::POLICY_MAPPINGS_OID => { + let mappings = ext.value::>()?; + let py_mappings = pyo3::types::PyList::empty(py); + for mapping in mappings { + let issuer_policy = oid_to_py_oid(py, &mapping.issuer_domain_policy)?; + let subject_policy = oid_to_py_oid(py, &mapping.subject_domain_policy)?; + py_mappings.append((issuer_policy, subject_policy))?; + } + Ok(Some(types::POLICY_MAPPINGS.get(py)?.call1((py_mappings,))?)) + } oid::OCSP_NO_CHECK_OID => { ext.value::<()>()?; Ok(Some(types::OCSP_NO_CHECK.get(py)?.call0()?)) diff --git a/src/rust/src/x509/extensions.rs b/src/rust/src/x509/extensions.rs index 37c633a395c4..60a7114ec2a3 100644 --- a/src/rust/src/x509/extensions.rs +++ b/src/rust/src/x509/extensions.rs @@ -595,6 +595,18 @@ pub(crate) fn encode_extension( }; Ok(Some(asn1::write_single(&pc)?)) } + &oid::POLICY_MAPPINGS_OID => { + let mut mappings = vec![]; + for py_mapping in ext.try_iter()? { + let py_mapping = py_mapping?; + mappings.push(extensions::PolicyMapping { + issuer_domain_policy: py_oid_to_oid(py_mapping.get_item(0)?)?, + subject_domain_policy: py_oid_to_oid(py_mapping.get_item(1)?)?, + }); + } + let mappings = asn1::SequenceOfWriter::new(mappings); + Ok(Some(asn1::write_single(&mappings)?)) + } &oid::NAME_CONSTRAINTS_OID => { let ka_bytes = cryptography_keepalive::KeepAlive::new(); let ka_str = cryptography_keepalive::KeepAlive::new(); diff --git a/tests/x509/test_x509_ext.py b/tests/x509/test_x509_ext.py index 80bda9d46fbf..ebfe09d36b05 100644 --- a/tests/x509/test_x509_ext.py +++ b/tests/x509/test_x509_ext.py @@ -28,6 +28,7 @@ ) from cryptography.x509.oid import ( AuthorityInformationAccessOID, + CertificatePoliciesOID, ExtendedKeyUsageOID, ExtensionOID, NameOID, @@ -3124,6 +3125,73 @@ def test_public_bytes(self): assert ext.public_bytes() == b"\x30\x03\x81\x01\x00" +class TestPolicyMappings: + issuer_policy = ObjectIdentifier("1.2.3.4") + subject_policy = ObjectIdentifier("1.2.3.5") + mapping = ((issuer_policy, subject_policy),) + + def test_invalid_mappings(self): + with pytest.raises(TypeError): + x509.PolicyMappings( + [(self.issuer_policy, typing.cast(typing.Any, "invalid"))] + ) + with pytest.raises(TypeError): + x509.PolicyMappings( + [typing.cast(typing.Any, (self.issuer_policy,))] + ) + + def test_empty_mappings(self): + with pytest.raises(ValueError): + x509.PolicyMappings([]) + + @pytest.mark.parametrize("position", [0, 1]) + def test_any_policy(self, position): + mapping = list(self.mapping[0]) + mapping[position] = CertificatePoliciesOID.ANY_POLICY + with pytest.raises(ValueError, match="must not contain anyPolicy"): + x509.PolicyMappings([tuple(mapping)]) # type: ignore[list-item] + + def test_iter_len_index(self): + mappings = x509.PolicyMappings(self.mapping) + assert len(mappings) == 1 + assert list(mappings) == list(self.mapping) + + def test_repr(self): + assert repr(x509.PolicyMappings(self.mapping)) == ( + ", )])>" + ) + + def test_eq_hash(self): + iss_to_sub = [(self.issuer_policy, self.subject_policy)] + sub_to_iss = [(self.subject_policy, self.issuer_policy)] + + mappings = x509.PolicyMappings(iss_to_sub) + mappings2 = x509.PolicyMappings(iss_to_sub) + mappings3 = x509.PolicyMappings(sub_to_iss) + assert mappings == mappings2 + assert mappings != mappings3 + assert mappings != object() + assert hash(mappings) == hash(mappings2) + assert hash(mappings) != hash(mappings3) + + def test_public_bytes(self): + mappings = x509.PolicyMappings(self.mapping) + assert mappings.public_bytes() == ( + b"\x30\x0c\x30\x0a\x06\x03\x2a\x03\x04\x06\x03\x2a\x03\x05" + ) + + def test_certbuilder(self, rsa_key_2048: rsa.RSAPrivateKey): + cert = ( + _make_certbuilder(rsa_key_2048) + .add_extension(x509.PolicyMappings(self.mapping), critical=True) + .sign(rsa_key_2048, hashes.SHA256()) + ) + ext = cert.extensions.get_extension_for_class(x509.PolicyMappings) + assert ext.critical is True + assert list(ext.value) == list(self.mapping) + + class TestAuthorityInformationAccess: def test_invalid_descriptions(self): with pytest.raises(TypeError):