From ce22de754a4375182b2966769c20b515dce42d86 Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Mon, 13 Jul 2026 23:26:07 +1000 Subject: [PATCH] Fix desktop WebSocket certificate verification --- desktop-client/build.ps1 | 4 +- desktop-client/main.py | 8 ++- desktop-client/pyproject.toml | 1 + desktop-client/sync_client.py | 88 +++++++++++++++++++++++------- desktop-client/test_sync_client.py | 24 ++++++++ desktop-client/uv.lock | 11 ++++ 6 files changed, 114 insertions(+), 22 deletions(-) create mode 100644 desktop-client/test_sync_client.py diff --git a/desktop-client/build.ps1 b/desktop-client/build.ps1 index 6e59fc8..925fb6f 100644 --- a/desktop-client/build.ps1 +++ b/desktop-client/build.ps1 @@ -1,2 +1,2 @@ -uv add pyinstaller pillow -uv run pyinstaller --noconfirm --onefile --windowed --name "VideoSync" --icon "icon.png" --add-data "icon.png;." --add-data "copy.svg;." --add-data "check.svg;." main.py +uv sync +uv run pyinstaller --noconfirm --onefile --windowed --name "VideoSync" --icon "icon.png" --collect-data "certifi" --add-data "icon.png;." --add-data "copy.svg;." --add-data "check.svg;." main.py diff --git a/desktop-client/main.py b/desktop-client/main.py index d9a2080..b5215f1 100644 --- a/desktop-client/main.py +++ b/desktop-client/main.py @@ -55,6 +55,7 @@ class VlcSyncApp(QMainWindow): self.sync_client.connected.connect(lambda: self.room_widget.update_connection_status(True)) self.sync_client.disconnected.connect(self.on_ws_disconnected) self.sync_client.disconnected.connect(lambda: self.room_widget.update_connection_status(False)) + self.sync_client.connection_error.connect(self.on_connection_error) self.sync_client.room_joined.connect(self.on_room_joined) self.sync_client.room_rejoined.connect(self.on_room_rejoined) self.sync_client.room_error.connect(self.on_room_error) @@ -135,6 +136,12 @@ class VlcSyncApp(QMainWindow): self.lobby_widget.reset_ui() self.pending_connect_action = None + def on_connection_error(self, msg: str): + QMessageBox.critical(self, "Secure Connection Error", msg) + self.pending_connect_action = None + self.lobby_widget.reset_ui() + self.sync_client.stop() + def on_room_error(self, msg: str): QMessageBox.critical(self, "Room Error", msg) self.lobby_widget.reset_ui() @@ -493,4 +500,3 @@ if __name__ == "__main__": window = VlcSyncApp() window.show() sys.exit(app.exec()) - diff --git a/desktop-client/pyproject.toml b/desktop-client/pyproject.toml index 603aeea..c7606d5 100644 --- a/desktop-client/pyproject.toml +++ b/desktop-client/pyproject.toml @@ -5,6 +5,7 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.14" dependencies = [ + "certifi>=2026.6.17", "pillow>=12.1.1", "pyinstaller>=6.19.0", "pyqt6>=6.10.2", diff --git a/desktop-client/sync_client.py b/desktop-client/sync_client.py index 095ff6b..f1b4299 100644 --- a/desktop-client/sync_client.py +++ b/desktop-client/sync_client.py @@ -1,22 +1,27 @@ import asyncio import json +import ssl import time + +import certifi import websockets from PyQt6.QtCore import QThread, pyqtSignal + class SyncClientThread(QThread): # Signals from WebSocket -> PyQt UI connected = pyqtSignal() disconnected = pyqtSignal() + connection_error = pyqtSignal(str) room_joined = pyqtSignal(dict) room_rejoined = pyqtSignal(dict) room_error = pyqtSignal(str) - file_check_needed = pyqtSignal(dict) # msg + file_check_needed = pyqtSignal(dict) # msg users_updated = pyqtSignal(list) - chat_message = pyqtSignal(str, str, int) # author, text, timestamp + chat_message = pyqtSignal(str, str, int) # author, text, timestamp system_message = pyqtSignal(str) sync_event = pyqtSignal(dict) - latency_updated = pyqtSignal(int) # latency in ms + latency_updated = pyqtSignal(int) # latency in ms def __init__(self, url="ws://localhost:3000/ws"): super().__init__() @@ -26,13 +31,31 @@ class SyncClientThread(QThread): self.running = False self._ping_sent_at = 0 + @staticmethod + def _create_ssl_context(): + """Build a verified TLS context from the CA bundle shipped with the app.""" + return ssl.create_default_context(cafile=certifi.where()) + + @staticmethod + def _certificate_error(exc): + """Return a nested certificate verification error, if present.""" + while exc is not None: + if isinstance(exc, ssl.SSLCertVerificationError): + return exc + exc = exc.__cause__ or exc.__context__ + return None + def run(self): """Runs strictly within the newly created QThread""" self.running = True self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) - self.loop.run_until_complete(self._connect_and_listen()) - self.loop.close() + try: + self.loop.run_until_complete(self._connect_and_listen()) + finally: + self.ws = None + self.running = False + self.loop.close() def stop(self): self.running = False @@ -61,12 +84,17 @@ class SyncClientThread(QThread): await asyncio.sleep(5) async def _connect_and_listen(self): + ssl_context = ( + self._create_ssl_context() if self.url.startswith("wss://") else None + ) + while self.running: try: - async with websockets.connect(self.url) as ws: + connect_options = {"ssl": ssl_context} if ssl_context else {} + async with websockets.connect(self.url, **connect_options) as ws: self.ws = ws self.connected.emit() - + ping_task = asyncio.create_task(self._ping_loop(ws)) try: async for message in ws: @@ -77,38 +105,60 @@ class SyncClientThread(QThread): pass finally: ping_task.cancel() - + except Exception as e: + certificate_error = self._certificate_error(e) + if certificate_error: + verification_reason = getattr( + certificate_error, "verify_message", str(certificate_error) + ) + message = ( + "Secure connection failed because the server certificate could not be " + f"verified ({verification_reason}). " + "Check that your computer's date and time are correct, then update or " + "restart VideoSync." + ) + print(f"WebSocket TLS Error: {certificate_error}") + self.connection_error.emit(message) + self.running = False + break print(f"WebSocket Error: {e}") - - self.disconnected.emit() + + finally: + self.ws = None + self.disconnected.emit() + if self.running: # Reconnect backoff await asyncio.sleep(2) def _handle_message(self, msg: dict): t = msg.get("type") - + if t == "room_created": self.room_joined.emit(msg) - + elif t == "room_joined": self.room_joined.emit(msg) - + elif t == "room_rejoined": self.room_rejoined.emit(msg) - + elif t == "error": self.room_error.emit(msg.get("message", "Unknown error")) - + elif t == "room_file_check": self.file_check_needed.emit(msg) - + elif t in ["user_joined", "user_left"]: self.users_updated.emit(msg.get("users", [])) - + elif t == "chat": - self.chat_message.emit(msg.get("username", "Unknown"), msg.get("message", ""), msg.get("timestamp", 0)) - + self.chat_message.emit( + msg.get("username", "Unknown"), + msg.get("message", ""), + msg.get("timestamp", 0), + ) + elif t == "sync": self.sync_event.emit(msg) diff --git a/desktop-client/test_sync_client.py b/desktop-client/test_sync_client.py new file mode 100644 index 0000000..c76d47e --- /dev/null +++ b/desktop-client/test_sync_client.py @@ -0,0 +1,24 @@ +import ssl + +from sync_client import SyncClientThread + + +def test_ssl_context_uses_bundled_ca_certificates(): + context = SyncClientThread._create_ssl_context() + + assert context.verify_mode == ssl.CERT_REQUIRED + assert context.check_hostname is True + assert context.cert_store_stats()["x509_ca"] > 0 + + +def test_certificate_error_finds_nested_verification_error(): + certificate_error = ssl.SSLCertVerificationError(1, "certificate has expired") + + try: + raise RuntimeError("connection failed") from certificate_error + except RuntimeError as error: + assert SyncClientThread._certificate_error(error) is certificate_error + + +def test_non_certificate_error_is_ignored(): + assert SyncClientThread._certificate_error(ConnectionError("offline")) is None diff --git a/desktop-client/uv.lock b/desktop-client/uv.lock index 8a02507..9239aa5 100644 --- a/desktop-client/uv.lock +++ b/desktop-client/uv.lock @@ -11,6 +11,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, ] +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + [[package]] name = "macholib" version = "1.16.4" @@ -163,6 +172,7 @@ name = "python-app" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "certifi" }, { name = "pillow" }, { name = "pyinstaller" }, { name = "pyqt6" }, @@ -172,6 +182,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "certifi", specifier = ">=2026.6.17" }, { name = "pillow", specifier = ">=12.1.1" }, { name = "pyinstaller", specifier = ">=6.19.0" }, { name = "pyqt6", specifier = ">=6.10.2" },