Syncplay / syncplay

Client/server to synchronize media playback on mpv/VLC/MPC-HC/MPC-BE on many computers
http://syncplay.pl/
Apache License 2.0
2.11k stars 214 forks source link

Add strict TLS mode #346

Closed EtiennePerot closed 3 years ago

EtiennePerot commented 4 years ago

It appears that Syncplay's approach to TLS is to send an initial message over the TCP stream to upgrade the connection to use TLS:

{"TLS": {"startTLS": "send"}}

The server then acknowledges this TLS upgrade request with:

{"TLS": {"startTLS": "true"}}

And then the client sends the TLS ClientHello message, i.e. the TLS handshake begins in earnest, using the same TCP connection.

For this to work properly, the process handling the TCP connection has to speak Syncplay's protocol, or at least it has to understand this particular {"TLS": {"startTLS": "send"}} message. In practice, this means that the Syncplay server itself needs to have access to the certificate and private key.

As a general principle, each process that has such a key loaded in its working memory is a liability -- they may each have their own TLS implementations that may or may not be robust against the latest side-channel attacks or whatnot. In general, the fewer things handling a sensitive piece of data, the better. For this reason, it's useful for most HTTP-like application protocols (e.g. Matrix.org, CalDAV, Mozilla Sync, etc.) to support being run through a reverse proxy that does all the TLS encapsulation, and then passes on the decrypted traffic through a lo-only connection or through a socket file on the machine.

Since Syncplay's protocol isn't HTTP-like, it can't be run through a reverse HTTP proxy. As such, I was trying to get it to work through stunnel, a similar utility that handles the connection, handles all the TLS aspects of it, and forwards all the unencrypted traffic to a local Syncplay server that listens on lo only. However, because Syncplay's client first sends {"TLS": {"startTLS": "send"}} to initiate TLS, this confuses stunnel as it expects the client to directly start with the TLS ClientHello message.

Hence the following request: Would it be possible to add a TLS-only mode to both the Syncplay server and client, which directly talks TLS without this initial startTLS message? This would have the following benefits:

Somewhat-related issue: https://github.com/Syncplay/syncplay/issues/245

Et0h commented 4 years ago

I'm happy for @daniel-123, @albertosottile and others to weigh in on this because I don't really have the necessary understanding of TLS and Syncplay's implementation of it to assess the pros and cons (let alone to actually code the implementaiton).

Relevant principles from #315 that may come into play include:

daniel-123 commented 3 years ago

My 2 cents in TL;DR: it's probably too complex to implement properly given our constraints for the amount of benefit it gives.

Most important point is handling compatibility - the initial decision to use opportunistic TLS was taken specifically to maintain backwards and forwards compatibility between clients and servers in first place. We would need a really long discussion what to do about it and probably plan to introduce it in steps over several versions.

Second point is that the benefits you listed, while definitely real, aren't going to used by vast majority of user base. And as small counterpoints to each:

One idea I could see as much easier to implement would be an option for both server and client to just drop connections where opportunistic TLS didn't result in encrypted connection. Though it still pretty much has to default to "off" simply because we cannot just expect everybody who runs a sever to bother with certificates.

Lastly - we would need somebody to volunteer their time to code something like described in last paragraph. If you are interested @EtiennePerot, then we can discuss some details within this issue. If not I'll just close it in two weeks or so (feel free to reopen it if you need more time than that).

EtiennePerot commented 3 years ago

I actually ended up hacking up a solution for this without modifying the Syncplay source code by doing the following:

Here's the stunnel configuration that works for me:

foreground = yes
options = NO_SSLv2
options = NO_SSLv3
options = SINGLE_ECDH_USE
options = SINGLE_DH_USE
[syncplay]
accept  = 9998 # Port that stunnel will listen on
connect = 9999 # Port that syncplay-server is listening on
cert = <path to a file that has both certificate AND private key data in it>

And here's the Python script sitting in front of that:

#!/usr/bin/python3

# See https://github.com/Syncplay/syncplay/issues/346 for this script's raison-d'etre.

import select
import socket
import socketserver
import sys

listen_port = int(sys.argv[1])
forward_port = int(sys.argv[2])

