[PATCH] https: support tls sni (server name indication) for https urls (issue3090)

Alex Orange crazycasta at gmail.com
Sun Feb 2 23:57:10 CST 2014


# HG changeset patch
# User Alex Orange <crazycasta at gmail.com>
# Date 1391402279 25200
# Branch stable
# Node ID 7fc7d98e8fdad6d82768dd71b8833e04567ea8ba
# Parent  9139dedeffa6d54367fcf410d583d401e8fd1318
https: support tls sni (server name indication) for https urls (issue3090)

SNI is a common way of sharing servers across multiple domains using separate
SSL certificates. Python 2.x does not, and will not, support SNI according to:
http://bugs.python.org/issue5639#msg192234.

In order to support SNI a sepearate package (ssl_sni) was written. The code
provided here is version 0.1 of the ssl_sni package provided on PyPI. The code
on PyPI is AGPLv3, however the version in this patch is provided under the
GPLv2 or later license (consistent with the hg license). This code is then used
by importing it before the builtin ssl and, if the import succeeds, using its
wrap_socket and constants instead of ssl's. The new version of wrap_socket takes
a parameter server_hostname that specifies the hostname that gets sent via SNI.
Therefore the code in url.py that uses wrap_socket has been modified to pass
the server hostname to wrap_socket.

