get_sans_from_csr using pyOpenSSL
diff --git a/letsencrypt/crypto_util.py b/letsencrypt/crypto_util.py
index f89f281cbc0..94617eef65d 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.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 bdd67da6ada..92cb4014bc9 100644
--- a/letsencrypt/tests/crypto_util_test.py
+++ b/letsencrypt/tests/crypto_util_test.py
@@ -7,6 +7,7 @@
 import unittest
 
 import M2Crypto
+import OpenSSL
 import mock
 
 
@@ -150,5 +151,41 @@ def test_it(self):  # pylint: disable=no-self-use
         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 00000000000..8f6b52bd77d
--- /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 00000000000..813db67b0be
--- /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 c719bea60e2..815403b5ab1 100644
--- a/setup.py
+++ b/setup.py
@@ -38,7 +38,8 @@ def read_file(filename, encoding='utf8'):
     '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',

Supersedes #247. Still hacky (using unexposed API, and using some, quite reasonable, assumptions), but we're getting there! I will try to submit appropriate patches upstream.