class SyncplayRequestHandler(socketserver.BaseRequestHandler):
    def handle(self):
        print('Handling connection from:', self.client_address)
        data = self.request.recv(1024)
        if data.strip() != b'{"TLS": {"startTLS": "send"}}':
            print('Bad connection header from:', self.client_address)
            try:
                self.request.close()
            except:
                pass
            return
        print('Opening forwarding connection on behalf of:', self.client_address)
        forwarded_conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        forwarded_conn.connect(('localhost', forward_port))
        # Acknowledge to the client that we are ready.
        self.request.sendall(b'{"TLS": {"startTLS": "true"}}\r\n')
        # Start proxying.
        print('Proxying started for:', self.client_address)
        sockets = (self.request, forwarded_conn)
        while True:
            rlist, _, xlist = select.select(sockets, (), sockets)
            if len(xlist):
                break
            for s in rlist:
                if s is self.request:
                    forwarded_conn.sendall(self.request.recv(1024))
                elif s is forwarded_conn:
                    self.request.sendall(forwarded_conn.recv(1024))
        print('Proxying stopped for:', self.client_address)
        try:
            forwarded_conn.close()
        except:
            pass
        try:
            self.request.close()
        except:
            pass

class SyncplayServer(socketserver.ThreadingTCPServer):
    def server_bind(self):
        self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        socketserver.ThreadingTCPServer.server_bind(self)

with SyncplayServer(('', listen_port), SyncplayRequestHandler) as server:
    server.serve_forever()

This is launched with arguments 9997 9998 (first is the port for the script to listen on, second is the port stunnel is listening on).

Then Syncplay clients can be pointed at servername:9997 and will connect to the Python script, which will strip off the startTLS header, and from then the TLS stream goes to stunnel. The Syncplay client negotiates TLS with stunnel, and once done stunnel forwards plaintext syncplay traffic to syncplay-server, none the wiser than TLS is happening.

This is "strict" TLS in the sense that no unencrypted traffic (beyond the initial startTLS message) is present on the wire, and that if the client were to try to talk plaintext to it, neither the Python script nor stunnel would be OK with that, and so the connection would never make it to syncplay-server.

I understand this isn't exactly code that is appropriate to check in as some official solution, just thought I'd share in case someone stumbles on this issue. I agree that your proposed solution would be superior and I encourage anyone who wants to implement it to do so rather than using the hack above.

tacerus commented 2 years ago

Thank you very much for your detailed comment including the proxy script, @EtiennePerot. Whilst setting up secure services on the internet with TLS is part of my daily job, I eventually gave up trying to make SyncPlay speak TLS - the built-in STARTTLS would not work (https://github.com/Syncplay/syncplay/issues/250#issuecomment-1200415061), and external TLS proxies like stunnel would choke on the required response.

With your workaround, secure, implicit, TLS, worked on the first try.

Small improvement: I made the proxy listen on dual stack IPv4 and IPv6 and talk to the backend using IPv6 as well:

$ diff -u syncplay-proxy.orig /usr/local/bin/syncplay-proxy 
--- syncplay-proxy.orig 2022-07-31 18:50:07.067941580 +0200
+++ /usr/local/bin/syncplay-proxy       2022-07-31 18:50:28.748540145 +0200
@@ -22,7 +22,7 @@
                 pass
             return
         print('Opening forwarding connection on behalf of:', self.client_address)
-        forwarded_conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        forwarded_conn = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
         forwarded_conn.connect(('localhost', forward_port))
         # Acknowledge to the client that we are ready.
         self.request.sendall(b'{"TLS": {"startTLS": "true"}}\r\n')
@@ -49,6 +49,7 @@
             pass

 class SyncplayServer(socketserver.ThreadingTCPServer):
+    address_family = socket.AF_INET6
     def server_bind(self):
         self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
         socketserver.ThreadingTCPServer.server_bind(self)

The client reports:

[18:55:07] Secure connection established (TLSv1.3)
[18:55:07] Successfully connected to server

I additionally wrote a systemd service for this, if anyone is interested: https://git.casa/syncplay.git/tree/syncplay-proxy