Why is there a handshake error when trying to start TLS through TLS with this code?

I tried to implement a protocol that can run TLS over TLS using twisted.protocols.tls , an OpenSSL interface using BIO memory.

I implemented this as a protocol wrapper, which basically looks like a normal TCP transport, but has startTLS and stopTLS for adding and removing a TLS layer, respectively. This works great for the first level of TLS. It also works great if I run it through the native Twisted TLS transport. However, if I try to add a second TLS layer using the startTLS method provided by this shell, a connection error will occur immediately and the connection will end in an unknown unsuitable state.

The wrapper and two helpers that let it work are as follows:

 from twisted.python.components import proxyForInterface from twisted.internet.error import ConnectionDone from twisted.internet.interfaces import ITCPTransport, IProtocol from twisted.protocols.tls import TLSMemoryBIOFactory, TLSMemoryBIOProtocol from twisted.protocols.policies import ProtocolWrapper, WrappingFactory class TransportWithoutDisconnection(proxyForInterface(ITCPTransport)): """ A proxy for a normal transport that disables actually closing the connection. This is necessary so that when TLSMemoryBIOProtocol notices the SSL EOF it doesn't actually close the underlying connection. All methods except loseConnection are proxied directly to the real transport. """ def loseConnection(self): pass class ProtocolWithoutConnectionLost(proxyForInterface(IProtocol)): """ A proxy for a normal protocol which captures clean connection shutdown notification and sends it to the TLS stacking code instead of the protocol. When TLS is shutdown cleanly, this notification will arrive. Instead of telling the protocol that the entire connection is gone, the notification is used to unstack the TLS code in OnionProtocol and hidden from the wrapped protocol. Any other kind of connection shutdown (SSL handshake error, network hiccups, etc) are treated as real problems and propagated to the wrapped protocol. """ def connectionLost(self, reason): if reason.check(ConnectionDone): self.onion._stopped() else: super(ProtocolWithoutConnectionLost, self).connectionLost(reason) class OnionProtocol(ProtocolWrapper): """ OnionProtocol is both a transport and a protocol. As a protocol, it can run over any other ITransport. As a transport, it implements stackable TLS. That is, whatever application traffic is generated by the protocol running on top of OnionProtocol can be encapsulated in a TLS conversation. Or, that TLS conversation can be encapsulated in another TLS conversation. Or **that** TLS conversation can be encapsulated in yet *another* TLS conversation. Each layer of TLS can use different connection parameters, such as keys, ciphers, certificate requirements, etc. At the remote end of this connection, each has to be decrypted separately, starting at the outermost and working in. OnionProtocol can do this itself, of course, just as it can encrypt each layer starting with the innermost. """ def makeConnection(self, transport): self._tlsStack = [] ProtocolWrapper.makeConnection(self, transport) def startTLS(self, contextFactory, client, bytes=None): """ Add a layer of TLS, with SSL parameters defined by the given contextFactory. If *client* is True, this side of the connection will be an SSL client. Otherwise it will be an SSL server. If extra bytes which may be (or almost certainly are) part of the SSL handshake were received by the protocol running on top of OnionProtocol, they must be passed here as the **bytes** parameter. """ # First, create a wrapper around the application-level protocol # (wrappedProtocol) which can catch connectionLost and tell this OnionProtocol # about it. This is necessary to pop from _tlsStack when the outermost TLS # layer stops. connLost = ProtocolWithoutConnectionLost(self.wrappedProtocol) connLost.onion = self # Construct a new TLS layer, delivering events and application data to the # wrapper just created. tlsProtocol = TLSMemoryBIOProtocol(None, connLost, False) tlsProtocol.factory = TLSMemoryBIOFactory(contextFactory, client, None) # Push the previous transport and protocol onto the stack so they can be # retrieved when this new TLS layer stops. self._tlsStack.append((self.transport, self.wrappedProtocol)) # Create a transport for the new TLS layer to talk to. This is a passthrough # to the OnionProtocol current transport, except for capturing loseConnection # to avoid really closing the underlying connection. transport = TransportWithoutDisconnection(self.transport) # Make the new TLS layer the current protocol and transport. self.wrappedProtocol = self.transport = tlsProtocol # And connect the new TLS layer to the previous outermost transport. self.transport.makeConnection(transport) # If the application accidentally got some bytes from the TLS handshake, deliver # them to the new TLS layer. if bytes is not None: self.wrappedProtocol.dataReceived(bytes) def stopTLS(self): """ Remove a layer of TLS. """ # Just tell the current TLS layer to shut down. When it has done so, we'll get # notification in *_stopped*. self.transport.loseConnection() def _stopped(self): # A TLS layer has completely shut down. Throw it away and move back to the # TLS layer it was wrapping (or possibly back to the original non-TLS # transport). self.transport, self.wrappedProtocol = self._tlsStack.pop() 

