Fix desktop WebSocket certificate verification
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Generated
+11
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user