Fix desktop WebSocket certificate verification

This commit is contained in:
Peter Stockings
2026-07-13 23:26:07 +10:00
parent aee78d5392
commit ce22de754a
6 changed files with 114 additions and 22 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
uv add pyinstaller pillow uv sync
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 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
+7 -1
View File
@@ -55,6 +55,7 @@ class VlcSyncApp(QMainWindow):
self.sync_client.connected.connect(lambda: self.room_widget.update_connection_status(True)) 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(self.on_ws_disconnected)
self.sync_client.disconnected.connect(lambda: self.room_widget.update_connection_status(False)) 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_joined.connect(self.on_room_joined)
self.sync_client.room_rejoined.connect(self.on_room_rejoined) self.sync_client.room_rejoined.connect(self.on_room_rejoined)
self.sync_client.room_error.connect(self.on_room_error) self.sync_client.room_error.connect(self.on_room_error)
@@ -135,6 +136,12 @@ class VlcSyncApp(QMainWindow):
self.lobby_widget.reset_ui() self.lobby_widget.reset_ui()
self.pending_connect_action = None 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): def on_room_error(self, msg: str):
QMessageBox.critical(self, "Room Error", msg) QMessageBox.critical(self, "Room Error", msg)
self.lobby_widget.reset_ui() self.lobby_widget.reset_ui()
@@ -493,4 +500,3 @@ if __name__ == "__main__":
window = VlcSyncApp() window = VlcSyncApp()
window.show() window.show()
sys.exit(app.exec()) sys.exit(app.exec())
+1
View File
@@ -5,6 +5,7 @@ description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [
"certifi>=2026.6.17",
"pillow>=12.1.1", "pillow>=12.1.1",
"pyinstaller>=6.19.0", "pyinstaller>=6.19.0",
"pyqt6>=6.10.2", "pyqt6>=6.10.2",
+69 -19
View File
@@ -1,22 +1,27 @@
import asyncio import asyncio
import json import json
import ssl
import time import time
import certifi
import websockets import websockets
from PyQt6.QtCore import QThread, pyqtSignal from PyQt6.QtCore import QThread, pyqtSignal
class SyncClientThread(QThread): class SyncClientThread(QThread):
# Signals from WebSocket -> PyQt UI # Signals from WebSocket -> PyQt UI
connected = pyqtSignal() connected = pyqtSignal()
disconnected = pyqtSignal() disconnected = pyqtSignal()
connection_error = pyqtSignal(str)
room_joined = pyqtSignal(dict) room_joined = pyqtSignal(dict)
room_rejoined = pyqtSignal(dict) room_rejoined = pyqtSignal(dict)
room_error = pyqtSignal(str) room_error = pyqtSignal(str)
file_check_needed = pyqtSignal(dict) # msg file_check_needed = pyqtSignal(dict) # msg
users_updated = pyqtSignal(list) 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) system_message = pyqtSignal(str)
sync_event = pyqtSignal(dict) 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"): def __init__(self, url="ws://localhost:3000/ws"):
super().__init__() super().__init__()
@@ -26,13 +31,31 @@ class SyncClientThread(QThread):
self.running = False self.running = False
self._ping_sent_at = 0 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): def run(self):
"""Runs strictly within the newly created QThread""" """Runs strictly within the newly created QThread"""
self.running = True self.running = True
self.loop = asyncio.new_event_loop() self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop) asyncio.set_event_loop(self.loop)
self.loop.run_until_complete(self._connect_and_listen()) try:
self.loop.close() self.loop.run_until_complete(self._connect_and_listen())
finally:
self.ws = None
self.running = False
self.loop.close()
def stop(self): def stop(self):
self.running = False self.running = False
@@ -61,12 +84,17 @@ class SyncClientThread(QThread):
await asyncio.sleep(5) await asyncio.sleep(5)
async def _connect_and_listen(self): async def _connect_and_listen(self):
ssl_context = (
self._create_ssl_context() if self.url.startswith("wss://") else None
)
while self.running: while self.running:
try: 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.ws = ws
self.connected.emit() self.connected.emit()
ping_task = asyncio.create_task(self._ping_loop(ws)) ping_task = asyncio.create_task(self._ping_loop(ws))
try: try:
async for message in ws: async for message in ws:
@@ -77,38 +105,60 @@ class SyncClientThread(QThread):
pass pass
finally: finally:
ping_task.cancel() ping_task.cancel()
except Exception as e: 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}") print(f"WebSocket Error: {e}")
self.disconnected.emit() finally:
self.ws = None
self.disconnected.emit()
if self.running: if self.running:
# Reconnect backoff # Reconnect backoff
await asyncio.sleep(2) await asyncio.sleep(2)
def _handle_message(self, msg: dict): def _handle_message(self, msg: dict):
t = msg.get("type") t = msg.get("type")
if t == "room_created": if t == "room_created":
self.room_joined.emit(msg) self.room_joined.emit(msg)
elif t == "room_joined": elif t == "room_joined":
self.room_joined.emit(msg) self.room_joined.emit(msg)
elif t == "room_rejoined": elif t == "room_rejoined":
self.room_rejoined.emit(msg) self.room_rejoined.emit(msg)
elif t == "error": elif t == "error":
self.room_error.emit(msg.get("message", "Unknown error")) self.room_error.emit(msg.get("message", "Unknown error"))
elif t == "room_file_check": elif t == "room_file_check":
self.file_check_needed.emit(msg) self.file_check_needed.emit(msg)
elif t in ["user_joined", "user_left"]: elif t in ["user_joined", "user_left"]:
self.users_updated.emit(msg.get("users", [])) self.users_updated.emit(msg.get("users", []))
elif t == "chat": 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": elif t == "sync":
self.sync_event.emit(msg) self.sync_event.emit(msg)
+24
View File
@@ -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
+11
View File
@@ -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" }, { 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]] [[package]]
name = "macholib" name = "macholib"
version = "1.16.4" version = "1.16.4"
@@ -163,6 +172,7 @@ name = "python-app"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "certifi" },
{ name = "pillow" }, { name = "pillow" },
{ name = "pyinstaller" }, { name = "pyinstaller" },
{ name = "pyqt6" }, { name = "pyqt6" },
@@ -172,6 +182,7 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "certifi", specifier = ">=2026.6.17" },
{ name = "pillow", specifier = ">=12.1.1" }, { name = "pillow", specifier = ">=12.1.1" },
{ name = "pyinstaller", specifier = ">=6.19.0" }, { name = "pyinstaller", specifier = ">=6.19.0" },
{ name = "pyqt6", specifier = ">=6.10.2" }, { name = "pyqt6", specifier = ">=6.10.2" },