I have simple client and server programs for this, accessible from the launchpad ( bzr branch lp:~exarkun/+junk/onion ). When I use it to call the startTLS method above twice, without an intermediate stopTLS call, this OpenSSL error occurs:

 OpenSSL.SSL.Error: [('SSL routines', 'SSL23_GET_SERVER_HELLO', 'unknown protocol')] 

Why is everything going wrong?

+63
twisted ssl openssl pyopenssl
Feb 26 2018-11-28T00:
source share
3 answers

There are at least two problems with OnionProtocol :

  • The innermost TLSMemoryBIOProtocol becomes wrappedProtocol when it should be the outermost;
  • ProtocolWithoutConnectionLost does not pop up any TLSMemoryBIOProtocol off OnionProtocol stack because connectionLost is only called after the FileDescriptor doRead or doWrite return the reason for the disconnection.

We cannot solve the first problem without changing the way OnionProtocol controls its stack, and we cannot solve the second until we figure out a new implementation of the stack. Not surprisingly, the right design is a direct result of the way data flows in Twisted, so we'll start by analyzing the data stream.

Twisted represents an established association with an instance of twisted.internet.tcp.Server or twisted.internet.tcp.Client . Since the only interactivity in our program occurs in stoptls_client , we will consider only the data flow to and from the Client instance.

Let me warm up with the minimal LineReceiver client, which selects the return lines received from the local server on port 9999:

 from twisted.protocols import basic from twisted.internet import defer, endpoints, protocol, task class LineReceiver(basic.LineReceiver): def lineReceived(self, line): self.sendLine(line) def main(reactor): clientEndpoint = endpoints.clientFromString( reactor, "tcp:localhost:9999") connected = clientEndpoint.connect( protocol.ClientFactory.forProtocol(LineReceiver)) def waitForever(_): return defer.Deferred() return connected.addCallback(waitForever) task.react(main) 

Once the connection is established, Client becomes our LineReceiver protocol and supports input and output:

Client and LineReceiver

New data from the server forces the reactor to call the Client doRead method, which, in turn, passes what it received to the LineReceiver dataReceived method. Finally, LineReceiver.dataReceived calls LineReceiver.lineReceived when at least one line is available.

Our application sends the data string back to the server by calling LineReceiver.sendLine . This calls write in the transport binding to the protocol instance, which is the same Client instance that processed the incoming data. Client.write organizes the data sent by the reactor, and Client.doWrite actually sends the data through the socket.

We are ready to look at the behavior of OnionClient , which never causes startTLS :

OnionClient without startTLS

OnionClient wrapped in OnionProtocol s , which are the essence of our attempted nested TLS. As a subclass of twisted.internet.policies.ProtocolWrapper instance is a kind of protocol transport sandwich; it is a protocol for lower-level transport and as a transport for the protocol that it carries through the masquerade set during the connection, WrappingFactory .

Now Client.doRead calls OnionProtocol.dataReceived , which proxies the data to OnionClient . As a transport, OnionClient OnionProtocol.write takes strings to send from OnionClient.sendLine and OnionClient.sendLine them to Client , its own transport. This is a normal interaction between ProtocolWrapper , its wrapped protocol and its own transport, so the natural data flows to each of them without any problems.

OnionProtocol.startTLS does something else. He tries to insert a new ProtocolWrapper - which is the TLSMemoryBIOProtocol - between the established protocol and transport pair. It seems pretty simple: ProtocolWrapper stores the top-level protocol as an attribute for wrappedProtocol and write proxies and other attributes up to its own transport . startTLS should be able to introduce a new TLSMemoryBIOProtocol , which wraps OnionClient in the connection, fixing this instance over its own wrappedProtocol and transport :

 def startTLS(self): ... connLost = ProtocolWithoutConnectionLost(self.wrappedProtocol) connLost.onion = self # Construct a new TLS layer, delivering events and application data to the # wrapper just created. tlsProtocol = TLSMemoryBIOProtocol(None, connLost, False) # Push the previous transport and protocol onto the stack so they can be # retrieved when this new TLS layer stops. self._tlsStack.append((self.transport, self.wrappedProtocol)) ... # Make the new TLS layer the current protocol and transport. self.wrappedProtocol = self.transport = tlsProtocol 

Here's the data stream after the first call to startTLS :

startTLS one TLSMemoryBIOProtocol, working

