From 36d26de7465607d002d7eee92ec993d5664a3936 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 19 Feb 2015 14:55:14 -0800 Subject: [PATCH 1/5] Implement get_sans via parsing Request.as_text() --- letsencrypt/client/crypto_util.py | 41 ++++++++++++ letsencrypt/client/tests/crypto_util_test.py | 67 +++++++++++++++++++ .../client/tests/testdata/csr-6sans.pem | 12 ++++ .../client/tests/testdata/csr-nosans.pem | 8 +++ 4 files changed, 128 insertions(+) create mode 100644 letsencrypt/client/tests/testdata/csr-6sans.pem create mode 100644 letsencrypt/client/tests/testdata/csr-nosans.pem diff --git a/letsencrypt/client/crypto_util.py b/letsencrypt/client/crypto_util.py index e2c4965fe..f5b836e82 100644 --- a/letsencrypt/client/crypto_util.py +++ b/letsencrypt/client/crypto_util.py @@ -1,4 +1,5 @@ """Let's Encrypt client crypto utility functions""" +import re import time import Crypto.Hash.SHA256 @@ -189,3 +190,43 @@ def get_cert_info(filename): "serial": cert.get_serial_number(), "pub_key": "RSA " + str(cert.get_pubkey().size() * 8), } + + +def get_sans_from_csr(csr): + """Get list of Subject Alternative Names from signing request. + + :param str csr: Certificate Signing Request in PEM format + + :returns: List of referenced subject alternative names + :rtype: list + """ + # TODO: This is a temporary solution involving parsing the .as_text() + # output because there doesn't seem to be a built-in feature in + # any Python cryptography module that performs this function. + # In the future we should try to replace this with a more direct + # use of relevant OpenSSL or other X509-parsing APIs. + req = M2Crypto.X509.load_request_string(csr) + text = req.as_text().split("\n") + if len(text) < 2 or text[0] != "Certificate Request:" or \ + text[1] != " Data:": + raise ValueError("Unable to parse CSR") + text = text[2:] + while text and text[0] != " Attributes:": + text = text[1:] + while text and text[0] != " Requested Extensions:": + text = text[1:] + while text and text[0] != " X509v3 Subject Alternative Name: ": + text = text[1:] + text = text[1:] + if not text: + raise ValueError("Unable to parse CSR") + # XXX: This might break for non-ASCII hostnames and for non-DNS + # names in SANs. There is also a parser safety concern about + # whether the CSR's contents are interpreted in the same way + # by this code and by any other code that might interpret the + # CSR for a difference purpose. + # All DNS names other than the last one + matches = re.findall(r"(?:DNS:([\w.]+), )", text[0]) + # The last DNS name + matches.append(re.search(r"(?:DNS:([\w.]+))$", text[0]).groups()[0]) + return matches diff --git a/letsencrypt/client/tests/crypto_util_test.py b/letsencrypt/client/tests/crypto_util_test.py index cb047281f..13832d886 100644 --- a/letsencrypt/client/tests/crypto_util_test.py +++ b/letsencrypt/client/tests/crypto_util_test.py @@ -1,5 +1,6 @@ """Tests for letsencrypt.client.crypto_util.""" import datetime +import mock import os import pkg_resources import unittest @@ -133,5 +134,71 @@ class GetCertInfoTest(unittest.TestCase): self._call('cert-san.pem') +class GetSansFromCsrTest(unittest.TestCase): + """Tests for letsencrypt.client.crypto_util.get_sans_from_csr.""" + def test_extract_one_san(self): + from letsencrypt.client.crypto_util import get_sans_from_csr + csr = pkg_resources.resource_string( + __name__, os.path.join('testdata', 'csr.pem')) + result = get_sans_from_csr(csr) + self.assertEqual(result, ['example.com']) + + def test_extract_two_sans(self): + from letsencrypt.client.crypto_util import get_sans_from_csr + csr = pkg_resources.resource_string( + __name__, os.path.join('testdata', 'csr-san.pem')) + result = get_sans_from_csr(csr) + self.assertEqual(result, ['example.com', 'www.example.com']) + + def test_extract_six_sans(self): + from letsencrypt.client.crypto_util import get_sans_from_csr + csr = pkg_resources.resource_string( + __name__, os.path.join('testdata', 'csr-6sans.pem')) + result = get_sans_from_csr(csr) + self.assertEqual( + result, ["example.com", "example.org", "example.net", + "example.info", "subdomain.example.com", + "other.subdomain.example.com"]) + + def test_parse_non_csr(self): + from M2Crypto.X509 import X509Error + from letsencrypt.client.crypto_util import get_sans_from_csr + self.assertRaises(X509Error, get_sans_from_csr, "hello there") + + def test_parse_no_sans(self): + from letsencrypt.client.crypto_util import get_sans_from_csr + csr = pkg_resources.resource_string( + __name__, os.path.join('testdata', 'csr-nosans.pem')) + self.assertRaises(ValueError, get_sans_from_csr, csr) + + @mock.patch("M2Crypto.X509.load_request_string") + def test_parse_weird_m2crypto_output(self, mock_lrs): + # It's not clear how to reach this exception with invalid input, + # because M2Crypto is likely to raise X509Error rather than + # returning invalid output, but we can test the possibility with + # mock. + mock_lrs.as_text.return_value = "Something other than OpenSSL output" + from letsencrypt.client.crypto_util import get_sans_from_csr + self.assertRaises(ValueError, get_sans_from_csr, "input") + +class MakeCSRTest(unittest.TestCase): # pylint: disable=too-few-public-methods + """Tests for letsencrypt.client.crypto_util.make_csr.""" + def test_make_csr(self): + from letsencrypt.client.crypto_util import make_csr, get_sans_from_csr + result = make_csr(RSA512_KEY, ["example.com", "foo.example.com"])[0] + self.assertEqual( + get_sans_from_csr(result), ["example.com", "foo.example.com"]) + req = M2Crypto.X509.load_request_string(result) + subject = req.get_subject().as_text() + modulus = req.get_pubkey().get_modulus() + self.assertEqual( + subject, "C=US, ST=Michigan, L=Ann Arbor, O=EFF, OU=University" + " of Michigan, CN=example.com") + self.assertEqual( + modulus, "F4B61171513736BFAA95E79C11C5FC2705439E3786D57EEE72C0" + "9AB2EB993347B4F5C998B94CF12243233BFF71E0055CBD75D15CF" + "115F8BCD65A47E44E5CD133") + + if __name__ == '__main__': unittest.main() diff --git a/letsencrypt/client/tests/testdata/csr-6sans.pem b/letsencrypt/client/tests/testdata/csr-6sans.pem new file mode 100644 index 000000000..8f6b52bd7 --- /dev/null +++ b/letsencrypt/client/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/client/tests/testdata/csr-nosans.pem b/letsencrypt/client/tests/testdata/csr-nosans.pem new file mode 100644 index 000000000..813db67b0 --- /dev/null +++ b/letsencrypt/client/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----- From 6e0049fded0a17a0a0a186211de8f6905c3ce937 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Fri, 20 Feb 2015 13:44:36 -0800 Subject: [PATCH 2/5] Some formatting cleanups --- letsencrypt/client/crypto_util.py | 8 +++-- letsencrypt/client/tests/crypto_util_test.py | 37 ++++++++++---------- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/letsencrypt/client/crypto_util.py b/letsencrypt/client/crypto_util.py index f5b836e82..d3cc75d20 100644 --- a/letsencrypt/client/crypto_util.py +++ b/letsencrypt/client/crypto_util.py @@ -195,7 +195,9 @@ def get_cert_info(filename): def get_sans_from_csr(csr): """Get list of Subject Alternative Names from signing request. - :param str csr: Certificate Signing Request in PEM format + :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 @@ -207,8 +209,8 @@ def get_sans_from_csr(csr): # use of relevant OpenSSL or other X509-parsing APIs. req = M2Crypto.X509.load_request_string(csr) text = req.as_text().split("\n") - if len(text) < 2 or text[0] != "Certificate Request:" or \ - text[1] != " Data:": + if (len(text) < 2 or text[0] != "Certificate Request:" or + text[1] != " Data:"): raise ValueError("Unable to parse CSR") text = text[2:] while text and text[0] != " Attributes:": diff --git a/letsencrypt/client/tests/crypto_util_test.py b/letsencrypt/client/tests/crypto_util_test.py index 13832d886..728d4a551 100644 --- a/letsencrypt/client/tests/crypto_util_test.py +++ b/letsencrypt/client/tests/crypto_util_test.py @@ -140,30 +140,28 @@ class GetSansFromCsrTest(unittest.TestCase): from letsencrypt.client.crypto_util import get_sans_from_csr csr = pkg_resources.resource_string( __name__, os.path.join('testdata', 'csr.pem')) - result = get_sans_from_csr(csr) - self.assertEqual(result, ['example.com']) + self.assertEqual(get_sans_from_csr(csr), ['example.com']) def test_extract_two_sans(self): from letsencrypt.client.crypto_util import get_sans_from_csr csr = pkg_resources.resource_string( __name__, os.path.join('testdata', 'csr-san.pem')) - result = get_sans_from_csr(csr) - self.assertEqual(result, ['example.com', 'www.example.com']) + self.assertEqual(get_sans_from_csr(csr), ['example.com', + 'www.example.com']) def test_extract_six_sans(self): from letsencrypt.client.crypto_util import get_sans_from_csr csr = pkg_resources.resource_string( __name__, os.path.join('testdata', 'csr-6sans.pem')) - result = get_sans_from_csr(csr) - self.assertEqual( - result, ["example.com", "example.org", "example.net", - "example.info", "subdomain.example.com", - "other.subdomain.example.com"]) + 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 M2Crypto.X509 import X509Error from letsencrypt.client.crypto_util import get_sans_from_csr - self.assertRaises(X509Error, get_sans_from_csr, "hello there") + self.assertRaises(M2Crypto.X509.X509Error, get_sans_from_csr, + "hello there") def test_parse_no_sans(self): from letsencrypt.client.crypto_util import get_sans_from_csr @@ -184,20 +182,21 @@ class GetSansFromCsrTest(unittest.TestCase): class MakeCSRTest(unittest.TestCase): # pylint: disable=too-few-public-methods """Tests for letsencrypt.client.crypto_util.make_csr.""" def test_make_csr(self): - from letsencrypt.client.crypto_util import make_csr, get_sans_from_csr + from letsencrypt.client.crypto_util import get_sans_from_csr + from letsencrypt.client.crypto_util import make_csr result = make_csr(RSA512_KEY, ["example.com", "foo.example.com"])[0] self.assertEqual( get_sans_from_csr(result), ["example.com", "foo.example.com"]) req = M2Crypto.X509.load_request_string(result) - subject = req.get_subject().as_text() - modulus = req.get_pubkey().get_modulus() self.assertEqual( - subject, "C=US, ST=Michigan, L=Ann Arbor, O=EFF, OU=University" - " of Michigan, CN=example.com") + req.get_subject().as_text(), + "C=US, ST=Michigan, L=Ann Arbor, O=EFF, OU=University" + " of Michigan, CN=example.com") self.assertEqual( - modulus, "F4B61171513736BFAA95E79C11C5FC2705439E3786D57EEE72C0" - "9AB2EB993347B4F5C998B94CF12243233BFF71E0055CBD75D15CF" - "115F8BCD65A47E44E5CD133") + req.get_pubkey().get_modulus(), + "F4B61171513736BFAA95E79C11C5FC2705439E3786D57EEE72C0" + "9AB2EB993347B4F5C998B94CF12243233BFF71E0055CBD75D15CF" + "115F8BCD65A47E44E5CD133") if __name__ == '__main__': From 38dc276b2263b4ca53c97509b6f7710a3317a319 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Thu, 26 Feb 2015 00:36:08 -0800 Subject: [PATCH 3/5] finish merge correctly --- letsencrypt/client/tests/crypto_util_test.py | 39 -------------------- 1 file changed, 39 deletions(-) diff --git a/letsencrypt/client/tests/crypto_util_test.py b/letsencrypt/client/tests/crypto_util_test.py index 449e3f3b0..1e02a1296 100644 --- a/letsencrypt/client/tests/crypto_util_test.py +++ b/letsencrypt/client/tests/crypto_util_test.py @@ -1,5 +1,4 @@ """Tests for letsencrypt.client.crypto_util.""" -import datetime import mock import os import pkg_resources @@ -95,44 +94,6 @@ class MakeSSCertTest(unittest.TestCase): make_ss_cert(RSA256_KEY, ['example.com', 'www.example.com']) -class GetCertInfoTest(unittest.TestCase): - """Tests for letsencrypt.client.crypto_util.get_cert_info.""" - - def setUp(self): - self.cert_info = { - 'not_before': datetime.datetime( - 2014, 12, 11, 22, 34, 45, tzinfo=M2Crypto.ASN1.UTC), - 'not_after': datetime.datetime( - 2014, 12, 18, 22, 34, 45, tzinfo=M2Crypto.ASN1.UTC), - 'subject': 'C=US, ST=Michigan, L=Ann Arbor, O=University ' - 'of Michigan and the EFF, CN=example.com', - 'cn': 'example.com', - 'issuer': 'C=US, ST=Michigan, L=Ann Arbor, O=University ' - 'of Michigan and the EFF, CN=example.com', - 'serial': 1337L, - 'pub_key': 'RSA 512', - } - - def _call(self, name): - from letsencrypt.client.crypto_util import get_cert_info - self.assertEqual(get_cert_info(pkg_resources.resource_filename( - __name__, os.path.join('testdata', name))), self.cert_info) - - def test_single_domain(self): - self.cert_info.update({ - 'san': '', - 'fingerprint': '9F8CE01450D288467C3326AC0457E351939C72E', - }) - self._call('cert.pem') - - def test_san(self): - self.cert_info.update({ - 'san': 'DNS:example.com, DNS:www.example.com', - 'fingerprint': '62F7110431B8E8F55905DBE5592518F9634AC50A', - }) - self._call('cert-san.pem') - - class GetSansFromCsrTest(unittest.TestCase): """Tests for letsencrypt.client.crypto_util.get_sans_from_csr.""" def test_extract_one_san(self): From f7dda7fcc2d14c625e8afc2790268a4ae198091b Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 26 Feb 2015 15:23:24 -0800 Subject: [PATCH 4/5] get_sans based on .split() instead of regexp --- letsencrypt/client/crypto_util.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/letsencrypt/client/crypto_util.py b/letsencrypt/client/crypto_util.py index fcfdf6647..6419aeebe 100644 --- a/letsencrypt/client/crypto_util.py +++ b/letsencrypt/client/crypto_util.py @@ -4,7 +4,6 @@ is capable of handling the signatures. """ -import re import time import Crypto.Hash.SHA256 @@ -194,13 +193,18 @@ def get_sans_from_csr(csr): text = text[1:] if not text: raise ValueError("Unable to parse CSR") + # XXX: This might break for non-ASCII hostnames and for non-DNS # names in SANs. There is also a parser safety concern about # whether the CSR's contents are interpreted in the same way # by this code and by any other code that might interpret the - # CSR for a difference purpose. - # All DNS names other than the last one - matches = re.findall(r"(?:DNS:([\w.]+), )", text[0]) - # The last DNS name - matches.append(re.search(r"(?:DNS:([\w.]+))$", text[0]).groups()[0]) - return matches + # CSR for a different purpose. Also, if there is a non-DNS + # name in a SAN that contains ", DNS:example.com, " as part + # of the name (for example, in the comment field of an e-mail + # SAN), this code will be fooled into returning that name as + # if it were an additional DNS SAN. The severity of this is + # unclear, because the client currently presents the results of + # this list to the user for confirmation before requesting the + # cert from the server. + return [san.split(":")[1] for san in text[0].strip().split(", ") + if san.startswith("DNS:")] From 532d155b1cd01392f53b2278e5d8163b90e94979 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sun, 10 May 2015 09:22:19 +0000 Subject: [PATCH 5/5] get_sans_from_csr using pyOpenSSL --- letsencrypt/client/crypto_util.py | 68 +++++++++----------- letsencrypt/client/tests/crypto_util_test.py | 14 +--- setup.py | 3 +- 3 files changed, 37 insertions(+), 48 deletions(-) diff --git a/letsencrypt/client/crypto_util.py b/letsencrypt/client/crypto_util.py index 6419aeebe..8a654c77b 100644 --- a/letsencrypt/client/crypto_util.py +++ b/letsencrypt/client/crypto_util.py @@ -4,6 +4,7 @@ is capable of handling the signatures. """ +import logging import time import Crypto.Hash.SHA256 @@ -11,6 +12,7 @@ import Crypto.PublicKey.RSA import Crypto.Signature.PKCS1_v1_5 import M2Crypto +import OpenSSL def make_csr(key_str, domains): @@ -163,7 +165,29 @@ def make_ss_cert(key_str, domains, not_before=None, return cert.as_pem() -def get_sans_from_csr(csr): +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 @@ -172,39 +196,11 @@ def get_sans_from_csr(csr): :returns: List of referenced subject alternative names :rtype: list - """ - # TODO: This is a temporary solution involving parsing the .as_text() - # output because there doesn't seem to be a built-in feature in - # any Python cryptography module that performs this function. - # In the future we should try to replace this with a more direct - # use of relevant OpenSSL or other X509-parsing APIs. - req = M2Crypto.X509.load_request_string(csr) - text = req.as_text().split("\n") - if (len(text) < 2 or text[0] != "Certificate Request:" or - text[1] != " Data:"): - raise ValueError("Unable to parse CSR") - text = text[2:] - while text and text[0] != " Attributes:": - text = text[1:] - while text and text[0] != " Requested Extensions:": - text = text[1:] - while text and text[0] != " X509v3 Subject Alternative Name: ": - text = text[1:] - text = text[1:] - if not text: - raise ValueError("Unable to parse CSR") - # XXX: This might break for non-ASCII hostnames and for non-DNS - # names in SANs. There is also a parser safety concern about - # whether the CSR's contents are interpreted in the same way - # by this code and by any other code that might interpret the - # CSR for a different purpose. Also, if there is a non-DNS - # name in a SAN that contains ", DNS:example.com, " as part - # of the name (for example, in the comment field of an e-mail - # SAN), this code will be fooled into returning that name as - # if it were an additional DNS SAN. The severity of this is - # unclear, because the client currently presents the results of - # this list to the user for confirmation before requesting the - # cert from the server. - return [san.split(":")[1] for san in text[0].strip().split(", ") - if san.startswith("DNS:")] + """ + 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/client/tests/crypto_util_test.py b/letsencrypt/client/tests/crypto_util_test.py index 1e02a1296..5d7c4a7ba 100644 --- a/letsencrypt/client/tests/crypto_util_test.py +++ b/letsencrypt/client/tests/crypto_util_test.py @@ -5,6 +5,7 @@ import pkg_resources import unittest import M2Crypto +import OpenSSL RSA256_KEY = pkg_resources.resource_string(__name__, 'testdata/rsa256_key.pem') @@ -120,24 +121,15 @@ class GetSansFromCsrTest(unittest.TestCase): def test_parse_non_csr(self): from letsencrypt.client.crypto_util import get_sans_from_csr - self.assertRaises(M2Crypto.X509.X509Error, get_sans_from_csr, + self.assertRaises(OpenSSL.crypto.Error, get_sans_from_csr, "hello there") def test_parse_no_sans(self): from letsencrypt.client.crypto_util import get_sans_from_csr csr = pkg_resources.resource_string( __name__, os.path.join('testdata', 'csr-nosans.pem')) - self.assertRaises(ValueError, get_sans_from_csr, csr) + self.assertEqual([], get_sans_from_csr(csr)) - @mock.patch("M2Crypto.X509.load_request_string") - def test_parse_weird_m2crypto_output(self, mock_lrs): - # It's not clear how to reach this exception with invalid input, - # because M2Crypto is likely to raise X509Error rather than - # returning invalid output, but we can test the possibility with - # mock. - mock_lrs.as_text.return_value = "Something other than OpenSSL output" - from letsencrypt.client.crypto_util import get_sans_from_csr - self.assertRaises(ValueError, get_sans_from_csr, "input") class MakeCSRTest(unittest.TestCase): # pylint: disable=too-few-public-methods """Tests for letsencrypt.client.crypto_util.make_csr.""" diff --git a/setup.py b/setup.py index 1fc643304..79ff892b7 100755 --- a/setup.py +++ b/setup.py @@ -28,7 +28,8 @@ install_requires = [ 'mock', 'psutil>=2.1.0', # net_connections introduced in 2.1.0 'pycrypto', - 'PyOpenSSL', + # https://pyopenssl.readthedocs.org/en/latest/api/crypto.html#OpenSSL.crypto.X509Req.get_extensions + 'PyOpenSSL>=0.15', 'python-augeas', 'python2-pythondialog', 'requests',