Pourquoi y a-t-il un échec de prise de contact lors de la tentative d’exécution de TLS sur TLS avec ce code?

J’ai essayé d’implémenter un protocole capable d’exécuter TLS sur TLS en utilisant twisted.protocols.tls , une interface avec OpenSSL utilisant une mémoire BIO.

Je l’ai implémenté comme un wrapper de protocole qui ressemble principalement à un transport TCP normal, mais qui a des méthodes startTLS et stopTLS pour append et supprimer une couche de TLS respectivement. Cela fonctionne bien pour la première couche de TLS. Cela fonctionne également très bien si je le lance sur un transport Twisted TLS “natif”. Cependant, si j’essaie d’append une deuxième couche TLS en utilisant la méthode startTLS fournie par ce wrapper, il y a immédiatement une erreur de prise de contact et la connexion se retrouve dans un état inconnu et inutilisable.

Le wrapper et les deux helpers qui le permettent ressemblent à ceci:

 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, certificatee 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 # resortingeved 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's 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() 

J’ai des programmes simples pour le client et le serveur pour l’exercer, disponible à partir du tableau de bord ( bzr branch lp:~exarkun/+junk/onion ). Lorsque je l’utilise pour appeler la méthode startTLS ci-dessus deux fois, sans appel stopTLS à stopTLS , cette erreur OpenSSL apparaît:

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