As expected, new data sent to OnionProtocol.dataReceived is redirected to TLSMemoryBIOProtocol stored in _tlsStack , which passes the decrypted plaintext to OnionClient.dataReceived . OnionClient.sendLine also passes its data to TLSMemoryBIOProtocol.write , which encrypts it and sends the received ciphertext to OnionProtocol.write , and then Client.write .

Unfortunately, this scheme failed after the second startTLS call. The main reason for this line:

  self.wrappedProtocol = self.transport = tlsProtocol 

Each startTLS call replaces wrappedProtocol with the innermost TLSMemoryBIOProtocol , although the data received with Client.doRead was encrypted with the outermost:

startTLS two TLSMemoryBIOProtocols broken

However, the transport s tags are nested correctly. OnionClient.sendLine can only call its write transport - that is, OnionProtocol.write - so OnionProtocol must replace its transport with the innermost TLSMemoryBIOProtocol to ensure that entries are sequentially nested inside additional levels of encryption.

Thus, the solution must ensure that the data passes through the first TLSMemoryBIOProtocol on _tlsStack to the next, in turn, so that each level of encryption is peeled in the reverse order:

startTLS with two TLSMemoryBIOProtocols, working

Presenting _tlsStack as a list seems less natural given this new requirement. Fortunately, presenting the incoming data stream linearly suggests a new data structure:

Incoming data as traversal of linked list

Both the erroneous and the correct incoming data flow resemble a singly linked list, with wrappedProtocol serving as the ProtocolWrapper following links and protocol , serving as Client . The list should grow down from OnionProtocol and always end with OnionClient . The error arises because this ordering invariant is violated.

A single list is good for pushing protocols onto the stack, but it is inconvenient to push them, because removing it requires going down from head to node. Of course, this bypass occurs every time the data is received, so the problem is the complexity caused by the additional bypass, and not the complexity of the time. Fortunately, the list is actually double linked:

Bidirectional list with protocols and transport

The transport attribute associates each nested protocol with its predecessor, so that transport.write can add sequentially lower encryption levels before finally sending data over the network. We have two sentries to help manage the list: Client should always be at the top, and OnionClient should always be at the bottom.

