]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
Issue #1589: Add ssl.match_hostname(), to help implement server identity
authorAntoine Pitrou <solipsis@pitrou.net>
Fri, 8 Oct 2010 10:37:08 +0000 (10:37 +0000)
committerAntoine Pitrou <solipsis@pitrou.net>
Fri, 8 Oct 2010 10:37:08 +0000 (10:37 +0000)
verification for higher-level protocols.

Doc/library/ssl.rst
Lib/ssl.py
Lib/test/test_ssl.py
Misc/NEWS

index c0397dbc246365a14128807816f5e0368f7e58e3..f36dbc766642f5c89be6706e60befe553e06b9cd 100644 (file)
@@ -45,11 +45,27 @@ Functions, Constants, and Exceptions
 
 .. exception:: SSLError
 
-   Raised to signal an error from the underlying SSL implementation.  This
-   signifies some problem in the higher-level encryption and authentication
-   layer that's superimposed on the underlying network connection.  This error
+   Raised to signal an error from the underlying SSL implementation
+   (currently provided by the OpenSSL library).  This signifies some
+   problem in the higher-level encryption and authentication layer that's
+   superimposed on the underlying network connection.  This error
    is a subtype of :exc:`socket.error`, which in turn is a subtype of
-   :exc:`IOError`.
+   :exc:`IOError`.  The error code and message of :exc:`SSLError` instances
+   are provided by the OpenSSL library.
+
+.. exception:: CertificateError
+
+   Raised to signal an error with a certificate (such as mismatching
+   hostname).  Certificate errors detected by OpenSSL, though, raise
+   an :exc:`SSLError`.
+
+
+Socket creation
+^^^^^^^^^^^^^^^
+
+The following function allows for standalone socket creation.  Starting from
+Python 3.2, it can be more flexible to use :meth:`SSLContext.wrap_socket`
+instead.
 
 .. function:: wrap_socket(sock, keyfile=None, certfile=None, server_side=False, cert_reqs=CERT_NONE, ssl_version={see docs}, ca_certs=None, do_handshake_on_connect=True, suppress_ragged_eofs=True, ciphers=None)
 
@@ -139,6 +155,9 @@ Functions, Constants, and Exceptions
    .. versionchanged:: 3.2
       New optional argument *ciphers*.
 
+Random generation
+^^^^^^^^^^^^^^^^^
+
 .. function:: RAND_status()
 
    Returns True if the SSL pseudo-random number generator has been seeded with
@@ -164,6 +183,32 @@ Functions, Constants, and Exceptions
    string (so you can always use :const:`0.0`).  See :rfc:`1750` for more
    information on sources of entropy.
 
+Certificate handling
+^^^^^^^^^^^^^^^^^^^^
+
+.. function:: match_hostname(cert, hostname)
+
+   Verify that *cert* (in decoded format as returned by
+   :meth:`SSLSocket.getpeercert`) matches the given *hostname*.  The rules
+   applied are those for checking the identity of HTTPS servers as outlined
+   in :rfc:`2818`, except that IP addresses are not currently supported.
+   In addition to HTTPS, this function should be suitable for checking the
+   identity of servers in various SSL-based protocols such as FTPS, IMAPS,
+   POPS and others.
+
+   :exc:`CertificateError` is raised on failure. On success, the function
+   returns nothing::
+
+      >>> cert = {'subject': ((('commonName', 'example.com'),),)}
+      >>> ssl.match_hostname(cert, "example.com")
+      >>> ssl.match_hostname(cert, "example.org")
+      Traceback (most recent call last):
+        File "<stdin>", line 1, in <module>
+        File "/home/py3k/Lib/ssl.py", line 130, in match_hostname
+      ssl.CertificateError: hostname 'example.org' doesn't match 'example.com'
+
+   .. versionadded:: 3.2
+
 .. function:: cert_time_to_seconds(timestring)
 
    Returns a floating-point value containing a normal seconds-after-the-epoch
@@ -178,7 +223,6 @@ Functions, Constants, and Exceptions
      >>> import time
      >>> time.ctime(ssl.cert_time_to_seconds("May  9 00:00:00 2007 GMT"))
      'Wed May  9 00:00:00 2007'
-     >>>
 
 .. function:: get_server_certificate(addr, ssl_version=PROTOCOL_SSLv3, ca_certs=None)
 