The research I did led me to the conclusion that an OpenSSL based solution
would best replicate that existing python ssl object, as opposed to a GNUTLS
solution like PyGnuTLS (https://gitorious.org/pygnutls). The two packages I
found that perform the basic functions of the python ssl object are pyOpenSSL
(http://pythonhosted.org/pyOpenSSL/) and M2Crypto
(http://www.heikkitoivonen.net/m2crypto/). M2Crypto does not support sending
the host name as far as I can tell, which leaves pyOpenSSL. The shortcoming of
both of these libraries is that they do not provide the subjectAltName
subobjects as separate objects. They give either the DER encoded raw data or
an openssl command line style string "DNS:a.b.com, DNS:c.d.com, ...". I did not
want to parse this string in case a certificate came up with a dNSName had the
form 'a.b.com, DNS:c.d.com' which could conceivably lead to a security problem.
In order to handle this problem I used pyasn1 to parse the subjectAltName data
along with code taken from ndg-httpsclient. There appears to also be a
way to do this directly through python's ssl module with an undocumented
function called _test_decode_cert. However this would involve writing
certificates to temporary files and then reading them back in. This seems like
a bad idea both because the function is undocumented (and therefore vulnerable
to going away without warning) and a possible security risk: writing to files
and then reading them back in.

Finally, to make all of this as have as little an impact on the mercurial code
as possible I wrapped this functionality into a module that emulates the
python ssl class called SSLSocket in openssl.py.

diff -r 9139dedeffa6 -r 7fc7d98e8fda mercurial/ssl_sni/openssl.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mercurial/ssl_sni/openssl.py	Sun Feb 02 21:37:59 2014 -0700
@@ -0,0 +1,159 @@
+# ssl_sni - A wrapper for pyopenssl and pyasn1 to provide SNI.
+#
+# Copyright 2013, 2014 Alex Orange <crazycasta at gmail.com>
+#
+# This software may be used and distributed according to the terms of the
+# GNU Gneral Public License version 2 or any later version.
+
+import OpenSSL
+import pyasn1.codec.der.decoder
+import subjaltname
+
+import socket
+
+# Try to use python's builtin SSLError if we can, otherwise imitate it as best
+# as we can
+try:
+    import ssl
+    _have_ssl = True
+except ImportError:
+    _have_ssl = False
+    class ssl(object):
+        class SSLError(socket.error):
+            pass
+
+# Based heavily on code from:
+# https://github.com/t-8ch/requests/blob/ \
+# d7908a9fdef7bca16e384ca42478d69d1894c8b6/requests/packages/urllib3/contrib/ \
+# pyopenssl.py
+
+
+if _have_ssl:
+    PROTOCOL_SSLv23 = ssl.PROTOCOL_SSLv23
+    PROTOCOL_SSLv3 = ssl.PROTOCOL_SSLv3
+    PROTOCOL_TLSv1 = ssl.PROTOCOL_TLSv1
+
+    CERT_NONE = ssl.CERT_NONE
+    CERT_OPTIONAL = ssl.CERT_OPTIONAL
+    CERT_REQUIRED = ssl.CERT_REQUIRED
+else:
+    PROTOCOL_SSLv23 = OpenSSL.SSL.SSLv23_METHOD
+    PROTOCOL_SSLv3 = OpenSSL.SSL.SSLv3_METHOD
+    PROTOCOL_TLSv1 = OpenSSL.SSL.TLSv1_METHOD
+
+    CERT_NONE = OpenSSL.SSL.VERIFY_NONE
+    CERT_OPTIONAL = OpenSSL.SSL.VERIFY_PEER
+    CERT_REQUIRED = OpenSSL.SSL.VERIFY_PEER | \
+            OpenSSL.SSL.VERIFY_FAIL_IF_NO_PEER_CERT
+
+_openssl_versions = {
+    PROTOCOL_SSLv23: OpenSSL.SSL.SSLv23_METHOD,
+    PROTOCOL_SSLv3: OpenSSL.SSL.SSLv3_METHOD,
+    PROTOCOL_TLSv1: OpenSSL.SSL.TLSv1_METHOD,
+}
+
+_openssl_cert_reqs = {
+    CERT_NONE: OpenSSL.SSL.VERIFY_NONE,
+    CERT_OPTIONAL: OpenSSL.SSL.VERIFY_PEER,
+    CERT_REQUIRED: OpenSSL.SSL.VERIFY_PEER | \
+            OpenSSL.SSL.VERIFY_FAIL_IF_NO_PEER_CERT
+}
+
+class SSLSocket(object):
+    def __init__(self, connection, sock):
+        self.connection = connection
+        self.sock = sock
+
+    def makefile(self, mode, bufsize=-1):
+        return socket._fileobject(self.connection, mode, bufsize)
+
+    def getpeercert(self, binary_form=False):
+        x509 = self.connection.get_peer_certificate()
+        if not x509:
+            raise ssl.SSLError('')
+
+        if binary_form:
+            return OpenSSL.crypto.dump_certificate(
+                OpenSSL.crypto.FILETYPE_ASN1,
+                x509)
+
+        dns_name = []
+        general_names = subjaltname.SubjectAltName()
+
+        for i in range(x509.get_extension_count()):
+            ext = x509.get_extension(i)
+            ext_name = ext.get_short_name()
+            if ext_name != 'subjectAltName':
+                continue
+
+            ext_dat = ext.get_data()
+            der_decoder = pyasn1.codec.der.decoder
+            decoded_dat = der_decoder.decode(ext_dat,
+                                             asn1Spec=general_names)
+
+            for name in decoded_dat:
+                if not isinstance(name, subjaltname.SubjectAltName):
+                    continue
+                for entry in range(len(name)):
+                    component = name.getComponentByPosition(entry)
+                    if component.getName() != 'dNSName':
+                        continue
+                    dns_name.append(('DNS', str(component.getComponent())))
+
+        return {
+            'subject': (
+                (('commonName', x509.get_subject().CN),),
+            ),
+            'subjectAltName': dns_name
+        }
+
+    # Pass any unhandle function calls on to connection
+    def __getattr__(self, name):
+        try:
+            return getattr(self.connection, name)
+        except AttributeError:
+            return getattr(self.sock, name)
+
+class OpenSSLReformattedError(Exception):
+    def __init__(self, e):
+        self.e = e
+
+    def __str__(self):
+        try:
+            return '*:%s:%s (glob)'%(self.e.args[0][0][1], self.e.args[0][0][2])
+        except Exception:
+            return '%s'%self.e
+
+
+def wrap_socket(sock, keyfile=None, certfile=None, server_side=False,
+                cert_reqs=CERT_NONE, ssl_version=PROTOCOL_TLSv1,
+                ca_certs=None, do_handshake_on_connect=True,
+                suppress_ragged_eofs=True, server_hostname=None):
+    cert_reqs = _openssl_cert_reqs[cert_reqs]
+    ssl_version = _openssl_versions[ssl_version]
+
+    ctx = OpenSSL.SSL.Context(ssl_version)
+    if certfile:
+        ctx.use_certificate_file(certfile)
+    if keyfile:
+        ctx.use_privatekey_file(keyfile)
+    if cert_reqs != OpenSSL.SSL.VERIFY_NONE:
+        ctx.set_verify(cert_reqs, lambda a, b, err_no, c, d: err_no == 0)
+    if ca_certs:
+        try:
+            ctx.load_verify_locations(ca_certs, None)
+        except OpenSSL.SSL.Error, e:
+            raise ssl.SSLError('bad ca_certs: %r' % ca_certs,
+                               OpenSSLReformattedError(e))
+
+    cnx = OpenSSL.SSL.Connection(ctx, sock)
+    if server_hostname is not None:
+        cnx.set_tlsext_host_name(server_hostname)
+    cnx.set_connect_state()
+    try:
+        cnx.do_handshake()
+    except OpenSSL.SSL.Error, e:
+        raise ssl.SSLError('bad handshake',
+                           OpenSSLReformattedError(e))
+
+    return SSLSocket(cnx, sock)
diff -r 9139dedeffa6 -r 7fc7d98e8fda mercurial/ssl_sni/subjaltname.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mercurial/ssl_sni/subjaltname.py	Sun Feb 02 21:37:59 2014 -0700
@@ -0,0 +1,126 @@
+"""NDG HTTPS Client package
+
+Use pyasn1 to provide support for parsing ASN.1 formatted subjectAltName
+content for SSL peer verification.  Code based on:
+
+http://stackoverflow.com/questions/5519958/how-do-i-parse-subjectaltname-extension-data-using-pyasn1
+"""
+__author__ = "P J Kershaw"
+__date__ = "01/02/12"
+__copyright__ = "(C) 2012 Science and Technology Facilities Council"
+__license__ = "BSD - see LICENSE file in top-level directory"
+__contact__ = "Philip.Kershaw at stfc.ac.uk"
+__revision__ = '$Id$'
+
+from pyasn1.type import univ, constraint, char, namedtype, tag
+
+
+class DirectoryString(univ.Choice):
+    """ASN.1 Directory string class"""
+    componentType = namedtype.NamedTypes(
+        namedtype.NamedType(
+            'teletexString', char.TeletexString()),
+        namedtype.NamedType(
+            'printableString', char.PrintableString()),
+        namedtype.NamedType(
+            'universalString', char.UniversalString()),
+        namedtype.NamedType(
+            'utf8String', char.UTF8String()),
+        namedtype.NamedType(
+            'bmpString', char.BMPString()),
+        namedtype.NamedType(
+            'ia5String', char.IA5String()),
+        )
+
+
+class AttributeValue(DirectoryString):
+    """ASN.1 Attribute value"""
+
+
+class AttributeType(univ.ObjectIdentifier):
+    """ASN.1 Attribute type"""
+
+
+class AttributeTypeAndValue(univ.Sequence):
+    """ASN.1 Attribute type and value class"""
+    componentType = namedtype.NamedTypes(
+        namedtype.NamedType('type', AttributeType()),
+        namedtype.NamedType('value', AttributeValue()),
+        )
+
+
+class RelativeDistinguishedName(univ.SetOf):
+    '''ASN.1 Realtive distinguished name'''
+    componentType = AttributeTypeAndValue()
+
+class RDNSequence(univ.SequenceOf):
+    '''ASN.1 RDN sequence class'''
+    componentType = RelativeDistinguishedName()
+
+
+class Name(univ.Choice):
+    '''ASN.1 name class'''
+    componentType = namedtype.NamedTypes(
+        namedtype.NamedType('', RDNSequence()),
+        )
+
+
+class Extension(univ.Sequence):
+    '''ASN.1 extension class'''
+    componentType = namedtype.NamedTypes(
+        namedtype.NamedType('extnID', univ.ObjectIdentifier()),
+        namedtype.DefaultedNamedType('critical', univ.Boolean('False')),
+        namedtype.NamedType('extnValue', univ.OctetString()),
+        )
+
+
+class Extensions(univ.SequenceOf):
+    '''ASN.1 extensions class'''
+    componentType = Extension()
+    sizeSpec = univ.SequenceOf.sizeSpec
+
+
+class GeneralName(univ.Choice):
+    '''ASN.1 configuration for X.509 certificate subjectAltNames fields'''
+    componentType = namedtype.NamedTypes(
+#        namedtype.NamedType('otherName', AnotherName().subtype(
+#                            implicitTag=tag.Tag(tag.tagClassContext,
+#                                                tag.tagFormatSimple, 0))),
+        namedtype.NamedType('rfc822Name', char.IA5String().subtype(
+                            implicitTag=tag.Tag(tag.tagClassContext,
+                                                tag.tagFormatSimple, 1))),
+        namedtype.NamedType('dNSName', char.IA5String().subtype(
+                            implicitTag=tag.Tag(tag.tagClassContext,
+                                                tag.tagFormatSimple, 2))),
+#        namedtype.NamedType('x400Address', ORAddress().subtype(
+#                            implicitTag=tag.Tag(tag.tagClassContext,
+#                                                tag.tagFormatSimple, 3))),
+        namedtype.NamedType('directoryName', Name().subtype(
+                            implicitTag=tag.Tag(tag.tagClassContext,
+                                                tag.tagFormatSimple, 4))),
+#        namedtype.NamedType('ediPartyName', EDIPartyName().subtype(
+#                            implicitTag=tag.Tag(tag.tagClassContext,
+#                                                tag.tagFormatSimple, 5))),
+        namedtype.NamedType('uniformResourceIdentifier', char.IA5String().\
+                            subtype(implicitTag=tag.Tag(tag.tagClassContext,
+                                    tag.tagFormatSimple, 6))),
+        namedtype.NamedType('iPAddress', univ.OctetString().subtype(
+                            implicitTag=tag.Tag(tag.tagClassContext,
+                                                tag.tagFormatSimple, 7))),
+        namedtype.NamedType('registeredID', univ.ObjectIdentifier().subtype(
+                            implicitTag=tag.Tag(tag.tagClassContext,
+                                                tag.tagFormatSimple, 8))),
+        )
+
+
+class GeneralNames(univ.SequenceOf):
+    '''Sequence of names for ASN.1 subjectAltNames settings'''
+    componentType = GeneralName()
+    sizeSpec = univ.SequenceOf.sizeSpec
+
+
+class SubjectAltName(GeneralNames):
+    '''ASN.1 implementation for subjectAltNames support'''
+
+# Not checking code because taken almost verbatim from another source.
+# no-check-code
diff -r 9139dedeffa6 -r 7fc7d98e8fda mercurial/sslutil.py
--- a/mercurial/sslutil.py	Sat Feb 01 15:20:49 2014 -0600
+++ b/mercurial/sslutil.py	Sun Feb 02 21:37:59 2014 -0700
@@ -11,39 +11,58 @@
 from mercurial import util
 from mercurial.i18n import _
 try:
-    # avoid using deprecated/broken FakeSocket in python 2.6
-    import ssl
-    CERT_REQUIRED = ssl.CERT_REQUIRED
-    PROTOCOL_SSLv23 = ssl.PROTOCOL_SSLv23
-    PROTOCOL_TLSv1 = ssl.PROTOCOL_TLSv1
+    # Force the import
+    from ssl_sni import openssl
+
+    CERT_REQUIRED = openssl.CERT_REQUIRED
+    PROTOCOL_SSLv23 = openssl.PROTOCOL_SSLv23
+    PROTOCOL_TLSv1 = openssl.PROTOCOL_TLSv1
     def ssl_wrap_socket(sock, keyfile, certfile, ssl_version=PROTOCOL_TLSv1,
-                cert_reqs=ssl.CERT_NONE, ca_certs=None):
-        sslsocket = ssl.wrap_socket(sock, keyfile, certfile,
-                                    cert_reqs=cert_reqs, ca_certs=ca_certs,
-                                    ssl_version=ssl_version)
-        # check if wrap_socket failed silently because socket had been closed
-        # - see http://bugs.python.org/issue13721
-        if not sslsocket.cipher():
-            raise util.Abort(_('ssl connection failed'))
+                        cert_reqs=openssl.CERT_NONE, ca_certs=None,
+                        server_hostname=None):
+        sslsocket = openssl.wrap_socket(sock, keyfile, certfile,
+                                        cert_reqs=cert_reqs,
+                                        ca_certs=ca_certs,
+                                        server_hostname=server_hostname)
         return sslsocket
 except ImportError:
-    CERT_REQUIRED = 2
+    try:
+        # avoid using deprecated/broken FakeSocket in python 2.6
+        import ssl
+        CERT_REQUIRED = ssl.CERT_REQUIRED
+        PROTOCOL_SSLv23 = ssl.PROTOCOL_SSLv23
+        PROTOCOL_TLSv1 = ssl.PROTOCOL_TLSv1
+        def ssl_wrap_socket(sock, keyfile, certfile, ssl_version=PROTOCOL_TLSv1,
+                            cert_reqs=ssl.CERT_NONE, ca_certs=None,
+                            server_hostname=None):
+            sslsocket = ssl.wrap_socket(sock, keyfile, certfile,
+                                        cert_reqs=cert_reqs, ca_certs=ca_certs,
+                                        ssl_version=ssl_version)
+            # check if wrap_socket failed silently because socket had been
+            # closed
+            # - see http://bugs.python.org/issue13721
+            if not sslsocket.cipher():
+                raise util.Abort(_('ssl connection failed'))
+            return sslsocket
+    except ImportError:
+        CERT_REQUIRED = 2
 
-    PROTOCOL_SSLv23 = 2
-    PROTOCOL_TLSv1 = 3
+        PROTOCOL_SSLv23 = 2
+        PROTOCOL_TLSv1 = 3
 
-    import socket, httplib
+        import socket, httplib
 
-    def ssl_wrap_socket(sock, keyfile, certfile, ssl_version=PROTOCOL_TLSv1,
-                        cert_reqs=CERT_REQUIRED, ca_certs=None):
-        if not util.safehasattr(socket, 'ssl'):
-            raise util.Abort(_('Python SSL support not found'))
-        if ca_certs:
-            raise util.Abort(_(
-                'certificate checking requires Python 2.6'))
+        def ssl_wrap_socket(sock, keyfile, certfile, ssl_version=PROTOCOL_TLSv1,
+                            cert_reqs=CERT_REQUIRED, ca_certs=None,
+                            server_hostname=None):
+            if not util.safehasattr(socket, 'ssl'):
+                raise util.Abort(_('Python SSL support not found'))
+            if ca_certs:
+                raise util.Abort(_(
+                    'certificate checking requires Python 2.6'))
 
-        ssl = socket.ssl(sock, keyfile, certfile)
-        return httplib.FakeSocket(sock, ssl)
+            ssl = socket.ssl(sock, keyfile, certfile)
+            return httplib.FakeSocket(sock, ssl)
 
 def _verifycert(cert, hostname):
     '''Verify that cert (in socket.getpeercert() format) matches hostname.
@@ -127,9 +146,14 @@
                 self.ui.warn(_("warning: certificate for %s can't be verified "
                                "(Python too old)\n") % host)
             return
+        try:
+            # work around http://bugs.python.org/issue13721
+            if not sock.cipher():
+                raise util.Abort(_('%s ssl connection error') % host)
+        except AttributeError:
+            # This is not the ssl object you are looking for
+            pass
 
-        if not sock.cipher(): # work around http://bugs.python.org/issue13721
-            raise util.Abort(_('%s ssl connection error') % host)
         try:
             peercert = sock.getpeercert(True)
             peercert2 = sock.getpeercert()
diff -r 9139dedeffa6 -r 7fc7d98e8fda mercurial/url.py
--- a/mercurial/url.py	Sat Feb 01 15:20:49 2014 -0600
+++ b/mercurial/url.py	Sun Feb 02 21:37:59 2014 -0700
@@ -185,7 +185,8 @@
             self.sock.connect((self.host, self.port))
             if _generic_proxytunnel(self):
                 # we do not support client X.509 certificates
-                self.sock = sslutil.ssl_wrap_socket(self.sock, None, None)
+                self.sock = sslutil.ssl_wrap_socket(self.sock, None, None,
+                                                    server_hostname=self.host)
         else:
             keepalive.HTTPConnection.connect(self)
 
@@ -342,7 +343,7 @@
                 _generic_proxytunnel(self)
                 host = self.realhostport.rsplit(':', 1)[0]
             self.sock = sslutil.ssl_wrap_socket(
-                self.sock, self.key_file, self.cert_file,
+                self.sock, self.key_file, self.cert_file, server_hostname=host,
                 **sslutil.sslkwargs(self.ui, host))
             sslutil.validator(self.ui, host)(self.sock)
 
diff -r 9139dedeffa6 -r 7fc7d98e8fda tests/test-check-code-hg.t
--- a/tests/test-check-code-hg.t	Sat Feb 01 15:20:49 2014 -0600
+++ b/tests/test-check-code-hg.t	Sun Feb 02 21:37:59 2014 -0700
@@ -34,3 +34,4 @@
   Skipping mercurial/httpclient/__init__.py it has no-che?k-code (glob)
   Skipping mercurial/httpclient/_readers.py it has no-che?k-code (glob)
   Skipping mercurial/httpclient/socketutil.py it has no-che?k-code (glob)
+  Skipping mercurial/ssl_sni/subjaltname.py it has no-che?k-code (glob)


More information about the Mercurial-devel mailing list