Index: docs/core/examples/index.rst =================================================================== --- docs/core/examples/index.rst (revision 45846) +++ docs/core/examples/index.rst (working copy) @@ -106,3 +106,4 @@ - :download:`wxacceptance.py` - acceptance tests for wxreactor - :download:`postfix.py` - test application for PostfixTCPMapServer - :download:`udpbroadcast.py` - broadcasting using UDP +- :download:`tls_alpn_npn_client.py` - example of TLS next-protocol negotiation using NPN and ALPN. Index: docs/core/howto/ssl.rst =================================================================== --- docs/core/howto/ssl.rst (revision 45846) +++ docs/core/howto/ssl.rst (working copy) @@ -220,6 +220,40 @@ Since Twisted uses a secure cipher configuration by default, it is discouraged to do so unless absolutely necessary. +Application Layer Protocol Negotiation (ALPN) and Next Protocol Negotiation (NPN) +--------------------------------------------------------------------------------- + +ALPN and NPN are TLS extensions that can be used by clients and servers to negotiate what application-layer protocol will be spoken once the encrypted connection is established. +This avoids the need for extra custom round trips once the encrypted connection is established. It is implemented as a standard part of the TLS handshake. + +NPN is supported from OpenSSL version 1.0.1. +ALPN is the newer of the two protocols, supported in OpenSSL versions 1.0.2 onward. +These functions require pyOpenSSL version 0.15 or higher. + +:api:`twisted.internet.ssl.CertificateOptions` allows for selecting the protocols your program is willing to speak after the connection is established. +Doing so is very simple: + +.. code-block:: python + + from twisted.internet.ssl import CertificateOptions + options = CertificateOptions(..., nextProtocol=[b'h2', b'http/1.1']) + +Twisted will attempt to use both ALPN and NPN if they're available, to maximise compatibility with peers. +If both ALPN and NPN are supported by the peer, then the result from ALPN is preferred. + +For NPN, the client selects the protocol to use; +For ALPN, the server does. +If Twisted is acting as the peer who is supposed to select the protocol, it will prefer the earliest protocol in the list that is supported by both peers. + +To determine what protocol was negotiated, use :api:`twisted.protocols.tls.TLSMemoryBIOProtocol.getNextProtocol `. +It will return one of the strings passed to the ``nextProtocol`` parameter. +It will return ``None`` if the peer did not offer ALPN or NPN, or if no overlap could be found and the connection was established regardless. In this case, the protocol that should be used is whatever protocol would have been used if negotiation had not been attempted at all. +If ALPN and NPN are used and no overlap can be found, then the remote peer may choose to terminate the connection. +This may cause the TLS handshake to fail, or may result in the connection being torn down immediately after being made. + +An example of using this functionality can be found in :download:`this example script `. + + Related facilities ------------------ Index: twisted/internet/_sslverify.py =================================================================== --- twisted/internet/_sslverify.py (revision 45846) +++ twisted/internet/_sslverify.py (working copy) @@ -1281,7 +1281,9 @@ extraCertChain=None, acceptableCiphers=None, dhParameters=None, - trustRoot=None): + trustRoot=None, + nextProtocols=None, + ): """ Create an OpenSSL context SSL connection context factory. @@ -1372,6 +1374,14 @@ @type trustRoot: L{IOpenSSLTrustRoot} + @param nextProtocols: The protocols this peer is willing to speak after + the TLS negotation has completed, advertised over both ALPN and + NPN. If this argument is specified, and no overlap can be found + with the other peer, the connection will fail to be established. + Protocols earlier in the list are preferred over those later in + the list. + @type nextProtocols: C{list} of C{bytes} + @raise ValueError: when C{privateKey} or C{certificate} are set without setting the respective other. @raise ValueError: when C{verify} is L{True} but C{caCerts} doesn't @@ -1467,7 +1477,9 @@ trustRoot = IOpenSSLTrustRoot(trustRoot) self.trustRoot = trustRoot + self._nextProtocols = nextProtocols + def __getstate__(self): d = self.__dict__.copy() try: @@ -1484,6 +1496,9 @@ def getContext(self): """ Return an L{OpenSSL.SSL.Context} object. + + @raises NotImplementedError: If nextProtocols were provided, but NPN is + not supported by OpenSSL (requires OpenSSL 1.0.1 or later). """ if self._context is None: self._context = self._makeContext() @@ -1536,6 +1551,68 @@ except BaseException: pass # ECDHE support is best effort only. + if self._nextProtocols: + # Try to set NPN and ALPN. + def protoSelectCallback(conn, protocols): + """ + NPN client-side and ALPN server-side callback used to select + the next protocol. + + @param conn: The PyOpenSSL connection object. + @type conn: L{OpenSSL.SSL.Connection} + + @param protocols: List of protocols supported by the remote + peer. + @type protocols: C{sequence} of C{bytes} + + @return: The selected next protocol or the empty string. + @rtype: C{bytes} + """ + overlap = set(protocols) & set(self._nextProtocols) + + for p in self._nextProtocols: + if p in overlap: + return p + else: + return b'' + + def npnAdvertiseCallback(conn): + """ + Server-side NPN callback used to advertise the supported + protocols. + + @param conn: The PyOpenSSL connection object. + @type conn: L{OpenSSL.SSL.Connection} + + @return: List of supported protocols. + @rype: C{list} of C{bytes}. + """ + return self._nextProtocols + + # If NPN is not supported this will raise a NotImplementedError, + # which is ideal. + # If PyOpenSSL is too old, this will raise an AttributeError: we + # catch it and re-raise a handy NotImplementedError instead. + try: + ctx.set_npn_advertise_callback(npnAdvertiseCallback) + ctx.set_npn_select_callback(protoSelectCallback) + except AttributeError: + raise NotImplementedError( + "nextProtocols requires PyOpenSSL 0.15 or later" + ) + + # Anything that does not support ALPN will also not support NPN, + # so we can just catch the NotImplementedError here. If PyOpenSSL + # is too old this will throw an AttributeError, but that cannot + # happen because it would have happened just above! + try: + # Server side callback + ctx.set_alpn_select_callback(protoSelectCallback) + # Client side advertisement. + ctx.set_alpn_protos(self._nextProtocols) + except NotImplementedError: + pass + return ctx Index: twisted/protocols/tls.py =================================================================== --- twisted/protocols/tls.py (revision 45846) +++ twisted/protocols/tls.py (working copy) @@ -586,6 +586,34 @@ return self._tlsConnection.get_peer_certificate() + def getNextProtocol(self): + """ + Returns the protocol selected to be spoken using ALPN/NPN. This + function prefers the result from ALPN. If no protocol was chosen, + returns C{None}. + + @return: The selected protocol, or C{None} if none is chosen. + @rtype: C{str} or C{None} + """ + protocolName = None + + try: + # If ALPN is not implemented that's ok, NPN might be. + protocolName = self._tlsConnection.get_alpn_proto_negotiated() + except NotImplementedError: + pass + + if protocolName != b'': + # A protocol was selected using ALPN. + return protocolName + + protocolName = self._tlsConnection.get_next_proto_negotiated() + if protocolName != b'': + return protocolName + + return None + + def registerProducer(self, producer, streaming): # If we've already disconnected, nothing to do here: if self._lostTLSConnection: Index: twisted/test/test_sslverify.py =================================================================== --- twisted/test/test_sslverify.py (revision 45846) +++ twisted/test/test_sslverify.py (working copy) @@ -15,11 +15,15 @@ skipSSL = None skipSNI = None +skipNPN = None +skipALPN = None try: import OpenSSL except ImportError: skipSSL = "OpenSSL is required for SSL tests." skipSNI = skipSSL + skipNPN = skipSSL + skipALPN = skipSSL else: from OpenSSL import SSL from OpenSSL.crypto import PKey, X509 @@ -27,6 +31,22 @@ if getattr(SSL.Context, "set_tlsext_servername_callback", None) is None: skipSNI = "PyOpenSSL 0.13 or greater required for SNI support." + try: + ctx = SSL.Context(SSL.SSLv23_METHOD) + ctx.set_npn_advertise_callback(lambda c: None) + except AttributeError: + skipNPN = "PyOpenSSL 0.15 or greater is required for NPN support" + except NotImplementedError: + skipNPN = "OpenSSL 1.0.1 or greater required for NPN support" + + try: + ctx = SSL.Context(SSL.SSLv23_METHOD) + ctx.set_alpn_select_callback(lambda c: None) + except AttributeError: + skipALPN = "PyOpenSSL 0.15 or greater is required for ALPN support" + except NotImplementedError: + skipALPN = "OpenSSL 1.0.2 or greater required for ALPN support" + from twisted.test.test_twisted import SetAsideModule from twisted.test.iosim import connectedServerAndClient @@ -290,6 +310,84 @@ +class NegotiatedProtocol(protocol.Protocol): + """ + A protocol that records the ALPN/NPN protocol negotiated in the TLS + handshake. + + This protocol should be used whenever it is useful to know what protocol + has been negotiated. It only records the next protocol when data is + received, because Twisted makes no guarantees that the TLS handshake will + have been completed before then, so if used with another protocol that + protocol must send some data. + + @ivar deferred: C{Deferred} that fires with the next protocol negotiated. + """ + def __init__(self): + self.deferred = defer.Deferred() + + def connectionMade(self): + self.transport.write(b'x') + + def dataReceived(self, data): + self.deferred.callback(self.transport.getNextProtocol()) + +if not skipSSL: + class ALPNOnlyOptions(sslverify.OpenSSLCertificateOptions): + """ + An OpenSSLCertificateOptions subclass that only sets ALPN. + """ + def getContext(self): + """ + Gets a SSL Context that does not do NPN. + + There are two things to do here: override the advertise callback to + advertise no protocols, and override the select callback to select + no protocols in case NPN was advertised. + """ + ctx = super(ALPNOnlyOptions, self).getContext() + + def _advertiseCallback(conn): + return [] + + def _selectCallback(conn, protocols): + return b'' + + ctx.set_npn_advertise_callback(_advertiseCallback) + ctx.set_npn_select_callback(_selectCallback) + + return ctx + + + class NPNOnlyOptions(sslverify.OpenSSLCertificateOptions): + """ + An OpenSSLCertificateOptions subclass that only sets NPN. + """ + def getContext(self): + """ + Gets a SSL Context that does not do ALPN. + + There are two things to do here: advertise no protocols, and + override the select callback to select no protocols in case ALPN + was advertised. + """ + ctx = super(NPNOnlyOptions, self).getContext() + + def _selectCallback(conn, protocols): + return b'' + + # If ALPN doesn't exist, we can't get it wrong anyway, so who + # cares? + try: + ctx.set_alpn_protos([]) + ctx.set_alpn_select_callback(_selectCallback) + except NotImplementedError: + pass + + return ctx + + + class FakeContext(object): """ Introspectable fake of an C{OpenSSL.SSL.Context}. @@ -1710,6 +1808,276 @@ +class NPNAndALPNBaseTestCase(unittest.TestCase): + """ + Base class for the NPN and ALPN test cases. + + Contains common code re-used throughout these tests. + """ + serverPort = None + clientConnection = None + + serverKey = None + serverCertificate = None + clientKey = None + clientCertificate = None + + def setUp(self): + """ + Create class variables of client and server certificates. + """ + self.serverKey, self.serverCertificate = makeCertificate( + O=b"Server Test Certificate", + CN=b"server") + self.clientKey, self.clientCertificate = makeCertificate( + O=b"Client Test Certificate", + CN=b"client") + self.caCert1 = makeCertificate( + O=b"CA Test Certificate 1", + CN=b"ca1")[1] + self.caCert2 = makeCertificate( + O=b"CA Test Certificate", + CN=b"ca2")[1] + + + def tearDown(self): + if self.serverPort is not None: + self.serverPort.stopListening() + if self.clientConnection is not None: + self.clientConnection.disconnect() + + + def _buildCertificateOptions(self, + key, + certificate, + ca_certificate, + nextProtocols, + options=sslverify.OpenSSLCertificateOptions): + """ + Build an C{OpenSSLCertificateOptions} object suitable for a peer that + will verify the remote certificate, offer one of its own, and that will + offer the selected next protocols. + """ + return options( + privateKey=key, + certificate=certificate, + verify=True, + requireCertificate=True, + caCerts=[ca_certificate], + nextProtocols=nextProtocols, + ) + + + def _buildServerCertificateOptions(self, nextProtocols): + """ + Build an C{OpenSSLCertificateOptions} object suitable for a server, + that will offer the selected next protocols. + + @param nextProtocols: The protocols the server is willing to negotiate + using NPN/ALPN. + """ + return self._buildCertificateOptions( + key=self.serverKey, + certificate=self.serverCertificate, + ca_certificate=self.clientCertificate, + nextProtocols=nextProtocols + ) + + + def _buildClientCertificateOptions(self, nextProtocols, clientOptions): + """ + Build an C{OpenSSLCertificateOptions} object suitable for a client, + that will offer the selected next protocols. + + @param nextProtocols: The protocols the client is willing to negotiate + using NPN/ALPN. + """ + return self._buildCertificateOptions( + key=self.clientKey, + certificate=self.clientCertificate, + ca_certificate=self.serverCertificate, + nextProtocols=nextProtocols, + options=clientOptions + ) + + + def connectViaLoopback(self, + serverProtocols, + clientProtocols, + clientOptions=sslverify.OpenSSLCertificateOptions): + """ + Create the TLS connection, and save the two ends of the connection + on the test class. + + @param serverProtocols: The protocols the server is willing to + negotiate. + @param clientProtocols: The protocols the client is willing to + negotiate. + @param clientOptions: The type of C{OpenSSLCertificateOptions} class to + use for the client. Defaults to C{OpenSSLCertificateOptions}. + """ + serverCertOpts = self._buildServerCertificateOptions( + serverProtocols + ) + clientCertOpts = self._buildClientCertificateOptions( + clientProtocols, + clientOptions=clientOptions + ) + + proto = NegotiatedProtocol() + + serverFactory = protocol.ServerFactory() + serverFactory.protocol = lambda: proto + + clientFactory = protocol.ClientFactory() + clientFactory.protocol = NegotiatedProtocol + + self.serverPort = reactor.listenSSL(0, serverFactory, serverCertOpts) + self.clientConnection = reactor.connectSSL('127.0.0.1', + self.serverPort.getHost().port, clientFactory, clientCertOpts) + + return proto + + + +class NPNOrALPNTests(NPNAndALPNBaseTestCase): + """ + NPN and ALPN protocol selection. + + These tests only run on platforms that have a PyOpenSSL version >= 0.15, + and OpenSSL version 1.0.1 or later. + """ + if skipSSL: + skip = skipSSL + elif skipNPN: + skip = skipNPN + + + def test_NPNAndALPNSuccess(self): + """ + When both ALPN and NPN are used, and both the client and server have + overlapping protocol choices, a protocol is successfully negotiated. + Further, the negotiated protocol is the first one in the list. + """ + protocols = [b'h2', b'http/1.1'] + proto = self.connectViaLoopback( + serverProtocols=protocols, + clientProtocols=protocols + ) + + return proto.deferred.addCallback( + self.assertEqual, b'h2') + + + def test_NPNAndALPNDifferent(self): + """ + Client and server have different protocol lists: only the common + element is chosen. + """ + serverProtocols = [b'h2', b'http/1.1', b'spdy/2'] + clientProtocols = [b'spdy/3', b'http/1.1'] + proto = self.connectViaLoopback( + serverProtocols=serverProtocols, + clientProtocols=clientProtocols + ) + + return proto.deferred.addCallback( + self.assertEqual, b'http/1.1') + + + def test_NPNAndALPNFailure(self): + """ + When the client and server have no overlap of protocols, no protocol is + negotiated. + """ + protocols = [b'h2', b'http/1.1'] + proto = self.connectViaLoopback( + serverProtocols=protocols, + clientProtocols=[] + ) + + return proto.deferred.addCallback( + self.assertEqual, None) + + + def test_NPNRespectsClientPreference(self): + """ + When NPN is used, the client's protocol preference is preferred. + """ + serverProtocols = [b'http/1.1', b'h2'] + clientProtocols = [b'h2', b'http/1.1'] + proto = self.connectViaLoopback( + serverProtocols=serverProtocols, + clientProtocols=clientProtocols, + clientOptions=NPNOnlyOptions + ) + + return proto.deferred.addCallback( + self.assertEqual, b'h2') + + + +class ALPNTests(NPNAndALPNBaseTestCase): + """ + ALPN protocol selection. + + These tests only run on platforms that have a PyOpenSSL version >= 0.15, + and OpenSSL version 1.0.2 or later. + + This covers only the ALPN specific logic, as any platform that has ALPN + will also have NPN and so will run the NPNAndALPNTest suite as well. + """ + if skipSSL: + skip = skipSSL + elif skipALPN: + skip = skipALPN + + + def test_ALPNRespectsServerPreference(self): + """ + When ALPN is used, the server's protocol preference is preferred. + """ + serverProtocols = [b'http/1.1', b'h2'] + clientProtocols = [b'h2', b'http/1.1'] + proto = self.connectViaLoopback( + serverProtocols=serverProtocols, + clientProtocols=clientProtocols, + clientOptions=ALPNOnlyOptions + ) + + return proto.deferred.addCallback( + self.assertEqual, b'http/1.1') + + + +class NPNAndALPNAbsentTests(unittest.TestCase): + """ + NPN/ALPN operations fail on platforms that do not support them. + + These tests only run on platforms that have a PyOpenSSL version < 0.15, + or an OpenSSL version earlier than 1.0.1 + """ + if skipSSL: + skip = skipSSL + elif not skipNPN: + skip = "NPN/ALPN is present on this platform" + + + def test_NPNAndALPNNotImplemented(self): + """ + A NotImplementedError is raised when using nextProtocols on a platform + that does not support either NPN or ALPN. + """ + protocols = [b'h2', b'http/1.1'] + self.assertRaises( + NotImplementedError, + self.connectViaLoopback, + serverProtocols=protocols, + clientProtocols=protocols, + ) + + + class _NotSSLTransport: def getHandle(self): return self Index: twisted/topfiles/7860.feature =================================================================== --- twisted/topfiles/7860.feature (revision 0) +++ twisted/topfiles/7860.feature (working copy) @@ -0,0 +1 @@ +twisted.internet.ssl.CertificateOptions now takes a nextProtos parameter that enables negotiation of the next protocol to speak, after the TLS handshake has completed. This field advertises protocols over both NPN and ALPN. \ No newline at end of file