diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..7bf3fdc --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "ms-python.python", + "ms-python.vscode-pylance", + "ms-python.black-formatter" + ] +} diff --git a/README.md b/README.md index 0e35198..557b4d8 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## Description -Certy provides a simple API for creating X509 certificates on demand when running unit tests. +Certy provides a simple API for creating X509 certificates and certificate revocation lists on demand when running unit tests. No more storing test certificates and private keys in the repository! Python-certy is a version of similar tool for command line and Golang called [certyaml](https://github.com/tsaarni/certyaml) and [java-certy](https://github.com/tsaarni/java-certy/) for Java. @@ -25,6 +25,7 @@ cred.write_private_key_as_pem("key.pem") ## Documentation The latest documentation is available [here](https://tsaarni.github.io/python-certy/). +See also [tests](tests) for more examples. ## Installation @@ -43,4 +44,4 @@ To find out coverage of tests, execute `coverage run -m pytest` and then `covera The coverage report is generated to `htmlcov/index.html`. Run `make html` on `docs` directory to generate documentation. -Open `docs/_build/html/index.html` to view the generated documentation. \ No newline at end of file +Open `docs/_build/html/index.html` to view the generated documentation. diff --git a/docs/index.rst b/docs/index.rst index 2a3bea5..e2b3ae9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,7 +1,7 @@ Welcome to certy's documentation! ================================= -Certy provides a simple API for creating X509 certificates on demand when running unit tests. +Certy provides a simple API for creating X509 certificates and certificate revocation lists on demand when running unit tests. No more storing test certificates and private keys in the repository! Python-certy is a version of similar tool for command line and Golang called `certyaml`_ and `java-certy`_ for Java. diff --git a/pyproject.toml b/pyproject.toml index 3b1ce4d..293e991 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,3 +18,6 @@ keywords = ["testing", "certificates", "x509", "pki"] [project.urls] Documentation = "https://tsaarni.github.io/python-certy/" Source = "https://github.com/tsaarni/python-certy" + +[tool.black] +line-length = 120 diff --git a/src/certy/__init__.py b/src/certy/__init__.py index c631ce8..30f596f 100644 --- a/src/certy/__init__.py +++ b/src/certy/__init__.py @@ -1,7 +1,30 @@ +# +# Copyright Certy Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + """Certy is a simple X509 certificate generator for unit and integration tests.""" __version__ = "0.1.4" from .credential import Credential, KeyType, KeyUsage, ExtendedKeyUsage +from .certificate_revocation_list import CertificateRevocationList -__all__ = ["Credential", "KeyType", "KeyUsage", "ExtendedKeyUsage"] +__all__ = [ + "Credential", + "KeyType", + "KeyUsage", + "ExtendedKeyUsage", + "CertificateRevocationList", +] diff --git a/src/certy/certificate_revocation_list.py b/src/certy/certificate_revocation_list.py new file mode 100644 index 0000000..0c6522e --- /dev/null +++ b/src/certy/certificate_revocation_list.py @@ -0,0 +1,212 @@ +# +# Copyright Certy Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +import datetime + +from cryptography import x509 +from cryptography.hazmat.primitives import serialization + +from certy import Credential + + +class CertificateRevocationList(object): + """CertificateRevocationList is a builder for X.509 CRLs.""" + + def __init__( + self, + issuer: Credential | None = None, + revoked_certificates: list[Credential] | None = None, + this_update: datetime.datetime | None = None, + next_update: datetime.datetime | None = None, + ): + self._issuer = issuer + self._revoked_certificates = revoked_certificates or [] + self._this_update = this_update + self._next_update = next_update + + # Generated attributes + self._crl: x509.CertificateRevocationList | None = None + + def __repr__(self) -> str: + issuer_name = self._issuer._subject if self._issuer else None + subject_names = [revoked._subject for revoked in self._revoked_certificates] + return f"CertificateRevocationList(issuer={issuer_name!r}, revoked_certificates={subject_names!r}, this_update={self._this_update!r}, next_update={self._next_update!r})" + + # Setter methods + + def issuer(self, issuer: Credential) -> CertificateRevocationList: + """Set the issuer of the CRL. + + If not called, the issuer will be inferred from the first certificate added to the CRL by calling :meth:`add`. + + :param issuer: The issuer of the CRL. + :type issuer: Credential + :return: self + :rtype: CertificateRevocationList + """ + self._issuer = issuer + return self + + def this_update(self, this_update: datetime.datetime) -> CertificateRevocationList: + """Set the ``thisUpdate`` field of the CRL. + + If not called, the ``thisUpdate`` field will be set to the current time. + + :param this_update: The ``thisUpdate`` field of the CRL. + :type this_update: datetime.datetime + :return: self + :rtype: CertificateRevocationList + """ + self._this_update = this_update + return self + + def next_update(self, next_update: datetime.datetime) -> CertificateRevocationList: + """Set the ``nextUpdate`` field of the CRL. + + If not called, the ``nextUpdate`` field will be set to ``thisUpdate`` plus 7 days. + + :param next_update: The nextUpdate field of the CRL. + :type next_update: datetime.datetime + :return: self + :rtype: CertificateRevocationList + """ + self._next_update = next_update + return self + + def add(self, certificate: Credential) -> CertificateRevocationList: + """Add a certificate to the CRL. + + All certificates added to the CRL must have the same issuer. + + :param certificate: The certificate to add to the CRL. + :type certificate: Credential + :return: self + :rtype: CertificateRevocationList + """ + + if self._issuer and certificate._issuer != self._issuer: + raise ValueError("issuer mismatch") + if self._revoked_certificates and certificate._issuer != self._revoked_certificates[0]._issuer: + raise ValueError("issuer mismatch") + + self._revoked_certificates.append(certificate) + return self + + # Builder methods + + def generate(self) -> CertificateRevocationList: + """Generate the CRL. + + This method will (re)generate the CRL. It will be called automatically if the CRL is not yet generated when + :meth:`get_as_pem`, :meth:`get_as_der`, :meth:`write_pem` or :meth:`write_der` is called. + + :return: self + :rtype: CertificateRevocationList + """ + if not self._issuer: + if len(self._revoked_certificates) == 0: + raise ValueError("issuer not known: either set issuer or add certificates to the CRL") + if self._revoked_certificates[0]._issuer is None: + raise ValueError("cannot determine issuer from first certificate in CRL") + self._issuer = self._revoked_certificates[0]._issuer + + # Ensure that the issuer has a key pair. + self._issuer._ensure_generated() + + effective_revocation_time = datetime.datetime.utcnow() + if self._this_update: + effective_revocation_time = self._this_update + + effective_expiry_time = effective_revocation_time + datetime.timedelta(days=7) + if self._next_update: + effective_expiry_time = self._next_update + + builder = ( + x509.CertificateRevocationListBuilder() + .issuer_name(self._issuer._certificate.subject) # type: ignore + .last_update(effective_revocation_time) + .next_update(effective_expiry_time) + ) + + for certificate in self._revoked_certificates: + certificate._ensure_generated() + builder = builder.add_revoked_certificate( + x509.RevokedCertificateBuilder() + .serial_number(certificate._certificate.serial_number) # type: ignore + .revocation_date(effective_revocation_time) + .build() + ) + + self._crl = builder.sign( + private_key=self._issuer._private_key, # type: ignore + algorithm=self._issuer._certificate.signature_hash_algorithm, # type: ignore + ) + + return self + + def get_as_pem(self) -> bytes: + """Get the CRL as PEM. + + :return: The CRL as PEM. + :rtype: bytes + """ + self._ensure_generated() + return self._crl.public_bytes(encoding=serialization.Encoding.PEM) # type: ignore + + def get_as_der(self) -> bytes: + """Get the CRL as DER. + + :return: The CRL as DER. + :rtype: bytes + """ + self._ensure_generated() + return self._crl.public_bytes(encoding=serialization.Encoding.DER) # type: ignore + + def write_pem(self, filename: str) -> CertificateRevocationList: + """Write the CRL as PEM to a file. + + :param filename: The filename to write the CRL to. + :type filename: str + :return: self + :rtype: CertificateRevocationList + """ + self._ensure_generated() + with open(filename, "wb") as f: + f.write(self.get_as_pem()) + return self + + def write_der(self, filename: str) -> CertificateRevocationList: + """Write the CRL as DER to a file. + + :param filename: The filename to write the CRL to. + :type filename: str + :return: self + :rtype: CertificateRevocationList + """ + self._ensure_generated() + with open(filename, "wb") as f: + f.write(self.get_as_der()) + return self + + # Helper methods + + def _ensure_generated(self) -> CertificateRevocationList: + """Ensure that the CRL has been generated.""" + if not self._crl: + self.generate() + return self diff --git a/src/certy/credential.py b/src/certy/credential.py index 20251d4..865e6ea 100644 --- a/src/certy/credential.py +++ b/src/certy/credential.py @@ -1,3 +1,19 @@ +# +# Copyright Certy Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + from __future__ import annotations import ipaddress @@ -67,6 +83,7 @@ def __init__( key_usages: list[KeyUsage] | None = None, ext_key_usages: list[ExtendedKeyUsage] | None = None, serial: int | None = None, + crl_distribution_point_uri: str | None = None, ): self._subject = subject self._subject_alt_names = subject_alt_names @@ -80,13 +97,14 @@ def __init__( self._key_usages = key_usages self._ext_key_usages = ext_key_usages self._serial = serial + self._crl_distribution_point_uri = crl_distribution_point_uri # Generated attributes self._private_key: rsa.RSAPrivateKey | ec.EllipticCurvePrivateKey | None = None self._certificate: x509.Certificate | None = None def __repr__(self): - return f"Credential(subject={self._subject!r}, key_type={self._key_type!r}, key_size={self._key_size!r}, expires={self._expires!r}, not_before={self._not_before!r}, not_after={self._not_after!r}, issuer={self._issuer!r}, is_ca={self._is_ca!r}, key_usages={self._key_usages!r}, ext_key_usages={self._ext_key_usages!r}, serial={self._serial!r})" + return f"Credential(subject={self._subject!r}, key_type={self._key_type!r}, key_size={self._key_size!r}, expires={self._expires!r}, not_before={self._not_before!r}, not_after={self._not_after!r}, issuer={self._issuer!r}, is_ca={self._is_ca!r}, key_usages={self._key_usages!r}, ext_key_usages={self._ext_key_usages!r}, serial={self._serial!r}, crl_distribution_point_uri={self._crl_distribution_point_uri!r})" # Setter methods @@ -283,12 +301,25 @@ def ext_key_usages(self, *ext_key_usages: ExtendedKeyUsage) -> Credential: """ for ext_key_usage in ext_key_usages: if not isinstance(ext_key_usage, ExtendedKeyUsage): - raise ValueError( - "Extended key usages must be a list of certy.ExtendedKeyUsage" - ) + raise ValueError("Extended key usages must be a list of certy.ExtendedKeyUsage") self._ext_key_usages = ext_key_usages return self + def crl_distribution_point_uri(self, uri: str) -> Credential: + """Set the CRL distribution point URI of this credential. + + If not called, the CRL distribution point extension is not included in the certificate. + + :param uri: The URI of the CRL distribution point. + :type uri: str + :return: This credential instance. + :rtype: Credential + """ + if not isinstance(uri, str): + raise ValueError("URI must be a string") + self._crl_distribution_point_uri = uri + return self + # Builder methods def generate(self) -> Credential: @@ -365,14 +396,10 @@ def generate(self) -> Credential: .public_key(self._private_key.public_key()) ) - builder = builder.add_extension( - x509.BasicConstraints(ca=self._is_ca, path_length=None), critical=True - ) + builder = builder.add_extension(x509.BasicConstraints(ca=self._is_ca, path_length=None), critical=True) if self._subject_alt_names is not None: - builder = builder.add_extension( - x509.SubjectAlternativeName(self._subject_alt_names), critical=False - ) + builder = builder.add_extension(x509.SubjectAlternativeName(self._subject_alt_names), critical=False) if self._key_usages is not None: builder = builder.add_extension( @@ -396,6 +423,21 @@ def generate(self) -> Credential: critical=False, ) + if self._crl_distribution_point_uri is not None: + builder = builder.add_extension( + x509.CRLDistributionPoints( + [ + x509.DistributionPoint( + full_name=[x509.UniformResourceIdentifier(self._crl_distribution_point_uri)], + relative_name=None, + crl_issuer=None, + reasons=None, + ) + ] + ), + critical=False, + ) + self._certificate = builder.sign( effective_issuer._private_key, # type: ignore _preferred_signature_hash_algorithm(effective_issuer._key_type, effective_issuer._key_size), # type: ignore @@ -446,10 +488,7 @@ def get_certificates_as_pem(self) -> bytes: :rtype: bytes """ self._ensure_generated() - return b"".join( - cert.public_bytes(encoding=serialization.Encoding.PEM) - for cert in self._get_chain() - ) + return b"".join(cert.public_bytes(encoding=serialization.Encoding.PEM) for cert in self._get_chain()) def get_private_key(self) -> rsa.RSAPrivateKey | ec.EllipticCurvePrivateKey: """Get the private key. @@ -473,13 +512,9 @@ def get_private_key_as_pem(self, password: str | None = None) -> bytes: """ self._ensure_generated() - encryption_algorithm: serialization.KeySerializationEncryption = ( - serialization.NoEncryption() - ) + encryption_algorithm: serialization.KeySerializationEncryption = serialization.NoEncryption() if password is not None: - encryption_algorithm = serialization.BestAvailableEncryption( - password.encode("utf-8") - ) + encryption_algorithm = serialization.BestAvailableEncryption(password.encode("utf-8")) return self._private_key.private_bytes( # type: ignore encoding=serialization.Encoding.PEM, @@ -516,9 +551,7 @@ def write_certificates_as_pem(self, path: str) -> Credential: f.write(self.get_certificates_as_pem()) return self - def write_private_key_as_pem( - self, path: str, password: str | None = None - ) -> Credential: + def write_private_key_as_pem(self, path: str, password: str | None = None) -> Credential: """Write the private key in PKCS#8 PEM format to a file. If the private key has not been generated yet by calling :meth:`generate`, it is generated automatically. @@ -553,9 +586,7 @@ def _get_chain(self) -> list[x509.Certificate]: # Helper functions -def _generate_new_key( - key_type: KeyType, key_size: int -) -> rsa.RSAPrivateKey | ec.EllipticCurvePrivateKey: +def _generate_new_key(key_type: KeyType, key_size: int) -> rsa.RSAPrivateKey | ec.EllipticCurvePrivateKey: if key_type == KeyType.RSA: return rsa.generate_private_key(public_exponent=65537, key_size=key_size) elif key_type == KeyType.EC: @@ -585,15 +616,11 @@ def _as_general_names(names: list[str]) -> x509.GeneralNames: elif name.startswith("URI:"): general_names.append(x509.UniformResourceIdentifier(name[4:])) else: - raise ValueError( - f"Invalid name '{name}', must start with DNS:, IP: or URI:" - ) + raise ValueError(f"Invalid name '{name}', must start with DNS:, IP: or URI:") return x509.GeneralNames(general_names) -def _preferred_signature_hash_algorithm( - key_type: KeyType, key_size: int -) -> hashes.HashAlgorithm: +def _preferred_signature_hash_algorithm(key_type: KeyType, key_size: int) -> hashes.HashAlgorithm: if key_type == KeyType.RSA: return hashes.SHA256() elif key_type == KeyType.EC: diff --git a/tests/test_certificate_revocation_list.py b/tests/test_certificate_revocation_list.py new file mode 100644 index 0000000..3b52a1a --- /dev/null +++ b/tests/test_certificate_revocation_list.py @@ -0,0 +1,122 @@ +# +# Copyright Certy Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from datetime import datetime +import pytest +from cryptography import x509 + +from certy import CertificateRevocationList, Credential + + +@pytest.fixture +def ca(): + return Credential().subject("CN=ca") + + +def test_add(ca): + first_revoked = Credential().issuer(ca).subject("CN=first-revoked") + second_revoked = Credential().issuer(ca).subject("CN=second-revoked") + not_revoked = Credential().issuer(ca).subject("CN=not-revoked") + crl = CertificateRevocationList().issuer(ca).add(first_revoked).add(second_revoked).get_as_der() + + got = x509.load_der_x509_crl(crl) + assert got is not None + assert got.get_revoked_certificate_by_serial_number(first_revoked.get_certificate().serial_number) is not None + assert got.get_revoked_certificate_by_serial_number(second_revoked.get_certificate().serial_number) is not None + assert got.get_revoked_certificate_by_serial_number(not_revoked.get_certificate().serial_number) is None + + +def test_this_update(ca): + crl = CertificateRevocationList().issuer(ca).this_update(datetime(2023, 10, 31, 9, 0)) + got = x509.load_der_x509_crl(crl.get_as_der()) + assert got is not None + assert got.last_update == datetime(2023, 10, 31, 9, 0) + + +def test_next_update(ca): + crl = CertificateRevocationList().issuer(ca).next_update(datetime(2023, 10, 31, 9, 0)) + got = x509.load_der_x509_crl(crl.get_as_der()) + assert got is not None + assert got.next_update == datetime(2023, 10, 31, 9, 0) + + +def test_issuer(ca): + crl = CertificateRevocationList().issuer(ca) + got = x509.load_der_x509_crl(crl.get_as_der()) + assert got is not None + assert got.issuer == ca.get_certificate().subject + + +def test_signature(ca): + crl = CertificateRevocationList().issuer(ca) + got = crl.get_as_der() + assert got is not None + assert x509.load_der_x509_crl(got).is_signature_valid(ca.get_private_key().public_key()) + + +def test_get_as_pem(ca): + revoked = Credential().issuer(ca).subject("CN=revoked") + crl = CertificateRevocationList().issuer(ca).add(revoked) + got = crl.get_as_pem() + assert got is not None + assert ( + x509.load_pem_x509_crl(got).get_revoked_certificate_by_serial_number(revoked.get_certificate().serial_number) + is not None + ) + + +def test_get_as_der(ca): + revoked = Credential().issuer(ca).subject("CN=revoked") + crl = CertificateRevocationList().add(revoked) + got = crl.get_as_der() + assert got is not None + assert ( + x509.load_der_x509_crl(got).get_revoked_certificate_by_serial_number(revoked.get_certificate().serial_number) + is not None + ) + + +def test_write_pem(ca, tmp_path): + revoked = Credential().issuer(ca).subject("CN=revoked") + crl = CertificateRevocationList().issuer(ca).add(revoked) + crl.write_pem(tmp_path / "crl.pem") + got = x509.load_pem_x509_crl((tmp_path / "crl.pem").read_bytes()) + assert got is not None + assert got.get_revoked_certificate_by_serial_number(revoked.get_certificate().serial_number) is not None + + +def test_write_der(ca, tmp_path): + revoked = Credential().issuer(ca).subject("CN=revoked") + crl = CertificateRevocationList().issuer(ca).add(revoked) + crl.write_der(tmp_path / "crl.der") + got = x509.load_der_x509_crl((tmp_path / "crl.der").read_bytes()) + assert got is not None + assert got.get_revoked_certificate_by_serial_number(revoked.get_certificate().serial_number) is not None + + +def test_cannot_determine_issuer(): + with pytest.raises(ValueError): + CertificateRevocationList().get_as_pem() + + +def test_cannot_revoke_self_signed(ca): + with pytest.raises(ValueError): + CertificateRevocationList().add(ca).get_as_pem() + + +def test_mismatched_issuer(ca): + with pytest.raises(ValueError): + CertificateRevocationList().issuer(ca).add(Credential().subject("CN=not-issued-by-ca")).get_as_pem() diff --git a/tests/test_credential.py b/tests/test_credential.py index a4f4b84..eb79423 100644 --- a/tests/test_credential.py +++ b/tests/test_credential.py @@ -1,3 +1,19 @@ +# +# Copyright Certy Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + import datetime import ipaddress from datetime import datetime, timedelta @@ -21,17 +37,13 @@ def test_subject_alt_name(): cert = ( Credential() .subject("CN=test") - .subject_alt_names( - "DNS:host.example.com", "URI:http://www.example.com", "IP:1.2.3.4" - ) + .subject_alt_names("DNS:host.example.com", "URI:http://www.example.com", "IP:1.2.3.4") .generate() .get_certificate() ) assert cert.subject.rfc4514_string() == "CN=test" assert cert.issuer.rfc4514_string() == "CN=test" - assert cert.extensions.get_extension_for_class( - x509.SubjectAlternativeName - ).value == x509.SubjectAlternativeName( + assert cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value == x509.SubjectAlternativeName( [ x509.DNSName("host.example.com"), x509.UniformResourceIdentifier("http://www.example.com"), @@ -39,17 +51,11 @@ def test_subject_alt_name(): ] ) - # Single subject alternative name given instead of list - cert = ( - Credential() - .subject("CN=test") - .subject_alt_names("DNS:host.example.com") - .generate() - .get_certificate() + # Single subject alternative name given instead of list. + cert = Credential().subject("CN=test").subject_alt_names("DNS:host.example.com").generate().get_certificate() + assert cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value == x509.SubjectAlternativeName( + [x509.DNSName("host.example.com")] ) - assert cert.extensions.get_extension_for_class( - x509.SubjectAlternativeName - ).value == x509.SubjectAlternativeName([x509.DNSName("host.example.com")]) def test_default_key_size(): @@ -69,9 +75,7 @@ def test_ec_key_sizes(): def test_rsa_key_sizes(): - cred = ( - Credential().subject("CN=test").key_type(KeyType.RSA).key_size(1024).generate() - ) + cred = Credential().subject("CN=test").key_type(KeyType.RSA).key_size(1024).generate() assert key_must_be(cred, rsa.RSAPrivateKey, 1024) cred.key_size(2048).generate() assert key_must_be(cred, rsa.RSAPrivateKey, 2048) @@ -87,9 +91,7 @@ def test_expires(): def test_key_usages(): cert = Credential().subject("CN=joe").generate().get_certificate() - assert cert.extensions.get_extension_for_class( - x509.KeyUsage - ).value == x509.KeyUsage( + assert cert.extensions.get_extension_for_class(x509.KeyUsage).value == x509.KeyUsage( digital_signature=False, content_commitment=False, key_encipherment=False, @@ -102,9 +104,7 @@ def test_key_usages(): ) cert = Credential().subject("CN=joe").ca(False).generate().get_certificate() - assert cert.extensions.get_extension_for_class( - x509.KeyUsage - ).value == x509.KeyUsage( + assert cert.extensions.get_extension_for_class(x509.KeyUsage).value == x509.KeyUsage( digital_signature=True, content_commitment=False, key_encipherment=True, @@ -133,9 +133,7 @@ def test_key_usages(): .generate() .get_certificate() ) - assert cert.extensions.get_extension_for_class( - x509.KeyUsage - ).value == x509.KeyUsage( + assert cert.extensions.get_extension_for_class(x509.KeyUsage).value == x509.KeyUsage( digital_signature=True, content_commitment=True, key_encipherment=True, @@ -164,9 +162,7 @@ def test_extended_key_usages(): .get_certificate() ) - assert cert.extensions.get_extension_for_class( - x509.ExtendedKeyUsage - ).value == x509.ExtendedKeyUsage( + assert cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage).value == x509.ExtendedKeyUsage( [ ExtendedKeyUsageOID.CLIENT_AUTH, ExtendedKeyUsageOID.SERVER_AUTH, @@ -183,25 +179,15 @@ def test_issuer(): def test_ca(): cert = Credential().subject("CN=ca").generate().get_certificate() - assert ( - cert.extensions.get_extension_for_class(x509.BasicConstraints).value.ca == True - ) + assert cert.extensions.get_extension_for_class(x509.BasicConstraints).value.ca == True cert = Credential().subject("CN=end-entity").ca(False).generate().get_certificate() - assert ( - cert.extensions.get_extension_for_class(x509.BasicConstraints).value.ca == False - ) + assert cert.extensions.get_extension_for_class(x509.BasicConstraints).value.ca == False def test_intermediate_ca(): root_ca = Credential().subject("CN=root-ca") intermediate_ca = Credential().subject("CN=intermediate-ca").issuer(root_ca).ca() - certs = ( - Credential() - .subject("CN=joe") - .issuer(intermediate_ca) - .generate() - .get_certificates() - ) + certs = Credential().subject("CN=joe").issuer(intermediate_ca).generate().get_certificates() assert len(certs) == 2 assert certs[0].subject.rfc4514_string() == "CN=joe" assert certs[1].subject.rfc4514_string() == "CN=intermediate-ca" @@ -240,6 +226,14 @@ def test_serial_number(): assert cert1.serial_number != cert2.serial_number +def test_crl_distribution_point_uri(): + cert = Credential().subject("CN=joe").crl_distribution_point_uri("http://example.com/crl").get_certificate() + assert ( + cert.extensions.get_extension_for_class(x509.CRLDistributionPoints).value[0].full_name[0].value + == "http://example.com/crl" + ) + + def test_certificate_as_pem(): cred = Credential().subject("CN=joe").generate() cert = cred.get_certificate() @@ -260,13 +254,11 @@ def test_write_pem_files(tmp_path): wanted.write_certificates_as_pem(tmp_path / "joe.pem") wanted.write_private_key_as_pem(tmp_path / "joe-key.pem") - # load certificate and key from files + # Load certificate and key from files. got_cert = x509.load_pem_x509_certificate((tmp_path / "joe.pem").read_bytes()) - got_key = serialization.load_pem_private_key( - (tmp_path / "joe-key.pem").read_bytes(), None - ) + got_key = serialization.load_pem_private_key((tmp_path / "joe-key.pem").read_bytes(), None) - # check that the certificate and key match + # Check that the certificate and key match. assert got_cert == wanted.get_certificate() assert private_keys_equal(got_key, wanted.get_private_key()) @@ -276,13 +268,11 @@ def test_write_pem_files_with_password(tmp_path): wanted.write_certificates_as_pem(tmp_path / "joe.pem") wanted.write_private_key_as_pem(tmp_path / "joe-key.pem", password="secret") - # load certificate and key from files + # Load certificate and key from files. got_cert = x509.load_pem_x509_certificate((tmp_path / "joe.pem").read_bytes()) - got_key = serialization.load_pem_private_key( - (tmp_path / "joe-key.pem").read_bytes(), b"secret" - ) + got_key = serialization.load_pem_private_key((tmp_path / "joe-key.pem").read_bytes(), b"secret") - # check that the certificate and key match + # Check that the certificate and key match. assert got_cert == wanted.get_certificate() assert private_keys_equal(got_key, wanted.get_private_key()) @@ -291,10 +281,7 @@ def test_write_pem_files_with_password(tmp_path): def key_must_be(cred, key_type, key_size): - return ( - isinstance(cred.get_private_key(), key_type) - and cred.get_private_key().key_size == key_size - ) + return isinstance(cred.get_private_key(), key_type) and cred.get_private_key().key_size == key_size def private_keys_equal(key1, key2): diff --git a/tests/test_credential_errors.py b/tests/test_credential_errors.py index c42131f..5c9ff58 100644 --- a/tests/test_credential_errors.py +++ b/tests/test_credential_errors.py @@ -74,22 +74,16 @@ def test_invalid_key_usage(): with pytest.raises(ValueError): Credential().subject("CN=joe").key_usages("not a valid key usage") with pytest.raises(ValueError): - Credential().subject("CN=joe").key_usages( - KeyUsage.DIGITAL_SIGNATURE, "not a valid key usage" - ) + Credential().subject("CN=joe").key_usages(KeyUsage.DIGITAL_SIGNATURE, "not a valid key usage") def test_invalid_ext_key_usage(): with pytest.raises(ValueError): Credential().subject("CN=joe").ext_key_usages("not a valid extended key usage") with pytest.raises(ValueError): - Credential().subject("CN=joe").ext_key_usages( - ExtendedKeyUsage.SERVER_AUTH, "not a valid extended key usage" - ) + Credential().subject("CN=joe").ext_key_usages(ExtendedKeyUsage.SERVER_AUTH, "not a valid extended key usage") def test_not_before_later_than_not_after(): with pytest.raises(ValueError): - Credential().subject("CN=joe").not_before(datetime(2023, 1, 1)).not_after( - datetime(2022, 1, 1) - ).generate() + Credential().subject("CN=joe").not_before(datetime(2023, 1, 1)).not_after(datetime(2022, 1, 1)).generate()