Combining the two, we get the following:

 from twisted.python.components import proxyForInterface from twisted.internet.interfaces import ITCPTransport from twisted.protocols.tls import TLSMemoryBIOFactory, TLSMemoryBIOProtocol from twisted.protocols.policies import ProtocolWrapper, WrappingFactory class PopOnDisconnectTransport(proxyForInterface(ITCPTransport)): """ L{TLSMemoryBIOProtocol.loseConnection} shuts down the TLS session and calls its own transport C{loseConnection}. A zero-length read also calls the transport C{loseConnection}. This proxy uses that behavior to invoke a C{pop} callback when a session has ended. The callback is invoked exactly once because C{loseConnection} must be idempotent. """ def __init__(self, pop, **kwargs): super(PopOnDisconnectTransport, self).__init__(**kwargs) self._pop = pop def loseConnection(self): self._pop() self._pop = lambda: None class OnionProtocol(ProtocolWrapper): """ OnionProtocol is both a transport and a protocol. As a protocol, it can run over any other ITransport. As a transport, it implements stackable TLS. That is, whatever application traffic is generated by the protocol running on top of OnionProtocol can be encapsulated in a TLS conversation. Or, that TLS conversation can be encapsulated in another TLS conversation. Or **that** TLS conversation can be encapsulated in yet *another* TLS conversation. Each layer of TLS can use different connection parameters, such as keys, ciphers, certificate requirements, etc. At the remote end of this connection, each has to be decrypted separately, starting at the outermost and working in. OnionProtocol can do this itself, of course, just as it can encrypt each layer starting with the innermost. """ def __init__(self, *args, **kwargs): ProtocolWrapper.__init__(self, *args, **kwargs) # The application level protocol is the sentinel at the tail # of the linked list stack of protocol wrappers. The stack # begins at this sentinel. self._tailProtocol = self._currentProtocol = self.wrappedProtocol def startTLS(self, contextFactory, client, bytes=None): """ Add a layer of TLS, with SSL parameters defined by the given contextFactory. If *client* is True, this side of the connection will be an SSL client. Otherwise it will be an SSL server. If extra bytes which may be (or almost certainly are) part of the SSL handshake were received by the protocol running on top of OnionProtocol, they must be passed here as the **bytes** parameter. """ # The newest TLS session is spliced in between the previous # and the application protocol at the tail end of the list. tlsProtocol = TLSMemoryBIOProtocol(None, self._tailProtocol, False) tlsProtocol.factory = TLSMemoryBIOFactory(contextFactory, client, None) if self._currentProtocol is self._tailProtocol: # This is the first and thus outermost TLS session. The # transport is the immutable sentinel that no startTLS or # stopTLS call will move within the linked list stack. # The wrappedProtocol will remain this outermost session # until it terminated. self.wrappedProtocol = tlsProtocol nextTransport = PopOnDisconnectTransport( original=self.transport, pop=self._pop ) # Store the proxied transport as the list head sentinel # to enable an easy identity check in _pop. self._headTransport = nextTransport else: # This a later TLS session within the stack. The previous # TLS session becomes its transport. nextTransport = PopOnDisconnectTransport( original=self._currentProtocol, pop=self._pop ) # Splice the new TLS session into the linked list stack. # wrappedProtocol serves as the link, so the protocol at the # current position takes our new TLS session as its # wrappedProtocol. self._currentProtocol.wrappedProtocol = tlsProtocol # Move down one position in the linked list. self._currentProtocol = tlsProtocol # Expose the new, innermost TLS session as the transport to # the application protocol. self.transport = self._currentProtocol # Connect the new TLS session to the previous transport. The # transport attribute also serves as the previous link. tlsProtocol.makeConnection(nextTransport) # Left over bytes are part of the latest handshake. Pass them # on to the innermost TLS session. if bytes is not None: tlsProtocol.dataReceived(bytes) def stopTLS(self): self.transport.loseConnection() def _pop(self): pop = self._currentProtocol previous = pop.transport # If the previous link is the head sentinel, we've run out of # linked list. Ensure that the application protocol, stored # as the tail sentinel, becomes the wrappedProtocol, and the # head sentinel, which is the underlying transport, becomes # the transport. if previous is self._headTransport: self._currentProtocol = self.wrappedProtocol = self._tailProtocol self.transport = previous else: # Splice out a protocol from the linked list stack. The # previous transport is a PopOnDisconnectTransport proxy, # so first retrieve proxied object off its original # attribute. previousProtocol = previous.original # The previous protocol next link becomes the popped # protocol next link previousProtocol.wrappedProtocol = pop.wrappedProtocol # Move up one position in the linked list. self._currentProtocol = previousProtocol # Expose the new, innermost TLS session as the transport # to the application protocol. self.transport = self._currentProtocol class OnionFactory(WrappingFactory): """ AL{WrappingFactory} that overrides L{WrappingFactory.registerProtocol} and L{WrappingFactory.unregisterProtocol}. These methods store in and remove from a dictionary L{ProtocolWrapper} instances. The C{transport} patching done as part of the linked-list management above causes the instances' hash to change, because the C{__hash__} is proxied through to the wrapped transport. They're not essential to this program, so the easiest solution is to make them do nothing. """ protocol = OnionProtocol def registerProtocol(self, protocol): pass def unregisterProtocol(self, protocol): pass 

(This is also available on GitHub .)

The solution to the second problem lies in PopOnDisconnectTransport . The source code attempted to pull a TLS session from the stack through connectionLost , but since only a private file descriptor called connectionLost , it was unable to delete the stopped TLS sessions that did not close the underlying socket.

At the time of this writing, TLSMemoryBIOProtocol calls its transport loseConnection exactly two places: _shutdownTLS and _tlsShutdownFinished . _shutdownTLS is called in active closes ( loseConnection , abortConnection , unregisterProducer and after loseConnection and all pending records were reset ), and _tlsShutdownFinished is called during passive closure ( communication failures , empty reading , reading errors and writing errors ). All this means that both sides of a closed connection can pop stop TLS sessions from the stack during loseConnection . PopOnDisconnectTransport does this idempotently because loseConnection usually idempotent, and TLSMemoryBIOProtocol certainly expects it to be.

The drawback of the stack control logic in loseConnection is that it depends on the specifics of the TLSMemoryBIOProtocol implementation. A generic solution will require new APIs at many levels of Twisted.

Until then, we have adhered to another example of the Hiram Law .

+16
Dec 09 '17 at 11:42 on
source share

You may need to tell the remote device that you want to start the environment and allocate resources for the second level before starting it, if this device has the capabilities.

+1
May 20 '11 at 13:04
source share

If you use the same TLS settings for both layers and you connect to the same host, then you are probably using the same key pair for both levels of encryption. Try using a different key pair for a nested level, such as tunneling to a third host / port. ie: localhost:30000 (client) → localhost:8080 (TLS 1 layer using key pair A) → localhost:8081 (TLS 2 layer using key pair B).

0
Jul 20 '11 at 6:41
source share



All Articles