diff --git a/newsfragments/727.bugfix.rst b/newsfragments/727.bugfix.rst new file mode 100644 index 0000000..454260c --- /dev/null +++ b/newsfragments/727.bugfix.rst @@ -0,0 +1,2 @@ +``CA.from_pem`` now preserves the imported certificate's CA path-length +constraint when creating child CAs, including an unlimited path length. diff --git a/src/trustme/__init__.py b/src/trustme/__init__.py index 1040892..fa85c58 100644 --- a/src/trustme/__init__.py +++ b/src/trustme/__init__.py @@ -235,7 +235,7 @@ class CA: def __init__( self, parent_cert: Optional[CA] = None, - path_length: int = 9, + path_length: Optional[int] = 9, organization_name: Optional[str] = None, organization_unit_name: Optional[str] = None, key_type: KeyType = KeyType.ECDSA, @@ -317,7 +317,7 @@ def create_child_ca(self, key_type: KeyType = KeyType.ECDSA) -> "CA": if self._path_length == 0: raise ValueError("Can't create child CA: path length is 0") - path_length = self._path_length - 1 + path_length = self._path_length - 1 if self._path_length is not None else None return CA(parent_cert=self, path_length=path_length, key_type=key_type) def issue_cert( @@ -499,6 +499,15 @@ def from_pem(cls, cert_bytes: bytes, private_key_bytes: bytes) -> "CA": ca.parent_cert = None ca._certificate = x509.load_pem_x509_certificate(cert_bytes) ca._private_key = load_pem_private_key(private_key_bytes, password=None) # type: ignore[assignment] + try: + constraints = ca._certificate.extensions.get_extension_for_class( + x509.BasicConstraints + ) + except x509.ExtensionNotFound: + # Preserve the default for older certificates without constraints. + pass + else: + ca._path_length = constraints.value.path_length return ca diff --git a/tests/test_trustme.py b/tests/test_trustme.py index 93275bc..a9d15ff 100644 --- a/tests/test_trustme.py +++ b/tests/test_trustme.py @@ -11,6 +11,7 @@ import pytest import service_identity.pyopenssl # type: ignore[import-not-found] from cryptography import x509 +from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.serialization import ( Encoding, PublicFormat, @@ -284,6 +285,46 @@ def test_ca_from_pem(tmp_path: Path) -> None: assert ca1.private_key_pem.bytes() == ca2.private_key_pem.bytes() +@pytest.mark.parametrize("path_length", [0, 1, 2, 9, None]) +def test_ca_from_pem_path_length(path_length: Optional[int]) -> None: + ca = CA(path_length=path_length) + restored = CA.from_pem(ca.cert_pem.bytes(), ca.private_key_pem.bytes()) + + # An unconstrained CA must also remain unconstrained after import, + # including beyond the default depth of nine intermediates. + for depth in range(path_length if path_length is not None else 11): + restored = restored.create_child_ca() + cert = x509.load_pem_x509_certificate(restored.cert_pem.bytes()) + expected = path_length - depth - 1 if path_length is not None else None + assert _path_length(cert) == expected + + if path_length is not None: + with pytest.raises(ValueError, match="path length is 0"): + restored.create_child_ca() + + +def test_ca_from_pem_without_basic_constraints() -> None: + ca = CA() + original = ca._certificate + cert = ( + x509.CertificateBuilder() + .subject_name(original.subject) + .issuer_name(original.issuer) + .public_key(ca._private_key.public_key()) + .serial_number(original.serial_number) + .not_valid_before(original.not_valid_before_utc) + .not_valid_after(original.not_valid_after_utc) + .add_extension( + x509.SubjectKeyIdentifier.from_public_key(ca._private_key.public_key()), + critical=False, + ) + .sign(ca._private_key, hashes.SHA256()) + ) + restored = CA.from_pem(cert.public_bytes(Encoding.PEM), ca.private_key_pem.bytes()) + child = restored.create_child_ca() + assert _path_length(x509.load_pem_x509_certificate(child.cert_pem.bytes())) == 8 + + def check_connection_end_to_end( wrap_client: Callable[[CA, socket.socket, str], SslSocket], wrap_server: Callable[[LeafCert, socket.socket], SslSocket],