Implement get_sans via parsing Request.as_text()

This commit is contained in:
Seth Schoen 2015-02-19 14:55:14 -08:00
parent 8ff99300e4
commit 36d26de746
4 changed files with 128 additions and 0 deletions

View file

@ -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

View file

@ -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()

View file

@ -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-----

View file

@ -0,0 +1,8 @@
-----BEGIN CERTIFICATE REQUEST-----
MIIBFTCBwAIBADBbMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEh
MB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMRQwEgYDVQQDDAtleGFt
cGxlLm9yZzBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQD0thFxUTc2v6qV55wRxfwn
BUOeN4bVfu5ywJqy65kzR7T1yZi5TPEiQyM7/3HgBVy9ddFc8RX4vNZaR+ROXNEz
AgMBAAGgADANBgkqhkiG9w0BAQsFAANBAMikGL8Ch7hQCStXH7chhDp6+pt2+VSo
wgsrPQ2Bw4veDMlSemUrH+4e0TwbbntHfvXTDHWs9P3BiIDJLxFrjuA=
-----END CERTIFICATE REQUEST-----