@@ -201,6 +245,9 @@ Functions, Constants, and Exceptions
    Given a certificate as an ASCII PEM string, returns a DER-encoded sequence of
    bytes for that same certificate.
 
+Constants
+^^^^^^^^^
+
 .. data:: CERT_NONE
 
    Possible value for :attr:`SSLContext.verify_mode`, or the ``cert_reqs``
@@ -683,68 +730,51 @@ should use the following idiom::
 Client-side operation
 ^^^^^^^^^^^^^^^^^^^^^
 
-This example connects to an SSL server, prints the server's address and
-certificate, sends some bytes, and reads part of the response::
+This example connects to an SSL server and prints the server's certificate::
 
    import socket, ssl, pprint
 
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
-
    # require a certificate from the server
    ssl_sock = ssl.wrap_socket(s,
                               ca_certs="/etc/ca_certs_file",
                               cert_reqs=ssl.CERT_REQUIRED)
-
    ssl_sock.connect(('www.verisign.com', 443))
 
-   print(repr(ssl_sock.getpeername()))
    pprint.pprint(ssl_sock.getpeercert())
-   print(pprint.pformat(ssl_sock.getpeercert()))
-
-   # Set a simple HTTP request -- use http.client in actual code.
-   ssl_sock.sendall(b"GET / HTTP/1.0\r\nHost: www.verisign.com\r\n\r\n")
-
-   # Read a chunk of data.  Will not necessarily
-   # read all the data returned by the server.
-   data = ssl_sock.recv()
-
    # note that closing the SSLSocket will also close the underlying socket
    ssl_sock.close()
 
-As of September 6, 2007, the certificate printed by this program looked like
+As of October 6, 2010, the certificate printed by this program looks like
 this::
 
-      {'notAfter': 'May  8 23:59:59 2009 GMT',
-       'subject': ((('serialNumber', '2497886'),),
-                   (('1.3.6.1.4.1.311.60.2.1.3', 'US'),),
-                   (('1.3.6.1.4.1.311.60.2.1.2', 'Delaware'),),
-                   (('countryName', 'US'),),
-                   (('postalCode', '94043'),),
-                   (('stateOrProvinceName', 'California'),),
-                   (('localityName', 'Mountain View'),),
-                   (('streetAddress', '487 East Middlefield Road'),),
-                   (('organizationName', 'VeriSign, Inc.'),),
-                   (('organizationalUnitName',
-                     'Production Security Services'),),
-                   (('organizationalUnitName',
-                     'Terms of use at www.verisign.com/rpa (c)06'),),
-                   (('commonName', 'www.verisign.com'),))}
-
-which is a fairly poorly-formed ``subject`` field.
+   {'notAfter': 'May 25 23:59:59 2012 GMT',
+    'subject': ((('1.3.6.1.4.1.311.60.2.1.3', 'US'),),
+                (('1.3.6.1.4.1.311.60.2.1.2', 'Delaware'),),
+                (('businessCategory', 'V1.0, Clause 5.(b)'),),
+                (('serialNumber', '2497886'),),
+                (('countryName', 'US'),),
+                (('postalCode', '94043'),),
+                (('stateOrProvinceName', 'California'),),
+                (('localityName', 'Mountain View'),),
+                (('streetAddress', '487 East Middlefield Road'),),
+                (('organizationName', 'VeriSign, Inc.'),),
+                (('organizationalUnitName', ' Production Security Services'),),
+                (('commonName', 'www.verisign.com'),))}
 
 This other example first creates an SSL context, instructs it to verify
 certificates sent by peers, and feeds it a set of recognized certificate
 authorities (CA)::
 
    >>> context = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
-   >>> context.verify_mode = ssl.CERT_OPTIONAL
+   >>> context.verify_mode = ssl.CERT_REQUIRED
    >>> context.load_verify_locations("/etc/ssl/certs/ca-bundle.crt")
 
 (it is assumed your operating system places a bundle of all CA certificates
 in ``/etc/ssl/certs/ca-bundle.crt``; if not, you'll get an error and have
 to adjust the location)
 
-When you use the context to connect to a server, :const:`CERT_OPTIONAL`
+When you use the context to connect to a server, :const:`CERT_REQUIRED`
 validates the server certificate: it ensures that the server certificate
 was signed with one of the CA certificates, and checks the signature for
 correctness::
@@ -752,11 +782,15 @@ correctness::
    >>> conn = context.wrap_socket(socket.socket(socket.AF_INET))
    >>> conn.connect(("linuxfr.org", 443))
 
