diff --git a/letsencrypt/crypto_util.py b/letsencrypt/crypto_util.py index f89f281cb..94617eef6 100644 --- a/letsencrypt/crypto_util.py +++ b/letsencrypt/crypto_util.py @@ -1,4 +1,4 @@ -"""Let's Encrypt client crypto utility functions +"""Let's Encrypt client crypto utility functions. .. todo:: Make the transition to use PSS rather than PKCS1_v1_5 when the server is capable of handling the signatures. @@ -13,6 +13,7 @@ import Crypto.PublicKey.RSA import Crypto.Signature.PKCS1_v1_5 import M2Crypto +import OpenSSL from letsencrypt import le_util @@ -231,3 +232,44 @@ def make_ss_cert(key_str, domains, not_before=None, assert cert.verify() # print check_purpose(,0 return cert.as_pem() + + +def _request_san(req): # TODO: implement directly in PyOpenSSL! + # constants based on implementation of + # OpenSSL.crypto.X509Error._subjectAltNameString + parts_separator = ", " + part_separator = ":" + extension_short_name = "subjectAltName" + + # pylint: disable=protected-access,no-member + label = OpenSSL.crypto.X509Extension._prefixes[OpenSSL.crypto._lib.GEN_DNS] + assert parts_separator not in label + prefix = label + part_separator + + extensions = [ext._subjectAltNameString().split(parts_separator) + for ext in req.get_extensions() + if ext.get_short_name() == extension_short_name] + # WARNING: this function assumes that no SAN can include + # parts_separator, hence the split! + + return [part.split(part_separator)[1] for parts in extensions + for part in parts if part.startswith(prefix)] + + +def get_sans_from_csr(csr, typ=OpenSSL.crypto.FILETYPE_PEM): + """Get list of Subject Alternative Names from signing request. + + :param str csr: Certificate Signing Request in PEM format (must contain + one or more subjectAlternativeNames, or the function will fail, + raising ValueError) + + :returns: List of referenced subject alternative names + :rtype: list + + """ + try: + request = OpenSSL.crypto.load_certificate_request(typ, csr) + except OpenSSL.crypto.Error as error: + logging.exception(error) + raise + return _request_san(request) diff --git a/letsencrypt/tests/crypto_util_test.py b/letsencrypt/tests/crypto_util_test.py index bdd67da6a..92cb4014b 100644 --- a/letsencrypt/tests/crypto_util_test.py +++ b/letsencrypt/tests/crypto_util_test.py @@ -7,6 +7,7 @@ import tempfile import unittest import M2Crypto +import OpenSSL import mock @@ -150,5 +151,41 @@ class MakeSSCertTest(unittest.TestCase): make_ss_cert(RSA512_KEY, ['example.com', 'www.example.com']) +class GetSansFromCsrTest(unittest.TestCase): + """Tests for letsencrypt.crypto_util.get_sans_from_csr.""" + def test_extract_one_san(self): + from letsencrypt.crypto_util import get_sans_from_csr + csr = pkg_resources.resource_string( + __name__, os.path.join('testdata', 'csr.pem')) + self.assertEqual(get_sans_from_csr(csr), ['example.com']) + + def test_extract_two_sans(self): + from letsencrypt.crypto_util import get_sans_from_csr + csr = pkg_resources.resource_string( + __name__, os.path.join('testdata', 'csr-san.pem')) + self.assertEqual(get_sans_from_csr(csr), ['example.com', + 'www.example.com']) + + def test_extract_six_sans(self): + from letsencrypt.crypto_util import get_sans_from_csr + csr = pkg_resources.resource_string( + __name__, os.path.join('testdata', 'csr-6sans.pem')) + self.assertEqual(get_sans_from_csr(csr), + ["example.com", "example.org", "example.net", + "example.info", "subdomain.example.com", + "other.subdomain.example.com"]) + + def test_parse_non_csr(self): + from letsencrypt.crypto_util import get_sans_from_csr + self.assertRaises(OpenSSL.crypto.Error, get_sans_from_csr, + "hello there") + + def test_parse_no_sans(self): + from letsencrypt.crypto_util import get_sans_from_csr + csr = pkg_resources.resource_string( + __name__, os.path.join('testdata', 'csr-nosans.pem')) + self.assertEqual([], get_sans_from_csr(csr)) + + if __name__ == '__main__': unittest.main() # pragma: no cover diff --git a/letsencrypt/tests/testdata/csr-6sans.pem b/letsencrypt/tests/testdata/csr-6sans.pem new file mode 100644 index 000000000..8f6b52bd7 --- /dev/null +++ b/letsencrypt/tests/testdata/csr-6sans.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIBuzCCAWUCAQAweTELMAkGA1UEBhMCVVMxETAPBgNVBAgTCE1pY2hpZ2FuMRIw +EAYDVQQHEwlBbm4gQXJib3IxDDAKBgNVBAoTA0VGRjEfMB0GA1UECxMWVW5pdmVy +c2l0eSBvZiBNaWNoaWdhbjEUMBIGA1UEAxMLZXhhbXBsZS5jb20wXDANBgkqhkiG +9w0BAQEFAANLADBIAkEA9LYRcVE3Nr+qleecEcX8JwVDnjeG1X7ucsCasuuZM0e0 +9cmYuUzxIkMjO/9x4AVcvXXRXPEV+LzWWkfkTlzRMwIDAQABoIGGMIGDBgkqhkiG +9w0BCQ4xdjB0MHIGA1UdEQRrMGmCC2V4YW1wbGUuY29tggtleGFtcGxlLm9yZ4IL +ZXhhbXBsZS5uZXSCDGV4YW1wbGUuaW5mb4IVc3ViZG9tYWluLmV4YW1wbGUuY29t +ghtvdGhlci5zdWJkb21haW4uZXhhbXBsZS5jb20wDQYJKoZIhvcNAQELBQADQQBd +k4BE5qvEvkYoZM/2++Xd9RrQ6wsdj0QiJQCozfsI4lQx6ZJnbtNc7HpDrX4W6XIv +IvzVBz/nD11drfz/RNuX +-----END CERTIFICATE REQUEST----- diff --git a/letsencrypt/tests/testdata/csr-nosans.pem b/letsencrypt/tests/testdata/csr-nosans.pem new file mode 100644 index 000000000..813db67b0 --- /dev/null +++ b/letsencrypt/tests/testdata/csr-nosans.pem @@ -0,0 +1,8 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIBFTCBwAIBADBbMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEh +MB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMRQwEgYDVQQDDAtleGFt +cGxlLm9yZzBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQD0thFxUTc2v6qV55wRxfwn +BUOeN4bVfu5ywJqy65kzR7T1yZi5TPEiQyM7/3HgBVy9ddFc8RX4vNZaR+ROXNEz +AgMBAAGgADANBgkqhkiG9w0BAQsFAANBAMikGL8Ch7hQCStXH7chhDp6+pt2+VSo +wgsrPQ2Bw4veDMlSemUrH+4e0TwbbntHfvXTDHWs9P3BiIDJLxFrjuA= +-----END CERTIFICATE REQUEST----- diff --git a/setup.py b/setup.py index c719bea60..815403b5a 100644 --- a/setup.py +++ b/setup.py @@ -38,7 +38,8 @@ install_requires = [ 'psutil>=2.1.0', # net_connections introduced in 2.1.0 'pyasn1', # urllib3 InsecurePlatformWarning (#304) 'pycrypto', - 'PyOpenSSL', + # https://pyopenssl.readthedocs.org/en/latest/api/crypto.html#OpenSSL.crypto.X509Req.get_extensions + 'PyOpenSSL>=0.15', 'pyparsing>=1.5.5', # Python3 support; perhaps unnecessary? 'pyrfc3339', 'python-augeas',