Pourquoi les choses tournent mal?

    Il y a au moins deux problèmes avec OnionProtocol :

    1. Le TLSMemoryBIOProtocol plus TLSMemoryBIOProtocol devient le wrappedProtocol , alors qu’il devrait être le plus externe ;
    2. ProtocolWithoutConnectionLost ne fait pas apparaître de TLSMemoryBIOProtocol sur la stack d’ TLSMemoryBIOProtocol , car connectionLost est uniquement appelé après que les méthodes doRead ou doWrite ont renvoyé une raison de déconnexion.

    Nous ne pouvons pas résoudre le premier problème sans changer la façon dont OnionProtocol gère sa stack, et nous ne pouvons pas résoudre le second jusqu’à ce que nous trouvions la nouvelle implémentation de la stack. Sans surprise, la conception correcte est une conséquence directe de la manière dont les données circulent dans Twisted, nous allons donc commencer par une parsing du stream de données.

    Twisted représente une connexion établie avec une instance de twisted.internet.tcp.Server ou twisted.internet.tcp.Client . Étant donné que la seule interactivité de notre programme se produit dans stoptls_client , nous ne prendrons en compte que le stream de données vers et depuis une instance du Client .

    LineReceiver avec un client LineReceiver minimal qui LineReceiver les lignes de retour reçues d’un serveur local sur le 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.clientFromSsortingng( reactor, "tcp:localhost:9999") connected = clientEndpoint.connect( protocol.ClientFactory.forProtocol(LineReceiver)) def waitForever(_): return defer.Deferred() return connected.addCallback(waitForever) task.react(main) 

    Une fois la connexion établie établie, un Client devient le transport de notre protocole LineReceiver et intervient dans les entrées et les sorties:

    Client et récepteur de ligne

    Les nouvelles données du serveur amènent le réacteur à appeler la méthode doRead du Client , qui à son tour transmet ce qu’elle a reçu à la méthode dataReceived . Enfin, LineReceiver.dataReceived appelle LineReceiver.lineReceived lorsqu’au moins une ligne est disponible.

    Notre application envoie une ligne de données au serveur en appelant LineReceiver.sendLine . Ces appels write sur le transport lié à l’instance de protocole, qui est la même instance du Client qui a traité les données entrantes. Client.write organise l’ Client.write des données par le réacteur, tandis que Client.doWrite envoie les données via le socket.

    Nous sums prêts à examiner les comportements d’un OnionClient qui OnionClient jamais startTLS :

    OnionClient sans startTLS

    OnionClient s est enveloppé dans OnionProtocol s , qui est au cœur de notre tentative de TLS nested. En tant que sous-classe de twisted.internet.policies.ProtocolWrapper , une instance d’ OnionProtocol est une sorte de sandwich de transport de protocole; il se présente comme un protocole pour un transport de niveau inférieur et comme un transport vers un protocole qu’il recouvre à travers une mascarade établie au moment de la connexion par une WrappingFactory .

    À présent, Client.doRead appelle Client.doRead , qui Client.doRead les données à OnionClient . En OnionClient transport d’ OnionClient , OnionProtocol.write accepte les lignes à envoyer à partir d’ OnionClient.sendLine et les transmet par proxy au Client , son propre transport. Il s’agit de l’interaction normale entre ProtocolWrapper , son protocole enveloppé et son propre transport, de sorte que les données circulent naturellement vers et depuis chacune d’entre elles sans aucun problème.

    OnionProtocol.startTLS fait quelque chose de différent. Il tente d’interposer un nouveau ProtocolWrapper – qui se trouve être un TLSMemoryBIOProtocol – entre une paire de transport de protocole établie . Cela semble assez facile: ProtocolWrapper stocke le protocole de niveau supérieur en tant wrappedProtocol , et les proxy write et d’autres atsortingbuts leur propre transport . startTLS devrait être capable d’injecter un nouveau TLSMemoryBIOProtocol qui OnionClient dans la connexion en corrigeant cette instance sur son propre wrappedProtocol et 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 # resortingeved 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 

    Voici le stream de données après le premier appel à startTLS :

    startTLS one TLSMemoryBIOProtocol, fonctionnant

    Comme prévu, les nouvelles données livrées à OnionProtocol.dataReceived sont acheminées vers TLSMemoryBIOProtocol stocké sur _tlsStack , qui transmet le texte OnionClient.dataReceived déchiffré à OnionClient.dataReceived . OnionClient.sendLine transmet également ses données à TLSMemoryBIOProtocol.write , qui les chiffre et envoie le texte chiffré résultant à Client.write puis à Client.write .

    Malheureusement, ce schéma échoue après un deuxième appel à startTLS . La cause profonde est cette ligne:

      self.wrappedProtocol = self.transport = tlsProtocol 

    Chaque appel à startTLS remplace le wrappedProtocol par le TLSMemoryBIOProtocol , même si les données reçues par Client.doRead été chiffrées par le Client.doRead le plus externe :

    startTLS deux TLSMemoryBIOProtocols, cassé

    Les transport , cependant, sont correctement nesteds. OnionClient.sendLine ne peut appeler que son write de transport, c’est-à-dire OnionProtocol.write . OnionProtocol doit donc remplacer son transport par le TLSMemoryBIOProtocol se TLSMemoryBIOProtocol à l’intérieur pour garantir que les écritures sont nestedes dans des couches de chiffrement supplémentaires.

    La solution consiste alors à s’assurer que les données transitent par le premier TLSMemoryBIOProtocol sur le _tlsStack vers le suivant , de sorte que chaque couche de chiffrement soit décollée dans l’ordre inverse de son application:

    startTLS avec deux TLSMemoryBIOProtocols, fonctionnant

    Représenter _tlsStack comme une liste semble moins naturel compte tenu de cette nouvelle exigence. Heureusement, la représentation du stream de données entrant suggère linéairement une nouvelle structure de données:

    Données entrantes en tant que traversée de liste chaînée

    Les stream de données entrants, wrappedProtocol et corrects, ressemblent tous à une liste à lien unique, avec le ProtocolWrapper wrappedProtocol servant de prochain lien protocol et de protocol servant de Client . La liste devrait se développer à partir d’ OnionProtocol et se terminer toujours par OnionClient . Le bogue se produit parce que cet invariant d’ordre est violé.

    Une liste à liens simples est idéale pour pousser des protocoles sur la stack, mais c’est gênant pour les supprimer, car elle nécessite une traversée vers le bas de sa tête vers le nœud à supprimer. Bien sûr, cette traversée se produit chaque fois que des données sont reçues. Le problème est la complexité impliquée par une traversée supplémentaire plutôt que par la complexité temporelle la plus défavorable. Heureusement, la liste est en réalité doublement liée:

    Liste doublement chaînée avec protocoles et transports

    L’atsortingbut de transport relie chaque protocole nested à son prédécesseur, de sorte que transport.write peut se superposer à des niveaux de chiffrement inférieurs avant d’envoyer les données sur le réseau. Nous avons deux sentinelles pour vous aider à gérer la liste: le Client doit toujours être en haut et OnionClient doit toujours être en bas.

    En mettant les deux ensemble, nous nous retrouvons avec ceci:

     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's C{loseConnection}. A zero-length read also calls the transport's 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, certificatee 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's terminated. self.wrappedProtocol = tlsProtocol nextTransport = PopOnDisconnectTransport( original=self.transport, pop=self._pop ) # Store the proxied transport as the list's 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 atsortingbute 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 resortingeve proxied object off its original # atsortingbute. previousProtocol = previous.original # The previous protocol's next link becomes the popped # protocol's 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 

    (Ceci est également disponible sur GitHub .)

    La solution au second problème réside dans PopOnDisconnectTransport . Le code d’origine a tenté de faire sortir une session TLS de la stack via connectionLost , mais comme seul un descripteur de fichier fermé provoque l’appel de connectionLost , il n’a pas réussi à supprimer les sessions TLS arrêtées qui ne fermaient pas le socket sous-jacent.

    Au moment de la rédaction de ce document, TLSMemoryBIOProtocol appelle le TLSMemoryBIOProtocol son transport à deux endroits exactement: _shutdownTLS et _tlsShutdownFinished . _shutdownTLS est appelée sur les fermetures actives ( loseConnection , loseConnection , abortConnection et after loseConnection et toutes les écritures en attente ont été vidées ), tandis que _tlsShutdownFinished est appelée sur les fermetures passives ( échecs de la poignée , erreurs de lecture et d’ écriture ). Tout cela signifie que les deux côtés d’une connexion fermée peuvent arrêter les sessions TLS arrêtées de la stack lors de la loseConnection . PopOnDisconnectTransport fait cela idempotently car loseConnection est généralement idempotent, et TLSMemoryBIOProtocol attend certainement qu’il soit.

    L’inconvénient de mettre la logique de gestion de la stack dans loseConnection est qu’elle dépend des particularités de l’ TLSMemoryBIOProtocol de TLSMemoryBIOProtocol . Une solution généralisée nécessiterait de nouvelles API sur plusieurs niveaux de Twisted.

    Jusque-là, nous sums coincés avec un autre exemple de la loi de Hyrum .

    Vous devrez peut-être informer le périphérique distant que vous souhaitez démarrer un environnement et allouer des ressources pour le deuxième calque avant de le démarrer, si ce périphérique a les capacités.

    Si vous utilisez les mêmes parameters TLS pour les deux couches et que vous vous connectez au même hôte, vous utilisez probablement la même paire de clés pour les deux couches de chiffrement. Essayez d’utiliser une autre paire de clés pour la couche nestede, telle que le tunneling vers un troisième hôte / port. ie: localhost:30000 (client) -> localhost:8080 (couche 1 TLS utilisant la paire de clés A) -> localhost:8081 (couche 2 TLS utilisant la paire de clés B).