-You should then fetch the certificate and check its fields for conformity.
-Here, the ``commonName`` field in the ``subject`` matches the desired HTTPS
-host ``linuxfr.org``::
+You should then fetch the certificate and check its fields for conformity::
 
-   >>> pprint.pprint(conn.getpeercert())
+   >>> cert = conn.getpeercert()
+   >>> ssl.match_hostname(cert, "linuxfr.org")
+
+Visual inspection shows that the certificate does identify the desired service
+(that is, the HTTPS host ``linuxfr.org``)::
+
+   >>> pprint.pprint(cert)
    {'notAfter': 'Jun 26 21:41:46 2011 GMT',
     'subject': ((('commonName', 'linuxfr.org'),),),
     'subjectAltName': (('DNS', 'linuxfr.org'), ('othername', '<unsupported>'))}
@@ -776,7 +810,6 @@ the server::
     b'',
     b'']
 
-
 See the discussion of :ref:`ssl-security` below.
 
 
@@ -842,12 +875,10 @@ peer, it can be insecure, especially in client mode where most of time you
 would like to ensure the authenticity of the server you're talking to.
 Therefore, when in client mode, it is highly recommended to use
 :const:`CERT_REQUIRED`.  However, it is in itself not sufficient; you also
-have to check that the server certificate (obtained with
-:meth:`SSLSocket.getpeercert`) matches the desired service.  The exact way
-of doing so depends on the higher-level protocol used; for example, with
-HTTPS, you'll check that the host name in the URL matches either the
-``commonName`` field in the ``subjectName``, or one of the ``DNS`` fields
-in the ``subjectAltName``.
+have to check that the server certificate, which can be obtained by calling
+:meth:`SSLSocket.getpeercert`, matches the desired service.  For many
+protocols and applications, the service can be identified by the hostname;
+in this case, the :func:`match_hostname` function can be used.
 
 In server mode, if you want to authenticate your clients using the SSL layer
 (rather than using a higher-level authentication mechanism), you'll also have
