diff --git a/acme/src/acme/client.py b/acme/src/acme/client.py index c7d45ee9a..671e1332e 100644 --- a/acme/src/acme/client.py +++ b/acme/src/acme/client.py @@ -121,18 +121,12 @@ class ClientV2: :rtype: OrderResource """ csr = x509.load_pem_x509_csr(csr_pem) - dnsNames = crypto_util.get_names_from_subject_and_extensions(csr.subject, csr.extensions) - try: - san_ext = csr.extensions.get_extension_for_class(x509.SubjectAlternativeName) - except x509.ExtensionNotFound: - ipNames = [] - else: - ipNames = san_ext.value.get_values_for_type(x509.IPAddress) + dns_names, ip_addrs = crypto_util.get_identifiers_from_x509(csr.subject, csr.extensions) identifiers = [] - for name in dnsNames: + for name in dns_names: identifiers.append(messages.Identifier(typ=messages.IDENTIFIER_FQDN, value=name)) - for ip in ipNames: + for ip in ip_addrs: identifiers.append(messages.Identifier(typ=messages.IDENTIFIER_IP, value=str(ip))) if profile is None: diff --git a/acme/src/acme/crypto_util.py b/acme/src/acme/crypto_util.py index e1f98d486..765cd7c10 100644 --- a/acme/src/acme/crypto_util.py +++ b/acme/src/acme/crypto_util.py @@ -112,7 +112,7 @@ def make_csr( def get_names_from_subject_and_extensions( subject: x509.Name, exts: x509.Extensions ) -> list[str]: - """Gets all DNS SAN names as well as the first Common Name from subject. + """Gets all DNS SANs as well as the first Common Name from subject. :param subject: Name of the x509 object, which may include Common Name :type subject: `cryptography.x509.Name` @@ -122,6 +122,24 @@ def get_names_from_subject_and_extensions( :returns: List of DNS Subject Alternative Names and first Common Name :rtype: `list` of `str` """ + dns_names, _ = get_identifiers_from_x509(subject, exts) + return dns_names + + +def get_identifiers_from_x509( + subject: x509.Name, exts: x509.Extensions +) -> tuple[list[str], list[str]]: + """Gets all DNS and/or IP address SANs as well as the first Common Name from subject. + + The CN will be first in the list of DNS names, if present. + + :param subject: Name of the x509 object, which may include Common Name + :type subject: `cryptography.x509.Name` + :param exts: Extensions of the x509 object, which may include SANs + :type exts: `cryptography.x509.Extensions` + + :returns: Tuple containing DNS names and IP addresses. + """ # We know these are always `str` because `bytes` is only possible for # other OIDs. cns = [ @@ -132,15 +150,17 @@ def get_names_from_subject_and_extensions( san_ext = exts.get_extension_for_class(x509.SubjectAlternativeName) except x509.ExtensionNotFound: dns_names = [] + ip_addresses = [] else: dns_names = san_ext.value.get_values_for_type(x509.DNSName) + ip_addresses = [str(ip) for ip in san_ext.value.get_values_for_type(x509.IPAddress)] if not cns: - return dns_names + return dns_names, ip_addresses else: # We only include the first CN, if there are multiple. This matches # the behavior of the previous implementation using pyOpenSSL. - return [cns[0]] + [d for d in dns_names if d != cns[0]] + return [cns[0]] + [d for d in dns_names if d != cns[0]], ip_addresses def _cryptography_cert_or_req_san( diff --git a/certbot/src/certbot/_internal/san.py b/certbot/src/certbot/_internal/san.py new file mode 100644 index 000000000..a7744c47d --- /dev/null +++ b/certbot/src/certbot/_internal/san.py @@ -0,0 +1,158 @@ +"""Types for representing IP addresses and DNS names internal to Certbot.""" +import ipaddress +from abc import abstractmethod +from typing import Any, Iterable + +from acme import crypto_util as acme_crypto_util +from cryptography import x509 + +from certbot import errors + +class SAN: + """A domain or IP address. + + These are Certbot-internal types, independent of the acme module's messages.Identifier. + """ + @abstractmethod + def is_wildcard(self) -> bool: + """Return True if this is a wildcard DNS name.""" + +class DNSName(SAN): + """An FQDN or wildcard domain name. + + Raises ConfigurationError if the domain name is syntactically invalid. + + Normalizes inputs by converting to lowercase and removing a trailing dot, if present. + """ + def __init__(self, dns_name: str) -> None: + if not isinstance(dns_name, str): + raise TypeError("tried to initialize DNSName with non-str") + try: + dns_name.encode('ascii') + except UnicodeError: + raise errors.ConfigurationError("Non-ASCII domain names not supported. " + "To issue for an Internationalized Domain Name, use Punycode.") + dns_name = dns_name.lower() + # Remove trailing dot + dns_name = dns_name.removesuffix(".") + + # Separately check for odd "domains" like "http://example.com" to fail + # fast and provide a clear error message + for scheme in ["http", "https"]: # Other schemes seem unlikely + if dns_name.startswith("{0}://".format(scheme)): + raise errors.ConfigurationError( + "Requested name {0} appears to be a URL, not a FQDN. " + "Try again without the leading \"{1}://\".".format( + dns_name, scheme + ) + ) + + try: + IPAddress(dns_name) + raise errors.ConfigurationError( + "Requested name {0} is an IP address. The Let's Encrypt " + "certificate authority will not issue certificates for a " + "bare IP address.".format(dns_name)) + except ValueError: + pass + + # FQDN checks according to RFC 2181: domain name should be less than 255 + # octets (inclusive). And each label is 1 - 63 octets (inclusive). + # https://tools.ietf.org/html/rfc2181#section-11 + msg = "Requested domain {0} is not a FQDN because".format(dns_name) + if len(dns_name) > 255: + raise errors.ConfigurationError("{0} it is too long.".format(msg)) + labels = dns_name.split('.') + for l in labels: + if not l: + raise errors.ConfigurationError("{0} it contains an empty label.".format(msg)) + if len(l) > 63: + raise errors.ConfigurationError("{0} label {1} is too long.".format(msg, l)) + + self.dns_name = dns_name + + def __str__(self) -> str: + return self.dns_name + + def __hash__(self) -> int: + return hash(self.dns_name) + + def __repr__(self) -> str: + return 'DNS(%s)' % self.dns_name + + def __eq__(self, other: Any) -> bool: + match other: + case DNSName(): + return self.dns_name == other.dns_name + case IPAddress(): + return False + case _: + raise TypeError(f"DNSName SAN compared to non-SAN: {type(other)}") + + def is_wildcard(self) -> bool: + """Return True if this DNS name is a wildcard.""" + return self.dns_name.startswith('*.') + +class IPAddress(SAN): + """An IP address (IPv4 or IPv6). + + Validated upon construction. + """ + def __init__(self, ip_address: str) -> None: + self.ip_address = ipaddress.ip_address(ip_address) + + def __str__(self) -> str: + return str(self.ip_address) + + def __hash__(self) -> int: + return hash(self.ip_address) + + def __repr__(self) -> str: + return 'IP(%s)' % self.ip_address + + def __eq__(self, other: Any) -> bool: + match other: + case IPAddress(): + return self.ip_address == other.ip_address + case DNSName(): + return False + case _: + raise TypeError(f"IPAddress SAN compared to non-SAN: {type(other)}") + + def is_wildcard(self) -> bool: + """Always False.""" + return False + +def split(sans: Iterable[SAN]) -> tuple[list[DNSName], list[IPAddress]]: + """Split a list of SANs into a list of DNSNames and one of IPAddress, in that order.""" + domains = [] + ip_addresses = [] + for s in sans: + match s: + case IPAddress(): + ip_addresses.append(s) + case DNSName(): + domains.append(s) + case _: + raise TypeError(f"SAN of type {type(s)}") + return domains, ip_addresses + +def display(sans: Iterable[SAN]) -> str: + """Return the list of SANs in string form, separated by comma and space.""" + return ", ".join(map(str, sans)) + +def from_x509(subject: x509.Name, exts: x509.Extensions) -> tuple[list[DNSName], list[IPAddress]]: + """Get all DNS names and IP addresses, plus the first Common Name from subject. + + The CN will be first in the list, if present. It will always be interpreted + as a DNS name. + + :param subject: Name of the x509 object, which may include Common Name + :type subject: `cryptography.x509.Name` + :param exts: Extensions of the x509 object, which may include SANs + :type exts: `cryptography.x509.Extensions` + + :returns: Tuple containing a list of DNSNames and a list of IPAddresses + """ + dns_names, ip_addresses = acme_crypto_util.get_identifiers_from_x509(subject, exts) + return [DNSName(d) for d in dns_names], [IPAddress(i) for i in ip_addresses] diff --git a/certbot/src/certbot/_internal/san_test.py b/certbot/src/certbot/_internal/san_test.py new file mode 100644 index 000000000..c14714f4a --- /dev/null +++ b/certbot/src/certbot/_internal/san_test.py @@ -0,0 +1,205 @@ +"""Tests for the san module""" +import ipaddress +from datetime import datetime + +import pytest +import unittest + +from cryptography import x509 +from cryptography.x509.oid import NameOID +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec + +from certbot import errors +from certbot._internal import san + +class SanTest(unittest.TestCase): + def test_str(self) -> None: + assert str(san.DNSName("example.com")) == "example.com" + assert str(san.IPAddress("192.168.1.1")) == "192.168.1.1" + + def test_hash(self) -> None: + assert hash(san.DNSName("example.com")) == hash(san.DNSName("example.com")) + assert hash(san.DNSName("EXAMPLE.COM.")) == hash(san.DNSName("example.com")) + assert hash(san.IPAddress("192.168.1.1")) == hash(san.IPAddress("192.168.1.1")) + + def test_repr(self) -> None: + assert repr(san.DNSName("example.com")) == "DNS(example.com)" + assert repr(san.IPAddress("192.168.1.1")) == "IP(192.168.1.1)" + + def test_eq(self) -> None: + with pytest.raises(TypeError): + san.DNSName("example.com") == "example.com" # pylint: disable=expression-not-assigned + with pytest.raises(TypeError): + "example.com" == san.DNSName("example.com") # pylint: disable=expression-not-assigned + with pytest.raises(TypeError): + san.IPAddress("192.168.1.1") == "192.168.1.1" # pylint: disable=expression-not-assigned + with pytest.raises(TypeError): + "192.168.1.1" == san.IPAddress("192.168.1.1") # pylint: disable=expression-not-assigned + assert san.DNSName("example.com") == san.DNSName("example.com") + assert san.DNSName("Example.com") == san.DNSName("example.com") + assert san.DNSName("example.com") == san.DNSName("Example.com") + assert san.DNSName("EXAMPLE.COM") == san.DNSName("Example.com") + assert san.DNSName("EXAMPLE.COM.") == san.DNSName("example.com") + + def test_is_wildcard(self) -> None: + assert not san.DNSName("example.com").is_wildcard() + assert not san.DNSName("example.*.com").is_wildcard() + assert san.DNSName("*.example.com").is_wildcard() + assert not san.IPAddress("192.168.1.1").is_wildcard() + + def test_split(self) -> None: + assert san.split([]) == ([], []) + assert san.split([san.IPAddress("192.168.1.1")]) == ([], [san.IPAddress("192.168.1.1")]) + assert san.split([san.DNSName("example.com")]) == ([san.DNSName("example.com")], []) + assert san.split([san.DNSName("example.com"), san.IPAddress("192.168.1.1"), + san.DNSName("example.org"), san.IPAddress("192.168.1.2")]) == ( + [san.DNSName("example.com"), san.DNSName("example.org")], + [san.IPAddress("192.168.1.1"), san.IPAddress("192.168.1.2")]) + + def test_display(self) -> None: + assert san.display([san.DNSName("example.com")]) == "example.com" + assert san.display([san.DNSName("example.com"), san.IPAddress("192.168.1.1")]) \ + == "example.com, 192.168.1.1" + + def test_ip_address(self) -> None: + with pytest.raises(ValueError): + san.IPAddress("example.com") + +class EnforceDomainSyntaxTest(unittest.TestCase): + """Test validation of domain names.""" + def _call(self, dns_name: str) -> None: + san.DNSName(dns_name) + + def test_nonascii_str(self) -> None: + with pytest.raises(errors.ConfigurationError): + self._call("eichh\u00f6rnchen.example.com") + + def test_too_long(self) -> None: + long_domain = "a"*256 + with pytest.raises(errors.ConfigurationError): + self._call(long_domain) + + def test_not_too_long(self) -> None: + not_too_long_domain = "{0}.{1}.{2}.{3}".format("a"*63, "b"*63, "c"*63, "d"*63) + self._call(not_too_long_domain) + + def test_empty_label(self) -> None: + empty_label_domain = "fizz..example.com" + with pytest.raises(errors.ConfigurationError): + self._call(empty_label_domain) + + def test_empty_trailing_label(self) -> None: + empty_trailing_label_domain = "example.com.." + with pytest.raises(errors.ConfigurationError): + self._call(empty_trailing_label_domain) + + def test_long_label_1(self) -> None: + long_label_domain = "a"*64 + with pytest.raises(errors.ConfigurationError): + self._call(long_label_domain) + + def test_long_label_2(self) -> None: + long_label_domain = "{0}.{1}.com".format("a"*64, "b"*63) + with pytest.raises(errors.ConfigurationError): + self._call(long_label_domain) + + def test_not_long_label(self) -> None: + not_too_long_label_domain = "{0}.{1}.com".format("a"*63, "b"*63) + self._call(not_too_long_label_domain) + + def test_empty_domain(self) -> None: + empty_domain = "" + with pytest.raises(errors.ConfigurationError): + self._call(empty_domain) + + def test_punycode_ok(self) -> None: + # Punycode is now legal, so no longer an error; instead check + # that it's _not_ an error (at the initial sanity check stage) + self._call('this.is.xn--ls8h.tld') + +class FromX509Test(unittest.TestCase): + def test_csr(self) -> None: + key = ec.generate_private_key(ec.SECP256R1()) + csr = ( + x509.CertificateSigningRequestBuilder() + .subject_name(x509.Name([])) + .add_extension( + x509.SubjectAlternativeName( + [x509.DNSName("example.com"), + x509.IPAddress(ipaddress.ip_address("192.168.1.1"))] + ), + critical=False, + ) + ).sign(key, hashes.SHA256()) + result = san.from_x509(csr.subject, csr.extensions) + assert result == ( + [san.DNSName("example.com")], + [san.IPAddress("192.168.1.1")], + ) + + def test_cert(self) -> None: + key = ec.generate_private_key(ec.SECP256R1()) + cert = ( + x509.CertificateBuilder() + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.now()) + .not_valid_after(datetime.now()) + .subject_name(x509.Name([])) + .issuer_name(x509.Name([])) + .public_key(key.public_key()) + .add_extension( + x509.SubjectAlternativeName( + [x509.DNSName("example.com"), + x509.IPAddress(ipaddress.ip_address("192.168.1.1"))] + ), + critical=False, + ) + ).sign(key, hashes.SHA256()) + result = san.from_x509(cert.subject, cert.extensions) + assert result == ( + [san.DNSName("example.com")], + [san.IPAddress("192.168.1.1")], + ) + + def test_cn(self) -> None: + key = ec.generate_private_key(ec.SECP256R1()) + csr = ( + x509.CertificateSigningRequestBuilder() + .subject_name(x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, "common.example"), + ])) + .add_extension( + x509.SubjectAlternativeName( + [x509.DNSName("example.com"), + x509.IPAddress(ipaddress.ip_address("192.168.1.1"))] + ), + critical=False, + ) + ).sign(key, hashes.SHA256()) + result = san.from_x509(csr.subject, csr.extensions) + assert result == ( + [san.DNSName("common.example"), san.DNSName("example.com")], + [san.IPAddress("192.168.1.1")], + ) + + def test_cn_duplicate(self) -> None: + key = ec.generate_private_key(ec.SECP256R1()) + csr = ( + x509.CertificateSigningRequestBuilder() + .subject_name(x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, "example.com"), + ])) + .add_extension( + x509.SubjectAlternativeName( + [x509.DNSName("example.com"), + x509.IPAddress(ipaddress.ip_address("192.168.1.1"))] + ), + critical=False, + ) + ).sign(key, hashes.SHA256()) + result = san.from_x509(csr.subject, csr.extensions) + assert result == ( + [san.DNSName("example.com")], + [san.IPAddress("192.168.1.1")], + )