diff --git a/.gitignore b/.gitignore index 5b13d1c..95fe6f5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ venv /SyncPlay.egg-info /build +/cert /dist /syncplay v* /syncplay_v* @@ -13,4 +14,4 @@ dist.7z .* !.travis.yml !.appveyor.yml -__pycache__ \ No newline at end of file +__pycache__ diff --git a/.travis.yml b/.travis.yml index cf2352b..fd8ec48 100644 --- a/.travis.yml +++ b/.travis.yml @@ -23,7 +23,7 @@ before_install: - python3 -c "from py2app.recipes import pyside2" install: -- pip3 install twisted appnope requests +- pip3 install twisted[tls] appnope requests certifi before_deploy: - pip3 install dmgbuild diff --git a/buildPy2app.py b/buildPy2app.py index 8824f67..4e921c9 100755 --- a/buildPy2app.py +++ b/buildPy2app.py @@ -12,11 +12,12 @@ import syncplay APP = ['syncplayClient.py'] DATA_FILES = [ ('resources', glob('resources/*.png') + glob('resources/*.rtf') + glob('resources/*.lua')), + ('resources/lua/intf', glob('resources/lua/intf/*.lua')) ] OPTIONS = { 'iconfile': 'resources/icon.icns', 'extra_scripts': 'syncplayServer.py', - 'includes': {'PySide2.QtCore', 'PySide2.QtUiTools', 'PySide2.QtGui', 'PySide2.QtWidgets', 'certifi'}, + 'includes': {'PySide2.QtCore', 'PySide2.QtUiTools', 'PySide2.QtGui', 'PySide2.QtWidgets', 'certifi', 'cffi'}, 'excludes': {'PySide', 'PySide.QtCore', 'PySide.QtUiTools', 'PySide.QtGui'}, 'qt_plugins': [ 'platforms/libqcocoa.dylib', diff --git a/buildPy2exe.py b/buildPy2exe.py index 0dfe4af..0daf7d0 100644 --- a/buildPy2exe.py +++ b/buildPy2exe.py @@ -658,6 +658,7 @@ guiIcons = [ 'resources/mpv.png', 'resources/vlc.png', 'resources/house.png', 'resources/film_link.png', 'resources/eye.png', 'resources/comments.png', 'resources/cog_delete.png', 'resources/chevrons_right.png', 'resources/user_key.png', 'resources/lock.png', 'resources/key_go.png', 'resources/page_white_key.png', + 'resources/lock_green.png', 'resources/lock_green_dialog.png', 'resources/tick.png', 'resources/lock_open.png', 'resources/empty_checkbox.png', 'resources/tick_checkbox.png', 'resources/world_explore.png', 'resources/application_get.png', 'resources/cog.png', 'resources/arrow_switch.png', 'resources/film_go.png', 'resources/world_go.png', 'resources/arrow_refresh.png', 'resources/bullet_right_grey.png', diff --git a/resources/lock_green.png b/resources/lock_green.png new file mode 100644 index 0000000..6119740 Binary files /dev/null and b/resources/lock_green.png differ diff --git a/resources/lock_green_dialog.png b/resources/lock_green_dialog.png new file mode 100644 index 0000000..fc62b05 Binary files /dev/null and b/resources/lock_green_dialog.png differ diff --git a/resources/third-party-notices.rtf b/resources/third-party-notices.rtf index 9d02349..13d3355 100644 --- a/resources/third-party-notices.rtf +++ b/resources/third-party-notices.rtf @@ -1,7 +1,8 @@ -{\rtf1\ansi\ansicpg1252\cocoartf1504\cocoasubrtf830 +{\rtf1\ansi\ansicpg1252\cocoartf1561\cocoasubrtf600 {\fonttbl\f0\fswiss\fcharset0 Helvetica;} {\colortbl;\red255\green255\blue255;} {\*\expandedcolortbl;;} +\vieww13920\viewh8980\viewkind0 \deftab529 \pard\tx529\pardeftab529\pardirnatural\partightenfactor0 @@ -24,25 +25,6 @@ The above copyright notice and this permission notice shall be included in all\ copies or substantial portions of the Software.\ \ -\b PySide\ - -\b0 \ -Copyright (C) 2013 Digia Plc and/or its subsidiary(-ies).\ -Contact: PySide team \ -\ -This library is free software; you can redistribute it and/or\ -modify it under the terms of the GNU Lesser General Public\ -License as published by the Free Software Foundation; either\ -version 2.1 of the License, or (at your option) any later version.\ -This library is distributed in the hope that it will be useful,\ -but WITHOUT ANY WARRANTY; without even the implied warranty of\ -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU\ -Lesser General Public License for more details.\ -\ -You should have received a copy of the GNU Lesser General Public License\ -along with this program. If not, see \ -\ - \b Qt for Python\ \b0 \ @@ -318,4 +300,143 @@ http://www.apache.org/licenses/LICENSE-2.0\ Unless required by applicable law or agreed to in writing, software distributed under the \ License is distributed on an \'93AS IS\'94 BASIS, WITHOUT WARRANTIES OR CONDI-\ TIONS OF ANY KIND, either express or implied. See the License for the specific lang-\ -uage governing permissions and limitations under the License.} \ No newline at end of file +uage governing permissions and limitations under the License.\ +\ + +\b python-certifi +\b0 \ +\ +This Source Code Form is subject to the terms of the Mozilla Public License,\ +v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain\ +one at http://mozilla.org/MPL/2.0/.\ +\ + +\b cffi +\b0 \ +\ +\pard\pardeftab720\partightenfactor0 +\cf0 This package has been mostly done by Armin Rigo with help from\ +Maciej Fija\uc0\u322 kowski. The idea is heavily based (although not directly\ +copied) from LuaJIT ffi by Mike Pall.\ +\ +Other contributors:\ +\ + Google Inc.\ +\pard\tx529\pardeftab529\pardirnatural\partightenfactor0 +\cf0 \ +The MIT License\ +\ +Permission is hereby granted, free of charge, to any person \ +obtaining a copy of this software and associated documentation \ +files (the "Software"), to deal in the Software without \ +restriction, including without limitation the rights to use, \ +copy, modify, merge, publish, distribute, sublicense, and/or \ +sell copies of the Software, and to permit persons to whom the \ +Software is furnished to do so, subject to the following conditions:\ +\ +The above copyright notice and this permission notice shall be included \ +in all copies or substantial portions of the Software.\ +\ +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS \ +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, \ +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL \ +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER \ +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING \ +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER \ +DEALINGS IN THE SOFTWARE.\ +\ + +\b service-identity +\b0 \ +\ +Copyright (c) 2014 Hynek Schlawack\ +\ +Permission is hereby granted, free of charge, to any person obtaining a copy of\ +this software and associated documentation files (the "Software"), to deal in\ +the Software without restriction, including without limitation the rights to\ +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies\ +of the Software, and to permit persons to whom the Software is furnished to do\ +so, subject to the following conditions:\ +\ +The above copyright notice and this permission notice shall be included in all\ +copies or substantial portions of the Software.\ +\ +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\ +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\ +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\ +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\ +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\ +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\ +SOFTWARE.\ +\ + +\b pyopenssl +\b0 \ +\ +Licensed under the Apache License, Version 2.0 (the \'93License\'94); you may not use this file\ +except in compliance with the License. You may obtain a copy of the License at\ +\ +http://www.apache.org/licenses/LICENSE-2.0\ +\ +Unless required by applicable law or agreed to in writing, software distributed under the \ +License is distributed on an \'93AS IS\'94 BASIS, WITHOUT WARRANTIES OR CONDI-\ +TIONS OF ANY KIND, either express or implied. See the License for the specific lang-\ +uage governing permissions and limitations under the License.\ +\ + +\b cryptography +\b0 \ +\ +Authors listed here: https://github.com/pyca/cryptography/blob/master/AUTHORS.rst\ +\ +Licensed under the Apache License, Version 2.0 (the \'93License\'94); you may not use this file\ +except in compliance with the License. You may obtain a copy of the License at\ +\ +http://www.apache.org/licenses/LICENSE-2.0\ +\ +Unless required by applicable law or agreed to in writing, software distributed under the \ +License is distributed on an \'93AS IS\'94 BASIS, WITHOUT WARRANTIES OR CONDI-\ +TIONS OF ANY KIND, either express or implied. See the License for the specific lang-\ +uage governing permissions and limitations under the License.\ +\ + +\b Icons\ +\ + +\b0 Syncplay uses the following icons and images:\ +\ +- Silk icon set 1.3\ +_________________________________________\ +Mark James\ +http://www.famfamfam.com/lab/icons/silk/\ +_________________________________________\ +\ +This work is licensed under a\ +Creative Commons Attribution 2.5 License.\ +[ http://creativecommons.org/licenses/by/2.5/ ]\ +\ +This means you may use it for any purpose,\ +and make any changes you like.\ +All I ask is that you include a link back\ +to this page in your credits.\ +\ +Are you using this icon set? Send me an email\ +(including a link or picture if available) to\ +mjames@gmail.com\ +\ +Any other questions about this icon set please\ +contact mjames@gmail.com\ +\ +- Silk Companion 1\ +\ +\pard\pardeftab720\partightenfactor0 +\cf0 Copyright Damien Guard - CC-BY 3.0\ +https://damieng.com/creative/icons/silk-companion-1-icons\ +\ +- Padlock free icon\ +CC-BY 3.0\ +Icon made by Maxim Basinski from https://www.flaticon.com/free-icon/padlock_291248\ +\ +\pard\tx529\pardeftab529\pardirnatural\partightenfactor0 +\cf0 \ +} \ No newline at end of file diff --git a/syncplay/__init__.py b/syncplay/__init__.py index 7eefcf6..3932bf1 100755 --- a/syncplay/__init__.py +++ b/syncplay/__init__.py @@ -1,5 +1,5 @@ version = '1.6.3' revision = ' beta' milestone = 'Yoitsu' -release_number = '72' +release_number = '73' projectURL = 'https://syncplay.pl/' diff --git a/syncplay/client.py b/syncplay/client.py index 0758e2e..4834775 100755 --- a/syncplay/client.py +++ b/syncplay/client.py @@ -2,6 +2,7 @@ import ast import collections import hashlib +import os import os.path import random import re @@ -16,6 +17,24 @@ from twisted.internet.protocol import ClientFactory from twisted.internet import reactor, task, defer, threads from twisted.application.internet import ClientService +try: + import certifi + from twisted.internet.ssl import Certificate, optionsForClientTLS + certPath = certifi.where() + if os.path.exists(certPath): + os.environ['SSL_CERT_FILE'] = certPath + elif 'zip' in certPath: + import tempfile + import zipfile + zipPath, memberPath = certPath.split('.zip/') + zipPath += '.zip' + archive = zipfile.ZipFile(zipPath, 'r') + tmpDir = tempfile.gettempdir() + extractedPath = archive.extract(memberPath, tmpDir) + os.environ['SSL_CERT_FILE'] = extractedPath +except: + pass + from syncplay import utils, constants, version from syncplay.constants import PRIVACY_SENDHASHED_MODE, PRIVACY_DONTSEND_MODE, \ PRIVACY_HIDDENFILENAME @@ -108,6 +127,8 @@ class SyncplayClient(object): self.fileSwitch = FileSwitchManager(self) self.playlist = SyncplayPlaylist(self) + self._serverSupportsTLS = True + if constants.LIST_RELATIVE_CONFIGS and 'loadedRelativePaths' in self._config and self._config['loadedRelativePaths']: paths = "; ".join(self._config['loadedRelativePaths']) self.ui.showMessage(getMessage("relative-config-notification").format(paths), noPlayer=True, noTimestamp=True) @@ -388,6 +409,7 @@ class SyncplayClient(object): self.ui.showMessage(getMessage("current-offset-notification").format(self._userOffset)) def onDisconnect(self): + self.ui.setSSLMode(False) if self._config['pauseOnLeave']: self.setPaused(True) self.lastPausedOnLeaveTime = time.time() @@ -704,9 +726,20 @@ class SyncplayClient(object): host = host.strip('[]') port = int(port) self._endpoint = HostnameEndpoint(reactor, host, port) + try: + caCertFP = open(os.environ['SSL_CERT_FILE']) + caCertTwisted = Certificate.loadPEM(caCertFP.read()) + caCertFP.close() + self.protocolFactory.options = optionsForClientTLS(hostname=host) + self._clientSupportsTLS = True + except Exception as e: + self.ui.showDebugMessage(str(e)) + self.protocolFactory.options = None + self._clientSupportsTLS = False def retry(retries): self._lastGlobalUpdate = None + self.ui.setSSLMode(False) if retries == 0: self.onDisconnect() if retries > constants.RECONNECT_RETRIES: @@ -719,7 +752,7 @@ class SyncplayClient(object): self.reconnecting = True return(0.1 * (2 ** min(retries, 5))) - self._reconnectingService = ClientService(self._endpoint, self.protocolFactory , retryPolicy=retry) + self._reconnectingService = ClientService(self._endpoint, self.protocolFactory, retryPolicy=retry) waitForConnection = self._reconnectingService.whenConnected(failAfterFailures=1) self._reconnectingService.startService() @@ -1456,6 +1489,9 @@ class UiManager(object): self.showOSDMessage(messageString, duration=constants.OSD_DURATION) self.__ui.showMessage(messageString) + def setSSLMode(self, sslMode, sslInformation=""): + self.__ui.setSSLMode(sslMode, sslInformation) + def showMessage(self, message, noPlayer=False, noTimestamp=False, OSDType=constants.OSD_NOTIFICATION, mood=constants.MESSAGE_NEUTRAL): if not noPlayer: self.showOSDMessage(message, duration=constants.OSD_DURATION, OSDType=OSDType, mood=mood) diff --git a/syncplay/messages_de.py b/syncplay/messages_de.py index fc09489..efefc19 100755 --- a/syncplay/messages_de.py +++ b/syncplay/messages_de.py @@ -310,6 +310,20 @@ de = { "userguide-menu-label": "&Benutzerhandbuch öffnen", "update-menu-label": "auf &Aktualisierung prüfen", + # startTLS messages - TODO: Translate + "startTLS-initiated": "Attempting secure connection", + "startTLS-secure-connection-ok": "Secure connection established ({})", + "startTLS-server-certificate-invalid": 'Secure connection failed. The server uses an invalid security certificate. This communication could be intercepted by a third party. For further details and troubleshooting see here.', + "startTLS-not-supported-client": "This client does not support TLS", + "startTLS-not-supported-server": "This server does not support TLS", + + # TLS certificate dialog - TODO: Translate + "tls-information-title": "Certificate Details", + "tls-dialog-status-label": "Syncplay is using an encrypted connection to {}.", + "tls-dialog-desc-label": "Encryption with a digital certificate keeps information private as it is sent to or from the
server {}.", + "tls-dialog-connection-label": "Information encrypted using Transport Layer Security (TLS), version {} with the cipher
suite: {}.", + "tls-dialog-certificate-label": "Certificate issued by {} valid until {}.", + # About dialog - TODO: Translate "about-menu-label": "&About Syncplay", "about-dialog-title": "About Syncplay", @@ -403,6 +417,8 @@ de = { "reset-tooltip": "Alle Einstellungen auf Standardwerte zurücksetzen.", "update-server-list-tooltip": "Mit syncplay.pl verbinden um die Liste öffentlicher Server zu aktualisieren.", + "sslconnection-tooltip": "Securely connected to server. Click for certificate details.", # TODO: Translate + "joinroom-tooltip": "Den aktuellen Raum verlassen und stattdessen den angegebenen betreten.", "seektime-msgbox-label": "Springe zur angegebenen Zeit (in Sekunden oder min:sek). Verwende +/- zum relativen Springen.", "ready-tooltip": "Zeigt an, ob du bereit zum anschauen bist", @@ -438,6 +454,7 @@ de = { "server-chat-maxchars-argument": "Maximum number of characters in a chat message (default is {})", # TODO: Translate "server-maxusernamelength-argument": "Maximum number of characters in a username (default is {})", # TODO: Translate "server-stats-db-file-argument": "Enable server stats using the SQLite db file provided", # TODO: Translate + "server-startTLS-argument": "Enable TLS connections using the certificate files in the path provided", # TODO: Translate "server-messed-up-motd-unescaped-placeholders": "Die Nachricht des Tages hat unmaskierte Platzhalter. Alle $-Zeichen sollten verdoppelt werden ($$).", "server-messed-up-motd-too-long": "Die Nachricht des Tages ist zu lang - Maximal {} Zeichen, aktuell {}.", diff --git a/syncplay/messages_en.py b/syncplay/messages_en.py index 8c869ee..e88161c 100755 --- a/syncplay/messages_en.py +++ b/syncplay/messages_en.py @@ -267,7 +267,7 @@ en = { "run-label": "Run Syncplay", "storeandrun-label": "Store configuration and run Syncplay", - "contact-label": "Feel free to e-mail dev@syncplay.pl, chat via the #Syncplay IRC channel on irc.freenode.net, raise an issue via GitHub, like us on Facebook, follow us on Twitter, or visit https://syncplay.pl/. NOTE: Chat messages are not encrypted so do not use Syncplay to send sensitive information.", + "contact-label": "Feel free to e-mail dev@syncplay.pl, chat via the #Syncplay IRC channel on irc.freenode.net, raise an issue via GitHub, like us on Facebook, follow us on Twitter, or visit https://syncplay.pl/. Do not use Syncplay to send sensitive information.", "joinroom-label": "Join room", "joinroom-menu-label": "Join room {}", @@ -312,6 +312,19 @@ en = { "userguide-menu-label": "Open user &guide", "update-menu-label": "Check for &update", + "startTLS-initiated": "Attempting secure connection", + "startTLS-secure-connection-ok": "Secure connection established ({})", + "startTLS-server-certificate-invalid": 'Secure connection failed. The server uses an invalid security certificate. This communication could be intercepted by a third party. For further details and troubleshooting see here.', + "startTLS-not-supported-client": "This client does not support TLS", + "startTLS-not-supported-server": "This server does not support TLS", + + # TLS certificate dialog + "tls-information-title": "Certificate Details", + "tls-dialog-status-label": "Syncplay is using an encrypted connection to {}.", + "tls-dialog-desc-label": "Encryption with a digital certificate keeps information private as it is sent to or from the
server {}.", + "tls-dialog-connection-label": "Information encrypted using Transport Layer Security (TLS), version {} with the cipher
suite: {}.", + "tls-dialog-certificate-label": "Certificate issued by {} valid until {}.", + # About dialog "about-menu-label": "&About Syncplay", "about-dialog-title": "About Syncplay", @@ -403,6 +416,8 @@ en = { "reset-tooltip": "Reset all settings to the default configuration.", "update-server-list-tooltip": "Connect to syncplay.pl to update list of public servers.", + "sslconnection-tooltip": "Securely connected to server. Click for certificate details.", + "joinroom-tooltip": "Leave current room and joins specified room.", "seektime-msgbox-label": "Jump to specified time (in seconds / min:sec). Use +/- for relative seek.", "ready-tooltip": "Indicates whether you are ready to watch.", @@ -439,6 +454,7 @@ en = { "server-chat-maxchars-argument": "Maximum number of characters in a chat message (default is {})", # Default number of characters "server-maxusernamelength-argument": "Maximum number of characters in a username (default is {})", "server-stats-db-file-argument": "Enable server stats using the SQLite db file provided", + "server-startTLS-argument": "Enable TLS connections using the certificate files in the path provided", "server-messed-up-motd-unescaped-placeholders": "Message of the Day has unescaped placeholders. All $ signs should be doubled ($$).", "server-messed-up-motd-too-long": "Message of the Day is too long - maximum of {} chars, {} given.", diff --git a/syncplay/messages_it.py b/syncplay/messages_it.py index a3d4c8c..bdb210e 100755 --- a/syncplay/messages_it.py +++ b/syncplay/messages_it.py @@ -267,7 +267,7 @@ it = { "run-label": "Avvia Syncplay", "storeandrun-label": "Salva la configurazione e avvia Syncplay", - "contact-label": "Sentiti libero di inviare un'e-mail a dev@syncplay.pl, chattare tramite il canale IRC #Syncplay su irc.freenode.net, segnalare un problema su GitHub, lasciare un like sulla nostra pagina Facebook, seguirci su Twitter, o visitare https://syncplay.pl/. NOTA: i messaggi di chat non sono cifrati, quindi non usare Syncplay per inviare dati sensibili.", + "contact-label": "Sentiti libero di inviare un'e-mail a dev@syncplay.pl, chattare tramite il canale IRC #Syncplay su irc.freenode.net, segnalare un problema su GitHub, lasciare un like sulla nostra pagina Facebook, seguirci su Twitter, o visitare https://syncplay.pl/. Non usare Syncplay per inviare dati sensibili.", # TODO: Check translation "joinroom-label": "Entra nella stanza", "joinroom-menu-label": "Entra nella stanza {}", @@ -312,6 +312,19 @@ it = { "userguide-menu-label": "Apri guida &utente", "update-menu-label": "Controlla la presenza di &aggiornamenti", + "startTLS-initiated": "Tentativo di connessione sicura in corso", + "startTLS-secure-connection-ok": "Connessione sicura stabilita ({})", + "startTLS-server-certificate-invalid": 'Connessione sicura non riuscita. Il certificato di sicurezza di questo server non è valido. La comunicazione potrebbe essere intercettata da una terza parte. Per ulteriori dettagli e informazioni sulla risoluzione del problema, clicca qui.', + "startTLS-not-supported-client": "Questo client non supporta TLS", + "startTLS-not-supported-server": "Questo server non supporta TLS", + + # TLS certificate dialog + "tls-information-title": "Informazioni sul certificato", + "tls-dialog-status-label": "Syncplay è connesso a {} tramite una connessione codificata.", + "tls-dialog-desc-label": "La codifica con un certificato digitale mantiene private le informazioni quando vengono
inviate dal/al server {}.", + "tls-dialog-connection-label": "Informazioni codificate usando Transport Layer Security (TLS), versione {} usando gli
algoritmi di cifratura: {}.", + "tls-dialog-certificate-label": "Certificato rilasciato da {} valido fino al {}.", + # About dialog "about-menu-label": "&Informazioni su Syncplay", "about-dialog-title": "Informazioni su Syncplay", @@ -403,6 +416,8 @@ it = { "reset-tooltip": "Ripristina le impostazioni iniziali di Syncplay.", "update-server-list-tooltip": "Scarica la lista dei server pubblici da syncplay.pl.", + "sslconnection-tooltip": "Connessione sicura al server. Clicca per informazioni sul certificato.", + "joinroom-tooltip": "Lascia la stanza attuale e entra in quella specificata.", "seektime-msgbox-label": "Salta all'istante di tempo specificato (in secondi / min:sec). Usa +/- per una ricerca relativa.", "ready-tooltip": "Indica quando sei pronto a guardare.", @@ -439,6 +454,7 @@ it = { "server-chat-maxchars-argument": "Numero massimo di caratteri in un messaggio di chat (default è {})", # Default number of characters "server-maxusernamelength-argument": "Numero massimo di caratteri in un nome utente (default è {})", "server-stats-db-file-argument": "Abilita la raccolta dei dati statistici nel file SQLite indicato", + "server-startTLS-argument": "Abilita il protocollo TLS usando i certificati contenuti nel percorso indicato", "server-messed-up-motd-unescaped-placeholders": "Il messaggio del giorno ha dei caratteri non 'escaped'. Tutti i simboli $ devono essere doppi ($$).", "server-messed-up-motd-too-long": "Il messaggio del giorno è troppo lungo - numero massimo di caratteri è {}, {} trovati.", diff --git a/syncplay/messages_ru.py b/syncplay/messages_ru.py index f9156d2..95f6904 100755 --- a/syncplay/messages_ru.py +++ b/syncplay/messages_ru.py @@ -269,7 +269,7 @@ ru = { "run-label": "Запустить", "storeandrun-label": "Сохранить и запустить", - "contact-label": "Есть идея, нашли ошибку или хотите оставить отзыв? Пишите на dev@syncplay.pl, в IRC канал #Syncplay на irc.freenode.net или задавайте вопросы через GitHub. Кроме того, заходите на www.syncplay.pl за инорфмацией, помощью и обновлениями! NOTE: Chat messages are not encrypted so do not use Syncplay to send sensitive information.", # TODO: Translate last sentence + "contact-label": "Есть идея, нашли ошибку или хотите оставить отзыв? Пишите на dev@syncplay.pl, в IRC канал #Syncplay на irc.freenode.net или задавайте вопросы через GitHub. Кроме того, заходите на www.syncplay.pl за инорфмацией, помощью и обновлениями! Do not use Syncplay to send sensitive information.", # TODO: Translate last sentence "joinroom-label": "Зайти в комнату", "joinroom-menu-label": "Зайти в комнату {}", @@ -313,6 +313,20 @@ ru = { "userguide-menu-label": "&Руководство пользователя", "update-menu-label": "Проверить &обновления", + # startTLS messages - TODO: Translate + "startTLS-initiated": "Attempting secure connection", + "startTLS-secure-connection-ok": "Secure connection established ({})", + "startTLS-server-certificate-invalid": 'Secure connection failed. The server uses an invalid security certificate. This communication could be intercepted by a third party. For further details and troubleshooting see here.', + "startTLS-not-supported-client": "This client does not support TLS", + "startTLS-not-supported-server": "This server does not support TLS", + + # TLS certificate dialog - TODO: Translate + "tls-information-title": "Certificate Details", + "tls-dialog-status-label": "Syncplay is using an encrypted connection to {}.", + "tls-dialog-desc-label": "Encryption with a digital certificate keeps information private as it is sent to or from the
server {}.", + "tls-dialog-connection-label": "Information encrypted using Transport Layer Security (TLS), version {} with the cipher
suite: {}.", + "tls-dialog-certificate-label": "Certificate issued by {} valid until {}.", + # About dialog - TODO: Translate "about-menu-label": "&About Syncplay", "about-dialog-title": "About Syncplay", @@ -404,6 +418,8 @@ ru = { "reset-tooltip": "Сбрасывает все настройки Syncplay в начальное состояние.", "update-server-list-tooltip": "Обновить список публичных серверов от syncplay.pl.", + "sslconnection-tooltip": "Securely connected to server. Click for certificate details.", # TODO: Translate + "joinroom-tooltip": "Покинуть комнату и зайти в другую, указанную комнату.", "seektime-msgbox-label": "Перемотать к определенному моменту времени (указывать в секундах или мин:сек). Используйте +/-, чтобы перемотать вперед/назад относительно настоящего момента.", "ready-tooltip": "Показывает, готовы ли Вы к просмотру или нет.", @@ -439,6 +455,7 @@ ru = { "server-chat-maxchars-argument": "Maximum number of characters in a chat message (default is {})", # TODO: Translate "server-maxusernamelength-argument": "Maximum number of characters in a username (default is {})", # TODO: Translate "server-stats-db-file-argument": "Enable server stats using the SQLite db file provided", # TODO: Translate + "server-startTLS-argument": "Enable TLS connections using the certificate files in the path provided", # TODO: Translate "server-messed-up-motd-unescaped-placeholders" : "MOTD-сообщение содержит неэкранированные спец.символы. Все знаки $ должны быть продублированы ($$).", "server-messed-up-motd-too-long" : "MOTD-сообщение слишком длинное: максимальная длина - {} символ(ов), текущая длина - {} символ(ов).", diff --git a/syncplay/protocols.py b/syncplay/protocols.py index c4394ac..78d6add 100755 --- a/syncplay/protocols.py +++ b/syncplay/protocols.py @@ -4,6 +4,8 @@ import time from functools import wraps from twisted.protocols.basic import LineReceiver +from twisted.internet.interfaces import IHandshakeListener +from zope.interface.declarations import implementer import syncplay from syncplay.constants import PING_MOVING_AVERAGE_WEIGHT, CONTROLLED_ROOMS_MIN_VERSION, USER_READY_MIN_VERSION, SHARED_PLAYLIST_MIN_VERSION, CHAT_MIN_VERSION @@ -27,6 +29,8 @@ class JSONCommandProtocol(LineReceiver): self.handleError(message[1]) elif command == "Chat": self.handleChat(message[1]) + elif command == "TLS": + self.handleTLS(message[1]) else: self.dropWithError(getMessage("unknown-command-server-error").format(message[1])) # TODO: log, not drop @@ -59,6 +63,7 @@ class JSONCommandProtocol(LineReceiver): raise NotImplementedError() +@implementer(IHandshakeListener) class SyncClientProtocol(JSONCommandProtocol): def __init__(self, client): self._client = client @@ -72,9 +77,27 @@ class SyncClientProtocol(JSONCommandProtocol): def connectionMade(self): self._client.initProtocol(self) - self.sendHello() + if self._client._clientSupportsTLS: + if self._client._serverSupportsTLS: + self.sendTLS({"startTLS": "send"}) + self._client.ui.showMessage(getMessage("startTLS-initiated")) + else: + self._client.ui.showErrorMessage(getMessage("startTLS-not-supported-server")) + self.sendHello() + else: + self._client.ui.showMessage(getMessage("startTLS-not-supported-client")) + self.sendHello() def connectionLost(self, reason): + try: + if "Invalid DNS-ID" in str(reason.value): + self._client._serverSupportsTLS = False + elif "tlsv1 alert protocol version" in str(reason.value): + self._client._clientSupportsTLS = False + elif "certificate verify failed" in str(reason.value): + self.dropWithError(getMessage("startTLS-server-certificate-invalid")) + except: + pass self._client.destroyProtocol() def dropWithError(self, error): @@ -296,11 +319,49 @@ class SyncClientProtocol(JSONCommandProtocol): }) def handleError(self, error): - self.dropWithError(error["message"]) + if "startTLS" in error["message"] and not self.logged: + self._client._serverSupportsTLS = False + else: + self.dropWithError(error["message"]) def sendError(self, message): self.sendMessage({"Error": {"message": message}}) + def sendTLS(self, message): + self.sendMessage({"TLS": message}) + + def handleTLS(self, message): + answer = message["startTLS"] if "startTLS" in message else None + if "true" in answer and not self.logged and self._client.protocolFactory.options is not None: + self.transport.startTLS(self._client.protocolFactory.options) + elif "false" in answer: + self._client.ui.showErrorMessage(getMessage("startTLS-not-supported-server")) + self.sendHello() + + def handshakeCompleted(self): + from datetime import datetime + self._serverCertificateTLS = self.transport.getPeerCertificate() + self._subjectTLS = self._serverCertificateTLS.get_subject().CN + self._issuerTLS = self._serverCertificateTLS.get_issuer().CN + self._expiredTLS =self._serverCertificateTLS.has_expired() + self._expireDateTLS = datetime.strptime(self._serverCertificateTLS.get_notAfter().decode('ascii'), '%Y%m%d%H%M%SZ') + + self._encryptedConnectionTLS = self.transport.protocol._tlsConnection + self._connVersionNumberTLS = self._encryptedConnectionTLS.get_protocol_version() + self._connVersionStringTLS = self._encryptedConnectionTLS.get_protocol_version_name() + self._cipherNameTLS = self._encryptedConnectionTLS.get_cipher_name() + + if self._connVersionNumberTLS == 771: + self._connVersionNumberTLS = '1.2' + elif self._connVersionNumberTLS == 772: + self._connVersionNumberTLS = '1.3' + + self._client.ui.showMessage(getMessage("startTLS-secure-connection-ok").format(self._connVersionStringTLS)) + self._client.ui.setSSLMode( True, + {'subject': self._subjectTLS, 'issuer': self._issuerTLS, 'expires': self._expireDateTLS, + 'protocolString': self._connVersionStringTLS, 'protocolVersion': self._connVersionNumberTLS, + 'cipher': self._cipherNameTLS}) + class SyncServerProtocol(JSONCommandProtocol): def __init__(self, factory): @@ -602,6 +663,18 @@ class SyncServerProtocol(JSONCommandProtocol): def sendError(self, message): self.sendMessage({"Error": {"message": message}}) + def sendTLS(self, message): + self.sendMessage({"TLS": message}) + + def handleTLS(self, message): + inquiry = message["startTLS"] if "startTLS" in message else None + if "send" in inquiry: + if not self.isLogged() and self._factory.options is not None: + self.sendTLS({"startTLS": "true"}) + self.transport.startTLS(self._factory.options) + else: + self.sendTLS({"startTLS": "false"}) + class PingService(object): diff --git a/syncplay/server.py b/syncplay/server.py index 2a93cba..36143bf 100755 --- a/syncplay/server.py +++ b/syncplay/server.py @@ -10,6 +10,12 @@ from twisted.enterprise import adbapi from twisted.internet import task, reactor from twisted.internet.protocol import Factory +try: + from OpenSSL import crypto + from twisted.internet import ssl +except: + pass + import syncplay from syncplay import constants from syncplay.messages import getMessage @@ -20,7 +26,7 @@ from syncplay.utils import RoomPasswordProvider, NotControlledRoom, RandomString class SyncFactory(Factory): def __init__(self, port='', password='', motdFilePath=None, isolateRooms=False, salt=None, disableReady=False, disableChat=False, maxChatMessageLength=constants.MAX_CHAT_MESSAGE_LENGTH, - maxUsernameLength=constants.MAX_USERNAME_LENGTH, statsDbFile=None): + maxUsernameLength=constants.MAX_USERNAME_LENGTH, statsDbFile=None, tlsCertPath=None): self.isolateRooms = isolateRooms print(getMessage("welcome-server-notification").format(syncplay.version)) self.port = port @@ -48,6 +54,9 @@ class SyncFactory(Factory): self._statsRecorder.startRecorder(statsDelay) else: self._statsDbHandle = None + self.options = None + if tlsCertPath is not None: + self._allowTLSconnections(tlsCertPath) def buildProtocol(self, addr): return SyncServerProtocol(self) @@ -194,6 +203,30 @@ class SyncFactory(Factory): else: watcher.setPlaylistIndex(room.getName(), room.getPlaylistIndex()) + def _allowTLSconnections(self, path): + try: + privKey = open(path+'/privkey.pem', 'rt').read() + certif = open(path+'/cert.pem', 'rt').read() + chain = open(path+'/chain.pem', 'rt').read() + + privKeyPySSL = crypto.load_privatekey(crypto.FILETYPE_PEM, privKey) + certifPySSL = crypto.load_certificate(crypto.FILETYPE_PEM, certif) + chainPySSL = [crypto.load_certificate(crypto.FILETYPE_PEM, chain)] + + cipherListString = "ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:"\ + "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:"\ + "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384" + accCiphers = ssl.AcceptableCiphers.fromOpenSSLCipherString(cipherListString) + + contextFactory = ssl.CertificateOptions(privateKey=privKeyPySSL, certificate=certifPySSL, + extraCertChain=chainPySSL, acceptableCiphers=accCiphers, + raiseMinimumTo=ssl.TLSVersion.TLSv1_2) + self.options = contextFactory + except Exception as e: + self.options = None + print(e) + print("TLS support is not enabled.") + class StatsRecorder(object): def __init__(self, dbHandle, roomManager): @@ -624,3 +657,4 @@ class ConfigurationGetter(object): self._argparser.add_argument('--max-chat-message-length', metavar='maxChatMessageLength', type=int, nargs='?', help=getMessage("server-chat-maxchars-argument").format(constants.MAX_CHAT_MESSAGE_LENGTH)) self._argparser.add_argument('--max-username-length', metavar='maxUsernameLength', type=int, nargs='?', help=getMessage("server-maxusernamelength-argument").format(constants.MAX_USERNAME_LENGTH)) self._argparser.add_argument('--stats-db-file', metavar='file', type=str, nargs='?', help=getMessage("server-stats-db-file-argument")) + self._argparser.add_argument('--tls', metavar='path', type=str, nargs='?', help=getMessage("server-startTLS-argument")) diff --git a/syncplay/ui/consoleUI.py b/syncplay/ui/consoleUI.py index 38f10ff..3063ff3 100755 --- a/syncplay/ui/consoleUI.py +++ b/syncplay/ui/consoleUI.py @@ -108,6 +108,9 @@ class ConsoleUI(threading.Thread): def showErrorMessage(self, message, criticalerror=False): print("ERROR:\t" + message) + def setSSLMode(self, sslMode, sslInformation): + pass + def _extractSign(self, m): if m: if m == "-": diff --git a/syncplay/ui/gui.py b/syncplay/ui/gui.py index 938d49c..a50ca16 100755 --- a/syncplay/ui/gui.py +++ b/syncplay/ui/gui.py @@ -124,6 +124,7 @@ class AboutDialog(QtWidgets.QDialog): self.setWindowTitle(getMessage("about-dialog-title")) if isWindows(): self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint) + self.setWindowIcon(QtGui.QPixmap(resourcespath + 'syncplay.png')) nameLabel = QtWidgets.QLabel("
Syncplay
") nameLabel.setFont(QtGui.QFont("Helvetica", 18)) linkLabel = QtWidgets.QLabel("
syncplay.pl
") @@ -171,11 +172,56 @@ class AboutDialog(QtWidgets.QDialog): QtGui.QDesktopServices.openUrl(QUrl("file://" + resourcespath + "third-party-notices.rtf")) +class CertificateDialog(QtWidgets.QDialog): + def __init__(self, tlsData, parent=None): + super(CertificateDialog, self).__init__(parent) + if isMacOS(): + self.setWindowTitle("") + self.setWindowFlags(Qt.Dialog | Qt.WindowTitleHint | Qt.WindowCloseButtonHint | Qt.CustomizeWindowHint) + else: + self.setWindowTitle(getMessage("tls-information-title")) + if isWindows(): + self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint) + self.setWindowIcon(QtGui.QPixmap(resourcespath + 'syncplay.png')) + statusLabel = QtWidgets.QLabel(getMessage("tls-dialog-status-label").format(tlsData["subject"])) + descLabel = QtWidgets.QLabel(getMessage("tls-dialog-desc-label").format(tlsData["subject"])) + connDataLabel = QtWidgets.QLabel(getMessage("tls-dialog-connection-label").format(tlsData["protocolVersion"], tlsData["cipher"])) + certDataLabel = QtWidgets.QLabel(getMessage("tls-dialog-certificate-label").format(tlsData["issuer"], tlsData["expires"])) + if isMacOS(): + statusLabel.setFont(QtGui.QFont("Helvetica", 12)) + descLabel.setFont(QtGui.QFont("Helvetica", 12)) + connDataLabel.setFont(QtGui.QFont("Helvetica", 12)) + certDataLabel.setFont(QtGui.QFont("Helvetica", 12)) + lockIconPixmap = QtGui.QPixmap(resourcespath + "lock_green_dialog.png") + lockIconLabel = QtWidgets.QLabel() + lockIconLabel.setPixmap(lockIconPixmap.scaled(64, 64, Qt.KeepAspectRatio)) + certLayout = QtWidgets.QGridLayout() + certLayout.addWidget(lockIconLabel, 1, 0, 3, 1, Qt.AlignLeft | Qt.AlignTop) + certLayout.addWidget(statusLabel, 0, 1, 1, 3) + certLayout.addWidget(descLabel, 1, 1, 1, 3) + certLayout.addWidget(connDataLabel, 2, 1, 1, 3) + certLayout.addWidget(certDataLabel, 3, 1, 1, 3) + closeButton = QtWidgets.QPushButton("Close") + closeButton.setFixedWidth(100) + closeButton.setAutoDefault(False) + closeButton.clicked.connect(self.closeDialog) + certLayout.addWidget(closeButton, 4, 3, 1, 1) + certLayout.setVerticalSpacing(10) + certLayout.setSizeConstraint(QtWidgets.QLayout.SetFixedSize) + self.setSizeGripEnabled(False) + self.setLayout(certLayout) + + def closeDialog(self): + self.close() + + class MainWindow(QtWidgets.QMainWindow): insertPosition = None playlistState = [] updatingPlaylist = False playlistIndex = None + sslInformation = "N/A" + sslMode = False def setPlaylistInsertPosition(self, newPosition): if not self.playlist.isEnabled(): @@ -431,6 +477,14 @@ class MainWindow(QtWidgets.QMainWindow): self.chatInput.setMaxLength(constants.MAX_CHAT_MESSAGE_LENGTH) self.roomInput.setMaxLength(constants.MAX_ROOM_NAME_LENGTH) + def setSSLMode(self, sslMode, sslInformation): + self.sslMode = sslMode + self.sslInformation = sslInformation + self.sslButton.setVisible(sslMode) + + def getSSLInformation(self): + return self.sslInformation + def showMessage(self, message, noTimestamp=False): message = str(message) username = None @@ -783,6 +837,7 @@ class MainWindow(QtWidgets.QMainWindow): if criticalerror: QtWidgets.QMessageBox.critical(self, "Syncplay", message) message = message.replace("&", "&").replace('"', """).replace("<", "<").replace(">", ">") + message = message.replace("<a href="https://syncplay.pl/trouble">", '').replace("</a>", "") message = message.replace("\n", "
") message = "".format(constants.STYLE_ERRORNOTIFICATION) + message + "" self.newMessage(time.strftime(constants.UI_TIME_FORMAT, time.localtime()) + message + "
") @@ -1204,6 +1259,7 @@ class MainWindow(QtWidgets.QMainWindow): window.outputbox.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) window.outputlabel = QtWidgets.QLabel(getMessage("notifications-heading-label")) + window.outputlabel.setMinimumHeight(27) window.chatInput = QtWidgets.QLineEdit() window.chatInput.setMaxLength(constants.MAX_CHAT_MESSAGE_LENGTH) window.chatInput.returnPressed.connect(self.sendChatMessage) @@ -1240,21 +1296,37 @@ class MainWindow(QtWidgets.QMainWindow): self.listTreeView.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) self.listTreeView.customContextMenuRequested.connect(self.openRoomMenu) window.listlabel = QtWidgets.QLabel(getMessage("userlist-heading-label")) + window.listlabel.setMinimumHeight(27) + if isMacOS: + window.sslButton = QtWidgets.QPushButton(QtGui.QPixmap(resourcespath + 'lock_green.png').scaled(14, 14),"") + window.sslButton.setVisible(False) + window.sslButton.setFixedHeight(21) + window.sslButton.setFixedWidth(21) + window.sslButton.setMinimumSize(21, 21) + window.sslButton.setStyleSheet("QPushButton:!hover{border: 1px solid gray;} QPushButton:hover{border:2px solid black;}") + else: + window.sslButton = QtWidgets.QPushButton(QtGui.QPixmap(resourcespath + 'lock_green.png'),"") + window.sslButton.setVisible(False) + window.sslButton.setFixedHeight(27) + window.sslButton.setFixedWidth(27) + window.sslButton.pressed.connect(self.openSSLDetails) + window.sslButton.setToolTip(getMessage("sslconnection-tooltip")) window.listFrame = QtWidgets.QFrame() window.listFrame.setLineWidth(0) window.listFrame.setMidLineWidth(0) window.listFrame.setSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) window.listLayout.setContentsMargins(0, 0, 0, 0) - window.userlistLayout = QtWidgets.QVBoxLayout() + window.userlistLayout = QtWidgets.QGridLayout() window.userlistFrame = QtWidgets.QFrame() window.userlistFrame.setLineWidth(0) window.userlistFrame.setMidLineWidth(0) window.userlistFrame.setSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) window.userlistLayout.setContentsMargins(0, 0, 0, 0) window.userlistFrame.setLayout(window.userlistLayout) - window.userlistLayout.addWidget(window.listlabel) - window.userlistLayout.addWidget(window.listTreeView) + window.userlistLayout.addWidget(window.listlabel, 0, 0, Qt.AlignLeft) + window.userlistLayout.addWidget(window.sslButton, 0, 2, Qt.AlignRight) + window.userlistLayout.addWidget(window.listTreeView, 1, 0, 1, 3) window.listSplit = QtWidgets.QSplitter(Qt.Vertical, self) window.listSplit.addWidget(window.userlistFrame) @@ -1513,6 +1585,12 @@ class MainWindow(QtWidgets.QMainWindow): if not isMacOS(): window.mainLayout.setMenuBar(window.menuBar) + @needsClient + def openSSLDetails(self): + sslDetailsBox = CertificateDialog(self.getSSLInformation()) + sslDetailsBox.exec_() + self.sslButton.setDown(False) + def openAbout(self): aboutMsgBox = AboutDialog() aboutMsgBox.exec_() diff --git a/syncplayServer.py b/syncplayServer.py index eaa9f34..b0538d3 100755 --- a/syncplayServer.py +++ b/syncplayServer.py @@ -13,38 +13,56 @@ except AttributeError: import warnings warnings.warn("You must run Syncplay with Python 3.4 or newer!") -from twisted.internet import reactor, tcp +from twisted.internet import reactor +from twisted.internet.endpoints import TCP4ServerEndpoint, TCP6ServerEndpoint +from twisted.internet.error import CannotListenError from syncplay.server import SyncFactory, ConfigurationGetter -class DualStackPort(tcp.Port): +class ServerStatus: pass - def __init__(self, port, factory, backlog=50, interface='', reactor=None): - tcp.Port.__init__(self, port, factory, backlog, interface, reactor) +def isListening6(f): + ServerStatus.listening6 = True + +def isListening4(f): + ServerStatus.listening4 = True + +def failed6(f): + ServerStatus.listening6 = False + print(f.value) + print("IPv6 listening failed.") + +def failed4(f): + ServerStatus.listening4 = False + if f.type is CannotListenError and ServerStatus.listening6: + pass + else: + print(f.value) + print("IPv4 listening failed.") - def createInternetSocket(self): - s = tcp.Port.createInternetSocket(self) - try: - s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) - except: - pass - return s if __name__ == '__main__': argsGetter = ConfigurationGetter() args = argsGetter.getConfiguration() - dsp = DualStackPort(int(args.port), - SyncFactory( - args.port, - args.password, - args.motd_file, - args.isolate_rooms, - args.salt, - args.disable_ready, - args.disable_chat, - args.max_chat_message_length, - args.max_username_length, - args.stats_db_file), - interface='::') - dsp.startListening() - reactor.run() + factory = SyncFactory( + args.port, + args.password, + args.motd_file, + args.isolate_rooms, + args.salt, + args.disable_ready, + args.disable_chat, + args.max_chat_message_length, + args.max_username_length, + args.stats_db_file, + args.tls + ) + endpoint6 = TCP6ServerEndpoint(reactor, int(args.port)) + endpoint6.listen(factory).addCallbacks(isListening6, failed6) + endpoint4 = TCP4ServerEndpoint(reactor, int(args.port)) + endpoint4.listen(factory).addCallbacks(isListening4, failed4) + if ServerStatus.listening6 or ServerStatus.listening4: + reactor.run() + else: + print("Unable to listen using either IPv4 and IPv6 protocols. Quitting the server now.") + sys.exit()