index d5e48748c7c2cc7a73839a4c53663a3b8f13885c..ae8aaefb4b6d695c32c2096990a548cc497c099e 100644 (file)
@@ -55,6 +55,7 @@ PROTOCOL_TLSv1
 """
 
 import textwrap
+import re
 
 import _ssl             # if we can't import it, let the error propagate
 
@@ -85,6 +86,64 @@ import traceback
 import errno
 
 
+class CertificateError(ValueError):
+    pass
+
+
+def _dnsname_to_pat(dn):
+    pats = []
+    for frag in dn.split(r'.'):
+        if frag == '*':
+            # When '*' is a fragment by itself, it matches a non-empty dotless
+            # fragment.
+            pats.append('[^.]+')
+        else:
+            # Otherwise, '*' matches any dotless fragment.
+            frag = re.escape(frag)
+            pats.append(frag.replace(r'\*', '[^.]*'))
+    return re.compile(r'\A' + r'\.'.join(pats) + r'\Z', re.IGNORECASE)
+
+
+def match_hostname(cert, hostname):
+    """Verify that *cert* (in decoded format as returned by
+    SSLSocket.getpeercert()) matches the *hostname*.  RFC 2818 rules
+    are mostly followed, but IP addresses are not accepted for *hostname*.
+
+    CertificateError is raised on failure. On success, the function
+    returns nothing.
+    """
+    if not cert:
+        raise ValueError("empty or no certificate")
+    dnsnames = []
+    san = cert.get('subjectAltName', ())
+    for key, value in san:
+        if key == 'DNS':
+            if _dnsname_to_pat(value).match(hostname):
+                return
+            dnsnames.append(value)
+    if not san:
+        # The subject is only checked when subjectAltName is empty
+        for sub in cert.get('subject', ()):
+            for key, value in sub:
+                # XXX according to RFC 2818, the most specific Common Name
+                # must be used.
+                if key == 'commonName':
+                    if _dnsname_to_pat(value).match(hostname):
+                        return
+                    dnsnames.append(value)
+    if len(dnsnames) > 1:
+        raise CertificateError("hostname %r "
+            "doesn't match either of %s"
+            % (hostname, ', '.join(map(repr, dnsnames))))
+    elif len(dnsnames) == 1:
+        raise CertificateError("hostname %r "
+            "doesn't match %r"
+            % (hostname, dnsnames[0]))
+    else:
+        raise CertificateError("no appropriate commonName or "
+            "subjectAltName fields were found")
+
+
 class SSLContext(_SSLContext):
     """An SSLContext holds various SSL-related configuration options and
     data, such as certificates and possibly a private key."""
index 4f29a647b882b69f6fab7c8352912a23221791af..0b9e6b524c04f2ab891cc7620e79fc6ebd1e58ee 100644 (file)
@@ -208,6 +208,77 @@ class BasicSocketTests(unittest.TestCase):
             ssl.wrap_socket(socket.socket(), certfile=WRONGCERT, keyfile=WRONGCERT)
         self.assertEqual(cm.exception.errno, errno.ENOENT)
 
+    def test_match_hostname(self):
+        def ok(cert, hostname):
+            ssl.match_hostname(cert, hostname)
+        def fail(cert, hostname):
+            self.assertRaises(ssl.CertificateError,
+                              ssl.match_hostname, cert, hostname)
+
+        cert = {'subject': ((('commonName', 'example.com'),),)}
+        ok(cert, 'example.com')
+        ok(cert, 'ExAmple.cOm')
+        fail(cert, 'www.example.com')
+        fail(cert, '.example.com')
+        fail(cert, 'example.org')
+        fail(cert, 'exampleXcom')
+
+        cert = {'subject': ((('commonName', '*.a.com'),),)}
+        ok(cert, 'foo.a.com')
+        fail(cert, 'bar.foo.a.com')
+        fail(cert, 'a.com')
+        fail(cert, 'Xa.com')
+        fail(cert, '.a.com')
+
+        cert = {'subject': ((('commonName', 'a.*.com'),),)}
+        ok(cert, 'a.foo.com')
+        fail(cert, 'a..com')
+        fail(cert, 'a.com')
+
+        cert = {'subject': ((('commonName', 'f*.com'),),)}
+        ok(cert, 'foo.com')
+        ok(cert, 'f.com')
+        fail(cert, 'bar.com')
+        fail(cert, 'foo.a.com')
+        fail(cert, 'bar.foo.com')
+
+        # Slightly fake real-world example
+        cert = {'notAfter': 'Jun 26 21:41:46 2011 GMT',
+                'subject': ((('commonName', 'linuxfrz.org'),),),
+                'subjectAltName': (('DNS', 'linuxfr.org'),
+                                   ('DNS', 'linuxfr.com'),
+                                   ('othername', '<unsupported>'))}
+        ok(cert, 'linuxfr.org')
+        ok(cert, 'linuxfr.com')
+        # Not a "DNS" entry
+        fail(cert, '<unsupported>')
+        # When there is a subjectAltName, commonName isn't used
+        fail(cert, 'linuxfrz.org')
+
+        # A pristine real-world example
+        cert = {'notAfter': 'Dec 18 23:59:59 2011 GMT',
+                'subject': ((('countryName', 'US'),),
+                            (('stateOrProvinceName', 'California'),),
+                            (('localityName', 'Mountain View'),),
+                            (('organizationName', 'Google Inc'),),
+                            (('commonName', 'mail.google.com'),))}
+        ok(cert, 'mail.google.com')
+        fail(cert, 'gmail.com')
+        # Only commonName is considered
+        fail(cert, 'California')
+
+        # Neither commonName nor subjectAltName
+        cert = {'notAfter': 'Dec 18 23:59:59 2011 GMT',
+                'subject': ((('countryName', 'US'),),
+                            (('stateOrProvinceName', 'California'),),
+                            (('localityName', 'Mountain View'),),
+                            (('organizationName', 'Google Inc'),))}
+        fail(cert, 'mail.google.com')
+
+        # Empty cert / no cert
+        self.assertRaises(ValueError, ssl.match_hostname, None, 'example.com')
+        self.assertRaises(ValueError, ssl.match_hostname, {}, 'example.com')
+
 
 class ContextTests(unittest.TestCase):
 
index 46042b42f2679623477bb2122f6913a322255686..7694e766f8288379e9d3f2eeb42328c46613acba 100644 (file)
--- a/Misc/NEWS
+++ b/Misc/NEWS
@@ -92,6 +92,9 @@ Core and Builtins
 Library
 -------
 
+- Issue #1589: Add ssl.match_hostname(), to help implement server identity
+  verification for higher-level protocols.
+
 - Issue #9759: GzipFile now raises ValueError when an operation is attempted
   after the file is closed.  Patch by Jeffrey Finkelstein.