From 36d26de7465607d002d7eee92ec993d5664a3936 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 19 Feb 2015 14:55:14 -0800 Subject: [PATCH] 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-----