diff --git a/acme/acme/client.py b/acme/acme/client.py index 93816abfb..e13b272c7 100644 --- a/acme/acme/client.py +++ b/acme/acme/client.py @@ -655,11 +655,15 @@ class ClientV2(ClientBase): csr = OpenSSL.crypto.load_certificate_request(OpenSSL.crypto.FILETYPE_PEM, csr_pem) # pylint: disable=protected-access dnsNames = crypto_util._pyopenssl_cert_or_req_all_names(csr) - + ipNames = crypto_util._pyopenssl_cert_or_req_san_ip(csr) + # ipNames is now []string identifiers = [] for name in dnsNames: identifiers.append(messages.Identifier(typ=messages.IDENTIFIER_FQDN, value=name)) + for ips in ipNames: + identifiers.append(messages.Identifier(typ=messages.IDENTIFIER_IP, + value=ips)) order = messages.NewOrder(identifiers=identifiers) response = self._post(self.directory['newOrder'], order) body = messages.Order.from_json(response.json()) diff --git a/acme/acme/crypto_util.py b/acme/acme/crypto_util.py index 359d9b0c3..2f3395486 100644 --- a/acme/acme/crypto_util.py +++ b/acme/acme/crypto_util.py @@ -187,23 +187,42 @@ def probe_sni(name, host, port=443, timeout=300, # pylint: disable=too-many-argu return client_ssl.get_peer_certificate() -def make_csr(private_key_pem, domains, must_staple=False): - """Generate a CSR containing a list of domains as subjectAltNames. +def make_csr(private_key_pem, domains=None, must_staple=False, ipaddrs=None): + """Generate a CSR containing domains or IPs as subjectAltNames. :param buffer private_key_pem: Private key, in PEM PKCS#8 format. :param list domains: List of DNS names to include in subjectAltNames of CSR. :param bool must_staple: Whether to include the TLS Feature extension (aka OCSP Must Staple: https://tools.ietf.org/html/rfc7633). + :param list ipaddrs: List of IPaddress(type ipaddress.IPv4Address or ipaddress.IPv6Address) + names to include in subbjectAltNames of CSR. + params ordered this way for backward competablity when called by positional argument. :returns: buffer PEM-encoded Certificate Signing Request. """ private_key = crypto.load_privatekey( crypto.FILETYPE_PEM, private_key_pem) csr = crypto.X509Req() + sanlist = [] + # if domain or ip list not supplied make it empty list so it's easier to iterate + if domains is None: + domains = [] + if ipaddrs is None: + ipaddrs = [] + if len(domains)+len(ipaddrs) == 0: + raise ValueError("At least one of domains or ipaddrs parameter need to be not empty") + for address in domains: + sanlist.append('DNS:' + address) + for ips in ipaddrs: + sanlist.append('IP:' + ips.exploded) + # make sure its ascii encoded + san_string = ', '.join(sanlist).encode('ascii') + # for IP san it's actually need to be octet-string, + # but somewhere downsteam thankfully handle it for us extensions = [ crypto.X509Extension( b'subjectAltName', critical=False, - value=', '.join('DNS:' + d for d in domains).encode('ascii') + value=san_string ), ] if must_staple: @@ -220,6 +239,7 @@ def make_csr(private_key_pem, domains, must_staple=False): def _pyopenssl_cert_or_req_all_names(loaded_cert_or_req): + # unlike its name this only outputs DNS names, other type of idents will ignored common_name = loaded_cert_or_req.get_subject().CN sans = _pyopenssl_cert_or_req_san(loaded_cert_or_req) @@ -239,20 +259,56 @@ def _pyopenssl_cert_or_req_san(cert_or_req): :param cert_or_req: Certificate or CSR. :type cert_or_req: `OpenSSL.crypto.X509` or `OpenSSL.crypto.X509Req`. - :returns: A list of Subject Alternative Names. + :returns: A list of Subject Alternative Names that is DNS. + :rtype: `list` of `unicode` + + """ + # This function finds SANs with dns name + + # constants based on PyOpenSSL certificate/CSR text dump + part_separator = ":" + prefix = "DNS" + part_separator + + sans_parts = _pyopenssl_extract_san_list_raw(cert_or_req) + + return [part.split(part_separator)[1] + for part in sans_parts if part.startswith(prefix)] + + +def _pyopenssl_cert_or_req_san_ip(cert_or_req): + """Get Subject Alternative Names IPs from certificate or CSR using pyOpenSSL. + + :param cert_or_req: Certificate or CSR. + :type cert_or_req: `OpenSSL.crypto.X509` or `OpenSSL.crypto.X509Req`. + + :returns: A list of Subject Alternative Names that are IP Addresses. + :rtype: `list` of `unicode`. note that this returns as string, not IPaddress object + + """ + + # constants based on PyOpenSSL certificate/CSR text dump + part_separator = ":" + prefix = "IP Address" + part_separator + + sans_parts = _pyopenssl_extract_san_list_raw(cert_or_req) + + return [part[len(prefix):] for part in sans_parts if part.startswith(prefix)] + + +def _pyopenssl_extract_san_list_raw(cert_or_req): + """Get raw SAN string from cert or csr, parse it as UTF-8 and return. + + :param cert_or_req: Certificate or CSR. + :type cert_or_req: `OpenSSL.crypto.X509` or `OpenSSL.crypto.X509Req`. + + :returns: raw san strings, parsed byte as utf-8 :rtype: `list` of `unicode` """ # This function finds SANs by dumping the certificate/CSR to text and # searching for "X509v3 Subject Alternative Name" in the text. This method - # is used to support PyOpenSSL version 0.13 where the - # `_subjectAltNameString` and `get_extensions` methods are not available - # for CSRs. - - # constants based on PyOpenSSL certificate/CSR text dump - part_separator = ":" - parts_separator = ", " - prefix = "DNS" + part_separator + # is used to because in PyOpenSSL version <0.17 `_subjectAltNameString` methods are + # not able to Parse IP Addresses in subjectAltName string. if isinstance(cert_or_req, crypto.X509): # pylint: disable=line-too-long @@ -262,17 +318,17 @@ def _pyopenssl_cert_or_req_san(cert_or_req): text = func(crypto.FILETYPE_TEXT, cert_or_req).decode("utf-8") # WARNING: this function does not support multiple SANs extensions. # Multiple X509v3 extensions of the same type is disallowed by RFC 5280. - match = re.search(r"X509v3 Subject Alternative Name:(?: critical)?\s*(.*)", text) + raw_san = re.search(r"X509v3 Subject Alternative Name:(?: critical)?\s*(.*)", text) + + parts_separator = ", " # WARNING: this function assumes that no SAN can include # parts_separator, hence the split! - sans_parts = [] if match is None else match.group(1).split(parts_separator) - - return [part.split(part_separator)[1] - for part in sans_parts if part.startswith(prefix)] + sans_parts = [] if raw_san is None else raw_san.group(1).split(parts_separator) + return sans_parts -def gen_ss_cert(key, domains, not_before=None, - validity=(7 * 24 * 60 * 60), force_san=True, extensions=None): +def gen_ss_cert(key, domains=None, not_before=None, + validity=(7 * 24 * 60 * 60), force_san=True, extensions=None, ips=None): """Generate new self-signed certificate. :type domains: `list` of `unicode` @@ -280,6 +336,7 @@ def gen_ss_cert(key, domains, not_before=None, :param bool force_san: :param extensions: List of additional extensions to include in the cert. :type extensions: `list` of `OpenSSL.crypto.X509Extension` + :type ips: `list` of (`ipaddress.IPv4Address` or `ipaddress.IPv6Address`) If more than one domain is provided, all of the domains are put into ``subjectAltName`` X.509 extension and first domain is set as the @@ -287,28 +344,39 @@ def gen_ss_cert(key, domains, not_before=None, extension is used, unless `force_san` is ``True``. """ - assert domains, "Must provide one or more hostnames for the cert." + assert domains or ips, "Must provide one or more hostnames or IPs for the cert." + cert = crypto.X509() cert.set_serial_number(int(binascii.hexlify(os.urandom(16)), 16)) cert.set_version(2) if extensions is None: extensions = [] - + if domains is None: + domains = [] + if ips is None: + ips = [] extensions.append( crypto.X509Extension( b"basicConstraints", True, b"CA:TRUE, pathlen:0"), ) - cert.get_subject().CN = domains[0] + if len(domains) > 0: + cert.get_subject().CN = domains[0] # TODO: what to put into cert.get_subject()? cert.set_issuer(cert.get_subject()) - if force_san or len(domains) > 1: + sanlist = [] + for address in domains: + sanlist.append('DNS:' + address) + for ip in ips: + sanlist.append('IP:' + ip.exploded) + san_string = ', '.join(sanlist).encode('ascii') + if force_san or len(domains) > 1 or len(ips) > 0: extensions.append(crypto.X509Extension( b"subjectAltName", critical=False, - value=b", ".join(b"DNS:" + d.encode() for d in domains) + value=san_string )) cert.add_extensions(extensions) diff --git a/acme/acme/messages.py b/acme/acme/messages.py index d047df7a4..9c519a009 100644 --- a/acme/acme/messages.py +++ b/acme/acme/messages.py @@ -172,7 +172,9 @@ STATUS_DEACTIVATED = Status('deactivated') class IdentifierType(_Constant): """ACME identifier type.""" POSSIBLE_NAMES: Dict[str, 'IdentifierType'] = {} + # class def ends here IDENTIFIER_FQDN = IdentifierType('dns') # IdentifierDNS in Boulder +IDENTIFIER_IP = IdentifierType('ip') # IdentifierIP in pebble - not in Boulder yet class Identifier(jose.JSONObjectWithFields): diff --git a/acme/acme/util.py b/acme/acme/util.py index 20fa455fd..f9c11713e 100644 --- a/acme/acme/util.py +++ b/acme/acme/util.py @@ -1,6 +1,5 @@ """ACME utilities.""" - def map_keys(dikt, func): """Map dictionary keys.""" return {func(key): value for key, value in dikt.items()} diff --git a/acme/tests/client_test.py b/acme/tests/client_test.py index 35cc0ba25..2eeceee18 100644 --- a/acme/tests/client_test.py +++ b/acme/tests/client_test.py @@ -3,6 +3,7 @@ import copy import datetime import http.client as http_client +import ipaddress import json import unittest from typing import Dict @@ -23,6 +24,7 @@ import test_util CERT_DER = test_util.load_vector('cert.der') CERT_SAN_PEM = test_util.load_vector('cert-san.pem') CSR_SAN_PEM = test_util.load_vector('csr-san.pem') +CSR_MIXED_PEM = test_util.load_vector('csr-mixed.pem') KEY = jose.JWKRSA.load(test_util.load_vector('rsa512_key.pem')) KEY2 = jose.JWKRSA.load(test_util.load_vector('rsa256_key.pem')) @@ -740,7 +742,7 @@ class ClientV2Test(ClientTestBase): self.orderr = messages.OrderResource( body=self.order, uri='https://www.letsencrypt-demo.org/acme/acct/1/order/1', - authorizations=[self.authzr, self.authzr2], csr_pem=CSR_SAN_PEM) + authorizations=[self.authzr, self.authzr2], csr_pem=CSR_MIXED_PEM) def test_new_account(self): self.response.status_code = http_client.CREATED @@ -770,7 +772,7 @@ class ClientV2Test(ClientTestBase): with mock.patch('acme.client.ClientV2._post_as_get') as mock_post_as_get: mock_post_as_get.side_effect = (authz_response, authz_response2) - self.assertEqual(self.client.new_order(CSR_SAN_PEM), self.orderr) + self.assertEqual(self.client.new_order(CSR_MIXED_PEM), self.orderr) @mock.patch('acme.client.datetime') def test_poll_and_finalize(self, mock_datetime): diff --git a/acme/tests/crypto_util_test.py b/acme/tests/crypto_util_test.py index cc81a2f1f..5c2eebc07 100644 --- a/acme/tests/crypto_util_test.py +++ b/acme/tests/crypto_util_test.py @@ -1,5 +1,6 @@ """Tests for acme.crypto_util.""" import itertools +import ipaddress import socket import socketserver import threading @@ -108,7 +109,6 @@ class PyOpenSSLCertOrReqAllNamesTest(unittest.TestCase): class PyOpenSSLCertOrReqSANTest(unittest.TestCase): """Test for acme.crypto_util._pyopenssl_cert_or_req_san.""" - @classmethod def _call(cls, loader, name): # pylint: disable=protected-access @@ -174,9 +174,50 @@ class PyOpenSSLCertOrReqSANTest(unittest.TestCase): ['chicago-cubs.venafi.example', 'cubs.venafi.example']) +class PyOpenSSLCertOrReqSANIPTest(unittest.TestCase): + """Test for acme.crypto_util._pyopenssl_cert_or_req_san_ip.""" -class RandomSnTest(unittest.TestCase): - """Test for random certificate serial numbers.""" + @classmethod + def _call(cls, loader, name): + # pylint: disable=protected-access + from acme.crypto_util import _pyopenssl_cert_or_req_san_ip + return _pyopenssl_cert_or_req_san_ip(loader(name)) + + def _call_cert(self, name): + return self._call(test_util.load_cert, name) + + def _call_csr(self, name): + return self._call(test_util.load_csr, name) + + def test_cert_no_sans(self): + self.assertEqual(self._call_cert('cert.pem'), []) + + def test_csr_no_sans(self): + self.assertEqual(self._call_csr('csr-nosans.pem'), []) + + def test_cert_domain_sans(self): + self.assertEqual(self._call_cert('cert-san.pem'), []) + + def test_csr_domain_sans(self): + self.assertEqual(self._call_csr('csr-san.pem'), []) + + def test_cert_ip_two_sans(self): + self.assertEqual(self._call_cert('cert-ipsans.pem'), ['192.0.2.145', '203.0.113.1']) + + def test_csr_ip_two_sans(self): + self.assertEqual(self._call_csr('csr-ipsans.pem'), ['192.0.2.145', '203.0.113.1']) + + def test_csr_ipv6_sans(self): + self.assertEqual(self._call_csr('csr-ipv6sans.pem'), + ['0:0:0:0:0:0:0:1', 'A3BE:32F3:206E:C75D:956:CEE:9858:5EC5']) + + def test_cert_ipv6_sans(self): + self.assertEqual(self._call_cert('cert-ipv6sans.pem'), + ['0:0:0:0:0:0:0:1', 'A3BE:32F3:206E:C75D:956:CEE:9858:5EC5']) + + +class GenSsCertTest(unittest.TestCase): + """Test for gen_ss_cert (generation of self-signed cert).""" def setUp(self): @@ -187,11 +228,19 @@ class RandomSnTest(unittest.TestCase): def test_sn_collisions(self): from acme.crypto_util import gen_ss_cert - for _ in range(self.cert_count): - cert = gen_ss_cert(self.key, ['dummy'], force_san=True) + cert = gen_ss_cert(self.key, ['dummy'], force_san=True, + ips=[ipaddress.ip_address("10.10.10.10")]) self.serial_num.append(cert.get_serial_number()) - self.assertGreater(len(set(self.serial_num)), 1) + self.assertGreaterEqual(len(set(self.serial_num)), self.cert_count) + + + def test_no_name(self): + from acme.crypto_util import gen_ss_cert + with self.assertRaises(AssertionError): + gen_ss_cert(self.key, ips=[ipaddress.ip_address("1.1.1.1")]) + gen_ss_cert(self.key) + class MakeCSRTest(unittest.TestCase): """Test for standalone functions.""" @@ -223,6 +272,27 @@ class MakeCSRTest(unittest.TestCase): ).get_data(), ) + def test_make_csr_ip(self): + csr_pem = self._call_with_key(["a.example"], False, [ipaddress.ip_address('127.0.0.1'), ipaddress.ip_address('::1')]) + self.assertIn(b'--BEGIN CERTIFICATE REQUEST--' , csr_pem) + self.assertIn(b'--END CERTIFICATE REQUEST--' , csr_pem) + csr = OpenSSL.crypto.load_certificate_request( + OpenSSL.crypto.FILETYPE_PEM, csr_pem) + # In pyopenssl 0.13 (used with TOXENV=py27-oldest), csr objects don't + # have a get_extensions() method, so we skip this test if the method + # isn't available. + if hasattr(csr, 'get_extensions'): + self.assertEqual(len(csr.get_extensions()), 1) + self.assertEqual(csr.get_extensions()[0].get_data(), + OpenSSL.crypto.X509Extension( + b'subjectAltName', + critical=False, + value=b'DNS:a.example, IP:127.0.0.1, IP:::1', + ).get_data(), + ) + # for IP san it's actually need to be octet-string, + # but somewhere downstream thankfully handle it for us + def test_make_csr_must_staple(self): csr_pem = self._call_with_key(["a.example"], must_staple=True) csr = OpenSSL.crypto.load_certificate_request( @@ -241,6 +311,9 @@ class MakeCSRTest(unittest.TestCase): self.assertEqual(len(must_staple_exts), 1, "Expected exactly one Must Staple extension") + def test_make_csr_without_hostname(self): + self.assertRaises(ValueError, self._call_with_key) + class DumpPyopensslChainTest(unittest.TestCase): """Test for dump_pyopenssl_chain.""" diff --git a/acme/tests/testdata/cert-ipsans.pem b/acme/tests/testdata/cert-ipsans.pem new file mode 100644 index 000000000..bb197b38b --- /dev/null +++ b/acme/tests/testdata/cert-ipsans.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDizCCAnOgAwIBAgIIPNBLQXwhoUkwDQYJKoZIhvcNAQELBQAwKDEmMCQGA1UE +AxMdUGViYmxlIEludGVybWVkaWF0ZSBDQSAxNzNiMjYwHhcNMjAwNTI5MTkxODA5 +WhcNMjUwNTI5MTkxODA5WjAWMRQwEgYDVQQDEwsxOTIuMC4yLjE0NTCCASIwDQYJ +KoZIhvcNAQEBBQADggEPADCCAQoCggEBALyChb+NDA26GF1AfC0nzEdfOTchKw0h +q41xEjonvg5UXgZf/aH/ntvugIkYP0MaFifNAjebOVVsemEVEtyWcUKTfBHKZGbZ +ukTDwFIjfTccCfo6U/B2H7ZLzJIywl8DcUw9DypadeQBm8PS0VVR2ncy73dvaqym +crhAwlASyXU0mhLqRDMMxfg5Bn/FWpcsIcDpLmPn8Q/FvdRc2t5ryBNw/aWOlwqT +Oy16nbfLj2T0zG1A3aPuD+eT/JFUe/o3K7R+FAx7wt+RziQO46wLVVF1SueZUrIU +zqN04Gl8Kt1WM2SniZ0gq/rORUNcPtT0NAEsEslTQfA+Trq6j2peqyMCAwEAAaOB +yjCBxzAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUF +BwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFHj1mwZzP//nMIH2i58NRUl/arHn +MB8GA1UdIwQYMBaAFF5DVAKabvIUvKFHGouscA2Qdpe6MDEGCCsGAQUFBwEBBCUw +IzAhBggrBgEFBQcwAYYVaHR0cDovLzEyNy4wLjAuMTo0MDAyMBUGA1UdEQQOMAyH +BMAAApGHBMsAcQEwDQYJKoZIhvcNAQELBQADggEBAHjSgDg76/UCIMSYddyhj18r +LdNKjA7p8ovnErSkebFT4lIZ9f3Sma9moNr0w64M33NamuFyHe/KTdk90mvoW8Uu +26aDekiRIeeMakzbAtDKn67tt2tbedKIYRATcSYVwsV46uZKbM621dZKIjjxOWpo +IY6rZYrku8LYhoXJXOqRduV3cTRVuTm5bBa9FfVNtt6N1T5JOtKKDEhuSaF4RSug +PDy3hQIiHrVvhPfVrXU3j6owz/8UCS5549inES9ONTFrvM9o0H1R/MsmGNXR5hF5 +iJqHKC7n8LZujhVnoFIpHu2Dsiefbfr+yRYJS4I+ezy6Nq/Ok8rc8zp0eoX+uyY= +-----END CERTIFICATE----- diff --git a/acme/tests/testdata/cert-ipv6sans.pem b/acme/tests/testdata/cert-ipv6sans.pem new file mode 100644 index 000000000..4c2b51156 --- /dev/null +++ b/acme/tests/testdata/cert-ipv6sans.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDmzCCAoOgAwIBAgIIFdxeZP+v2rgwDQYJKoZIhvcNAQELBQAwKDEmMCQGA1UE +AxMdUGViYmxlIEludGVybWVkaWF0ZSBDQSA0M2M5NTcwHhcNMjAwNTMwMDQwNzMw +WhcNMjUwNTMwMDQwNzMwWjAOMQwwCgYDVQQDEwM6OjEwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQC7VidVduJvqKtrSH0fw6PjE0cqL4Kfzo7klexWUkHG +KVAa0fRVZFZ462jxKOt417V2U4WJQ6WHHO9PJ+3gW62d/MhCw8FRtUQS4nYFjqB6 +32+RFU21VRN7cWoQEqSwnEPbh/v/zv/KS5JhQ+swWUo79AOLm1kjnZWCKtcqh1Lc +Ug5Tkpot6luoxTKp52MkchvXDpj0q2B/XpLJ8/pw5cqjv7mH12EDOK2HXllA+WwX +ZpstcEhaA4FqtaHOW/OHnwTX5MUbINXE5YYHVEDR6moVM31/W/3pe9NDUMTDE7Si +lVQnZbXM9NYbzZqlh+WhemDWwnIfGI6rtsfNEiirVEOlAgMBAAGjgeIwgd8wDgYD +VR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNV +HRMBAf8EAjAAMB0GA1UdDgQWBBS8DL+MZfDIy6AKky69Tgry2Vxq5DAfBgNVHSME +GDAWgBRAsFqVenRRKgB1YPzWKzb9bzZ/ozAxBggrBgEFBQcBAQQlMCMwIQYIKwYB +BQUHMAGGFWh0dHA6Ly8xMjcuMC4wLjE6NDAwMjAtBgNVHREEJjAkhxAAAAAAAAAA +AAAAAAAAAAABhxCjvjLzIG7HXQlWDO6YWF7FMA0GCSqGSIb3DQEBCwUAA4IBAQBY +M9UTZ3uaKMQ+He9kWR3p9jh6hTSD0FNi79ZdfkG0lgSzhhduhN7OhzQH2ihUUfa6 +rtKTw74fGbszhizCd9UB8YPKlm3si1Xbg6ZUQlA1RtoQo7RUGEa6ZbR68PKGm9Go +hTTFIl/JS8jzxBR8jywZdyqtprUx+nnNUDiNk0hJtFLhw7OJH0AHlAUNqHsfD08m +HXRdaV6q14HXU5g31slBat9H4D6tCU/2uqBURwW0wVdnqh4QeRfAeqiatJS9EmSF +ctbc7n894Idy2Xce7NFoIy5cht3m6Rd42o/LmBsJopBmQcDPZT70/XzRtc2qE0cS +CzBIGQHUJ6BfmBjrCQnp +-----END CERTIFICATE----- diff --git a/acme/tests/testdata/csr-ipsans.pem b/acme/tests/testdata/csr-ipsans.pem new file mode 100644 index 000000000..07b3b1330 --- /dev/null +++ b/acme/tests/testdata/csr-ipsans.pem @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIICbTCCAVUCAQIwADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKT/ +CE7Y5EYBvI4p7Frt763upIKHDHO/R5/TWMjG8Jm9qTMui8sbMgyh2Yh+lR/j/5Xd +tQrhgC6wx10MrW2+3JtYS88HP1p6si8zU1dbK34n3NyyklR2RivW0R7dXgnYNy7t +5YcDYLCrbRMIPINV/uHrmzIHWYUDNcZVdAfIM2AHfKYuV6Mepcn///5GR+l4GcAh +Nkf9CW8OdAIuKdbyLCxVr0mUW/vJz1b12uxPsgUdax9sjXgZdT4pfMXADsFd1NeF +atpsXU073inqtHru+2F9ijHTQ75TC+u/rr6eYl3BnBntac0gp/ADtDBii7/Q1JOO +Bhq7xJNqqxIEdiyM7zcCAwEAAaAoMCYGCSqGSIb3DQEJDjEZMBcwFQYDVR0RBA4w +DIcEwAACkYcEywBxATANBgkqhkiG9w0BAQsFAAOCAQEADG5g3zdbSCaXpZhWHkzE +Mek3f442TUE1pB+ITRpthmM4N3zZWETYmbLCIAO624uMrRnbCCMvAoLs/L/9ETg/ +XMMFtonQC8u9i9tV8B1ceBh8lpIfa+8b9TMWH3bqnrbWQ+YIl+Yd0gXiCZWJ9vK4 +eM1Gddu/2bR6s/k4h/XAWRgEexqk57EHr1z0N+T9OoX939n3mVcNI+u9kfd5VJ0z +VyA3R8WR6T6KlEl5P5pcWe5Kuyhi7xMmLVImXqBtvKq4O1AMfM+gQr/yn9aE8IRq +khP7JrMBLUIub1c/qu2TfvnynNPSM/ZcOX+6PHdHmRkR3nI0Ndpv7Ntv31FTplAm +Dw== +-----END CERTIFICATE REQUEST----- diff --git a/acme/tests/testdata/csr-ipv6sans.pem b/acme/tests/testdata/csr-ipv6sans.pem new file mode 100644 index 000000000..3f0ba1600 --- /dev/null +++ b/acme/tests/testdata/csr-ipv6sans.pem @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIChTCCAW0CAQIwADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOIc +UAppcqJfTkSqqOFqGt1v7lIJZPOcF4bcKI3d5cHAGbOuVxbC7uMaDuObwYLzoiED +qnvs1NaEq2phO6KsgGESB7IE2LUjJivO7OnSZjNRpL5si/9egvBiNCn/50lULaWG +gLEuyMfk3awZy2mVAymy7Grhbx069A4TH8TqsHuq2RpKyuDL27e/jUt6yYecb3pu +hWMiWy3segif4tI46pkOW0/I6DpxyYD2OqOvzxm/voS9RMqE2+7YJA327H7bEi3N +lJZEZ1zy7clZ9ga5fBQaetzbg2RyxTrZ7F919NQXSFoXgxb10Eg64wIpz0L3ooCm +GEHehsZZexa3J5ccIvMCAwEAAaBAMD4GCSqGSIb3DQEJDjExMC8wLQYDVR0RBCYw +JIcQAAAAAAAAAAAAAAAAAAAAAYcQo74y8yBux10JVgzumFhexTANBgkqhkiG9w0B +AQsFAAOCAQEALvwVn0A/JPTCiNzcozHFnp5M23C9PXCplWc5u4k34d4XXzpSeFDz +fL4gy7NpYIueme2K2ppw2j3PNQUdR6vQ5a75sriegWYrosL+7Q6Joh51ZyEUZQoD +mNl4M4S4oX85EaChR6NFGBywTfjFarYi32XBTbFE7rK8N8KM+DQkNdwL1MXqaHWz +F1obQKpNXlLedbCBOteV5Eg4zG3565zu/Gw/NhwzzV3mQmgxUcd1sMJxAfHQz4Vl +ImLL+xMcR03nDsH2bgtDbK2tJm7WszSxA9tC+Xp2lRewxrnQloRWPYDz177WGQ5Q +SoGDzTTtA6uWZxG8h7CkNLOGvA8LtU2rNA== +-----END CERTIFICATE REQUEST----- diff --git a/acme/tests/testdata/csr-mixed.pem b/acme/tests/testdata/csr-mixed.pem new file mode 100644 index 000000000..eb8e8b548 --- /dev/null +++ b/acme/tests/testdata/csr-mixed.pem @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIICdjCCAV4CAQIwADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMXq +v1y8EIcCbaUIzCtOcLkLS0MJ35oS+6DmV5WB1A0cIk6YrjsHIsY2lwMm13BWIvmw +tY+Y6n0rr7eViNx5ZRGHpHEI/TL3Neb+VefTydL5CgvK3dd4ex2kSbTaed3fmpOx +qMajEduwNcZPCcmoEXPkfrCP8w2vKQUkQ+JRPcdX1nTuzticeRP5B7YCmJsmxkEh +Y0tzzZ+NIRDARoYNofefY86h3e5q66gtJxccNchmIM3YQahhg5n3Xoo8hGfM/TIc +R7ncCBCLO6vtqo0QFva/NQODrgOmOsmgvqPkUWQFdZfWM8yIaU826dktx0CPB78t +TudnJ1rBRvGsjHMsZikCAwEAAaAxMC8GCSqGSIb3DQEJDjEiMCAwHgYDVR0RBBcw +FYINYS5leGVtcGxlLmNvbYcEwAACbzANBgkqhkiG9w0BAQsFAAOCAQEAdGMcRCxq +1X09gn1TNdMt64XUv+wdJCKDaJ+AgyIJj7QvVw8H5k7dOnxS4I+a/yo4jE+LDl2/ +AuHcBLFEI4ddewdJSMrTNZjuRYuOdr3KP7fL7MffICSBi45vw5EOXg0tnjJCEiKu +6gcJgbLSP5JMMd7Haf33Q/VWsmHofR3VwOMdrnakwAU3Ff5WTuXTNVhL1kT/uLFX +yW1ru6BF4unwNqSR2UeulljpNfRBsiN4zJK11W6n9KT0NkBr9zY5WCM4sW7i8k9V +TeypWGo3jBKzYAGeuxZsB97U77jZ2lrGdBLZKfbcjnTeRVqCvCRrui4El7UGYFmj +7s6OJyWx5DSV8w== +-----END CERTIFICATE REQUEST----- diff --git a/certbot/CHANGELOG.md b/certbot/CHANGELOG.md index 5e86b470e..463d8f3b5 100644 --- a/certbot/CHANGELOG.md +++ b/certbot/CHANGELOG.md @@ -9,6 +9,8 @@ Certbot adheres to [Semantic Versioning](https://semver.org/). * The certbot-dns-rfc2136 plugin always assumed the use of an IP address as the target server, but this was never checked. Until now. The plugin raises an error if the configured target server is not a valid IPv4 or IPv6 address. +* Our acme library now supports requesting certificates for IP addresses. + This feature is still unsupported by Certbot and Let's Encrypt. ### Changed diff --git a/certbot/certbot/util.py b/certbot/certbot/util.py index 2d7da68ea..97cb6f82b 100644 --- a/certbot/certbot/util.py +++ b/certbot/certbot/util.py @@ -483,6 +483,7 @@ def enforce_le_validity(domain): Encrypt currently will not issue certificates """ + domain = enforce_domain_sanity(domain) if not re.match("^[A-Za-z0-9.-]*$", domain): raise errors.ConfigurationError( @@ -541,17 +542,11 @@ def enforce_domain_sanity(domain): ) ) - # Explain separately that IP addresses aren't allowed (apart from not - # being FQDNs) because hope springs eternal concerning this point - try: - socket.inet_aton(domain) + if is_ipaddress(domain): 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(domain)) - except socket.error: - # It wasn't an IP address, so that's good - pass # FQDN checks according to RFC 2181: domain name should be less than 255 # octets (inclusive). And each label is 1 - 63 octets (inclusive). @@ -569,6 +564,29 @@ def enforce_domain_sanity(domain): return domain +def is_ipaddress(address): + """Is given address string form of IP(v4 or v6) address? + + :param address: address to check + :type address: `str` or `unicode` + + :returns: True if address is valid IP address, otherwise return False. + :rtype: bool + + """ + try: + socket.inet_pton(socket.AF_INET, address) + # If this line runs it was ip address (ipv4) + return True + except socket.error: + # It wasn't an IPv4 address, so try ipv6 + try: + socket.inet_pton(socket.AF_INET6, address) + return True + except socket.error: + return False + + def is_wildcard_domain(domain): """"Is domain a wildcard domain? diff --git a/certbot/tests/main_test.py b/certbot/tests/main_test.py index 976122660..de202d006 100644 --- a/certbot/tests/main_test.py +++ b/certbot/tests/main_test.py @@ -1000,6 +1000,10 @@ class MainTest(test_util.ConfigTestCase): self.assertRaises(errors.ConfigurationError, self._call, ['-d', '204.11.231.35']) + # Bare IPv6 address + self.assertRaises(errors.ConfigurationError, + self._call, + ['-d', '2001:db8:ac69:3ff:b1cb:c8c6:5a84:a31b']) def test_csr_with_besteffort(self): self.assertRaises(