From d5fa521fa13b1404f9ba96f6751731f3bec3b920 Mon Sep 17 00:00:00 2001 From: Ana Custura Date: Sat, 14 Dec 2024 14:26:10 +0000 Subject: [PATCH] feat: break up validate_tls_keys and add unit tests I've split the existing code in several new functions: - load_certificates_from_pem (takes pem data as bytes) - build_certificate_chain (takes a list of Certificates) - validate_certificate_chain (takes a list of Certificates) - validate_key (takes pem data as a string) - validate_key_matches_cert (now takes a pem key string and a Certificate) - extract_sans (now takes a Certificate) - validate_end_entity_expired (now takes a Certificate) - validate_end_entity_not_yet_valid (now takes a Certificate) When a relevant exception arises, these functions raise a type of TLSValidationError, these are appended to the list of errors when validating a cert. --- app/util/x509.py | 378 ++++++++---- .../invalid-algorithm/dsa_private_key.pem | 15 + tests/data/invalid-algorithm/fullchain.pem | 64 +++ tests/data/letsencrypt-issued/cert.pem | 22 + tests/data/letsencrypt-issued/chain.pem | 26 + tests/data/letsencrypt-issued/fullchain.pem | 48 ++ tests/data/letsencrypt-issued/privkey.pem | 5 + tests/data/no-end-entity-chain/fullchain.pem | 54 ++ tests/data/untrusted-root-ca/fullchain.pem | 57 ++ tests/utils/test_x509.py | 542 ++++++++++++++++++ 10 files changed, 1091 insertions(+), 120 deletions(-) create mode 100644 tests/data/invalid-algorithm/dsa_private_key.pem create mode 100644 tests/data/invalid-algorithm/fullchain.pem create mode 100644 tests/data/letsencrypt-issued/cert.pem create mode 100644 tests/data/letsencrypt-issued/chain.pem create mode 100644 tests/data/letsencrypt-issued/fullchain.pem create mode 100644 tests/data/letsencrypt-issued/privkey.pem create mode 100644 tests/data/no-end-entity-chain/fullchain.pem create mode 100644 tests/data/untrusted-root-ca/fullchain.pem create mode 100644 tests/utils/test_x509.py diff --git a/app/util/x509.py b/app/util/x509.py index e0f0650..294ff3a 100644 --- a/app/util/x509.py +++ b/app/util/x509.py @@ -6,19 +6,77 @@ from cryptography import x509 from cryptography.hazmat._oid import ExtensionOID from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import padding, rsa +from cryptography.hazmat.primitives.asymmetric import padding, rsa, ec from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15 from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey +from cryptography.hazmat.primitives.asymmetric.ec import ( + EllipticCurvePublicKey, + EllipticCurvePrivateKey, +) +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.utils import Prehashed +from cryptography.hazmat.primitives import hashes + + +class TLSValidationError(ValueError): + key: str + message: str + + def __str__(self) -> str: + return self.message + + def as_dict(self) -> Dict[str, str]: + return {"key": self.key, "message": self.message} + + +class TLSCertificateParsingError(TLSValidationError): + key = "could_not_parse_certificate" + message = "TLS certificate parsing error" + + +class TLSInvalidPrivateKeyError(TLSValidationError): + key = "invalid_private_key" + message = "Private key is invalid" + + +class TLSNoEndEntityError(TLSValidationError): + key = "could_not_identify_end_entity" + message = "Cannot identify the end-entity certificate." + + +class TLSChainNotValidError(TLSValidationError): + key = "invalid_tls_chain" + message = "Certificates do not form a valid chain." + + +class TLSUnsupportedAlgorithmError(TLSValidationError): + key = "invalid_algorithm" + message = "Certificate using unsupported algorithm" + + +class TLSMissingHashError(TLSValidationError): + key = "missing_hash_algorithm" + message = "Certificate missing hash algorithm." + + +class TLSUntrustedRootCAError(TLSValidationError): + key = "untrusted_root_ca" + message = "Certificate chain does not terminate at a trusted root CA." def load_certificates_from_pem(pem_data: bytes) -> list[x509.Certificate]: certificates = [] - for pem_block in pem_data.split(b"-----END CERTIFICATE-----"): - pem_block = pem_block.strip() - if pem_block: - pem_block += b"-----END CERTIFICATE-----" - certificate = x509.load_pem_x509_certificate(pem_block, default_backend()) - certificates.append(certificate) + try: + for pem_block in pem_data.split(b"-----END CERTIFICATE-----"): + pem_block = pem_block.strip() + if pem_block: + pem_block += b"-----END CERTIFICATE-----" + certificate = x509.load_pem_x509_certificate( + pem_block, default_backend() + ) + certificates.append(certificate) + except ValueError: + raise TLSCertificateParsingError() return certificates @@ -33,17 +91,22 @@ def build_certificate_chain( ( cert for cert in certificates - if cert.subject.rfc4514_string() not in cert_map + if not any(cert.subject == other_cert.issuer for other_cert in certificates) ), None, ) if not end_entity: - raise ValueError("Cannot identify the end-entity certificate.") + raise TLSNoEndEntityError() chain.append(end_entity) current_cert = end_entity - while current_cert.issuer.rfc4514_string() in cert_map: - next_cert = cert_map[current_cert.issuer.rfc4514_string()] + # when there is 1 item left in cert_map, that will be the Root CA + while len(cert_map) > 1: + issuer_key = current_cert.issuer.rfc4514_string() + if issuer_key not in cert_map: + raise TLSChainNotValidError() + next_cert = cert_map[issuer_key] chain.append(next_cert) + cert_map.pop(current_cert.subject.rfc4514_string()) current_cert = next_cert return chain @@ -56,28 +119,142 @@ def validate_certificate_chain(chain: list[x509.Certificate]) -> bool: for i in range(len(chain) - 1): next_public_key = chain[i + 1].public_key() - if not (isinstance(next_public_key, RSAPublicKey)): - raise ValueError( - f"Certificate using unsupported algorithm: {type(next_public_key)}" - ) + if not isinstance(next_public_key, RSAPublicKey) and not isinstance( + next_public_key, EllipticCurvePublicKey + ): + raise TLSUnsupportedAlgorithmError() hash_algorithm = chain[i].signature_hash_algorithm - if hash_algorithm is None: - raise ValueError("Certificate missing hash algorithm") - next_public_key.verify( - chain[i].signature, - chain[i].tbs_certificate_bytes, - PKCS1v15(), - hash_algorithm, - ) + if TYPE_CHECKING: + if hash_algorithm is None: + raise TLSMissingHashError() + if isinstance(next_public_key, RSAPublicKey): + next_public_key.verify( + chain[i].signature, + chain[i].tbs_certificate_bytes, + PKCS1v15(), + hash_algorithm, + ) + elif isinstance(next_public_key, EllipticCurvePublicKey): + digest = hashes.Hash(hash_algorithm) + digest.update(chain[i].tbs_certificate_bytes) + digest_value = digest.finalize() + next_public_key.verify( + chain[i].signature, + digest_value, + ec.ECDSA(Prehashed(hash_algorithm)), + ) end_cert = chain[-1] if not any( end_cert.issuer == trusted_cert.subject for trusted_cert in trusted_certificates ): - raise ValueError("Certificate chain does not terminate at a trusted root CA.") + raise TLSUntrustedRootCAError() return True +def validate_key(tls_private_key_pem: Optional[str]) -> bool: + if tls_private_key_pem: + try: + private_key = serialization.load_pem_private_key( + tls_private_key_pem.encode("utf-8"), + password=None, + backend=default_backend(), + ) + return isinstance(private_key, rsa.RSAPrivateKey) or isinstance( + private_key, ec.EllipticCurvePrivateKey + ) + except ValueError: + raise TLSInvalidPrivateKeyError() + return False + + +def extract_sans(cert: x509.Certificate) -> List[str]: + try: + san_extension = cert.extensions.get_extension_for_oid( + ExtensionOID.SUBJECT_ALTERNATIVE_NAME + ) + sans: List[str] = san_extension.value.get_values_for_type(x509.DNSName) # type: ignore[attr-defined] + return sans + except Exception: + return [] + + +def validate_end_entity_expired(certificate: x509.Certificate) -> bool: + if certificate.not_valid_after_utc < datetime.now(timezone.utc): + return True + return False + + +def validate_end_entity_not_yet_valid(certificate: x509.Certificate) -> bool: + if certificate.not_valid_before_utc > datetime.now(timezone.utc): + return True + return False + + +def validate_key_matches_cert( + tls_private_key_pem: Optional[str], certificate: x509.Certificate +) -> bool: + if not tls_private_key_pem or not certificate: + return False + private_key = serialization.load_pem_private_key( + tls_private_key_pem.encode("utf-8"), + password=None, + backend=default_backend(), + ) + public_key = certificate.public_key() + signature_hash_algorithm = certificate.signature_hash_algorithm + if TYPE_CHECKING: + assert isinstance(public_key, rsa.RSAPublicKey) or isinstance( + public_key, EllipticCurvePublicKey + ) # nosec: B101 + assert isinstance(private_key, rsa.RSAPrivateKey) or isinstance( + public_key, EllipticCurvePrivateKey + ) # nosec: B101 + assert signature_hash_algorithm is not None # nosec: B101 + if not ( + ( + isinstance(private_key, rsa.RSAPrivateKey) + and isinstance(public_key, rsa.RSAPublicKey) + ) + or ( + isinstance(private_key, ec.EllipticCurvePrivateKey) + and isinstance(public_key, ec.EllipticCurvePublicKey) + ) + ): + return False + try: + test_message = b"test" + if isinstance(public_key, RSAPublicKey) and isinstance( + private_key, rsa.RSAPrivateKey + ): + signature = private_key.sign( + test_message, + padding.PKCS1v15(), + signature_hash_algorithm, + ) + public_key.verify( + signature, + test_message, + padding.PKCS1v15(), + signature_hash_algorithm, + ) + if isinstance(public_key, EllipticCurvePublicKey) and isinstance( + private_key, ec.EllipticCurvePrivateKey + ): + signature = private_key.sign( + test_message, + ec.ECDSA(signature_hash_algorithm), + ) + public_key.verify( + signature, + test_message, + ec.ECDSA(signature_hash_algorithm), + ) + return True + except InvalidSignature: + return False + + def validate_tls_keys( tls_private_key_pem: Optional[str], tls_certificate_pem: Optional[str], @@ -91,114 +268,75 @@ def validate_tls_keys( skip_chain_verification = skip_chain_verification or False skip_name_verification = skip_name_verification or False - + certificates = [] try: - private_key = None - if tls_private_key_pem: - private_key = serialization.load_pem_private_key( - tls_private_key_pem.encode("utf-8"), - password=None, - backend=default_backend(), - ) - if not isinstance(private_key, rsa.RSAPrivateKey): - errors.append( - { - "Error": "tls_private_key_invalid", - "Message": "Private key must be RSA.", - } - ) - if tls_certificate_pem: - certificates = list( - load_certificates_from_pem(tls_certificate_pem.encode("utf-8")) + certificates = load_certificates_from_pem( + tls_certificate_pem.encode("utf-8") ) - if not certificates: + except TLSValidationError as e: + errors.append(e.as_dict()) + if len(certificates) > 0: + try: + chain = build_certificate_chain(certificates) + end_entity_cert = chain[0] + # validate expiry + if validate_end_entity_expired(end_entity_cert): errors.append( { - "Error": "tls_certificate_invalid", - "Message": "No valid certificate found.", + "key": "public_key_expired", + "message": "TLS public key is expired.", } ) - else: - chain = build_certificate_chain(certificates) - end_entity_cert = chain[0] - - if end_entity_cert.not_valid_after_utc < datetime.now(timezone.utc): - errors.append( - { - "Error": "tls_public_key_expired", - "Message": "TLS public key is expired.", - } - ) - - if end_entity_cert.not_valid_before_utc > datetime.now(timezone.utc): - errors.append( - { - "Error": "tls_public_key_future", - "Message": "TLS public key is not yet valid.", - } - ) - - if private_key: - public_key = end_entity_cert.public_key() - if TYPE_CHECKING: - assert isinstance(public_key, rsa.RSAPublicKey) # nosec: B101 - assert isinstance(private_key, rsa.RSAPrivateKey) # nosec: B101 - assert ( - end_entity_cert.signature_hash_algorithm is not None - ) # nosec: B101 - try: - test_message = b"test" - signature = private_key.sign( - test_message, - padding.PKCS1v15(), - end_entity_cert.signature_hash_algorithm, - ) - public_key.verify( - signature, - test_message, - padding.PKCS1v15(), - end_entity_cert.signature_hash_algorithm, - ) - except Exception: + # validate beginning + if validate_end_entity_not_yet_valid(end_entity_cert): + errors.append( + { + "key": "public_key_future", + "message": "TLS public key is not yet valid.", + } + ) + # chain verification + if not skip_chain_verification: + validate_certificate_chain(chain) + # name verification + if not skip_name_verification: + san_list = extract_sans(end_entity_cert) + for expected_hostname in [hostname, f"*.{hostname}"]: + if expected_hostname not in san_list: errors.append( { - "Error": "tls_key_mismatch", - "Message": "Private key does not match certificate.", + "key": "hostname_not_in_san", + "message": f"{expected_hostname} not found in SANs.", } ) + # check if key is valid + if validate_key(tls_private_key_pem): + # check if key matches cert + if not validate_key_matches_cert(tls_private_key_pem, end_entity_cert): + errors.append( + { + "key": "key_mismatch", + "message": "Private key does not match certificate.", + } + ) + else: + errors.append( + { + "key": "private_key_not_rsa_or_ec", + "message": "Private key must be RSA or Elliptic-Curve.", + } + ) - if not skip_chain_verification: - try: - validate_certificate_chain(chain) - except ValueError as e: - errors.append( - {"Error": "certificate_chain_invalid", "Message": str(e)} - ) + except TLSValidationError as e: + errors.append(e.as_dict()) - if not skip_name_verification: - san_list = extract_sans(end_entity_cert) - for expected_hostname in [hostname, f"*.{hostname}"]: - if expected_hostname not in san_list: - errors.append( - { - "Error": "hostname_not_in_san", - "Message": f"{expected_hostname} not found in SANs.", - } - ) - - except Exception as e: - errors.append({"Error": "tls_validation_error", "Message": str(e)}) + else: + errors.append( + { + "key": "no_valid_certificates", + "message": "No valid certificates supplied.", + } + ) return chain, san_list, errors - - -def extract_sans(cert: x509.Certificate) -> List[str]: - try: - san_extension = cert.extensions.get_extension_for_oid( - ExtensionOID.SUBJECT_ALTERNATIVE_NAME - ) - sans: List[str] = san_extension.value.get_values_for_type(x509.DNSName) # type: ignore[attr-defined] - return sans - except Exception: - return [] diff --git a/tests/data/invalid-algorithm/dsa_private_key.pem b/tests/data/invalid-algorithm/dsa_private_key.pem new file mode 100644 index 0000000..8295271 --- /dev/null +++ b/tests/data/invalid-algorithm/dsa_private_key.pem @@ -0,0 +1,15 @@ +-----BEGIN PRIVATE KEY----- +MIICXAIBADCCAjUGByqGSM44BAEwggIoAoIBAQCBw6/R4rq+pgDdS7neUlWAEggZ +zX388OjRI0cxk3HuI6gbfpPrWGKhTk3QUkepTTTIJB4OIneoBTwldOAMvoGpmkon +U4yyuGKsSEZD21OH9uPCAYRj7+D+qk7FzGL2ynXerxc8QvSEONrcnEDxhMiioBXx +CQM3HxTBhTu6MMYjtA09uwPtkULgC8MslLNjJYMFXl7xLK2rMmpI46shH9OYWGQJ +0MVMNzrBi/OvSy+1AJ6B0FtK94UR7uRvr0JBvdAaohes1T9DHkuO7UKf3mjXXaJv +2Ukd37zEjpphFekxyjBx7wpDpo/+p6xycm3YseokAu7uVfbhWZ0chrWbg2kBAh0A +7YfQiEV/y0zDdGeQf8lWR5EpzPmDlJk58B7YhwKCAQAzk3tubCoZVR3BM9P9yrId +toSOKS1bet8JS3SLsahfeHu4Q3aSoEZYP+/Oj3Qh0Zz5DaG85ME9RPlVZ5so3rCF +55CIceSeE5HwTRr0uMVYblHQcyjn9pcW/p8JNr2thSj8MHbhLaMBYJa35V2deNhR +Kn1Iv9lT4IBSvqxkMAkJmFQ8m7UQKec+mzWe5d1EYk0nlqpDdO7x787TJbT1y8QJ +ymXoLBlbXHch37bGOjEDwRSXNvAnZmPYdDECWkFejAGMbFIuO8TGdj5HIwDTHOE1 +G9fIljlOlnc9PGHF7Qin1Ugu/CfmgumD4bJUelO6PV+Xwe+zkO3B2B2Sy38DTRDP +BB4CHB1aXGj21WgZAhGIqRztHvYPUtwoPhXH1YFwgbA= +-----END PRIVATE KEY----- diff --git a/tests/data/invalid-algorithm/fullchain.pem b/tests/data/invalid-algorithm/fullchain.pem new file mode 100644 index 0000000..244de14 --- /dev/null +++ b/tests/data/invalid-algorithm/fullchain.pem @@ -0,0 +1,64 @@ +-----BEGIN CERTIFICATE----- +MIICPjCCAeugAwIBAgIUMtswhWE3qOep0Tr/lZyJbfzGyx0wCwYJYIZIAWUDBAMC +MBsxGTAXBgNVBAMMEEludGVybWVkaWF0ZSBEU0EwHhcNMjQxMjEyMTA1NDM4WhcN +MjUxMjEyMTA1NDM4WjAaMRgwFgYDVQQDDA9FbmQgQ2VydGlmaWNhdGUwggEiMA0G +CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC20ip7ZPS9sR/lrg2thvH4aSMebaHF +w/6b4azLDhLWXIHlqU4cYw/MntKQCdfqPSVatVB/FmEzH92dSQQC3+yxalSdqiXD +nvTN7AfD2WUrFJYBfCVspo0LdrP4fATmaVq9BhjW/NXHKwchw/RmUIBhQW7dxXQM +l5y49bWnO2r1p5gN+uRN41mm7CVQZIe3jOP5TwN+eVpJKohGdycjwLI+piTZe0Mq +JWJvsminRVQagPxtqeq9JKz+pf/Bl3acAt/8gOCK4fm46CcU0Pe1B+Em2a8IeboE +gkZXAOAfHCd6gWR1VMVhmXKqwDhVHg2jMnk0/kNRm929G6qP2hdyk7XVAgMBAAGj +QjBAMB0GA1UdDgQWBBT8kjOpmCxOBxSdZ4dJzlhTA0UMJDAfBgNVHSMEGDAWgBRt +fRgaeqy5LNjf7r3lAu/ppFw9EjALBglghkgBZQMEAwIDQAAwPQIcON9Tuq2KrNbL +8InxxFHT3RJVL3J3ZgRnkSVmsgIdAIBAYzCTRCsGy5a28eaWBZlzhdpvlc12R+3Z +LXs= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIFHjCCBAagAwIBAgIUJDqOTxBoNrRRcLl6Ymb5fVG52WQwDQYJKoZIhvcNAQEL +BQAwEjEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yNDEyMTIxMDU0MjJaFw0yNTEyMTIx +MDU0MjJaMBsxGTAXBgNVBAMMEEludGVybWVkaWF0ZSBEU0EwggNDMIICNQYHKoZI +zjgEATCCAigCggEBAL0A7VybU8lkzHwn8gL5smYSQURgULnHX+pNyGkoOAJgvR6/ +ChA49GpL930IezhkSJ4zEoFmOdwbtk3k9Kld/6J2ZPwaZAF7/JSt13Y9Al35ZWD7 +TEyWoviXRsdgYVK2jONmKtLPyLRK3kMq9aYhmBqlFN7vP3D1w0GJf1fVvWTwVxI1 +X05rqv92DI22WcVOdOUoE6mbGJ7g4umL9TEXmcfvMEbf8xqOIwrlUUNLbt12lUN1 +6kxu2/eaHE/1HWVrmOsSQvF2uIQEG3TXAN8fVOdLvuwYTuOpCcq4quu5Y0FwyKPH +wabxurWXTgY/jDT7bcHI+iqm3ri+fZL1qTKoiK8CHQClITgtGFBKNcd5bF+5CU6X +llBSIxgMEdpOHrFDAoIBACCUHjZKhPszIMfKvw5YtaxEn5ZcP9/hRHblLLBQWrYc +5kCa5t6RefJANdVjPhck1y5K1Jj8XhYZ2Hjs6f5WxwG4UL7gnLu4roXM70XxL6Ym +kbh0wy2LQENbU4nSezr4egI2w88gx9O3+tXiXt43PLB/lN614hU2gVGvyj/bLKrZ +RlWsx3JW2RkNKlj2gZ25Xu4fBwIhZB99rRubLxaGOz0qDNJ2Uw1AEXriVQt/g59l +hxbv7lARsRyrOgFSknrtBKJMFNNaDzBfVWGvQB+C7rkyuOYqiPDIydgvpdAjpX6+ +LjxUMHV94m5stQEUzi409jaurfRr8vrTO8iRz4dWYjYDggEGAAKCAQEAmi2zHuGJ +ZfWhZr5IihkDORCrLvPRveyJ4sZg34DsuACGpxfDhggI8z0cVpboRSpBrxLLm7h+ +tgW7/Z5HBBZrkkI5sBm5ZGqk5vn/v61NFJUhtLxmauvJq7+IaAp0rsDvezyf8lhS +4gjJNRdcWG7pFaOoFknhEFq2jYszqn5Bks8M/jO6GrmukYoWrczc3IJZh7/5L69c +ZxNAj03FMOUxOTfVQA253HhDfv3KNsniP3eCdBdzq6OAJyKtEdeBQ7ebj0eKBP6r +4WomtgtdxMxDX96xJ5rUS1n8VtRWZ8nxKMQazSlCp57jzdezFhO/vnXXhZjMFAnQ +CW2666KTx5ecwqNCMEAwHQYDVR0OBBYEFG19GBp6rLks2N/uveUC7+mkXD0SMB8G +A1UdIwQYMBaAFGElSAN6N667yAHK4qkJYYyzN+QLMA0GCSqGSIb3DQEBCwUAA4IB +AQCCqDtIXD5tfONmnCeT9pIqnf3lNBcrTJF+mJIq7lGSV9fJMdCIp1sGznsJv6OQ +cI/BilUsqF4uar6YqcQ3fIRrc76vArb007D4kj/JskanZ7xFq8EdGabINwZw3T7q +p6C7DoIME3bevic8FmDLsiVLwbqnMh2GpbOYd3R91EyfDD5Bw54+ymLcofxcajzt +Uj2yh0S7ThhL0+iXavNDme4G4MybBR8csLWy8QiQo+MwqGvF2n9RzjLkUN0M7ujE +WC6ZKYpw8jVpEKc/JQY7G+XsYnp2Gt/QUARMs7ElUm19XB4tF50uwEitGsBk6XxN +7dSfBqsFCFe2Kh+mys7WYFyJ +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDBTCCAe2gAwIBAgIUasHgLn4F+lxGqCoR1QstduimLXgwDQYJKoZIhvcNAQEL +BQAwEjEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yNDEyMTIxMDUyMTlaFw0zNDEyMTAx +MDUyMTlaMBIxEDAOBgNVBAMMB1Jvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQCcIH9On+m7J0OI4R6MesITPVCtXk9a9NfGD5K568rxvzYo1UUE +AnmLlKgtiIU83MienWRnDxulyYX8wLzvR4Ykiv2tW24pK+FFrh3cPO8TChqgtG9Z +JC1V4bA8k+h1Rqm4C5mn4GbDdiH8zWm8GNMW1l7qqZI2G6zepxZhPboSnz0EapTd +mZkv7InW0V5Hw30vPpOYWjdIy6n5sdZ/ZwiwXfzma8RoBg5dUbVmaMt/rBZxu8iC +sdmmQNTLqnWKh3pG6Ys7eRADuYYLTzz8SXYQCM10pNP+rVXgbyAJESmWkHZwkev2 +U6DPtOqFg+21YmN6sd/7WlAU5KDWj/iIhdfVAgMBAAGjUzBRMB0GA1UdDgQWBBRh +JUgDejeuu8gByuKpCWGMszfkCzAfBgNVHSMEGDAWgBRhJUgDejeuu8gByuKpCWGM +szfkCzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCZcfXUJsnH +4U+11dKR8/PgrTePP4CvYzpW7/nWAGCD6yqlxkkf/1fycBIRj4JGbOJdNB/E6Uux +huNzmBIl4Y7Rz48pCxJkdDTAMIuRJ9RQSZEN5HdgLCL4K/2RCTKjOTVorJsv8FOl +W5WoSIDujNVls2+aoMAANNP9rp19y+6X2ak3B4mqj/yAN16IAtL6nq01uVNs9S4u +x4xNgjVabH5Ycl16lsvtn8ohvBnhgp16YmHEm1kNSRyAtA3vZOh3+26DyqQ+VJb8 +i3EH0xI53yuqXftetnX0cm4lRFi7SZIckojGLCPwpOjVMGkJFjIfU8ysHBVhRN0i +rROJJzlybSil +-----END CERTIFICATE----- diff --git a/tests/data/letsencrypt-issued/cert.pem b/tests/data/letsencrypt-issued/cert.pem new file mode 100644 index 0000000..0af5b23 --- /dev/null +++ b/tests/data/letsencrypt-issued/cert.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDqTCCAzCgAwIBAgISAyOnEK9hzGpwd7AWHdk5kO8tMAoGCCqGSM49BAMDMDIx +CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF +NTAeFw0yNDEyMTExNDE2MjRaFw0yNTAzMTExNDE2MjNaMDAxLjAsBgNVBAMTJXhh +aG02YWVjaDFtaWU1cXVleW84LmNlbnNvcnNoaXAuZ3VpZGUwWTATBgcqhkjOPQIB +BggqhkjOPQMBBwNCAAQ9qRr1MEI3IFrA1il9d10Mu3J+cP/vyk07nT7k4Qo25Ie3 +1umSk5dUJBki4vaBVFQH9aa0N/xbdYyZFKiamfQco4ICJjCCAiIwDgYDVR0PAQH/ +BAQDAgeAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8E +AjAAMB0GA1UdDgQWBBT5aUwD70GI2Dw6DWh/BMVDADhgZjAfBgNVHSMEGDAWgBSf +K1/PPCFPnQS37SssxMZwi9LXDTBVBggrBgEFBQcBAQRJMEcwIQYIKwYBBQUHMAGG +FWh0dHA6Ly9lNS5vLmxlbmNyLm9yZzAiBggrBgEFBQcwAoYWaHR0cDovL2U1Lmku +bGVuY3Iub3JnLzAwBgNVHREEKTAngiV4YWhtNmFlY2gxbWllNXF1ZXlvOC5jZW5z +b3JzaGlwLmd1aWRlMBMGA1UdIAQMMAowCAYGZ4EMAQIBMIIBAwYKKwYBBAHWeQIE +AgSB9ASB8QDvAHUAfVkeEuF4KnscYWd8Xv340IdcFKBOlZ65Ay/ZDowuebgAAAGT +tkmQ5wAABAMARjBEAiAa/F5n5AzoazNkBxe4gdBf973aS0eR+68+jrlTyHW1ygIg +O6gt85PdB0sCLg4iNakV10Tvt10yuDrXflUfSFIrZC8AdgDPEVbu1S58r/OHW9lp +LpvpGnFnSrAX7KwB0lt3zsw7CAAAAZO2SZEZAAAEAwBHMEUCICRbjyc8lDW4g4Y6 +C6dFKLu+C5nvuyQuRw18sx7x/2ZLAiEA7tBM3Ut1ITIsKpPi1J+0e6NMWI5xYH8m +PoV2bWr0UjEwCgYIKoZIzj0EAwMDZwAwZAIwA17A//MH+iYcl1erjHWmyb5RwfUv +zKoRHQHHZRIHv+SY7UDBNeEYmBgClXVUl1fpAjBhoZp0Riw4EtEfrsFDKjAuFUj8 +B5/Cjw4Dvg5aqYGT/LmvFRubeALiKFwHNIuVQjs= +-----END CERTIFICATE----- diff --git a/tests/data/letsencrypt-issued/chain.pem b/tests/data/letsencrypt-issued/chain.pem new file mode 100644 index 0000000..e5b24bc --- /dev/null +++ b/tests/data/letsencrypt-issued/chain.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIEVzCCAj+gAwIBAgIRAIOPbGPOsTmMYgZigxXJ/d4wDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw +WhcNMjcwMzEyMjM1OTU5WjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg +RW5jcnlwdDELMAkGA1UEAxMCRTUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQNCzqK +a2GOtu/cX1jnxkJFVKtj9mZhSAouWXW0gQI3ULc/FnncmOyhKJdyIBwsz9V8UiBO +VHhbhBRrwJCuhezAUUE8Wod/Bk3U/mDR+mwt4X2VEIiiCFQPmRpM5uoKrNijgfgw +gfUwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcD +ATASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSfK1/PPCFPnQS37SssxMZw +i9LXDTAfBgNVHSMEGDAWgBR5tFnme7bl5AFzgAiIyBpY9umbbjAyBggrBgEFBQcB +AQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wEwYDVR0g +BAwwCjAIBgZngQwBAgEwJwYDVR0fBCAwHjAcoBqgGIYWaHR0cDovL3gxLmMubGVu +Y3Iub3JnLzANBgkqhkiG9w0BAQsFAAOCAgEAH3KdNEVCQdqk0LKyuNImTKdRJY1C +2uw2SJajuhqkyGPY8C+zzsufZ+mgnhnq1A2KVQOSykOEnUbx1cy637rBAihx97r+ +bcwbZM6sTDIaEriR/PLk6LKs9Be0uoVxgOKDcpG9svD33J+G9Lcfv1K9luDmSTgG +6XNFIN5vfI5gs/lMPyojEMdIzK9blcl2/1vKxO8WGCcjvsQ1nJ/Pwt8LQZBfOFyV +XP8ubAp/au3dc4EKWG9MO5zcx1qT9+NXRGdVWxGvmBFRAajciMfXME1ZuGmk3/GO +koAM7ZkjZmleyokP1LGzmfJcUd9s7eeu1/9/eg5XlXd/55GtYjAM+C4DG5i7eaNq +cm2F+yxYIPt6cbbtYVNJCGfHWqHEQ4FYStUyFnv8sjyqU8ypgZaNJ9aVcWSICLOI +E1/Qv/7oKsnZCWJ926wU6RqG1OYPGOi1zuABhLw61cuPVDT28nQS/e6z95cJXq0e +K1BcaJ6fJZsmbjRgD5p3mvEf5vdQM7MCEvU0tHbsx2I5mHHJoABHb8KVBgWp/lcX +GWiWaeOyB7RP+OfDtvi2OsapxXiV7vNVs7fMlrRjY1joKaqmmycnBvAq14AEbtyL +sVfOS66B8apkeFX2NY4XPEYV4ZSCe8VHPrdrERk2wILG3T/EGmSIkCYVUMSnjmJd +VQD9F6Na/+zmXCc= +-----END CERTIFICATE----- diff --git a/tests/data/letsencrypt-issued/fullchain.pem b/tests/data/letsencrypt-issued/fullchain.pem new file mode 100644 index 0000000..eae6ff0 --- /dev/null +++ b/tests/data/letsencrypt-issued/fullchain.pem @@ -0,0 +1,48 @@ +-----BEGIN CERTIFICATE----- +MIIDqTCCAzCgAwIBAgISAyOnEK9hzGpwd7AWHdk5kO8tMAoGCCqGSM49BAMDMDIx +CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF +NTAeFw0yNDEyMTExNDE2MjRaFw0yNTAzMTExNDE2MjNaMDAxLjAsBgNVBAMTJXhh +aG02YWVjaDFtaWU1cXVleW84LmNlbnNvcnNoaXAuZ3VpZGUwWTATBgcqhkjOPQIB +BggqhkjOPQMBBwNCAAQ9qRr1MEI3IFrA1il9d10Mu3J+cP/vyk07nT7k4Qo25Ie3 +1umSk5dUJBki4vaBVFQH9aa0N/xbdYyZFKiamfQco4ICJjCCAiIwDgYDVR0PAQH/ +BAQDAgeAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8E +AjAAMB0GA1UdDgQWBBT5aUwD70GI2Dw6DWh/BMVDADhgZjAfBgNVHSMEGDAWgBSf +K1/PPCFPnQS37SssxMZwi9LXDTBVBggrBgEFBQcBAQRJMEcwIQYIKwYBBQUHMAGG +FWh0dHA6Ly9lNS5vLmxlbmNyLm9yZzAiBggrBgEFBQcwAoYWaHR0cDovL2U1Lmku +bGVuY3Iub3JnLzAwBgNVHREEKTAngiV4YWhtNmFlY2gxbWllNXF1ZXlvOC5jZW5z +b3JzaGlwLmd1aWRlMBMGA1UdIAQMMAowCAYGZ4EMAQIBMIIBAwYKKwYBBAHWeQIE +AgSB9ASB8QDvAHUAfVkeEuF4KnscYWd8Xv340IdcFKBOlZ65Ay/ZDowuebgAAAGT +tkmQ5wAABAMARjBEAiAa/F5n5AzoazNkBxe4gdBf973aS0eR+68+jrlTyHW1ygIg +O6gt85PdB0sCLg4iNakV10Tvt10yuDrXflUfSFIrZC8AdgDPEVbu1S58r/OHW9lp +LpvpGnFnSrAX7KwB0lt3zsw7CAAAAZO2SZEZAAAEAwBHMEUCICRbjyc8lDW4g4Y6 +C6dFKLu+C5nvuyQuRw18sx7x/2ZLAiEA7tBM3Ut1ITIsKpPi1J+0e6NMWI5xYH8m +PoV2bWr0UjEwCgYIKoZIzj0EAwMDZwAwZAIwA17A//MH+iYcl1erjHWmyb5RwfUv +zKoRHQHHZRIHv+SY7UDBNeEYmBgClXVUl1fpAjBhoZp0Riw4EtEfrsFDKjAuFUj8 +B5/Cjw4Dvg5aqYGT/LmvFRubeALiKFwHNIuVQjs= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIEVzCCAj+gAwIBAgIRAIOPbGPOsTmMYgZigxXJ/d4wDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw +WhcNMjcwMzEyMjM1OTU5WjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg +RW5jcnlwdDELMAkGA1UEAxMCRTUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQNCzqK +a2GOtu/cX1jnxkJFVKtj9mZhSAouWXW0gQI3ULc/FnncmOyhKJdyIBwsz9V8UiBO +VHhbhBRrwJCuhezAUUE8Wod/Bk3U/mDR+mwt4X2VEIiiCFQPmRpM5uoKrNijgfgw +gfUwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcD +ATASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSfK1/PPCFPnQS37SssxMZw +i9LXDTAfBgNVHSMEGDAWgBR5tFnme7bl5AFzgAiIyBpY9umbbjAyBggrBgEFBQcB +AQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wEwYDVR0g +BAwwCjAIBgZngQwBAgEwJwYDVR0fBCAwHjAcoBqgGIYWaHR0cDovL3gxLmMubGVu +Y3Iub3JnLzANBgkqhkiG9w0BAQsFAAOCAgEAH3KdNEVCQdqk0LKyuNImTKdRJY1C +2uw2SJajuhqkyGPY8C+zzsufZ+mgnhnq1A2KVQOSykOEnUbx1cy637rBAihx97r+ +bcwbZM6sTDIaEriR/PLk6LKs9Be0uoVxgOKDcpG9svD33J+G9Lcfv1K9luDmSTgG +6XNFIN5vfI5gs/lMPyojEMdIzK9blcl2/1vKxO8WGCcjvsQ1nJ/Pwt8LQZBfOFyV +XP8ubAp/au3dc4EKWG9MO5zcx1qT9+NXRGdVWxGvmBFRAajciMfXME1ZuGmk3/GO +koAM7ZkjZmleyokP1LGzmfJcUd9s7eeu1/9/eg5XlXd/55GtYjAM+C4DG5i7eaNq +cm2F+yxYIPt6cbbtYVNJCGfHWqHEQ4FYStUyFnv8sjyqU8ypgZaNJ9aVcWSICLOI +E1/Qv/7oKsnZCWJ926wU6RqG1OYPGOi1zuABhLw61cuPVDT28nQS/e6z95cJXq0e +K1BcaJ6fJZsmbjRgD5p3mvEf5vdQM7MCEvU0tHbsx2I5mHHJoABHb8KVBgWp/lcX +GWiWaeOyB7RP+OfDtvi2OsapxXiV7vNVs7fMlrRjY1joKaqmmycnBvAq14AEbtyL +sVfOS66B8apkeFX2NY4XPEYV4ZSCe8VHPrdrERk2wILG3T/EGmSIkCYVUMSnjmJd +VQD9F6Na/+zmXCc= +-----END CERTIFICATE----- diff --git a/tests/data/letsencrypt-issued/privkey.pem b/tests/data/letsencrypt-issued/privkey.pem new file mode 100644 index 0000000..f735fb4 --- /dev/null +++ b/tests/data/letsencrypt-issued/privkey.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg1IXBCW4hoVNlI+nb +Vmr0GL1Z7n607+GVTz9PlhkrhS2hRANCAAQ9qRr1MEI3IFrA1il9d10Mu3J+cP/v +yk07nT7k4Qo25Ie31umSk5dUJBki4vaBVFQH9aa0N/xbdYyZFKiamfQc +-----END PRIVATE KEY----- diff --git a/tests/data/no-end-entity-chain/fullchain.pem b/tests/data/no-end-entity-chain/fullchain.pem new file mode 100644 index 0000000..27f7ee1 --- /dev/null +++ b/tests/data/no-end-entity-chain/fullchain.pem @@ -0,0 +1,54 @@ +-----BEGIN CERTIFICATE----- +MIIC8DCCAdigAwIBAgIUJUTd4WRtEtcpAxNbg16AAyDdYuQwDQYJKoZIhvcNAQEL +BQAwEDEOMAwGA1UEAwwFQ2VydEMwHhcNMjQxMjEyMTUyNzQwWhcNMzQxMjEwMTUy +NzQwWjAQMQ4wDAYDVQQDDAVDZXJ0QTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBAOtbosZu34pTFdlQZDJ9TE6qVtInel5l8AOkiHqzsgp82xPvqqgVDfo4 +6azPHgrCDChylpNDSVrsJnZ+AFs0rLhJhy6184nnYAIGF6y5RrDAOyIu4QqrDlaF +dENdmBwyNZsM11TMj5Ims56w2m7SMiIU+yIxBlKEFlsy6Xd8DODuIjmhBxcw4k1f +HCAZQ98CcERaq22TTmGaKCL2o1navm4ku0xd3ZYbEd8xSkcXE/oymcevcfvAWAOD +Hf7wfYqgpnXn8NW47QUBiVCer7bvYcgh6Fll4MuTFnOY50gDblRcdn+My5m28Bct +Ilq8rmbx4cWcz/4FpcuttmRoM7KJgvkCAwEAAaNCMEAwHQYDVR0OBBYEFLUcB3bI +hsjEj3iGFZ7JCf8zVuezMB8GA1UdIwQYMBaAFHazPU3yY+LBXuhI31moiPRY9W+V +MA0GCSqGSIb3DQEBCwUAA4IBAQBBMBuxEOOuRDxxdMpGE8cvOXQpQt5xzPZO/+K5 +ACXrH5cQBoHUTQL3MtnjhFroKhlFY4VuU5VgyAsdKcnA/MWM7KZTt/pP9VQCTFeC +sicWS/s8/A+y/8wKloQjP0AIjwAvxAfOjjsPEV5ztb3nt36kGFMd/3Cuz6GYnF9R +agloB+997/XuRKWHfDmAEdz4tPoslFEQEc9gYI2PRpuePu4I5FpM7/8B2WowyHD+ ++yUPQNZCoW6ZJdp5WM3BGcw5B5T4K3h6TvEw8+BzbYachYp5BHu0odrow7tlP6c1 +YQCaRCUPWiINpidfjKnXMfWAiqp6PzMF4AGyy4/C0FEI/dYB +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIC8DCCAdigAwIBAgIUB6S7PZ3pqNT2EHzNrr2bSnxDR4cwDQYJKoZIhvcNAQEL +BQAwEDEOMAwGA1UEAwwFQ2VydEEwHhcNMjQxMjEyMTUyNzIxWhcNMzQxMjEwMTUy +NzIxWjAQMQ4wDAYDVQQDDAVDZXJ0QjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBAKq1xmDfGy1t6noK4hCJw+n+wEuLh/02NTNjLFa3WZRd7Zuv0ouV0ilG +VxMNIw31P/tlhvna6PGwQT3DwqD8TLdBiTW6qqZfw2iZhSqk4PGD05sLktHcDw5r +DCQve3fGzMGBK55bHrlFKo9DaERxzenSVqe6YgtCnnYOdpUtbGoxjBVtD9cHbE5F +t/S+gvCL+v9uDv3FzF/BjJ+A+sy7GNqCNqu43RMQK3DPLupZ+6Jz1IbTqfht5PYV +P2DMrlrAJVikIAlalhpYogFnjwyMOIg6/ZtT9xgNHOaDklH8EjgqXhM7/DDRgYzc +X+WjfHN5so/fvO+FdepEtb1Fa9/yh7kCAwEAAaNCMEAwHQYDVR0OBBYEFESpI/r4 +5uM04EpbCMXduTnZBVyqMB8GA1UdIwQYMBaAFD56a0oPcVXFV5aMEzZzm8ycqbjy +MA0GCSqGSIb3DQEBCwUAA4IBAQBm0AqU1uNVObyVnXN6O0MSQFpTcnXZ5rEx0N9v +WZ+oHTyKIlnTtgXdtkC7ZN8P6AJUacWifttiPMA1MU9jCkNtFiVQICjeDI18XQn+ +X+tv/50FdlTcRrRU34lxdGRW/UxlCs6OWG0UAimokTETr2a9L1oIee+LoPl99iDp +FwJCuNBOA9bLbayl8u7EgtGVfZaqk7AV4apAsits8jdjrT9UFj1SFsNHMHSk9ZRw +HcYzbQM3+yvJWtNoHP9fX2a7JlZfqdHdhO4nmYbWYXuv+5Z7zZkgcgqehsptcoUe +/uud4LZjAYFkRIiYZ9jEdBSWUr3wQjS/Ed0d56NeXEQVbjDO +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIC8DCCAdigAwIBAgIUObylipFiamGeDkUl3B2x9Qoe0X8wDQYJKoZIhvcNAQEL +BQAwEDEOMAwGA1UEAwwFQ2VydEIwHhcNMjQxMjEyMTUyNzI3WhcNMzQxMjEwMTUy +NzI3WjAQMQ4wDAYDVQQDDAVDZXJ0QzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBAJmRdT01g1lJjQXkwybJWbYfLRCHxW2zkNkejD5uHGv/hhrhOIuU3Ihy +u39t1vDMHQkxOHREXZ9zki4ArXNKe2+NjVEMcZS3d03wEM+Mx8iusgn75xEKZ/C4 +wU8MHQwC5tOm4h76H2l5NV5IbLENYZNV6YmgsE24te5qQltXpVAVkUxqgejFsFWh +q8UxCeDtSyAq4pTFre88G29XFyRqhLZk+x4YuX/IqgFQwm2w+WqgY1yYyLIDYcuG +n01izKYAUj+1+iWT9TQWUo6a3oz5rtuUm7BQM1AsLk3XqOX8no9znHwBTiidpUhd +1oyjkTLg+uCJIcNB165PNbPkP+ZUda0CAwEAAaNCMEAwHQYDVR0OBBYEFHazPU3y +Y+LBXuhI31moiPRY9W+VMB8GA1UdIwQYMBaAFESpI/r45uM04EpbCMXduTnZBVyq +MA0GCSqGSIb3DQEBCwUAA4IBAQBh5F1uw/VQ43hF5n+gMk4kIBmo80NYe36NeW8m +KcKFlrDhdAKVs9wd0Wql1yQJaOiY2KLoJCk9Yu4WDP8c+C0BMYsn0XcBzHQYqIvW +qkU0+YHl29xApHGc5uGmwUIYORaxth/Ts8nK55JIiXoNXMvYiFGDJu65yFLAf1iM +cK/sJRV5QmdOTZLIrcfo6IB7vfbpL2S1GW2kHCOPYqTyVcrmzos3akMkdHeSkcoW +ayrzuUuW4livtAApNOm6Sp+F12wWyve9iy/Lcm5zmITGJSJ7kHufF5GdrQSUkzDi +a/0n8+rWY3K1jiSju1p3dob6Hn31F6JVXnYX7IwA0BUQ5Sis +-----END CERTIFICATE----- diff --git a/tests/data/untrusted-root-ca/fullchain.pem b/tests/data/untrusted-root-ca/fullchain.pem new file mode 100644 index 0000000..78be954 --- /dev/null +++ b/tests/data/untrusted-root-ca/fullchain.pem @@ -0,0 +1,57 @@ +-----BEGIN CERTIFICATE----- +MIIDFDCCAfygAwIBAgIUKffY79zMWn0EhgRSqWiXbnR1IvMwDQYJKoZIhvcNAQEL +BQAwIzEhMB8GA1UEAwwYSW50ZXJtZWRpYXRlIENlcnRpZmljYXRlMB4XDTI0MTIx +MjEzNDUwN1oXDTI1MTIxMjEzNDUwN1owITEfMB0GA1UEAwwWRW5kIEVudGl0eSBD +ZXJ0aWZpY2F0ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMIzUM7O +LnPT8qZ/Rc3w2cb6sQsatHoGkdxRq4yI1gSXT14Tuv8c93WXhCGnASGELFWW511u +B+XCn3BeJ5dMhsGAGT7/NDVZpxKXmev9T70QIW0FToEbqC2Yp9+brZckYJIl9JyX +AsD2LpKOCZ8pVSMVmRbAU+U29s3ZaGwfGJbfgmXLS/tXYB7OPg8mSF4Vvyfsj33s +0wKQQyGI2PtmKljGqtDCmhR6gEs+oKTSaXJF9Q7ggNyXuUwkosM/rUkouKzEb6rj +6yyiy4RRPeXT3UTPLJQcPwxJi48W3w08ivX2E9NbruHotl8kRQ/+qqPd04V4l1uk +AjOOmNLWwblOLVUCAwEAAaNCMEAwHQYDVR0OBBYEFGLjBzPXCO20+kCKJxJkUBzz +l9REMB8GA1UdIwQYMBaAFE6WzIs4ON0xLZS7m2x1whhFf0YhMA0GCSqGSIb3DQEB +CwUAA4IBAQC28JJK+nIR0M7FnHEu9iqCS8MQd9CzAKqyk+f9cTtvoTcIq3ACZyXB +LvxP7KFgfyTJUiPiR52dhnbDZt+GaaFghUE7RuSNkRhyAAlc49I22i8zborR0MFj +9/L7VjrogALhV0vQ8PyYiYBYz1M2QaVTyVfrmLqqOkbd9xX1PAu25vhTBWMOVAP+ +1+1XDBIkRPCGIxPofJapLGtVeajX//mkKzvpTrubY82FYwZFnLxJ+37pb4D5Hrla +876ghFgzK3cSqdfRJNYgkTaikrUnvtWJsiDgRRq5A4CgXPgrSHBVNOg1O8wzHJKJ +09V4kiRQtbPBN/rezPpqbMy5Po0uhbGT +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDDjCCAfagAwIBAgIUdnEBQPjg1OUGZ2Zt2DMTkk8EI2EwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQUm9vdCBDZXJ0aWZpY2F0ZTAeFw0yNDEyMTIxMzQ0Mzha +Fw0yOTEyMTExMzQ0MzhaMCMxITAfBgNVBAMMGEludGVybWVkaWF0ZSBDZXJ0aWZp +Y2F0ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALppGVRHNi2olRop +KIIuRcKHuy3GXLO0ds9LDaTIKEksQyHwx9WwoZ0gIIdtQAg6NTxfJlGtJ08yE5+R +79nO6sQhRBakEveVhr3COc0MBTdo9SQrYLeMMUH6qd5tTh/EUXYpguq24ZCMbpuL ++DCRuKH77/Js1a/8A4Cx7welUi+JgEQoH0/F5o1pBC4NzLygI71tcxSdZsbqBTtu +eQdXTBGUezhnU1wxzZGgz36vV7rPQEJTK/TAABfPsFQDo+LzEki4gY0VOJ7P3RGk +roWZc6LepqN/1+C2FSfiwAZQYFdB5Y+vGe0z7Q8C7U1zrmEdxHv+Q4N1KbnKKnCn ++WSlgzECAwEAAaNCMEAwHQYDVR0OBBYEFE6WzIs4ON0xLZS7m2x1whhFf0YhMB8G +A1UdIwQYMBaAFHbSd9I5sg9dIYYomK8vAZOwMtC1MA0GCSqGSIb3DQEBCwUAA4IB +AQAfiRihMxaAphKlRIozma0/5KK9eXXp8pf49IWqeQQWO6yoBGq4N6dIhZ5cocqy +AkdrMFFRCIttt8oDuXZb93047i5W6t2vDloo+OomF0DN5RCR938OD6pyzvnkWXTP +YqhT4JH9rCaQvhUmykaIjWzAmHe/HLhGsqY+6fMmCdTbjn4SJmfo2qNNfoVA22Q1 +QdboRHanu2pcg/NBDePjwWdIXvjCAND5q14pCrPn755PVoOoS7uezqcINnlP7gZl +PMDVIioZ857QTbG0iQMqZEZbwAtkUUX/HQY88k/rkGm4y7qs0fM33rxlTtIucZkY +uc3Sfz4zOk8YUzQOTwF6T2es +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDFzCCAf+gAwIBAgIUOseMixTFbS0J7ZrqkZnXUziOqhkwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQUm9vdCBDZXJ0aWZpY2F0ZTAeFw0yNDEyMTIxMzQ0MTha +Fw0zNDEyMTAxMzQ0MThaMBsxGTAXBgNVBAMMEFJvb3QgQ2VydGlmaWNhdGUwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCv37pAd4vWkaKqpKZLi+8nZI6k +l5qPbnVUKQ5aBIdDooF5wVwoSd0ObuvJ4WZtJFxDc3U8wDJ04dmeha6k3OH+7BHn +/G7HzRb3ijbuCZT14CQZ9BrC3oSPXwKq+Ud4BvY3HAPm+2NoHjHwnddfqX/3kR6E +xAmwjKu4H9kKQwRAeetdV6AX+NPhlrIpgD0ASVzPzEDln00PVMau14Gr/L9m7m6X +uhpJXIdUn7/VLrakG2/A3fAaCtzzfDBRqES78Kq36F9lP/0EcLmkKbvph4Yu84lY +RJNFweuBfuh7vElyQDLjfwNEqX7P/GTe1VuSDIjHAxxAUgi871AE+s4R9mwJAgMB +AAGjUzBRMB0GA1UdDgQWBBR20nfSObIPXSGGKJivLwGTsDLQtTAfBgNVHSMEGDAW +gBR20nfSObIPXSGGKJivLwGTsDLQtTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3 +DQEBCwUAA4IBAQBnZdVKZdJ3W/7sCWDoA0qjSlbq4Ry0Gz2OPcNdmlykUBBDO+c9 +rK1QZkyoIBMTOAvs0M+SPvPD2EDQSTzSHMUYEEUZlEahEMrhNNhQWp47K0OENE+p +0J+saj7HvpZPmd9pAphvUa1Bvh8BxFkT5qZrEPMSQntfUd8xWSqavmOD3vcmw4YC +Njb7y4Jly5P06oi0OYkWwff7Jw7QWXF+/SvWWVq9kb/sXPsCKoOBo5/ii3UnQbWP +y8+M+ndcrgLQvbnGXt98m9/0PzmxIcHdRyg7bkNO14tE4kmFE1LjDSrcZjfV1vix +wN+oDCby9xuuBiqPPztII8ixq+6bCu0i5BcB +-----END CERTIFICATE----- diff --git a/tests/utils/test_x509.py b/tests/utils/test_x509.py new file mode 100644 index 0000000..8ec8e2c --- /dev/null +++ b/tests/utils/test_x509.py @@ -0,0 +1,542 @@ +import pytest +from cryptography.x509 import Certificate +from cryptography.x509.oid import NameOID +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.backends import default_backend +from datetime import datetime, timedelta + +from app.util import x509 +from app.util.x509 import TLSValidationError +from tests.api.test_onion import generate_self_signed_tls_certificate +from datetime import datetime, timedelta +import shutil +from itertools import permutations + + +@pytest.fixture +def self_signed_rsa_cert(tmp_path): + directory = ( + tmp_path / "cert" + ) # tmp_path is a pytest fixture implemented as a pathlib.Path object + if directory.exists(): + shutil.rmtree(directory) + directory.mkdir() + onion_address = "test.onion" + valid_from = datetime.now() + valid_to = valid_from + timedelta(days=30) + dns_names = ["test.onion", "www.test.onion"] + + # Generate certificate + private_key_path, cert_path = generate_self_signed_tls_certificate( + str(directory), onion_address, valid_from, valid_to, dns_names=dns_names + ) + return private_key_path, cert_path + + +@pytest.fixture +def expired_cert(tmp_path): + # Fixture for generating a certificate that is expired + directory = tmp_path / "cert_expired" + if directory.exists(): + shutil.rmtree(directory) + directory.mkdir() + onion_address = "test.onion" + valid_from = datetime.now() - timedelta(days=60) + valid_to = datetime.now() - timedelta(days=30) + dns_names = ["test.onion", "www.test.onion"] + + private_key_path, cert_path = generate_self_signed_tls_certificate( + str(directory), onion_address, valid_from, valid_to, dns_names=dns_names + ) + return private_key_path, cert_path + + +@pytest.fixture +def not_yet_valid_cert(tmp_path): + # Fixture for generating a certificate that is not yet valid + directory = tmp_path / "cert_not_yet_valid" + if directory.exists(): + shutil.rmtree(directory) + directory.mkdir() + onion_address = "test.onion" + valid_from = datetime.now() + timedelta(days=30) + valid_to = valid_from + timedelta(days=30) + dns_names = ["test.onion", "www.test.onion"] + private_key_path, cert_path = generate_self_signed_tls_certificate( + str(directory), onion_address, valid_from, valid_to, dns_names=dns_names + ) + return private_key_path, cert_path + + +@pytest.fixture +def letsencrypt_cert(): + cert_path = "tests/data/letsencrypt-issued/fullchain.pem" + private_key_path = "tests/data/letsencrypt-issued/privkey.pem" + return private_key_path, cert_path + + +@pytest.fixture +def invalid_algorithm_key(): + key_path = "tests/data/invalid-algorithm/dsa_private_key.pem" + return key_path + + +@pytest.fixture +def letsencrypt_valid_chain(): + cert_path = "tests/data/letsencrypt-issued/fullchain.pem" + with open(cert_path, "rb") as cert_file: + pem_cert = cert_file.read() + certificates = x509.load_certificates_from_pem(pem_cert) + return certificates + + +@pytest.fixture +def invalid_algorithm_chain(): + cert_path = "tests/data/invalid-algorithm/fullchain.pem" + with open(cert_path, "rb") as cert_file: + pem_cert = cert_file.read() + certificates = x509.load_certificates_from_pem(pem_cert) + return certificates + + +@pytest.fixture +def untrusted_root_ca_chain(): + cert_path = "tests/data/untrusted-root-ca/fullchain.pem" + with open(cert_path, "rb") as cert_file: + pem_cert = cert_file.read() + certificates = x509.load_certificates_from_pem(pem_cert) + return certificates + + +@pytest.fixture +def circular_chain(): + cert_path = "tests/data/no-end-entity-chain/fullchain.pem" + with open(cert_path, "rb") as cert_file: + pem_cert = cert_file.read() + certificates = x509.load_certificates_from_pem(pem_cert) + return certificates + + +def test_load_single_certificate(self_signed_rsa_cert): + """Test loading a single certificate from PEM data.""" + _, cert_path = self_signed_rsa_cert + with open(cert_path, "rb") as cert_file: + pem_cert = cert_file.read() + + certificates = x509.load_certificates_from_pem(pem_cert) + assert len(certificates) == 1 + assert isinstance(certificates[0], Certificate) + + +def test_load_multiple_certificates(self_signed_rsa_cert): + """Test loading multiple certificates from PEM data.""" + _, cert_path = self_signed_rsa_cert + + with open(cert_path, "rb") as cert_file: + pem_cert = cert_file.read() + pem_data = pem_cert + b"\n" + pem_cert + certificates = x509.load_certificates_from_pem(pem_data) + assert len(certificates) == 2 + for loaded_cert in certificates: + assert isinstance(loaded_cert, Certificate) + + +def test_load_invalid_pem_data(): + """Test handling of invalid PEM data.""" + invalid_pem = b"-----BEGIN CERTIFICATE-----\nInvalidCertificateData\n-----END CERTIFICATE-----" + with pytest.raises(TLSValidationError, match="TLS certificate parsing error"): + x509.load_certificates_from_pem(invalid_pem) + + +def test_validate_key_with_valid_RSA_key(self_signed_rsa_cert): + """Test that a valid RSA private key is accepted.""" + private_key_path, _ = self_signed_rsa_cert + with open(private_key_path, "r") as private_key_file: + private_key_pem = private_key_file.read() + assert x509.validate_key(private_key_pem) is True + + +def test_validate_key_with_valid_EC_key(letsencrypt_cert): + """Test that a valid EC private key is accepted.""" + private_key_path, _ = letsencrypt_cert + with open(private_key_path, "r") as private_key_file: + private_key_pem = private_key_file.read() + assert x509.validate_key(private_key_pem) is True + + +def test_validate_key_with_valid_DSA_key(invalid_algorithm_key): + """Test that a valid DSA private key is not accepted.""" + with open(invalid_algorithm_key, "r") as private_key_file: + private_key_pem = private_key_file.read() + assert x509.validate_key(private_key_pem) is False + + +@pytest.mark.parametrize( + "scenario, key_data, expected_result, expected_exception", + [ + ( + "invalid_key_format", + "-----BEGIN INVALID KEY-----\nInvalidData\n-----END INVALID KEY-----", + None, + x509.TLSInvalidPrivateKeyError, + ), + ("none_key", None, False, None), + ("empty_key", "", False, None), + ], +) +def test_validate_key_with_invalid_key( + scenario, key_data, expected_result, expected_exception +): + """Test that various invalid private keys are not recognized.""" + if expected_exception: + with pytest.raises(expected_exception): + x509.validate_key(key_data) + else: + assert x509.validate_key(key_data) is expected_result + + +@pytest.mark.parametrize( + "scenario, certificate_fixture, expected_result", + [ + ("expired_certificate", "expired_cert", True), + ("valid_certificate", "self_signed_rsa_cert", False), + ], +) +def test_validate_end_entity_expired( + scenario, certificate_fixture, expected_result, request +): + """Test with both expired and valid certificates""" + _, cert_path = request.getfixturevalue( + certificate_fixture + ) # Access cert_path from the fixture + with open(cert_path, "r") as cert_file: + tls_certificate_pem = cert_file.read().encode("utf-8") + end_entity_cert = x509.load_certificates_from_pem(tls_certificate_pem)[0] + assert x509.validate_end_entity_expired(end_entity_cert) is expected_result + + +@pytest.mark.parametrize( + "scenario, certificate_fixture, expected_result", + [ + ("not_yet_valid_certificate", "not_yet_valid_cert", True), + ("valid_certificate", "self_signed_rsa_cert", False), + ], +) +def test_validate_end_entity_not_yet_valid( + scenario, certificate_fixture, expected_result, request +): + # Test with a certificate that is not yet valid + _, cert_path = request.getfixturevalue( + certificate_fixture + ) # Access cert_path from the fixture + with open(cert_path, "r") as cert_file: + tls_certificate_pem = cert_file.read().encode("utf-8") + end_entity_cert = x509.load_certificates_from_pem(tls_certificate_pem)[0] + assert x509.validate_end_entity_not_yet_valid(end_entity_cert) is expected_result + + +@pytest.mark.parametrize( + "scenario, cert_fixture, key_fixture, expected_result", + [ + ( + "matching_keys_rsa", + "self_signed_rsa_cert", + "self_signed_rsa_cert", + True, + ), # Matching certificate and key + ( + "matching_keys_ec", + "letsencrypt_cert", + "letsencrypt_cert", + True, + ), # Matching certificate and key + ( + "mismatched_keys", + "self_signed_rsa_cert", + "expired_cert", + False, + ), # Mismatched certificate and key + ("missing_key", "self_signed_rsa_cert", None, False), # Missing private key + ("missing_cert", None, "self_signed_rsa_cert", False), # Missing certificate + ], +) +def test_validate_key_matches_cert( + scenario, cert_fixture, key_fixture, expected_result, request +): + """Test the validate_key_matches_cert function with different scenarios""" + cert_path = None + key_path = None + + if cert_fixture: + _, cert_path = request.getfixturevalue(cert_fixture) + if key_fixture: + key_path, _ = request.getfixturevalue(key_fixture) + + if cert_path is None or key_path is None: + assert x509.validate_key_matches_cert(key_path, cert_path) is expected_result + return + with open(cert_path, "r") as cert_file: + tls_certificate_pem = cert_file.read().encode("utf-8") + end_entity_cert = x509.load_certificates_from_pem(tls_certificate_pem)[0] + with open(key_path, "r") as key_file: + tls_private_key_pem = key_file.read() + assert ( + x509.validate_key_matches_cert(tls_private_key_pem, end_entity_cert) + is expected_result + ) + + +def test_extract_sans(letsencrypt_cert): + _, cert_path = letsencrypt_cert + with open(cert_path, "rb") as cert_file: + pem_cert = cert_file.read() + certs = x509.load_certificates_from_pem(pem_cert) + sans = x509.extract_sans(certs[0]) + assert isinstance(sans, list) + expected_sans = ["xahm6aech1mie5queyo8.censorship.guide"] + assert sans == expected_sans + + +@pytest.mark.parametrize( + "scenario, certificate_input, expected_result", + [ + ( + "invalid_certificate_format", + "-----BEGIN CERTIFICATE-----\nINVALID_CERTIFICATE\n-----END CERTIFICATE-----", + False, + ), + ("certificate_is_none", None, False), + ("certificate_is_empty_string", "", False), + ], +) +def test_extract_sans_invalid(scenario, certificate_input, expected_result): + sans = x509.extract_sans(certificate_input) + assert isinstance(sans, list) + + +def test_validate_certificate_chain_invalid_algorithm(invalid_algorithm_chain): + with pytest.raises( + TLSValidationError, match="Certificate using unsupported algorithm" + ): + x509.validate_certificate_chain(invalid_algorithm_chain) + + +def test_validate_certificate_chain_invalid_root_ca(untrusted_root_ca_chain): + with pytest.raises( + TLSValidationError, + match="Certificate chain does not terminate at a trusted root CA.", + ): + x509.validate_certificate_chain(untrusted_root_ca_chain) + + +def test_validate_certificate_chain_valid(letsencrypt_valid_chain): + assert x509.validate_certificate_chain(letsencrypt_valid_chain) is True + + +def test_build_chain_single_certificate(self_signed_rsa_cert): + _, cert_path = self_signed_rsa_cert + with open(cert_path, "rb") as cert_file: + pem_cert = cert_file.read() + certs = x509.load_certificates_from_pem(pem_cert) + assert x509.build_certificate_chain(certs) == certs + + +def test_build_chain_valid_certificate_letsencrypt(letsencrypt_valid_chain): + assert ( + x509.build_certificate_chain(letsencrypt_valid_chain) == letsencrypt_valid_chain + ) + + +def test_build_chain_certificates_in_order(untrusted_root_ca_chain): + assert ( + x509.build_certificate_chain(untrusted_root_ca_chain) == untrusted_root_ca_chain + ) + + +def test_build_chain_certificates_in_random_order(untrusted_root_ca_chain): + for perm in permutations(untrusted_root_ca_chain): + assert x509.build_certificate_chain(perm) == untrusted_root_ca_chain + + +def test_build_chain_empty_list(): + with pytest.raises( + TLSValidationError, match="Cannot identify the end-entity certificate." + ): + x509.build_certificate_chain([]) == [] + + +def test_build_chain_invalid_chain_no_end_entity(circular_chain): + with pytest.raises( + TLSValidationError, match="Cannot identify the end-entity certificate." + ): + x509.build_certificate_chain(circular_chain) + + +def test_build_chain_certificates_missing_issuers(untrusted_root_ca_chain): + untrusted_root_ca_chain.pop(1) + with pytest.raises( + TLSValidationError, match="Certificates do not form a valid chain." + ): + x509.build_certificate_chain(untrusted_root_ca_chain) + + +def test_build_chain_duplicate_certificates(untrusted_root_ca_chain): + duplicate_untrusted_root_ca_chain = untrusted_root_ca_chain * 2 + assert ( + x509.build_certificate_chain(duplicate_untrusted_root_ca_chain) + == untrusted_root_ca_chain + ) + + +def test_build_chain_non_chain_certificates( + untrusted_root_ca_chain, letsencrypt_valid_chain +): + non_chain = untrusted_root_ca_chain + letsencrypt_valid_chain + with pytest.raises( + TLSValidationError, match="Certificates do not form a valid chain." + ): + x509.build_certificate_chain(non_chain) + + +@pytest.mark.parametrize( + "scenario, cert_fixture, key_fixture, hostname, expected_result, expected_errors", + [ + ( + "empty_cert", + None, + None, + None, + None, + [ + { + "key": "no_valid_certificates", + "message": "No valid certificates supplied.", + }, + ], + ), + ( + "trusted_cert_wrong_hostname", + "letsencrypt_cert", + "letsencrypt_cert", + "wrong.example.com", + None, + [ + { + "key": "hostname_not_in_san", + "message": "wrong.example.com not found in SANs.", + }, + { + "key": "hostname_not_in_san", + "message": "*.wrong.example.com not found in SANs.", + }, + ], + ), + ( + "expired_self_signed_cert", + "expired_cert", + "expired_cert", + None, + None, + [ + {"key": "public_key_expired", "message": "TLS public key is expired."}, + { + "key": "untrusted_root_ca", + "message": "Certificate chain does not terminate at a trusted root CA.", + }, + ], + ), + ( + "not_yet_valid_self_signed_cert", + "not_yet_valid_cert", + "not_yet_valid_cert", + None, + None, + [ + { + "key": "public_key_future", + "message": "TLS public key is not yet valid.", + }, + { + "key": "untrusted_root_ca", + "message": "Certificate chain does not terminate at a trusted root CA.", + }, + ], + ), + ( + "mismatched_key_cert", + "letsencrypt_cert", + "self_signed_rsa_cert", + None, + None, + [ + { + "key": "key_mismatch", + "message": "Private key does not match certificate.", + }, + ], + ), + ], +) +def test_validate_tls_keys( + scenario, + cert_fixture, + key_fixture, + hostname, + expected_result, + expected_errors, + request, +): + # Get the certificate and key paths from the fixture + if cert_fixture and key_fixture: + _, cert_path = request.getfixturevalue(cert_fixture) + key_path, _ = request.getfixturevalue(key_fixture) + + # Read the certificate and key files + with open(cert_path, "rb") as cert_file: + cert_pem = cert_file.read().decode("utf-8") + with open(key_path, "rb") as key_file: + key_pem = key_file.read().decode("utf-8") + else: + cert_pem = None + key_pem = None + + # Call the validate_tls_keys function + skip_name_verification = False + if not hostname: + skip_name_verification = True + chain, san_list, errors = x509.validate_tls_keys( + tls_private_key_pem=key_pem, + tls_certificate_pem=cert_pem, + hostname=hostname, + skip_name_verification=skip_name_verification, + skip_chain_verification=False, + ) + + # Assert that the errors match the expected errors + assert errors == expected_errors + + +def test_validate_tls_keys_invalid_algorithn( + invalid_algorithm_key, self_signed_rsa_cert +): + with open(invalid_algorithm_key, "rb") as key_file: + key_pem = key_file.read().decode("utf-8") + _, cert_path = self_signed_rsa_cert + with open(cert_path, "rb") as cert_file: + cert_pem = cert_file.read().decode("utf-8") + + chain, san_list, errors = x509.validate_tls_keys( + tls_private_key_pem=key_pem, + tls_certificate_pem=cert_pem, + hostname=None, + skip_name_verification=True, + skip_chain_verification=True, + ) + expected_errors = [ + { + "key": "private_key_not_rsa_or_ec", + "message": "Private key must be RSA or Elliptic-Curve.", + } + ] + assert errors == expected_errors