From bd624da58c6ce1a2fdc9acfd62213d9d91c24e74 Mon Sep 17 00:00:00 2001 From: Phuc Nguyen Date: Wed, 23 Sep 2026 10:43:42 +0700 Subject: [PATCH 01/12] fix(desktop): wire Windows Pit helper and explicit HTTPS setup --- .github/workflows/release.yml | 2 + .github/workflows/windows-pit.yml | 31 ++ .../extensions/ai-sidebar/sidepanel.js | 4 +- apps/desktop/launcher/tron-tor-helper | 43 +- apps/desktop/launcher/tron-windows.py | 226 +++++++++++ apps/desktop/launcher/tronbrowser | 2 +- apps/desktop/launcher/tronbrowser.cmd | 55 ++- apps/desktop/scripts/build-release.sh | 1 + .../desktop/test/fixtures/moshpit-root-ca.crt | 11 + apps/desktop/test/test_windows_pit.py | 378 ++++++++++++++++++ docs/moshpit-pit-toggle.md | 66 ++- 11 files changed, 804 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/windows-pit.yml create mode 100644 apps/desktop/launcher/tron-windows.py create mode 100644 apps/desktop/test/fixtures/moshpit-root-ca.crt create mode 100644 apps/desktop/test/test_windows_pit.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11f0d36b..e39c4b6d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -112,6 +112,8 @@ jobs: New-Item -ItemType Directory -Force -Path "$stage/extensions" | Out-Null Copy-Item apps/desktop/launcher/tronbrowser "$stage/tronbrowser" Copy-Item apps/desktop/launcher/tronbrowser.cmd "$stage/tronbrowser.cmd" + Copy-Item apps/desktop/launcher/tron-tor-helper "$stage/tron-tor-helper" + Copy-Item apps/desktop/launcher/tron-windows.py "$stage/tron-windows.py" Copy-Item -Recurse apps/desktop/extensions/ai-sidebar "$stage/extensions/ai-sidebar" # Same wholesale-copy problem build-release.sh has: the vitest files # next to the extension sources ride along into the zip. Chrome never diff --git a/.github/workflows/windows-pit.yml b/.github/workflows/windows-pit.yml new file mode 100644 index 00000000..15ef8b41 --- /dev/null +++ b/.github/workflows/windows-pit.yml @@ -0,0 +1,31 @@ +name: Network helper regression tests + +on: + pull_request: + paths: + - 'apps/desktop/launcher/**' + - 'apps/desktop/test/test_windows_pit.py' + - 'apps/desktop/test/fixtures/**' + - 'apps/desktop/scripts/build-release.sh' + - '.github/workflows/release.yml' + - '.github/workflows/windows-pit.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + helper: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Test helper and Windows launcher (no certificate imports) + run: python -B -m unittest discover -s apps/desktop/test -p test_windows_pit.py -v diff --git a/apps/desktop/extensions/ai-sidebar/sidepanel.js b/apps/desktop/extensions/ai-sidebar/sidepanel.js index bf3589c4..2b863c7a 100644 --- a/apps/desktop/extensions/ai-sidebar/sidepanel.js +++ b/apps/desktop/extensions/ai-sidebar/sidepanel.js @@ -324,6 +324,8 @@ async function togglePit() { ? 'https:// on a pit name is trusted per name on first use, when the registry publishes its pin.' : trust.why === 'flatpak-engine' ? 'This TronBrowser is running the Flatpak Chromium, which ignores per-name trust, so https:// on a pit name will warn. Run tron upgrade to get TronBrowser’s own engine, then relaunch.' + : trust.why === 'windows-root-setup' + ? 'On Windows, registry-signed HTTPS needs the optional tronbrowser.cmd --setup-pit-https setup. It asks before adding a persistent root CA for all apps in your Windows account. Per-name self-signed certificates are not automatically trusted.' : trust.why === 'no-certutil' ? 'https:// on a pit name will warn until certutil is installed (Debian/Ubuntu: libnss3-tools, Fedora: nss-tools, Arch: nss).' : 'https:// on a pit name will warn on this platform; run moshcode dns enable for the certificate.'; @@ -345,7 +347,7 @@ async function togglePit() { if (err === 'tor-on') { showNetStatus('warn', 'Turn 🧅 Tor off first — Moshpit names can’t resolve through Tor, and checking them would leak lookups outside it.'); } else if (err === 'unreachable') { - showNetStatus('warn', 'Couldn’t reach the TronBrowser helper. Restart TronBrowser and try again, or run tron upgrade.'); + showNetStatus('warn', 'Couldn’t reach the TronBrowser helper. Restart through the TronBrowser launcher. On Windows, use the complete ZIP and install Python 3.9+; loading only the extension cannot start the helper. On Linux/macOS, run tron upgrade if needed.'); } else if (err === 'pit-port-busy') { showNetStatus('warn', `Port ${PIT_SOCKS_PORT} on this machine is taken by another program, so the pit resolver couldn’t start.`); } else if (err === 'helper-stale') { diff --git a/apps/desktop/launcher/tron-tor-helper b/apps/desktop/launcher/tron-tor-helper index ae7ab0c0..657885a0 100755 --- a/apps/desktop/launcher/tron-tor-helper +++ b/apps/desktop/launcher/tron-tor-helper @@ -54,7 +54,7 @@ BUNDLED_DIR = os.environ.get("TRON_TOR_BIN_DIR", "") PIDFILE = os.environ.get("TRON_TOR_PIDFILE", "") # Bumped whenever the helper protocol/behaviour changes; the launcher kills a # stale helper so the current version always runs. -HELPER_VERSION = "3.4.3" +HELPER_VERSION = "3.4.4" _lock = threading.Lock() _proc = None # the running tor subprocess (or None) _ready = False # True once tor reported Bootstrapped 100% @@ -450,6 +450,8 @@ def _safe_name(name): def trust_available(): """Can this machine take a per-name import at all? {available, why, engine}.""" + if platform.system() == "Windows": + return {"available": False, "why": "windows-root-setup", "engine": PIT_ENGINE} if platform.system() != "Linux": return {"available": False, "why": "unsupported-platform", "engine": PIT_ENGINE} if not shutil.which("certutil"): @@ -686,7 +688,10 @@ class PitSocks(threading.Thread): super().__init__(daemon=True, name="pit-socks") self.port = port self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + if sys.platform == "win32": + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1) + else: + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.sock.bind((HOST, port)) self.sock.listen(64) self._stopping = threading.Event() @@ -770,7 +775,8 @@ def pit_status(): with _pit_lock: running = _pit is not None and _pit.is_alive() return {"running": running, "port": PIT_SOCKS_PORT, "doh": PIT_DOH_URL, - "trust": trust_available(), "version": HELPER_VERSION} + "trust": trust_available(), "version": HELPER_VERSION, + "helper": "tronbrowser-network", "pid": os.getpid()} class Handler(BaseHTTPRequestHandler): @@ -779,13 +785,28 @@ class Handler(BaseHTTPRequestHandler): self.send_response(code) self.send_header("Content-Type", "application/json") # The toggle (a chrome-extension:// page) is the only intended caller. - self.send_header("Access-Control-Allow-Origin", "*") + origin = self.headers.get("Origin", "") + if re.fullmatch(r"chrome-extension://[a-p]{32}", origin): + self.send_header("Access-Control-Allow-Origin", origin) + self.send_header("Vary", "Origin") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _route(self): + # The extension uses literal loopback; reject web-page requests and DNS + # rebinding hosts. CLI clients without an Origin remain supported. + if self.headers.get("Host") != "%s:%d" % (HOST, self.server.server_port): + self._send(403, {"error": "invalid-host"}) + return + origin = self.headers.get("Origin") + if origin is not None and not re.fullmatch(r"chrome-extension://[a-p]{32}", origin): + self._send(403, {"error": "invalid-origin"}) + return path = self.path.split("?", 1)[0].rstrip("/") or "/" + if path in ("/start", "/stop", "/pit/start", "/pit/stop") and self.command != "POST": + self._send(405, {"error": "post-required"}) + return if path == "/start": # Non-blocking: kick Tor off, then report live state. The caller polls # /status for `progress` and `ready`. @@ -824,6 +845,10 @@ class Handler(BaseHTTPRequestHandler): def do_POST(self): self._route() + def do_OPTIONS(self): + # No web origin may authorize its own access to the helper. + self._send(403, {"error": "preflight-not-supported"}) + def log_message(self, *args): pass # quiet — the launcher routes our stdout to a log already @@ -853,11 +878,19 @@ def _remove_pidfile(): pass +class HelperServer(ThreadingHTTPServer): + def server_bind(self): + if sys.platform == "win32": + self.allow_reuse_address = False + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1) + super().server_bind() + + def main(): signal.signal(signal.SIGTERM, _shutdown) signal.signal(signal.SIGINT, _shutdown) try: - server = ThreadingHTTPServer((HOST, PORT), Handler) + server = HelperServer((HOST, PORT), Handler) except OSError: # Port already bound → another helper is running. Nothing to do. (The # launcher kills a stale helper before us, so this is rare.) diff --git a/apps/desktop/launcher/tron-windows.py b/apps/desktop/launcher/tron-windows.py new file mode 100644 index 00000000..edee0290 --- /dev/null +++ b/apps/desktop/launcher/tron-windows.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Windows network-helper startup and explicitly requested Pit HTTPS setup.""" +import hashlib +import json +import os +from pathlib import Path +import re +import runpy +import ssl +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request + +HERE = Path(__file__).resolve().parent +REGISTRY = "https://pit.moshcode.sh" +# Rotation is a reviewed release change, never trust whatever a server offers. +ROOT_SHA256 = "4A5766EC8C1F10F875C98965FBE8DC361A32C72BC3959516EA7B8161001E1557" + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise ValueError("Redirect refused: " + req.full_url) + + +def read_url(url, *, local=False, timeout=10, limit=65536): + handlers = [NoRedirect()] + if local: + handlers.append(urllib.request.ProxyHandler({})) + opener = urllib.request.build_opener(*handlers) + with opener.open(url, timeout=timeout) as response: + data = response.read(limit + 1) + if len(data) > limit: + raise ValueError("Response too large") + return data + + +def helper_state(port): + try: + raw = read_url("http://127.0.0.1:%d/pit/status" % port, local=True, timeout=0.5) + except urllib.error.URLError as exc: + # Only a refused connection means it is safe to try starting a helper. + if isinstance(exc.reason, ConnectionRefusedError): + return None + raise RuntimeError("Helper port is occupied or unresponsive") from exc + except (TimeoutError, OSError) as exc: + raise RuntimeError("Helper port is occupied or unresponsive") from exc + try: + state = json.loads(raw) + if (not isinstance(state, dict) or state.get("helper") != "tronbrowser-network" + or type(state.get("running")) is not bool + or type(state.get("port")) is not int + or type(state.get("pid")) is not int + or not isinstance(state.get("version"), str)): + raise ValueError("Unrecognized helper") + return state + except (ValueError, TypeError) as exc: + raise RuntimeError("Unrecognized or older service on the helper port; not replacing it") from exc + + +def start_helper(directory=HERE, data=None, timeout=6): + directory = Path(directory) + helper = directory / "tron-tor-helper" + if not helper.is_file(): + raise RuntimeError("Release is missing tron-tor-helper; reinstall the complete Windows ZIP") + config = runpy.run_path(str(helper)) + port, version = config["PORT"], config["HELPER_VERSION"] + existing = helper_state(port) + if existing is not None: + if existing["version"] != version: + raise RuntimeError("Older helper is running. Restart Windows after upgrading TronBrowser") + return existing + data = Path(data or os.environ.get("TRONBROWSER_DATA") or Path.home() / ".tronbrowser") + data.mkdir(parents=True, exist_ok=True) + env = os.environ.copy() + env.update(TRON_TOR_BIN_DIR=str(directory), TRON_TOR_DATA=str(data / "tor"), + PYTHONUTF8="1", PYTHONUNBUFFERED="1") + options = {"creationflags": subprocess.CREATE_NO_WINDOW | subprocess.DETACHED_PROCESS} if sys.platform == "win32" else {"start_new_session": True} + log_path = data / "tor-helper.log" + if log_path.exists() and log_path.stat().st_size > 5 * 1024 * 1024: + log_path.replace(data / "tor-helper.previous.log") + with log_path.open("ab") as log: + child = subprocess.Popen([sys.executable, str(helper)], stdin=subprocess.DEVNULL, + stdout=log, stderr=subprocess.STDOUT, env=env, **options) + deadline = time.monotonic() + timeout + try: + while time.monotonic() < deadline: + state = helper_state(port) + if state is not None: + if state["version"] != version: + raise RuntimeError("A different helper version owns the port") + return state + if child.poll() is not None: + raise RuntimeError("Helper exited before becoming ready") + time.sleep(0.1) + raise RuntimeError("Helper startup timed out") + except Exception: + # Terminate only the process we just created, never a PID read from disk. + if child.poll() is None: + child.terminate() + child.wait(timeout=5) + raise + + +# No interpolated commands, policy changes, machine store, or TLS exceptions. +# Certificate validation is repeated immediately before adding to the store. +CERTIFICATE_COMMAND = r''' +$ErrorActionPreference = 'Stop' +$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($env:TRON_CA_FILE) +$sha = [System.Security.Cryptography.SHA256]::Create() +try { $fingerprint = ([BitConverter]::ToString($sha.ComputeHash($cert.RawData))).Replace('-', '') } +finally { $sha.Dispose() } +if ($fingerprint -cne $env:TRON_CA_SHA256) { throw 'Certificate fingerprint mismatch' } +$constraints = @($cert.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.19' }) +if ($constraints.Count -ne 1) { throw 'Missing or duplicate CA constraints' } +$basic = New-Object System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension +$basic.CopyFrom($constraints[0]) +if (-not $basic.CertificateAuthority) { throw 'Not a CA certificate' } +$usages = @($cert.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.15' }) +if ($usages.Count -ne 1) { throw 'Missing CA key usage' } +$usage = New-Object System.Security.Cryptography.X509Certificates.X509KeyUsageExtension +$usage.CopyFrom($usages[0]) +if (-not ($usage.KeyUsages -band [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyCertSign)) { throw 'CA cannot sign certificates' } +$now = [DateTime]::UtcNow +if ($now -lt $cert.NotBefore.ToUniversalTime() -or $now -gt $cert.NotAfter.ToUniversalTime()) { throw 'Certificate is not currently valid' } +if ($cert.Subject -ne $cert.Issuer) { throw 'Not a root certificate' } +if ($cert.HasPrivateKey) { throw 'Unexpected private key' } +$store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root', 'CurrentUser') +try { + $flags = [System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly + if ($env:TRON_CA_MODE -eq 'install') { $flags = [System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite } + $store.Open($flags) + $existing = $store.Certificates.Find([System.Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, $cert.Thumbprint, $false) + $already = $existing.Count -gt 0 + if ($env:TRON_CA_MODE -eq 'install' -and -not $already) { $store.Add($cert) } + @{fingerprint=$fingerprint; thumbprint=$cert.Thumbprint; alreadyTrusted=$already; subject=$cert.Subject} | ConvertTo-Json -Compress +} finally { $store.Close(); $cert.Dispose() } +''' + + +def certificate_action(path, fingerprint, mode): + if sys.platform != "win32": + raise RuntimeError("Certificate setup is Windows-only") + if mode not in ("inspect", "install"): + raise ValueError("Invalid certificate action") + powershell = Path(os.environ["SystemRoot"]) / "System32/WindowsPowerShell/v1.0/powershell.exe" + env = os.environ.copy() + env.update(TRON_CA_FILE=str(path), TRON_CA_SHA256=fingerprint, TRON_CA_MODE=mode) + result = subprocess.run([str(powershell), "-NoProfile", "-NonInteractive", "-Command", CERTIFICATE_COMMAND], + env=env, capture_output=True, text=True, timeout=90) + if result.returncode != 0: + raise RuntimeError("Windows certificate validation/import failed: " + result.stderr.strip()) + return json.loads(result.stdout) + + +def download_root(): + metadata = json.loads(read_url(REGISTRY + "/api/moshpit/ca")) + if not isinstance(metadata, dict) or metadata.get("enabled") is not True: + raise ValueError("Registry CA is not enabled") + root = metadata.get("root") + wanted = root.get("fingerprint_sha256", "") if isinstance(root, dict) else "" + if not isinstance(wanted, str): + raise ValueError("Invalid CA fingerprint") + wanted = wanted.replace(":", "").upper() + if not re.fullmatch(r"[0-9A-F]{64}", wanted): + raise ValueError("Invalid CA fingerprint") + if wanted != ROOT_SHA256: + raise ValueError("Registry CA changed; a reviewed TronBrowser update is required") + pem = read_url(REGISTRY + "/api/moshpit/ca.crt").decode("ascii").strip() + if not re.fullmatch(r"-----BEGIN CERTIFICATE-----\s+[A-Za-z0-9+/=\s]+-----END CERTIFICATE-----", pem): + raise ValueError("Expected exactly one PEM certificate") + der = ssl.PEM_cert_to_DER_cert(pem) + if hashlib.sha256(der).hexdigest().upper() != wanted: + raise ValueError("Registry CA fingerprint mismatch") + return der, wanted + + +def setup_https(): + if sys.platform != "win32": + raise RuntimeError("Certificate setup is Windows-only") + der, fingerprint = download_root() + with tempfile.TemporaryDirectory(prefix="tron-pit-ca-") as temp: + path = Path(temp) / "root.cer" + path.write_bytes(der) + info = certificate_action(path, fingerprint, "inspect") + print("Registry: " + REGISTRY) + print("CA SHA-256: " + fingerprint) + print("Windows thumbprint: " + info["thumbprint"]) + if info["alreadyTrusted"]: + print("This exact CA is already trusted; no changes made.") + return + print("This adds a persistent root CA to your Windows CURRENT USER trusted roots.") + print("It can authenticate sites in ALL apps using that store, not just TronBrowser.") + print("This CA is NOT restricted to Moshpit names. Trust its operator only if you") + print("accept that authority; turning Pit off does not remove the certificate.") + print("No DNS or machine-wide settings will change. Do not do this on a managed") + print("work computer without your administrator's approval. Cancel if unsure.") + if input("Type TRUST to continue (anything else cancels): ").strip() != "TRUST": + print("Cancelled; no certificate was installed.") + return + certificate_action(path, fingerprint, "install") + print("Root CA installed. Restart TronBrowser, then turn Pit on.") + print("To undo: open certmgr.msc > Trusted Root Certification Authorities >") + print("Certificates, and remove ONLY the certificate with this thumbprint:") + print(info["thumbprint"]) + + +def main(): + try: + if sys.argv[1:] == ["setup-https"]: + setup_https() + elif sys.argv[1:] == ["start"]: + state = start_helper() + print("TronBrowser network helper ready (PID %d). Pit stays off until enabled." % state["pid"]) + else: + raise ValueError("Usage: tron-windows.py start|setup-https") + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError, EOFError) as exc: + print("TronBrowser: %s" % exc, file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/apps/desktop/launcher/tronbrowser b/apps/desktop/launcher/tronbrowser index 9a395b0d..46cd024f 100755 --- a/apps/desktop/launcher/tronbrowser +++ b/apps/desktop/launcher/tronbrowser @@ -210,7 +210,7 @@ if [ "$TOR" != "1" ]; then # running helper isn't this version — otherwise leave a healthy current # helper alone (don't drop an active Tor session). All backgrounded so the # kill+settle never holds up the browser launch. - HELPER_VERSION=3.4.3 + HELPER_VERSION=3.4.4 ( _pf="$DATA/tor-helper.pid" _rv="$(curl -fsS --max-time 1 http://127.0.0.1:9061/status 2>/dev/null | sed -n 's/.*"version"[^"]*"\([^"]*\)".*/\1/p')" diff --git a/apps/desktop/launcher/tronbrowser.cmd b/apps/desktop/launcher/tronbrowser.cmd index c1aa39a7..cbd44258 100644 --- a/apps/desktop/launcher/tronbrowser.cmd +++ b/apps/desktop/launcher/tronbrowser.cmd @@ -1,5 +1,5 @@ @echo off -setlocal enabledelayedexpansion +setlocal DisableDelayedExpansion rem TronBrowser launcher (Windows). Runs Ungoogled Chromium ONLY (never Chrome, rem Edge, Brave, or regular Chromium) with TronBrowser privacy flags + bundled rem extensions (AI sidebar + MarkSyncr). `tron ` passes URLs through. @@ -7,12 +7,44 @@ rem Mirrors apps/desktop/launcher/tronbrowser (POSIX). Override the binary with rem set "TRONBROWSER_BROWSER=C:\path\to\ungoogled-chromium\chrome.exe". set "DIR=%~dp0" set "DATA=%USERPROFILE%\.tronbrowser" +if defined TRONBROWSER_DATA set "DATA=%TRONBROWSER_DATA%" + +rem Validate the interpreter: Windows may have a Store alias named python.exe. +set "PYTHON=" +set "PYTHON_ARGS=" +if exist "%DIR%python\python.exe" ( + "%DIR%python\python.exe" -c "import sys; sys.exit(sys.version_info < (3, 9))" >nul 2>&1 + if not errorlevel 1 set "PYTHON=%DIR%python\python.exe" +) +if not defined PYTHON ( + py -3 -c "import sys; sys.exit(sys.version_info < (3, 9))" >nul 2>&1 + if not errorlevel 1 ( + set "PYTHON=py" + set "PYTHON_ARGS=-3" + ) +) +if not defined PYTHON ( + for /f "delims=" %%P in ('where python.exe 2^>nul') do ( + if not defined PYTHON if /i not "%%P"=="%LOCALAPPDATA%\Microsoft\WindowsApps\python.exe" ( + "%%P" -c "import sys; sys.exit(sys.version_info < (3, 9))" >nul 2>&1 + if not errorlevel 1 set "PYTHON=%%P" + ) + ) +) +if /i "%~1"=="--setup-pit-https" goto setup_https rem Load every bundled extension (each subdir with a manifest.json). set "EXT=" for /d %%D in ("%DIR%extensions\*") do ( if exist "%%D\manifest.json" ( - if defined EXT (set "EXT=!EXT!,%%D") else (set "EXT=%%D") + rem Enable delayed expansion only after capturing paths (which may contain !). + set "NEXT_EXT=%%D" + setlocal EnableDelayedExpansion + if defined EXT (set "NEXT_EXT=!EXT!,!NEXT_EXT!") + for /f "delims=" %%E in ("!NEXT_EXT!") do ( + endlocal + set "EXT=%%E" + ) ) ) @@ -39,6 +71,16 @@ if not defined BROWSER ( exit /b 1 ) +rem Start only the loopback helper, not Tor, Pit, or certificate installation. +if not defined PYTHON ( + echo TronBrowser: Pit needs Python 3.9+ from python.org, then restart TronBrowser. >&2 +) else if not exist "%DIR%tron-windows.py" ( + echo TronBrowser: incomplete Windows package; reinstall the complete ZIP. >&2 +) else ( + "%PYTHON%" %PYTHON_ARGS% "%DIR%tron-windows.py" start + if errorlevel 1 echo TronBrowser: network helper unavailable; ordinary browsing still works. >&2 +) + rem --enable-features=EnableTabMuting makes the tab audio indicator a clickable rem mute/unmute control (media::kEnableTabMuting is DISABLED_BY_DEFAULT upstream; rem stock Chrome only enables it via Finch, which an ungoogled build never gets). @@ -47,3 +89,12 @@ rem stock Chrome only enables it via Finch, which an ungoogled build never gets) --disable-sync --disable-features=Translate,OptimizationHints,InterestFeedContentSuggestions ^ --enable-features=EnableTabMuting ^ --log-level=2 --load-extension="%EXT%" %* +exit /b %errorlevel% + +:setup_https +if not defined PYTHON ( + echo TronBrowser: install Python 3.9+ from python.org before setting up Pit HTTPS. >&2 + exit /b 1 +) +"%PYTHON%" %PYTHON_ARGS% "%DIR%tron-windows.py" setup-https +exit /b %errorlevel% diff --git a/apps/desktop/scripts/build-release.sh b/apps/desktop/scripts/build-release.sh index cefe93b5..5991d062 100755 --- a/apps/desktop/scripts/build-release.sh +++ b/apps/desktop/scripts/build-release.sh @@ -71,6 +71,7 @@ stage() { # dest dir # On-demand network helper for the in-browser 🧅 Tor and 🤘 Pit toggles (the # launcher starts it; it starts Tor / the Moshpit resolver only when asked). install -m 0755 "$DESKTOP/launcher/tron-tor-helper" "$s/tron-tor-helper" + install -m 0644 "$DESKTOP/launcher/tron-windows.py" "$s/tron-windows.py" # Repoints installed-web-app desktop icons at the launcher. The shim runs it # on every start (the engine rewrites those files behind us); `tron pwa` is # the manual handle. diff --git a/apps/desktop/test/fixtures/moshpit-root-ca.crt b/apps/desktop/test/fixtures/moshpit-root-ca.crt new file mode 100644 index 00000000..87a6d74c --- /dev/null +++ b/apps/desktop/test/fixtures/moshpit-root-ca.crt @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBnDCCAUGgAwIBAgIQRGAxezez71rzl6e5/lPTRTAKBggqhkjOPQQDAjAsMRgw +FgYDVQQDEw9Nb3NocGl0IFJvb3QgQ0ExEDAOBgNVBAoTB01vc2hwaXQwHhcNMjYw +OTE2MTYyNjUzWhcNNDYwOTExMTYyNjUzWjAsMRgwFgYDVQQDEw9Nb3NocGl0IFJv +b3QgQ0ExEDAOBgNVBAoTB01vc2hwaXQwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNC +AAT04M/Vm4ypLIuYVqLhPhjp29v7CWwGzape9UE5RiDwNSApkCVJBBeRuu/xWCMI +pAn4BbwmozfCIwZkI+9XOHBTo0UwQzASBgNVHRMBAf8ECDAGAQH/AgEBMA4GA1Ud +DwEB/wQEAwIBBjAdBgNVHQ4EFgQU5X5CZpvtc/QpAdNcfO2MSyCJu3EwCgYIKoZI +zj0EAwIDSQAwRgIhAOuKm14PMGs4qDiwIgSzzhNLQrs119RllEKjfFYEc6l5AiEA +0wYe9QqkmywcXM6uGCYy5iAIMCZtXMVY1W0tOHxQssE= +-----END CERTIFICATE----- diff --git a/apps/desktop/test/test_windows_pit.py b/apps/desktop/test/test_windows_pit.py new file mode 100644 index 00000000..47bd5488 --- /dev/null +++ b/apps/desktop/test/test_windows_pit.py @@ -0,0 +1,378 @@ +"""Stdlib-only tests. No public network, DNS changes, or certificate imports.""" +import contextlib +import hashlib +import http.client +import importlib.util +import io +import json +import os +from pathlib import Path +import runpy +import shutil +import socket +import ssl +import struct +import subprocess +import sys +import tempfile +import threading +import unittest +from unittest import mock +import urllib.error + +LAUNCHER = Path(__file__).resolve().parents[1] / "launcher" +SPEC = importlib.util.spec_from_file_location("tron_windows", LAUNCHER / "tron-windows.py") +windows = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(windows) + + +def helper_module(): + return runpy.run_path(str(LAUNCHER / "tron-tor-helper")) + + +def unused_port(): + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + return listener.getsockname()[1] + + +def state(**overrides): + return dict(helper="tronbrowser-network", version="3.4.4", running=False, + port=9081, pid=123, **overrides) + + +class StartupTests(unittest.TestCase): + def test_refused_port_is_only_absence_case(self): + with mock.patch.object(windows, "read_url", side_effect=urllib.error.URLError(ConnectionRefusedError())): + self.assertIsNone(windows.helper_state(1234)) + for error in (urllib.error.URLError(TimeoutError()), TimeoutError(), OSError("busy")): + with self.subTest(error=error), mock.patch.object(windows, "read_url", side_effect=error): + with self.assertRaises(RuntimeError): + windows.helper_state(1234) + + def test_invalid_or_old_responder_not_replaced(self): + for response in ({}, [], {"version": "old"}, {**state(), "pid": True}, {**state(), "version": None}): + with self.subTest(response=response), mock.patch.object(windows, "read_url", return_value=json.dumps(response).encode()): + with self.assertRaises(RuntimeError): + windows.helper_state(1234) + + def test_reuses_matching_helper_without_spawn(self): + with mock.patch.object(windows, "helper_state", return_value=state()), mock.patch.object(windows.subprocess, "Popen") as spawn: + self.assertEqual(windows.start_helper(LAUNCHER)["pid"], 123) + spawn.assert_not_called() + + def test_does_not_kill_stale_helper(self): + with mock.patch.object(windows, "helper_state", return_value={**state(), "version": "old"}), mock.patch.object(windows.subprocess, "Popen") as spawn: + with self.assertRaisesRegex(RuntimeError, "Older helper"): + windows.start_helper(LAUNCHER) + spawn.assert_not_called() + + def test_missing_bundle_rejected(self): + with tempfile.TemporaryDirectory() as temp: + with self.assertRaisesRegex(RuntimeError, "missing tron-tor-helper"): + windows.start_helper(temp) + + def test_failed_child_is_reaped(self): + with tempfile.TemporaryDirectory() as temp, mock.patch.object(windows, "helper_state", return_value=None), mock.patch.object(windows.subprocess, "Popen") as spawn: + spawn.return_value.poll.return_value = None + with self.assertRaisesRegex(RuntimeError, "timed out"): + windows.start_helper(LAUNCHER, data=temp, timeout=0) + spawn.return_value.terminate.assert_called_once() + spawn.return_value.wait.assert_called_once() + self.assertEqual(spawn.call_args.kwargs["env"]["TRON_TOR_BIN_DIR"], str(LAUNCHER)) + + def test_detached_windows_flags(self): + with tempfile.TemporaryDirectory() as temp, mock.patch.object(windows, "helper_state", side_effect=[None, state()]), mock.patch.object(windows.sys, "platform", "win32"), mock.patch.object(windows.subprocess, "CREATE_NO_WINDOW", 0x08000000, create=True), mock.patch.object(windows.subprocess, "DETACHED_PROCESS", 8, create=True), mock.patch.object(windows.subprocess, "Popen") as spawn: + windows.start_helper(LAUNCHER, data=temp) + self.assertEqual(spawn.call_args.kwargs["creationflags"], 0x08000008) + self.assertNotIn("shell", spawn.call_args.kwargs) + + +class HttpTests(unittest.TestCase): + def setUp(self): + self.helper = helper_module() + self.server = self.helper["HelperServer"](("127.0.0.1", 0), self.helper["Handler"]) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=3) + + def request(self, path, method="GET", headers=None): + with contextlib.closing(http.client.HTTPConnection("127.0.0.1", self.server.server_port, timeout=3)) as client: + client.request(method, path, headers=headers or {}) + response = client.getresponse() + return response.status, dict(response.getheaders()), json.loads(response.read()) + + def test_readiness_does_not_activate_network_services(self): + status, headers, body = self.request("/pit/status") + self.assertEqual(status, 200) + self.assertFalse(body["running"]) + self.assertEqual(body["helper"], "tronbrowser-network") + self.assertNotIn("Access-Control-Allow-Origin", headers) + + def test_web_origin_cannot_control_helper(self): + for origin in ("https://attacker.invalid", "null", "chrome-extension://invalid"): + for method in ("GET", "POST"): + with self.subTest(origin=origin, method=method): + status, headers, _ = self.request("/pit/start", method, {"Origin": origin}) + self.assertEqual(status, 403) + self.assertNotIn("Access-Control-Allow-Origin", headers) + + def test_dns_rebinding_host_rejected(self): + self.assertEqual(self.request("/pit/status", headers={"Host": "attacker.invalid"})[0], 403) + + def test_get_cannot_start_or_stop_services(self): + for path in ("/start", "/stop", "/pit/start", "/pit/stop"): + self.assertEqual(self.request(path)[0], 405) + + def test_extension_origin_and_post_still_work(self): + origin = "chrome-extension://" + "a" * 32 + status, headers, body = self.request("/pit/stop", "POST", {"Origin": origin}) + self.assertEqual(status, 200) + self.assertTrue(body["stopped"]) + self.assertEqual(headers["Access-Control-Allow-Origin"], origin) + + def test_proxy_settings_cannot_redirect_loopback_probe(self): + with mock.patch.dict(os.environ, {"HTTP_PROXY": "http://127.0.0.1:1", "http_proxy": "http://127.0.0.1:1", "NO_PROXY": "", "no_proxy": ""}): + self.assertEqual(windows.helper_state(self.server.server_port)["helper"], "tronbrowser-network") + + +class RootSetupTests(unittest.TestCase): + def test_committed_public_ca_matches_release_pin(self): + pem = (LAUNCHER.parent / "test/fixtures/moshpit-root-ca.crt").read_text() + der = ssl.PEM_cert_to_DER_cert(pem) + self.assertEqual(hashlib.sha256(der).hexdigest().upper(), windows.ROOT_SHA256) + + @unittest.skipUnless(sys.platform == "win32", "Needs native Windows certificate APIs") + def test_windows_inspects_ca_without_modifying_trust_store(self): + powershell = str(Path(os.environ["SystemRoot"]) / "System32/WindowsPowerShell/v1.0/powershell.exe") + + def snapshot(): + return subprocess.run([powershell, "-NoProfile", "-NonInteractive", "-Command", + "Get-ChildItem Cert:\\CurrentUser\\Root | Sort-Object Thumbprint | ForEach-Object { $_.Thumbprint }"], + capture_output=True, text=True, check=True, timeout=15).stdout + + before = snapshot() + with tempfile.TemporaryDirectory() as temp: + pem = (LAUNCHER.parent / "test/fixtures/moshpit-root-ca.crt").read_text() + cert = Path(temp) / "CA space !.cer" + cert.write_bytes(ssl.PEM_cert_to_DER_cert(pem)) + info = windows.certificate_action(cert, windows.ROOT_SHA256, "inspect") + self.assertEqual(info["fingerprint"], windows.ROOT_SHA256) + self.assertIn("Moshpit Root CA", info["subject"]) + with self.assertRaisesRegex(RuntimeError, "fingerprint mismatch"): + windows.certificate_action(cert, "0" * 64, "inspect") + self.assertEqual(snapshot(), before) + + def test_download_requires_release_pin_and_metadata_match(self): + der = b"fixture DER bytes: download validates bytes, Windows validates X509" + pin = hashlib.sha256(der).hexdigest().upper() + metadata = json.dumps({"enabled": True, "root": {"fingerprint_sha256": pin}}).encode() + pem = ssl.DER_cert_to_PEM_cert(der).encode() + with mock.patch.object(windows, "ROOT_SHA256", pin), mock.patch.object(windows, "read_url", side_effect=[metadata, pem]): + self.assertEqual(windows.download_root(), (der, pin)) + with mock.patch.object(windows, "read_url", return_value=metadata) as fetch: + with self.assertRaisesRegex(ValueError, "reviewed TronBrowser update"): + windows.download_root() + self.assertEqual(fetch.call_count, 1) + + def test_download_rejects_wrong_bytes_and_multiple_certs(self): + pin = windows.ROOT_SHA256 + metadata = json.dumps({"enabled": True, "root": {"fingerprint_sha256": pin}}).encode() + pem = ssl.DER_cert_to_PEM_cert(b"wrong").encode() + for cert in (pem, pem + pem): + with mock.patch.object(windows, "read_url", side_effect=[metadata, cert]): + with self.assertRaises(ValueError): + windows.download_root() + + def test_invalid_metadata_never_downloads_certificate(self): + for metadata in ([], {}, {"enabled": "true"}, {"enabled": True, "root": {"fingerprint_sha256": []}}): + with mock.patch.object(windows, "read_url", return_value=json.dumps(metadata).encode()) as fetch: + with self.assertRaises(ValueError): + windows.download_root() + self.assertEqual(fetch.call_count, 1) + + def test_redirects_are_refused(self): + request = windows.urllib.request.Request(windows.REGISTRY) + with self.assertRaisesRegex(ValueError, "Redirect refused"): + windows.NoRedirect().redirect_request(request, None, 302, "", {}, "http://untrusted.invalid") + + def test_cancel_never_imports_and_temp_file_removed(self): + info = {"thumbprint": "A" * 40, "alreadyTrusted": False} + with mock.patch.object(windows.sys, "platform", "win32"), mock.patch.object(windows, "download_root", return_value=(b"der", windows.ROOT_SHA256)), mock.patch.object(windows, "certificate_action", return_value=info) as action, mock.patch("builtins.input", return_value="no"), contextlib.redirect_stdout(io.StringIO()) as output: + windows.setup_https() + self.assertEqual(action.call_count, 1) + self.assertEqual(action.call_args.args[2], "inspect") + self.assertFalse(action.call_args.args[0].exists()) + self.assertIn("ALL apps", output.getvalue()) + self.assertIn("NOT restricted", output.getvalue()) + + def test_already_trusted_never_prompts_or_imports(self): + info = {"thumbprint": "A" * 40, "alreadyTrusted": True} + with mock.patch.object(windows.sys, "platform", "win32"), mock.patch.object(windows, "download_root", return_value=(b"der", windows.ROOT_SHA256)), mock.patch.object(windows, "certificate_action", return_value=info) as action, mock.patch("builtins.input") as prompt, contextlib.redirect_stdout(io.StringIO()): + windows.setup_https() + prompt.assert_not_called() + self.assertEqual(action.call_count, 1) + + def test_explicit_consent_imports_exact_inspected_bytes(self): + info = {"thumbprint": "A" * 40, "alreadyTrusted": False} + with mock.patch.object(windows.sys, "platform", "win32"), mock.patch.object(windows, "download_root", return_value=(b"der", windows.ROOT_SHA256)), mock.patch.object(windows, "certificate_action", return_value=info) as action, mock.patch("builtins.input", return_value="TRUST"), contextlib.redirect_stdout(io.StringIO()): + windows.setup_https() + self.assertEqual([call.args[2] for call in action.call_args_list], ["inspect", "install"]) + self.assertEqual(action.call_args_list[0].args[:2], action.call_args_list[1].args[:2]) + + +class RuntimeTests(unittest.TestCase): + @unittest.skipUnless(shutil.which("openssl"), "OpenSSL fixture generator is required") + def test_socks_relays_verified_tls_and_does_not_hide_certificate_errors(self): + with tempfile.TemporaryDirectory() as temp: + cert, key = Path(temp) / "cert.pem", Path(temp) / "key.pem" + subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-keyout", str(key), "-out", str(cert), "-days", "1", + "-subj", "/CN=fixture.invalid", "-addext", "subjectAltName=DNS:fixture.invalid"], + check=True, capture_output=True, timeout=15) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(cert, key) + listener = socket.socket() + listener.bind(("127.0.0.1", 0)) + listener.listen() + listener.settimeout(3) + port = listener.getsockname()[1] + helper = helper_module() + globals_ = helper["_pit_serve"].__globals__ + socks = helper["PitSocks"](0) + socks_port = socks.sock.getsockname()[1] + errors = [] + + def serve(): + for _ in range(2): + try: + raw, _addr = listener.accept() + with raw: + try: + with context.wrap_socket(raw, server_side=True) as tls: + tls.recv(1024) + tls.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + except ssl.SSLError: + pass # the second client deliberately does not trust our fixture + except Exception as exc: + errors.append(exc) + + server = threading.Thread(target=serve, daemon=True) + with mock.patch.dict(globals_, {"pit_resolve": lambda name: ["127.0.0.1"]}): + socks.start() + server.start() + try: + for trusted in (True, False): + with socket.create_connection(("127.0.0.1", socks_port), timeout=3) as client: + client.sendall(b"\x05\x01\x00") + self.assertEqual(helper["_recv_exact"](client, 2), b"\x05\x00") + host = b"fixture.invalid" + client.sendall(b"\x05\x01\x00\x03" + bytes([len(host)]) + host + struct.pack(">H", port)) + self.assertEqual(helper["_recv_exact"](client, 10)[1], 0) + tls_context = ssl.create_default_context(cafile=str(cert) if trusted else None) + if trusted: + with tls_context.wrap_socket(client, server_hostname="fixture.invalid") as tls: + tls.sendall(b"GET / HTTP/1.1\r\nHost: fixture.invalid\r\n\r\n") + self.assertIn(b"200 OK", tls.recv(4096)) + else: + with self.assertRaises(ssl.SSLCertVerificationError): + tls_context.wrap_socket(client, server_hostname="fixture.invalid") + finally: + socks.stop() + listener.close() + server.join(timeout=5) + self.assertFalse(errors) + + def test_real_helper_start_reuse_and_stop_without_external_network(self): + with tempfile.TemporaryDirectory(prefix="tron pit space ! ") as temp: + control, socks = unused_port(), unused_port() + processes = [] + popen = subprocess.Popen + + def record(*args, **kwargs): + child = popen(*args, **kwargs) + processes.append(child) + return child + + env = {"TRON_TOR_HELPER_PORT": str(control), "TRON_PIT_SOCKS_PORT": str(socks), + "TRON_TOR_SOCKS_PORT": str(unused_port()), "TRON_PIT_DOH_URL": "http://127.0.0.1:1/dns-query"} + try: + with mock.patch.dict(os.environ, env), mock.patch.object(windows.subprocess, "Popen", side_effect=record): + first = windows.start_helper(LAUNCHER, data=temp) + second = windows.start_helper(LAUNCHER, data=temp) + self.assertEqual(first["pid"], second["pid"]) + self.assertEqual(len(processes), 1) + self.assertFalse(first["running"]) + for _ in range(2): + with contextlib.closing(http.client.HTTPConnection("127.0.0.1", control, timeout=4)) as client: + client.request("POST", "/pit/start") + started = json.loads(client.getresponse().read()) + self.assertTrue(started["started"]) + self.assertFalse(started["check"]["ok"]) + client.request("POST", "/pit/stop") + self.assertTrue(json.loads(client.getresponse().read())["stopped"]) + with self.assertRaises(OSError): + socket.create_connection(("127.0.0.1", socks), timeout=0.3) + finally: + for child in processes: + child.terminate() + child.wait(timeout=5) + + def test_windows_exclusive_socket_binding(self): + helper = helper_module() + with mock.patch.object(sys, "platform", "win32"), mock.patch.object(socket, "SO_EXCLUSIVEADDRUSE", -5, create=True), mock.patch.object(socket, "socket") as sock: + helper["PitSocks"](1234) + sock.return_value.setsockopt.assert_called_once_with(socket.SOL_SOCKET, -5, 1) + + def test_both_packagers_ship_windows_entrypoints(self): + root = LAUNCHER.parents[2] + shell = (root / "apps/desktop/scripts/build-release.sh").read_text() + workflow = (root / ".github/workflows/release.yml").read_text() + for filename in ("tron-tor-helper", "tron-windows.py", "tronbrowser.cmd"): + self.assertIn(filename, shell) + self.assertIn(filename, workflow) + + @unittest.skipUnless(sys.platform == "win32", "Needs the native cmd.exe parser") + def test_cmd_launches_helper_and_preserves_paths_and_arguments(self): + with tempfile.TemporaryDirectory(prefix="tron pit space ! ") as temp: + directory = Path(temp) + for filename in ("tron-windows.py", "tron-tor-helper", "tronbrowser.cmd"): + shutil.copyfile(LAUNCHER / filename, directory / filename) + for name in ("ai-sidebar", "another extension !"): + extension = directory / "extensions" / name + extension.mkdir(parents=True) + (extension / "manifest.json").write_text("{}") + recorder = directory / "record.py" + recorder.write_text("import json,os,sys\nfrom pathlib import Path\nPath(os.environ['ARGV_OUT']).write_text(json.dumps(sys.argv[1:]))\n") + browser = directory / "browser.cmd" + browser.write_text('@echo off\n"%TEST_PYTHON%" "%TEST_RECORDER%" %*\nexit /b %errorlevel%\n') + output = directory / "args.json" + pidfile = directory / "helper.pid" + control = unused_port() + env = {**os.environ, "TRONBROWSER_BROWSER": str(browser), "TRONBROWSER_DATA": str(directory / "profile"), + "TRON_TOR_HELPER_PORT": str(control), "TRON_TOR_PIDFILE": str(pidfile), "TRON_TOR_SOCKS_PORT": str(unused_port()), + "TEST_PYTHON": sys.executable, "TEST_RECORDER": str(recorder), "ARGV_OUT": str(output)} + try: + command = '""%s" "https://example.invalid/path?a=1&b=2""' % (directory / "tronbrowser.cmd") + result = subprocess.run([os.environ["COMSPEC"], "/d", "/s", "/c", command], env=env, capture_output=True, text=True, timeout=20) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertTrue(output.exists(), result.stdout + result.stderr) + args = json.loads(output.read_text()) + self.assertIn("--user-data-dir=" + str(directory / "profile"), args) + self.assertIn("https://example.invalid/path?a=1&b=2", args) + extensions = next(arg for arg in args if arg.startswith("--load-extension=")) + self.assertIn("another extension !", extensions) + self.assertIn("ai-sidebar", extensions) + self.assertEqual(windows.helper_state(control)["pid"], int(pidfile.read_text())) + finally: + if pidfile.exists(): + # This PID belongs to our isolated test subprocess and directory. + subprocess.run(["taskkill", "/PID", pidfile.read_text().strip(), "/F"], capture_output=True, timeout=5) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/moshpit-pit-toggle.md b/docs/moshpit-pit-toggle.md index e95f787e..c19a06b1 100644 --- a/docs/moshpit-pit-toggle.md +++ b/docs/moshpit-pit-toggle.md @@ -1,6 +1,6 @@ # 🤘 Pit toggle — Moshpit names for one browser session -**Status:** shipped with the AI-sidebar extension + `tron-tor-helper` 3.4.3 +**Status:** AI-sidebar extension + `tron-tor-helper` 3.4.4 **Owner:** desktop (`apps/desktop`) **Scope:** resolve Moshpit names in the running browser with one click. Not a replacement for `moshcode dns enable`, which does it for the whole machine. @@ -194,13 +194,67 @@ curl -X POST http://127.0.0.1:19061/pit/stop ## Not in this version -- **`https://` on macOS and Windows.** Per-name trust writes the NSS database, - which only Chromium on Linux reads. `moshcode dns enable` remains the answer - there. +- **Per-name HTTPS trust on macOS and Windows.** Per-name trust writes the NSS + database, which only Chromium on Linux reads. Windows can explicitly opt into + registry-root trust using the setup below. This does not trust self-signed + origins, and macOS trust setup remains unchanged. - **"Moshpit wins."** The resolvers' `MOSHPIT_RESOLVE_MODE=moshpit` lets a registered name override a clearnet one. The toggle only implements the default `fallback` policy. - **Persisting across launches.** Mirrors Tor deliberately; a setting to keep the pit on would be a small follow-up. -- **Windows.** The `.cmd` shim does not start the helper, so neither toggle - works there yet. + +## Windows launcher and HTTPS + +Extract the **complete Windows release ZIP** and run `tronbrowser.cmd`. Loading +only the extension into a portable browser cannot start the helper. Install +Python 3.9+ (3.12 or newer recommended) from python.org first. The launcher checks +`python/python.exe` beside the launcher, then `py -3`, then Python on PATH; it +does not download an interpreter. Set `TRONBROWSER_BROWSER` to your Ungoogled +Chromium executable if it is not in a standard install location. + +The launcher starts the bundled `tron-tor-helper` on loopback and waits for a +bounded readiness check before opening the browser. It does not enable Pit or +Tor, change DNS, or install certificates. A failed helper cannot prevent ordinary +browsing; details are in `%USERPROFILE%\.tronbrowser\tor-helper.log` (or the +`TRONBROWSER_DATA` directory). A compatible existing helper is reused. An unknown +service or stale helper is never killed by PID; restart Windows after upgrading +if a stale helper is still running. Like the Linux helper, it can outlive the +browser, while the extension's proxy selection resets each browser session. + +For **registry-signed HTTPS** there is a separate opt-in command: + +```bat +tronbrowser.cmd --setup-pit-https +``` + +This command fetches the CA over verified HTTPS, checks both registry metadata +and the root SHA-256 pinned in the release, validates its CA constraints, key +usage and dates with Windows, and requires typing `TRUST` before adding it to +**Current User / Trusted Root Certification Authorities**. It never changes +Local Machine roots or DNS and never disables TLS verification. A root rotation +requires a reviewed code update, not just new metadata from the registry. + +**Trust boundary:** this root is unconstrained. It can vouch for arbitrary DNS +names in **all apps that use this Windows user store**, not only Moshpit names or +TronBrowser. The certificate remains installed when Pit is off. Do not approve +it on a managed/company machine without administrator authorization. Cancel the +prompt if that trust is not acceptable; HTTP Pit routing remains usable and +HTTPS certificate warnings remain in place. Never bypass those warnings. + +After setup, fully restart TronBrowser and enable Pit. HTTPS still requires a +valid certificate for the requested hostname; an arbitrary self-signed origin +will not become trusted. To undo a newly installed root, use `certmgr.msc` and +remove only the certificate whose exact thumbprint the setup printed. The setup +does not change or claim ownership of a root that was already trusted. + +Regression tests (no CA imports or public-network calls): + +```sh +python -B -m unittest discover -s apps/desktop/test -p test_windows_pit.py -v +``` + +Run on Windows as well as Linux: the native `.cmd` argument/path test is skipped +on other systems. Real Ungoogled Chromium/Pit HTTPS acceptance still needs a +Windows machine with the intended trust policy. Automated socket/TLS tests alone +do not establish that a specific browser build honors the Windows trust store. From 0d903c352a667ffd425730b82ea281c4e5161d21 Mon Sep 17 00:00:00 2001 From: Phuc Nguyen Date: Wed, 23 Sep 2026 10:47:37 +0700 Subject: [PATCH 02/12] fix(desktop): address native Windows startup checks --- apps/desktop/launcher/tron-windows.py | 5 ++++- apps/desktop/launcher/tronbrowser.cmd | 2 +- apps/desktop/test/test_windows_pit.py | 13 +++++++++---- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/desktop/launcher/tron-windows.py b/apps/desktop/launcher/tron-windows.py index edee0290..ec817ffc 100644 --- a/apps/desktop/launcher/tron-windows.py +++ b/apps/desktop/launcher/tron-windows.py @@ -39,7 +39,8 @@ def read_url(url, *, local=False, timeout=10, limit=65536): def helper_state(port): try: - raw = read_url("http://127.0.0.1:%d/pit/status" % port, local=True, timeout=0.5) + # Windows can take about a second to report a refused loopback socket. + raw = read_url("http://127.0.0.1:%d/pit/status" % port, local=True, timeout=2) except urllib.error.URLError as exc: # Only a refused connection means it is safe to try starting a helper. if isinstance(exc.reason, ConnectionRefusedError): @@ -147,6 +148,8 @@ def certificate_action(path, fingerprint, mode): raise ValueError("Invalid certificate action") powershell = Path(os.environ["SystemRoot"]) / "System32/WindowsPowerShell/v1.0/powershell.exe" env = os.environ.copy() + # Let Windows PowerShell construct its own module path, not inherit pwsh 7's. + env.pop("PSModulePath", None) env.update(TRON_CA_FILE=str(path), TRON_CA_SHA256=fingerprint, TRON_CA_MODE=mode) result = subprocess.run([str(powershell), "-NoProfile", "-NonInteractive", "-Command", CERTIFICATE_COMMAND], env=env, capture_output=True, text=True, timeout=90) diff --git a/apps/desktop/launcher/tronbrowser.cmd b/apps/desktop/launcher/tronbrowser.cmd index cbd44258..8aa11f9f 100644 --- a/apps/desktop/launcher/tronbrowser.cmd +++ b/apps/desktop/launcher/tronbrowser.cmd @@ -37,7 +37,7 @@ rem Load every bundled extension (each subdir with a manifest.json). set "EXT=" for /d %%D in ("%DIR%extensions\*") do ( if exist "%%D\manifest.json" ( - rem Enable delayed expansion only after capturing paths (which may contain !). + rem Capture paths before enabling delayed expansion to preserve punctuation. set "NEXT_EXT=%%D" setlocal EnableDelayedExpansion if defined EXT (set "NEXT_EXT=!EXT!,!NEXT_EXT!") diff --git a/apps/desktop/test/test_windows_pit.py b/apps/desktop/test/test_windows_pit.py index 47bd5488..eca828c9 100644 --- a/apps/desktop/test/test_windows_pit.py +++ b/apps/desktop/test/test_windows_pit.py @@ -151,9 +151,13 @@ def test_windows_inspects_ca_without_modifying_trust_store(self): powershell = str(Path(os.environ["SystemRoot"]) / "System32/WindowsPowerShell/v1.0/powershell.exe") def snapshot(): - return subprocess.run([powershell, "-NoProfile", "-NonInteractive", "-Command", + env = os.environ.copy() + env.pop("PSModulePath", None) + result = subprocess.run([powershell, "-NoProfile", "-NonInteractive", "-Command", "Get-ChildItem Cert:\\CurrentUser\\Root | Sort-Object Thumbprint | ForEach-Object { $_.Thumbprint }"], - capture_output=True, text=True, check=True, timeout=15).stdout + env=env, capture_output=True, text=True, timeout=15) + self.assertEqual(result.returncode, 0, result.stderr) + return result.stdout before = snapshot() with tempfile.TemporaryDirectory() as temp: @@ -357,8 +361,9 @@ def test_cmd_launches_helper_and_preserves_paths_and_arguments(self): "TRON_TOR_HELPER_PORT": str(control), "TRON_TOR_PIDFILE": str(pidfile), "TRON_TOR_SOCKS_PORT": str(unused_port()), "TEST_PYTHON": sys.executable, "TEST_RECORDER": str(recorder), "ARGV_OUT": str(output)} try: - command = '""%s" "https://example.invalid/path?a=1&b=2""' % (directory / "tronbrowser.cmd") - result = subprocess.run([os.environ["COMSPEC"], "/d", "/s", "/c", command], env=env, capture_output=True, text=True, timeout=20) + # cmd.exe does not use CRT argv quoting; pass /s /c intact. + command = '"%s" /d /s /c ""%s" "https://example.invalid/path?a=1&b=2""' % (os.environ["COMSPEC"], directory / "tronbrowser.cmd") + result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=25) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertTrue(output.exists(), result.stdout + result.stderr) args = json.loads(output.read_text()) From 225596611658f5adc971d80fa7669821c5a8e08d Mon Sep 17 00:00:00 2001 From: Phuc Nguyen Date: Wed, 23 Sep 2026 10:50:30 +0700 Subject: [PATCH 03/12] fix(desktop): detect idle Windows ports without SYN timeout --- apps/desktop/launcher/tron-windows.py | 30 +++++++++++++++++++++++---- apps/desktop/test/test_windows_pit.py | 8 ++++++- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/apps/desktop/launcher/tron-windows.py b/apps/desktop/launcher/tron-windows.py index ec817ffc..15c55a63 100644 --- a/apps/desktop/launcher/tron-windows.py +++ b/apps/desktop/launcher/tron-windows.py @@ -6,6 +6,7 @@ from pathlib import Path import re import runpy +import socket import ssl import subprocess import sys @@ -25,6 +26,23 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): raise ValueError("Redirect refused: " + req.full_url) +class HelperUnavailable(RuntimeError): + pass + + +def port_available(port): + # Some Windows stacks silently drop SYNs for closed loopback ports. Binding + # with exclusive ownership distinguishes that case from an occupied port. + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + if sys.platform == "win32": + probe.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1) + try: + probe.bind(("127.0.0.1", port)) + return True + except OSError: + return False + + def read_url(url, *, local=False, timeout=10, limit=65536): handlers = [NoRedirect()] if local: @@ -45,9 +63,9 @@ def helper_state(port): # Only a refused connection means it is safe to try starting a helper. if isinstance(exc.reason, ConnectionRefusedError): return None - raise RuntimeError("Helper port is occupied or unresponsive") from exc + raise HelperUnavailable("Helper port is occupied or unresponsive") from exc except (TimeoutError, OSError) as exc: - raise RuntimeError("Helper port is occupied or unresponsive") from exc + raise HelperUnavailable("Helper port is occupied or unresponsive") from exc try: state = json.loads(raw) if (not isinstance(state, dict) or state.get("helper") != "tronbrowser-network" @@ -68,7 +86,7 @@ def start_helper(directory=HERE, data=None, timeout=6): raise RuntimeError("Release is missing tron-tor-helper; reinstall the complete Windows ZIP") config = runpy.run_path(str(helper)) port, version = config["PORT"], config["HELPER_VERSION"] - existing = helper_state(port) + existing = None if port_available(port) else helper_state(port) if existing is not None: if existing["version"] != version: raise RuntimeError("Older helper is running. Restart Windows after upgrading TronBrowser") @@ -88,7 +106,11 @@ def start_helper(directory=HERE, data=None, timeout=6): deadline = time.monotonic() + timeout try: while time.monotonic() < deadline: - state = helper_state(port) + try: + state = helper_state(port) + except HelperUnavailable: + # Only retry transport failures while our own child starts. + state = None if state is not None: if state["version"] != version: raise RuntimeError("A different helper version owns the port") diff --git a/apps/desktop/test/test_windows_pit.py b/apps/desktop/test/test_windows_pit.py index eca828c9..984e7d47 100644 --- a/apps/desktop/test/test_windows_pit.py +++ b/apps/desktop/test/test_windows_pit.py @@ -42,6 +42,11 @@ def state(**overrides): class StartupTests(unittest.TestCase): + def setUp(self): + probe = mock.patch.object(windows, "port_available", return_value=False) + probe.start() + self.addCleanup(probe.stop) + def test_refused_port_is_only_absence_case(self): with mock.patch.object(windows, "read_url", side_effect=urllib.error.URLError(ConnectionRefusedError())): self.assertIsNone(windows.helper_state(1234)) @@ -154,7 +159,8 @@ def snapshot(): env = os.environ.copy() env.pop("PSModulePath", None) result = subprocess.run([powershell, "-NoProfile", "-NonInteractive", "-Command", - "Get-ChildItem Cert:\\CurrentUser\\Root | Sort-Object Thumbprint | ForEach-Object { $_.Thumbprint }"], + "$s = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root', 'CurrentUser'); " + "try { $s.Open('ReadOnly'); $s.Certificates | Sort-Object Thumbprint | ForEach-Object { $_.Thumbprint } } finally { $s.Close() }"], env=env, capture_output=True, text=True, timeout=15) self.assertEqual(result.returncode, 0, result.stderr) return result.stdout From fd6fcb47b6cc9d4556e7ce1c64d7e754142d0b3a Mon Sep 17 00:00:00 2001 From: Phuc Nguyen Date: Wed, 23 Sep 2026 11:00:11 +0700 Subject: [PATCH 04/12] test(desktop): cover helper readiness and extension request contracts --- .github/workflows/windows-pit.yml | 9 +++ .../ai-sidebar/background-helper.test.js | 76 +++++++++++++++++++ apps/desktop/launcher/tron-tor-helper | 2 +- apps/desktop/launcher/tron-windows.py | 13 +++- apps/desktop/test/test_windows_pit.py | 36 ++++++++- docs/moshpit-pit-toggle.md | 7 ++ 6 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/extensions/ai-sidebar/background-helper.test.js diff --git a/.github/workflows/windows-pit.yml b/.github/workflows/windows-pit.yml index 15ef8b41..a86a3aee 100644 --- a/.github/workflows/windows-pit.yml +++ b/.github/workflows/windows-pit.yml @@ -1,6 +1,15 @@ name: Network helper regression tests on: + push: + branches: [main] + paths: + - 'apps/desktop/launcher/**' + - 'apps/desktop/test/test_windows_pit.py' + - 'apps/desktop/test/fixtures/**' + - 'apps/desktop/scripts/build-release.sh' + - '.github/workflows/release.yml' + - '.github/workflows/windows-pit.yml' pull_request: paths: - 'apps/desktop/launcher/**' diff --git a/apps/desktop/extensions/ai-sidebar/background-helper.test.js b/apps/desktop/extensions/ai-sidebar/background-helper.test.js new file mode 100644 index 00000000..6a7afc89 --- /dev/null +++ b/apps/desktop/extensions/ai-sidebar/background-helper.test.js @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('extension network helper requests', () => { + let listeners; + let fetchMock; + + beforeEach(async () => { + vi.resetModules(); + listeners = []; + const done = () => vi.fn().mockResolvedValue(undefined); + const storage = () => ({ get: vi.fn().mockResolvedValue({}), set: done(), remove: done() }); + vi.stubGlobal('chrome', { + sidePanel: { setPanelBehavior: done() }, + action: { + onClicked: { addListener: vi.fn() }, setBadgeText: done(), + setBadgeBackgroundColor: done(), setTitle: done(), + }, + runtime: { + onInstalled: { addListener: vi.fn() }, + onMessage: { addListener: (listener) => listeners.push(listener) }, + sendMessage: done(), + }, + storage: { local: storage(), session: storage() }, + proxy: { settings: { set: done(), clear: done() } }, + privacy: { network: { webRTCIPHandlingPolicy: { set: done(), clear: done() } } }, + }); + fetchMock = vi.fn(async (url) => ({ + json: async () => url.endsWith('/pit/start') + ? { started: true, port: 9081, check: { ok: true } } + : { started: true, ready: true, IsTor: true }, + })); + vi.stubGlobal('fetch', fetchMock); + await import('./background.js'); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + function send(message) { + return new Promise((resolve, reject) => { + if (!listeners.some((listener) => listener(message, {}, resolve) === true)) { + reject(new Error('No listener handled ' + message.type)); + } + }); + } + + function helperCalls() { + return fetchMock.mock.calls.filter(([url]) => url.startsWith('http://127.0.0.1:9061/')); + } + + function expectSimpleRequests() { + for (const [, options] of helperCalls()) { + expect(options.headers).toBeUndefined(); + expect(options.body).toBeUndefined(); + expect(options.signal).toBeInstanceOf(AbortSignal); + } + } + + it('starts and stops Pit using simple POST requests to literal loopback', async () => { + expect(await send({ type: 'pit-set', on: true })).toMatchObject({ enabled: true, port: 9081 }); + expect(await send({ type: 'pit-set', on: false })).toEqual({ enabled: false }); + expect(helperCalls().map(([url, options]) => [new URL(url).pathname, options.method])) + .toEqual([['/pit/start', 'POST'], ['/pit/stop', 'POST']]); + expectSimpleRequests(); + }); + + it('uses POST for Tor mutations and GET only for status', async () => { + expect(await send({ type: 'tor-set', on: true })).toMatchObject({ enabled: true }); + expect(await send({ type: 'tor-set', on: false })).toEqual({ enabled: false }); + expect(helperCalls().map(([url, options]) => [new URL(url).pathname, options.method])) + .toEqual([['/start', 'POST'], ['/status', 'GET'], ['/stop', 'POST']]); + expectSimpleRequests(); + }); +}); diff --git a/apps/desktop/launcher/tron-tor-helper b/apps/desktop/launcher/tron-tor-helper index 657885a0..e86682c2 100755 --- a/apps/desktop/launcher/tron-tor-helper +++ b/apps/desktop/launcher/tron-tor-helper @@ -804,7 +804,7 @@ class Handler(BaseHTTPRequestHandler): self._send(403, {"error": "invalid-origin"}) return path = self.path.split("?", 1)[0].rstrip("/") or "/" - if path in ("/start", "/stop", "/pit/start", "/pit/stop") and self.command != "POST": + if path not in ("/", "/status", "/pit/status") and self.command != "POST": self._send(405, {"error": "post-required"}) return if path == "/start": diff --git a/apps/desktop/launcher/tron-windows.py b/apps/desktop/launcher/tron-windows.py index 15c55a63..1cfccfc6 100644 --- a/apps/desktop/launcher/tron-windows.py +++ b/apps/desktop/launcher/tron-windows.py @@ -157,7 +157,11 @@ def start_helper(directory=HERE, data=None, timeout=6): $store.Open($flags) $existing = $store.Certificates.Find([System.Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, $cert.Thumbprint, $false) $already = $existing.Count -gt 0 - if ($env:TRON_CA_MODE -eq 'install' -and -not $already) { $store.Add($cert) } + if ($env:TRON_CA_MODE -eq 'install' -and -not $already) { + $store.Add($cert) + $confirmed = $store.Certificates.Find([System.Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, $cert.Thumbprint, $false) + if ($confirmed.Count -eq 0) { throw 'Certificate was not added to CurrentUser Root' } + } @{fingerprint=$fingerprint; thumbprint=$cert.Thumbprint; alreadyTrusted=$already; subject=$cert.Subject} | ConvertTo-Json -Compress } finally { $store.Close(); $cert.Dispose() } ''' @@ -168,7 +172,10 @@ def certificate_action(path, fingerprint, mode): raise RuntimeError("Certificate setup is Windows-only") if mode not in ("inspect", "install"): raise ValueError("Invalid certificate action") - powershell = Path(os.environ["SystemRoot"]) / "System32/WindowsPowerShell/v1.0/powershell.exe" + system_root = os.environ.get("SystemRoot") + if not system_root: + raise RuntimeError("Windows SystemRoot is missing; certificate setup cannot continue") + powershell = Path(system_root) / "System32/WindowsPowerShell/v1.0/powershell.exe" env = os.environ.copy() # Let Windows PowerShell construct its own module path, not inherit pwsh 7's. env.pop("PSModulePath", None) @@ -176,7 +183,7 @@ def certificate_action(path, fingerprint, mode): result = subprocess.run([str(powershell), "-NoProfile", "-NonInteractive", "-Command", CERTIFICATE_COMMAND], env=env, capture_output=True, text=True, timeout=90) if result.returncode != 0: - raise RuntimeError("Windows certificate validation/import failed: " + result.stderr.strip()) + raise RuntimeError("Windows certificate validation/import failed (device policy may block it): " + result.stderr.strip()) return json.loads(result.stdout) diff --git a/apps/desktop/test/test_windows_pit.py b/apps/desktop/test/test_windows_pit.py index 984e7d47..ed673ba4 100644 --- a/apps/desktop/test/test_windows_pit.py +++ b/apps/desktop/test/test_windows_pit.py @@ -92,6 +92,26 @@ def test_detached_windows_flags(self): self.assertEqual(spawn.call_args.kwargs["creationflags"], 0x08000008) self.assertNotIn("shell", spawn.call_args.kwargs) + def test_own_startup_retries_transport_failures(self): + responses = [None, windows.HelperUnavailable("timeout"), windows.HelperUnavailable("reset"), state()] + with tempfile.TemporaryDirectory() as temp, mock.patch.object(windows, "helper_state", side_effect=responses), mock.patch.object(windows.subprocess, "Popen") as spawn: + spawn.return_value.poll.return_value = None + self.assertEqual(windows.start_helper(LAUNCHER, data=temp)["pid"], 123) + spawn.return_value.terminate.assert_not_called() + + def test_occupied_unresponsive_port_never_spawns(self): + with mock.patch.object(windows, "helper_state", side_effect=windows.HelperUnavailable("timeout")), mock.patch.object(windows.subprocess, "Popen") as spawn: + with self.assertRaises(windows.HelperUnavailable): + windows.start_helper(LAUNCHER) + spawn.assert_not_called() + + def test_bad_responder_during_own_startup_fails_closed(self): + with tempfile.TemporaryDirectory() as temp, mock.patch.object(windows, "helper_state", side_effect=[None, RuntimeError("Unrecognized helper")]), mock.patch.object(windows.subprocess, "Popen") as spawn: + spawn.return_value.poll.return_value = None + with self.assertRaisesRegex(RuntimeError, "Unrecognized helper"): + windows.start_helper(LAUNCHER, data=temp) + spawn.return_value.terminate.assert_called_once() + class HttpTests(unittest.TestCase): def setUp(self): @@ -130,9 +150,17 @@ def test_dns_rebinding_host_rejected(self): self.assertEqual(self.request("/pit/status", headers={"Host": "attacker.invalid"})[0], 403) def test_get_cannot_start_or_stop_services(self): - for path in ("/start", "/stop", "/pit/start", "/pit/stop"): + for path in ("/start", "/stop", "/pit/start", "/pit/stop", "/future-mutation"): self.assertEqual(self.request(path)[0], 405) + def test_web_preflight_cannot_grant_access(self): + status, headers, _ = self.request("/pit/start", "OPTIONS", { + "Origin": "https://attacker.invalid", "Access-Control-Request-Method": "POST", + "Access-Control-Request-Private-Network": "true"}) + self.assertEqual(status, 403) + self.assertNotIn("Access-Control-Allow-Origin", headers) + self.assertNotIn("Access-Control-Allow-Private-Network", headers) + def test_extension_origin_and_post_still_work(self): origin = "chrome-extension://" + "a" * 32 status, headers, body = self.request("/pit/stop", "POST", {"Origin": origin}) @@ -146,6 +174,12 @@ def test_proxy_settings_cannot_redirect_loopback_probe(self): class RootSetupTests(unittest.TestCase): + def test_missing_windows_environment_fails_before_powershell(self): + with mock.patch.object(windows.sys, "platform", "win32"), mock.patch.dict(os.environ, {}, clear=True), mock.patch.object(windows.subprocess, "run") as run: + with self.assertRaisesRegex(RuntimeError, "SystemRoot is missing"): + windows.certificate_action(Path("root.cer"), windows.ROOT_SHA256, "inspect") + run.assert_not_called() + def test_committed_public_ca_matches_release_pin(self): pem = (LAUNCHER.parent / "test/fixtures/moshpit-root-ca.crt").read_text() der = ssl.PEM_cert_to_DER_cert(pem) diff --git a/docs/moshpit-pit-toggle.md b/docs/moshpit-pit-toggle.md index c19a06b1..c41ec07c 100644 --- a/docs/moshpit-pit-toggle.md +++ b/docs/moshpit-pit-toggle.md @@ -184,6 +184,13 @@ Environment knobs on the helper: `TRON_PIT_SOCKS_PORT` (9081), ## Testing the helper by hand +The control API accepts only the literal `127.0.0.1:` Host, not +`localhost` or an absent Host. GET is read-only (`/`, `/status`, `/pit/status`); +start and stop use POST. The bundled extension uses simple requests without +custom headers. Web-page origins and preflights are rejected. These checks +reduce web-origin access; they are not authentication against other local +processes or installed extensions. + ```sh TRON_TOR_HELPER_PORT=19061 TRON_PIT_SOCKS_PORT=19081 python3 apps/desktop/launcher/tron-tor-helper & curl -X POST http://127.0.0.1:19061/pit/start From a385d984115b1c55950fa155b3ba58cdca3baae5 Mon Sep 17 00:00:00 2001 From: Phuc Nguyen Date: Wed, 23 Sep 2026 13:56:03 +0700 Subject: [PATCH 05/12] test(desktop): exercise real Windows Pit browser and HTTPS trust --- .github/workflows/windows-pit.yml | 48 ++++++ apps/desktop/test/windows-pit-browser.mjs | 86 ++++++++++ apps/desktop/test/windows_pit_acceptance.py | 164 ++++++++++++++++++++ 3 files changed, 298 insertions(+) create mode 100644 apps/desktop/test/windows-pit-browser.mjs create mode 100644 apps/desktop/test/windows_pit_acceptance.py diff --git a/.github/workflows/windows-pit.yml b/.github/workflows/windows-pit.yml index a86a3aee..8dca3085 100644 --- a/.github/workflows/windows-pit.yml +++ b/.github/workflows/windows-pit.yml @@ -6,6 +6,9 @@ on: paths: - 'apps/desktop/launcher/**' - 'apps/desktop/test/test_windows_pit.py' + - 'apps/desktop/test/windows_pit_acceptance.py' + - 'apps/desktop/test/windows-pit-browser.mjs' + - 'apps/desktop/extensions/ai-sidebar/**' - 'apps/desktop/test/fixtures/**' - 'apps/desktop/scripts/build-release.sh' - '.github/workflows/release.yml' @@ -14,6 +17,9 @@ on: paths: - 'apps/desktop/launcher/**' - 'apps/desktop/test/test_windows_pit.py' + - 'apps/desktop/test/windows_pit_acceptance.py' + - 'apps/desktop/test/windows-pit-browser.mjs' + - 'apps/desktop/extensions/ai-sidebar/**' - 'apps/desktop/test/fixtures/**' - 'apps/desktop/scripts/build-release.sh' - '.github/workflows/release.yml' @@ -38,3 +44,45 @@ jobs: python-version: '3.12' - name: Test helper and Windows launcher (no certificate imports) run: python -B -m unittest discover -s apps/desktop/test -p test_windows_pit.py -v + + windows-browser: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - uses: actions/setup-node@v5 + with: + node-version: '24' + - name: Fetch checksum-pinned portable Ungoogled Chromium + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $zip = Join-Path $env:RUNNER_TEMP 'ungoogled.zip' + $dest = Join-Path $env:RUNNER_TEMP 'ungoogled' + Invoke-WebRequest 'https://github.com/ungoogled-software/ungoogled-chromium-windows/releases/download/153.0.8010.52-1.1/ungoogled-chromium_153.0.8010.52-1.1_windows_x64.zip' -OutFile $zip + if ((Get-FileHash $zip -Algorithm SHA256).Hash -ne '824857dcd68bca34ff21ffd06f55610fdea98be91b6328a826f3497e4881ea4a') { throw 'Browser checksum mismatch' } + Expand-Archive $zip $dest + $browser = @(Get-ChildItem $dest -Filter chrome.exe -Recurse) + if ($browser.Count -ne 1) { throw 'Unexpected browser archive layout' } + "PIT_BROWSER=$($browser[0].FullName)" >> $env:GITHUB_ENV + "PIT_PLAYWRIGHT_DIR=$env:RUNNER_TEMP/pit-playwright" >> $env:GITHUB_ENV + "PIT_EVIDENCE=$env:RUNNER_TEMP/pit-evidence" >> $env:GITHUB_ENV + - name: Install isolated browser test driver (no browser download) + shell: pwsh + run: npm install --prefix "$env:PIT_PLAYWRIGHT_DIR" --ignore-scripts --no-audit --no-fund --package-lock=false playwright-core@1.63.0 + - name: Real Windows browser and opt-in CA acceptance + env: + TRON_PIT_DISPOSABLE_CA_TEST: '1' + run: python -B apps/desktop/test/windows_pit_acceptance.py + - uses: actions/upload-artifact@v4 + if: always() + with: + name: windows-pit-browser-evidence + path: ${{ runner.temp }}/pit-evidence + retention-days: 7 diff --git a/apps/desktop/test/windows-pit-browser.mjs b/apps/desktop/test/windows-pit-browser.mjs new file mode 100644 index 00000000..279dcdcd --- /dev/null +++ b/apps/desktop/test/windows-pit-browser.mjs @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +const require = createRequire(path.join(process.env.PIT_PLAYWRIGHT_DIR, 'package.json')); +const { chromium } = require('playwright-core'); +const [profile, phase, evidence, invalidTlsUrl] = process.argv.slice(2); +const [port] = fs.readFileSync(path.join(profile, 'DevToolsActivePort'), 'utf8').split('\n'); +const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`); +const results = { phase, browser: browser.version(), checks: [] }; +const check = (name) => { results.checks.push(name); console.log(`PASS: ${name}`); }; +let context; +try { + context = browser.contexts()[0]; + const worker = context.serviceWorkers()[0] + || await context.waitForEvent('serviceworker', { timeout: 20000 }); + const id = new URL(worker.url()).host; + const controls = await context.newPage(); + await controls.goto(`chrome-extension://${id}/options.html`); + const message = (type, on) => controls.evaluate( + ({ type, on }) => chrome.runtime.sendMessage({ type, on }), { type, on }); + + assert.equal((await message('pit-status')).enabled, false); + check('fresh browser session starts with Pit off'); + const enabled = await message('pit-set', true); + assert.equal(enabled.enabled, true, JSON.stringify(enabled)); + assert.equal(enabled.port, 9081); + assert.equal(enabled.check?.ok, true, JSON.stringify(enabled.check)); + const proxy = await controls.evaluate(() => chrome.proxy.settings.get({ incognito: false })); + assert.equal(proxy.value.mode, 'pac_script'); + check('real extension starts helper and installs PAC without preflight failure'); + + const page = await context.newPage(); + const http = await page.goto('http://mosh.eggs/', { waitUntil: 'domcontentloaded', timeout: 45000 }); + assert.equal(http.status(), 200); + check('HTTP Moshpit name resolves through the browser'); + + if (phase === 'before-trust') { + await assert.rejects(page.goto('https://profullstack.agent/', { + waitUntil: 'domcontentloaded', timeout: 45000, + }), /ERR_CERT_AUTHORITY_INVALID/); + await page.screenshot({ path: path.join(evidence, 'before-trust.png') }); + check('registry HTTPS is rejected before root consent'); + } else { + const response = await page.goto('https://profullstack.agent/', { + waitUntil: 'domcontentloaded', timeout: 45000, + }); + assert.equal(response.status(), 200); + assert.equal(new URL(page.url()).hostname, 'profullstack.agent'); + assert.ok((await page.locator('body').innerText()).length > 50); + results.title = await page.title(); + results.url = page.url(); + results.security = await response.securityDetails(); + await page.screenshot({ path: path.join(evidence, 'after-trust.png'), fullPage: true }); + check('registry HTTPS succeeds with normal browser certificate verification'); + } + + await assert.rejects(page.goto(invalidTlsUrl, { + waitUntil: 'domcontentloaded', timeout: 15000, + }), /ERR_CERT_AUTHORITY_INVALID/); + check('unrelated self-signed HTTPS remains rejected'); + + assert.equal((await message('pit-set', false)).enabled, false); + const off = await controls.evaluate(() => chrome.proxy.settings.get({ incognito: false })); + assert.notEqual(off.value.mode, 'pac_script'); + const normal = await page.goto('https://example.com/', { + waitUntil: 'domcontentloaded', timeout: 30000, + }); + assert.equal(normal.status(), 200); + check('Pit off restores normal HTTPS browsing'); + + // Leave persisted local state ON, then require the next fresh session to + // clear it. This exercises real storage.session reset, not a mocked reset. + assert.equal((await message('pit-set', true)).enabled, true); + check('Pit can be re-enabled in the same session'); +} catch (error) { + results.error = String(error.stack || error); + for (const [index, page] of (context?.pages() || []).entries()) { + await page.screenshot({ path: path.join(evidence, `${phase}-failure-${index}.png`) }).catch(() => {}); + } + throw error; +} finally { + fs.writeFileSync(path.join(evidence, `${phase}.json`), JSON.stringify(results, null, 2)); + await browser.close(); +} diff --git a/apps/desktop/test/windows_pit_acceptance.py b/apps/desktop/test/windows_pit_acceptance.py new file mode 100644 index 00000000..629edb83 --- /dev/null +++ b/apps/desktop/test/windows_pit_acceptance.py @@ -0,0 +1,164 @@ +"""Opt-in, disposable GitHub Windows runner only; never a developer PC. + +Exercises the real cmd launcher, extension, registry root install, and browser. +Only the exact root absent before this test may be removed during cleanup. +""" +import http.server +import importlib.util +import json +import os +from pathlib import Path +import shutil +import ssl +import subprocess +import sys +import tempfile +import threading +import time + +HERE = Path(__file__).resolve().parent +LAUNCHER = HERE.parent / "launcher" +SPEC = importlib.util.spec_from_file_location("tron_windows", LAUNCHER / "tron-windows.py") +windows = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(windows) + + +def run(args, **kwargs): + result = subprocess.run(args, capture_output=True, text=True, timeout=120, **kwargs) + print(result.stdout, flush=True) + if result.returncode: + raise RuntimeError("Command failed: %s\n%s" % (args, result.stderr)) + return result + + +def remove_test_root(cert, fingerprint): + # Exact SHA-256 pin is checked again before the CurrentUser-only removal. + command = r''' +$ErrorActionPreference = 'Stop' +$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($env:TRON_CA_FILE) +$sha = [System.Security.Cryptography.SHA256]::Create() +try { $hash = ([BitConverter]::ToString($sha.ComputeHash($cert.RawData))).Replace('-', '') } +finally { $sha.Dispose() } +if ($hash -cne $env:TRON_CA_SHA256) { throw 'Cleanup fingerprint mismatch' } +$store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root', 'CurrentUser') +try { + $store.Open('ReadWrite') + $matches = $store.Certificates.Find([System.Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, $cert.Thumbprint, $false) + foreach ($match in $matches) { $store.Remove($match) } +} finally { $store.Close(); $cert.Dispose() } +''' + env = os.environ.copy() + env.pop("PSModulePath", None) + env.update(TRON_CA_FILE=str(cert), TRON_CA_SHA256=fingerprint) + powershell = Path(env["SystemRoot"]) / "System32/WindowsPowerShell/v1.0/powershell.exe" + run([str(powershell), "-NoProfile", "-NonInteractive", "-Command", command], env=env) + + +def command_line(launcher, tail=""): + return 'cmd.exe /d /s /c ""%s" %s"' % (launcher, tail) + + +def main(): + if not (sys.platform == "win32" and os.environ.get("GITHUB_ACTIONS") == "true" + and os.environ.get("RUNNER_ENVIRONMENT") == "github-hosted" + and os.environ.get("TRON_PIT_DISPOSABLE_CA_TEST") == "1"): + raise RuntimeError("This intrusive acceptance test requires an opted-in disposable GitHub Windows runner") + evidence = Path(os.environ["PIT_EVIDENCE"]) + evidence.mkdir(parents=True, exist_ok=True) + browser = Path(os.environ["PIT_BROWSER"]) + if not browser.is_file(): + raise RuntimeError("Pinned Ungoogled Chromium executable is missing") + for port in (9061, 9081): + if not windows.port_available(port): + raise RuntimeError("Refusing to touch an existing helper on port %d" % port) + with tempfile.TemporaryDirectory(prefix="tron-pit-acceptance-") as temp: + root = Path(temp) + bundle = root / "bundle space !" + bundle.mkdir() + for name in ("tronbrowser.cmd", "tron-tor-helper", "tron-windows.py"): + shutil.copy2(LAUNCHER / name, bundle / name) + shutil.copytree(HERE.parent / "extensions/ai-sidebar", bundle / "extensions/ai-sidebar") + for test in (bundle / "extensions").rglob("*.test.js"): + test.unlink() + profile = root / "profile space !" + pidfile = root / "owned-helper.pid" + env = os.environ.copy() + env.update(TRONBROWSER_BROWSER=str(browser), TRONBROWSER_DATA=str(profile), + TRON_TOR_PIDFILE=str(pidfile), PYTHONUTF8="1") + cmd = bundle / "tronbrowser.cmd" + der, pin = windows.download_root() + cert = root / "registry.cer" + cert.write_bytes(der) + initial = windows.certificate_action(cert, pin, "inspect") + if initial["alreadyTrusted"]: + raise RuntimeError("Disposable runner unexpectedly already trusts this CA; will not alter it") + + # An unrelated self-signed local origin must fail before AND after setup. + run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", + "-subj", "/CN=localhost", "-addext", "subjectAltName=IP:127.0.0.1", + "-keyout", str(root / "bad.key"), "-out", str(root / "bad.crt")]) + class QuietHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.end_headers() + self.wfile.write(b"Untrusted fixture") + + def log_message(self, *_): + pass + + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler) + tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + tls.load_cert_chain(root / "bad.crt", root / "bad.key") + server.socket = tls.wrap_socket(server.socket, server_side=True) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + run(command_line(cmd, "--setup-pit-https"), env=env, input="CANCEL\n") + assert not windows.certificate_action(cert, pin, "inspect")["alreadyTrusted"] + print("PASS: cancelling real setup leaves trust unchanged", flush=True) + for phase in ("before-trust", "after-trust"): + if phase == "after-trust": + run(command_line(cmd, "--setup-pit-https"), env=env, input="TRUST\n") + assert windows.certificate_action(cert, pin, "inspect")["alreadyTrusted"] + print("PASS: explicit real setup imports the pinned root", flush=True) + active_port = profile / "DevToolsActivePort" + active_port.unlink(missing_ok=True) + with (evidence / (phase + "-launcher.log")).open("w", encoding="utf8") as log: + child = subprocess.Popen(command_line(cmd, "--headless=new --remote-debugging-port=0 about:blank"), + env=env, stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT) + try: + deadline = time.monotonic() + 45 + while not active_port.is_file(): + if child.poll() is not None or time.monotonic() > deadline: + raise RuntimeError("Browser did not expose DevTools; see launcher log") + time.sleep(0.2) + run(["node", str(HERE / "windows-pit-browser.mjs"), str(profile), phase, + str(evidence), "https://127.0.0.1:%d/" % server.server_port], env=env) + finally: + if child.poll() is None: + try: + child.wait(timeout=10) + except subprocess.TimeoutExpired: + subprocess.run(["taskkill", "/PID", str(child.pid), "/T", "/F"], capture_output=True) + child.wait(timeout=10) + print("PASS: Windows browser acceptance complete", flush=True) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) + # This file was written only by the helper spawned in our isolated + # bundle; never take ownership of a pre-existing machine PID. + if pidfile.is_file(): + pid = int(pidfile.read_text()) + subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True) + log = profile / "tor-helper.log" + if log.is_file(): + shutil.copy2(log, evidence / "tor-helper.log") + remove_test_root(cert, pin) + assert not windows.certificate_action(cert, pin, "inspect")["alreadyTrusted"] + (evidence / "cleanup.json").write_text(json.dumps({"removedTestRoot": True, "sha256": pin}), encoding="utf8") + print("PASS: exact test root removed from disposable runner", flush=True) + + +if __name__ == "__main__": + main() From 56714b74a4c1a37964d9a5cf7470d424a706ce82 Mon Sep 17 00:00:00 2001 From: Phuc Nguyen Date: Wed, 23 Sep 2026 13:57:40 +0700 Subject: [PATCH 06/12] ci(desktop): disable implicit pnpm cache for browser acceptance --- .github/workflows/windows-pit.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/windows-pit.yml b/.github/workflows/windows-pit.yml index 8dca3085..a3ea35b5 100644 --- a/.github/workflows/windows-pit.yml +++ b/.github/workflows/windows-pit.yml @@ -59,6 +59,7 @@ jobs: - uses: actions/setup-node@v5 with: node-version: '24' + package-manager-cache: false - name: Fetch checksum-pinned portable Ungoogled Chromium shell: pwsh run: | From 8c7b07dafd3e6729b11c8e1b31e118bbdf6d4325 Mon Sep 17 00:00:00 2001 From: Phuc Nguyen Date: Wed, 23 Sep 2026 14:05:53 +0700 Subject: [PATCH 07/12] fix(desktop): support offline Pit trust rollback and native consent testing --- .github/workflows/windows-pit.yml | 9 +- apps/desktop/launcher/tron-windows.py | 63 ++++++++- apps/desktop/launcher/tronbrowser.cmd | 9 ++ .../test/browser-driver/package-lock.json | 27 ++++ apps/desktop/test/browser-driver/package.json | 8 ++ apps/desktop/test/test_windows_pit.py | 15 ++ apps/desktop/test/windows-pit-browser.mjs | 26 +++- apps/desktop/test/windows_pit_acceptance.py | 132 +++++++++++++++--- docs/moshpit-pit-toggle.md | 12 +- 9 files changed, 264 insertions(+), 37 deletions(-) create mode 100644 apps/desktop/test/browser-driver/package-lock.json create mode 100644 apps/desktop/test/browser-driver/package.json diff --git a/.github/workflows/windows-pit.yml b/.github/workflows/windows-pit.yml index a3ea35b5..d58eb64c 100644 --- a/.github/workflows/windows-pit.yml +++ b/.github/workflows/windows-pit.yml @@ -8,6 +8,7 @@ on: - 'apps/desktop/test/test_windows_pit.py' - 'apps/desktop/test/windows_pit_acceptance.py' - 'apps/desktop/test/windows-pit-browser.mjs' + - 'apps/desktop/test/browser-driver/**' - 'apps/desktop/extensions/ai-sidebar/**' - 'apps/desktop/test/fixtures/**' - 'apps/desktop/scripts/build-release.sh' @@ -19,6 +20,7 @@ on: - 'apps/desktop/test/test_windows_pit.py' - 'apps/desktop/test/windows_pit_acceptance.py' - 'apps/desktop/test/windows-pit-browser.mjs' + - 'apps/desktop/test/browser-driver/**' - 'apps/desktop/extensions/ai-sidebar/**' - 'apps/desktop/test/fixtures/**' - 'apps/desktop/scripts/build-release.sh' @@ -67,7 +69,7 @@ jobs: $zip = Join-Path $env:RUNNER_TEMP 'ungoogled.zip' $dest = Join-Path $env:RUNNER_TEMP 'ungoogled' Invoke-WebRequest 'https://github.com/ungoogled-software/ungoogled-chromium-windows/releases/download/153.0.8010.52-1.1/ungoogled-chromium_153.0.8010.52-1.1_windows_x64.zip' -OutFile $zip - if ((Get-FileHash $zip -Algorithm SHA256).Hash -ne '824857dcd68bca34ff21ffd06f55610fdea98be91b6328a826f3497e4881ea4a') { throw 'Browser checksum mismatch' } + if ((Get-FileHash $zip -Algorithm SHA256).Hash -cne '824857DCD68BCA34FF21FFD06F55610FDEA98BE91B6328A826F3497E4881EA4A') { throw 'Browser checksum mismatch' } Expand-Archive $zip $dest $browser = @(Get-ChildItem $dest -Filter chrome.exe -Recurse) if ($browser.Count -ne 1) { throw 'Unexpected browser archive layout' } @@ -76,7 +78,10 @@ jobs: "PIT_EVIDENCE=$env:RUNNER_TEMP/pit-evidence" >> $env:GITHUB_ENV - name: Install isolated browser test driver (no browser download) shell: pwsh - run: npm install --prefix "$env:PIT_PLAYWRIGHT_DIR" --ignore-scripts --no-audit --no-fund --package-lock=false playwright-core@1.63.0 + run: | + New-Item -ItemType Directory -Force $env:PIT_PLAYWRIGHT_DIR | Out-Null + Copy-Item apps/desktop/test/browser-driver/package*.json $env:PIT_PLAYWRIGHT_DIR + npm ci --prefix "$env:PIT_PLAYWRIGHT_DIR" --ignore-scripts --no-audit --no-fund - name: Real Windows browser and opt-in CA acceptance env: TRON_PIT_DISPOSABLE_CA_TEST: '1' diff --git a/apps/desktop/launcher/tron-windows.py b/apps/desktop/launcher/tron-windows.py index 1cfccfc6..8273fadc 100644 --- a/apps/desktop/launcher/tron-windows.py +++ b/apps/desktop/launcher/tron-windows.py @@ -168,10 +168,15 @@ def start_helper(directory=HERE, data=None, timeout=6): def certificate_action(path, fingerprint, mode): - if sys.platform != "win32": - raise RuntimeError("Certificate setup is Windows-only") if mode not in ("inspect", "install"): raise ValueError("Invalid certificate action") + return run_certificate_command(CERTIFICATE_COMMAND, TRON_CA_FILE=str(path), + TRON_CA_SHA256=fingerprint, TRON_CA_MODE=mode) + + +def run_certificate_command(command, **values): + if sys.platform != "win32": + raise RuntimeError("Certificate setup is Windows-only") system_root = os.environ.get("SystemRoot") if not system_root: raise RuntimeError("Windows SystemRoot is missing; certificate setup cannot continue") @@ -179,14 +184,54 @@ def certificate_action(path, fingerprint, mode): env = os.environ.copy() # Let Windows PowerShell construct its own module path, not inherit pwsh 7's. env.pop("PSModulePath", None) - env.update(TRON_CA_FILE=str(path), TRON_CA_SHA256=fingerprint, TRON_CA_MODE=mode) - result = subprocess.run([str(powershell), "-NoProfile", "-NonInteractive", "-Command", CERTIFICATE_COMMAND], + env.update(values) + result = subprocess.run([str(powershell), "-NoProfile", "-NonInteractive", "-Command", command], env=env, capture_output=True, text=True, timeout=90) if result.returncode != 0: raise RuntimeError("Windows certificate validation/import failed (device policy may block it): " + result.stderr.strip()) return json.loads(result.stdout) +# Rollback works offline and selects by the release's full SHA-256, never name. +ROOT_REMOVAL_COMMAND = r''' +$ErrorActionPreference = 'Stop' +$store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root', 'CurrentUser') +$sha = [System.Security.Cryptography.SHA256]::Create() +try { + $flags = [System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly + if ($env:TRON_CA_MODE -eq 'remove') { $flags = [System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite } + $store.Open($flags) + $matches = @($store.Certificates | Where-Object { + ([BitConverter]::ToString($sha.ComputeHash($_.RawData))).Replace('-', '') -ceq $env:TRON_CA_SHA256 + }) + if ($env:TRON_CA_MODE -eq 'remove') { + foreach ($cert in $matches) { $store.Remove($cert) } + $remaining = @($store.Certificates | Where-Object { + ([BitConverter]::ToString($sha.ComputeHash($_.RawData))).Replace('-', '') -ceq $env:TRON_CA_SHA256 + }) + if ($remaining.Count -gt 0) { throw 'Root is still trusted; device policy or a machine-level root may require your administrator' } + } + @{count=$matches.Count; fingerprint=$env:TRON_CA_SHA256} | ConvertTo-Json -Compress +} finally { $sha.Dispose(); $store.Close() } +''' + + +def remove_https(): + info = run_certificate_command(ROOT_REMOVAL_COMMAND, TRON_CA_SHA256=ROOT_SHA256, TRON_CA_MODE="inspect") + if info["count"] == 0: + print("The pinned Moshpit root is not trusted in this user store; no changes made.") + return + print("This removes the Moshpit root with SHA-256 " + ROOT_SHA256) + print("from Current User roots, even if it was installed by another application.") + print("ALL apps relying on that root may stop trusting registry-signed HTTPS.") + print("No machine roots, DNS, or unrelated certificates will be changed.") + if input("Type REMOVE to continue (anything else cancels): ").strip() != "REMOVE": + print("Cancelled; trust was not changed.") + return + run_certificate_command(ROOT_REMOVAL_COMMAND, TRON_CA_SHA256=ROOT_SHA256, TRON_CA_MODE="remove") + print("Pinned root removed. Fully restart applications to clear cached trust.") + + def download_root(): metadata = json.loads(read_url(REGISTRY + "/api/moshpit/ca")) if not isinstance(metadata, dict) or metadata.get("enabled") is not True: @@ -232,10 +277,12 @@ def setup_https(): if input("Type TRUST to continue (anything else cancels): ").strip() != "TRUST": print("Cancelled; no certificate was installed.") return + print("Windows may show a Security Warning. Approve only if its thumbprint") + print("matches " + info["thumbprint"] + "; otherwise cancel.", flush=True) certificate_action(path, fingerprint, "install") print("Root CA installed. Restart TronBrowser, then turn Pit on.") - print("To undo: open certmgr.msc > Trusted Root Certification Authorities >") - print("Certificates, and remove ONLY the certificate with this thumbprint:") + print("To undo offline: tronbrowser.cmd --remove-pit-https (requires REMOVE).") + print("Or use certmgr.msc and remove ONLY the certificate with this thumbprint:") print(info["thumbprint"]) @@ -243,11 +290,13 @@ def main(): try: if sys.argv[1:] == ["setup-https"]: setup_https() + elif sys.argv[1:] == ["remove-https"]: + remove_https() elif sys.argv[1:] == ["start"]: state = start_helper() print("TronBrowser network helper ready (PID %d). Pit stays off until enabled." % state["pid"]) else: - raise ValueError("Usage: tron-windows.py start|setup-https") + raise ValueError("Usage: tron-windows.py start|setup-https|remove-https") except (OSError, ValueError, RuntimeError, subprocess.SubprocessError, EOFError) as exc: print("TronBrowser: %s" % exc, file=sys.stderr) return 1 diff --git a/apps/desktop/launcher/tronbrowser.cmd b/apps/desktop/launcher/tronbrowser.cmd index 8aa11f9f..1b00542f 100644 --- a/apps/desktop/launcher/tronbrowser.cmd +++ b/apps/desktop/launcher/tronbrowser.cmd @@ -32,6 +32,7 @@ if not defined PYTHON ( ) ) if /i "%~1"=="--setup-pit-https" goto setup_https +if /i "%~1"=="--remove-pit-https" goto remove_https rem Load every bundled extension (each subdir with a manifest.json). set "EXT=" @@ -98,3 +99,11 @@ if not defined PYTHON ( ) "%PYTHON%" %PYTHON_ARGS% "%DIR%tron-windows.py" setup-https exit /b %errorlevel% + +:remove_https +if not defined PYTHON ( + echo TronBrowser: Python 3.9+ is required; alternatively use certmgr.msc to remove the exact root. >&2 + exit /b 1 +) +"%PYTHON%" %PYTHON_ARGS% "%DIR%tron-windows.py" remove-https +exit /b %errorlevel% diff --git a/apps/desktop/test/browser-driver/package-lock.json b/apps/desktop/test/browser-driver/package-lock.json new file mode 100644 index 00000000..495a5fa0 --- /dev/null +++ b/apps/desktop/test/browser-driver/package-lock.json @@ -0,0 +1,27 @@ +{ + "name": "tron-windows-browser-acceptance", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tron-windows-browser-acceptance", + "version": "1.0.0", + "dependencies": { + "playwright-core": "1.63.0" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/apps/desktop/test/browser-driver/package.json b/apps/desktop/test/browser-driver/package.json new file mode 100644 index 00000000..f16d537a --- /dev/null +++ b/apps/desktop/test/browser-driver/package.json @@ -0,0 +1,8 @@ +{ + "name": "tron-windows-browser-acceptance", + "private": true, + "version": "1.0.0", + "dependencies": { + "playwright-core": "1.63.0" + } +} diff --git a/apps/desktop/test/test_windows_pit.py b/apps/desktop/test/test_windows_pit.py index ed673ba4..cf52571c 100644 --- a/apps/desktop/test/test_windows_pit.py +++ b/apps/desktop/test/test_windows_pit.py @@ -174,6 +174,21 @@ def test_proxy_settings_cannot_redirect_loopback_probe(self): class RootSetupTests(unittest.TestCase): + def test_removal_absent_or_cancelled_never_writes(self): + for count, answer in ((0, "REMOVE"), (1, "CANCEL")): + with self.subTest(count=count), mock.patch.object(windows, "run_certificate_command", return_value={"count": count}) as command, mock.patch("builtins.input", return_value=answer), contextlib.redirect_stdout(io.StringIO()): + windows.remove_https() + self.assertEqual(command.call_count, 1) + self.assertEqual(command.call_args.kwargs["TRON_CA_MODE"], "inspect") + + def test_removal_requires_explicit_consent_and_pinned_fingerprint(self): + with mock.patch.object(windows, "run_certificate_command", return_value={"count": 1}) as command, mock.patch("builtins.input", return_value="REMOVE"), contextlib.redirect_stdout(io.StringIO()) as output, mock.patch.object(windows, "read_url") as network: + windows.remove_https() + self.assertEqual([call.kwargs["TRON_CA_MODE"] for call in command.call_args_list], ["inspect", "remove"]) + self.assertEqual(command.call_args.kwargs["TRON_CA_SHA256"], windows.ROOT_SHA256) + self.assertIn("ALL apps", output.getvalue()) + network.assert_not_called() + def test_missing_windows_environment_fails_before_powershell(self): with mock.patch.object(windows.sys, "platform", "win32"), mock.patch.dict(os.environ, {}, clear=True), mock.patch.object(windows.subprocess, "run") as run: with self.assertRaisesRegex(RuntimeError, "SystemRoot is missing"): diff --git a/apps/desktop/test/windows-pit-browser.mjs b/apps/desktop/test/windows-pit-browser.mjs index 279dcdcd..347d728c 100644 --- a/apps/desktop/test/windows-pit-browser.mjs +++ b/apps/desktop/test/windows-pit-browser.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { createRequire } from 'node:module'; +import { pitProxyConfig } from '../extensions/ai-sidebar/pit-proxy.js'; const require = createRequire(path.join(process.env.PIT_PLAYWRIGHT_DIR, 'package.json')); const { chromium } = require('playwright-core'); @@ -21,27 +22,40 @@ try { const message = (type, on) => controls.evaluate( ({ type, on }) => chrome.runtime.sendMessage({ type, on }), { type, on }); - assert.equal((await message('pit-status')).enabled, false); - check('fresh browser session starts with Pit off'); + const deadline = Date.now() + 5000; + let initial, initialProxy; + do { + initial = await message('pit-status'); + initialProxy = await controls.evaluate(() => chrome.proxy.settings.get({ incognito: false })); + if (!initial.enabled && initialProxy.value.mode !== 'pac_script') break; + await new Promise((resolve) => setTimeout(resolve, 100)); + } while (Date.now() < deadline); + assert.equal(initial.enabled, false); + assert.notEqual(initialProxy.value.mode, 'pac_script'); + check('fresh browser session resets both Pit state and persisted PAC'); const enabled = await message('pit-set', true); assert.equal(enabled.enabled, true, JSON.stringify(enabled)); assert.equal(enabled.port, 9081); assert.equal(enabled.check?.ok, true, JSON.stringify(enabled.check)); const proxy = await controls.evaluate(() => chrome.proxy.settings.get({ incognito: false })); assert.equal(proxy.value.mode, 'pac_script'); + assert.equal(proxy.value.pacScript.data, pitProxyConfig().pacScript.data); check('real extension starts helper and installs PAC without preflight failure'); const page = await context.newPage(); + const clearnet = await page.goto('https://example.com/', { waitUntil: 'domcontentloaded', timeout: 30000 }); + assert.equal(clearnet.status(), 200); + check('normal HTTPS works while Pit is enabled with the tested fallback-only PAC'); const http = await page.goto('http://mosh.eggs/', { waitUntil: 'domcontentloaded', timeout: 45000 }); assert.equal(http.status(), 200); check('HTTP Moshpit name resolves through the browser'); - if (phase === 'before-trust') { + if (phase === 'before-trust' || phase === 'after-removal') { await assert.rejects(page.goto('https://profullstack.agent/', { waitUntil: 'domcontentloaded', timeout: 45000, }), /ERR_CERT_AUTHORITY_INVALID/); - await page.screenshot({ path: path.join(evidence, 'before-trust.png') }); - check('registry HTTPS is rejected before root consent'); + await page.screenshot({ path: path.join(evidence, `${phase}.png`) }); + check('registry HTTPS is rejected without root trust'); } else { const response = await page.goto('https://profullstack.agent/', { waitUntil: 'domcontentloaded', timeout: 45000, @@ -52,7 +66,7 @@ try { results.title = await page.title(); results.url = page.url(); results.security = await response.securityDetails(); - await page.screenshot({ path: path.join(evidence, 'after-trust.png'), fullPage: true }); + await page.screenshot({ path: path.join(evidence, `${phase}.png`), fullPage: true }); check('registry HTTPS succeeds with normal browser certificate verification'); } diff --git a/apps/desktop/test/windows_pit_acceptance.py b/apps/desktop/test/windows_pit_acceptance.py index 629edb83..84c98c82 100644 --- a/apps/desktop/test/windows_pit_acceptance.py +++ b/apps/desktop/test/windows_pit_acceptance.py @@ -7,6 +7,7 @@ import importlib.util import json import os +import re from pathlib import Path import shutil import ssl @@ -44,13 +45,16 @@ def remove_test_root(cert, fingerprint): try { $store.Open('ReadWrite') $matches = $store.Certificates.Find([System.Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, $cert.Thumbprint, $false) - foreach ($match in $matches) { $store.Remove($match) } + foreach ($match in $matches) { + if ([Convert]::ToBase64String($match.RawData) -cne [Convert]::ToBase64String($cert.RawData)) { throw 'Cleanup certificate bytes mismatch' } + $store.Remove($match) + } } finally { $store.Close(); $cert.Dispose() } ''' env = os.environ.copy() env.pop("PSModulePath", None) env.update(TRON_CA_FILE=str(cert), TRON_CA_SHA256=fingerprint) - powershell = Path(env["SystemRoot"]) / "System32/WindowsPowerShell/v1.0/powershell.exe" + powershell = Path(os.environ["SystemRoot"]) / "System32/WindowsPowerShell/v1.0/powershell.exe" run([str(powershell), "-NoProfile", "-NonInteractive", "-Command", command], env=env) @@ -58,6 +62,86 @@ def command_line(launcher, tail=""): return 'cmd.exe /d /s /c ""%s" %s"' % (launcher, tail) +def stop_owned_helper(pidfile, bundle): + if not pidfile.is_file(): + return + pid = int(pidfile.read_text()) + if pid <= 0: + raise RuntimeError("Invalid owned helper PID") + env = os.environ.copy() + env.pop("PSModulePath", None) + env.update(TRON_OWNED_PID=str(pid), TRON_HELPER_SCRIPT=str(bundle / "tron-tor-helper")) + powershell = Path(os.environ["SystemRoot"]) / "System32/WindowsPowerShell/v1.0/powershell.exe" + run([str(powershell), "-NoProfile", "-NonInteractive", "-Command", r''' +$ErrorActionPreference = 'Stop' +$p = Get-CimInstance Win32_Process -Filter "ProcessId=$env:TRON_OWNED_PID" +if ($null -eq $p) { exit 0 } +if ($p.Name -notmatch '^python([0-9.]+)?\.exe$' -or $p.CommandLine.IndexOf($env:TRON_HELPER_SCRIPT, [StringComparison]::OrdinalIgnoreCase) -lt 0) { throw 'Refusing to stop a process outside our unique test bundle' } +Stop-Process -Id $p.ProcessId -Force +'''], env=env) + + +def consent_to_test_root(cmd, env, thumbprint, evidence): + """Click the native Windows warning ONLY for the already-verified test root. + + This UI automation stays inside the guarded disposable-runner harness. + Product code still requires both typed consent and Windows' own approval. + """ + import ctypes + from ctypes import wintypes + user32 = ctypes.WinDLL("user32", use_last_error=True) + callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) + user32.EnumWindows.argtypes = [callback_type, wintypes.LPARAM] + user32.EnumChildWindows.argtypes = [wintypes.HWND, callback_type, wintypes.LPARAM] + user32.GetWindowTextLengthW.argtypes = [wintypes.HWND] + user32.GetWindowTextW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int] + user32.GetDlgItem.argtypes = [wintypes.HWND, ctypes.c_int] + user32.GetDlgItem.restype = wintypes.HWND + user32.SendMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM] + user32.SendMessageW.restype = wintypes.LPARAM + stop = threading.Event() + events = [] + + def text(hwnd): + buffer = ctypes.create_unicode_buffer(user32.GetWindowTextLengthW(hwnd) + 1) + user32.GetWindowTextW(hwnd, buffer, len(buffer)) + return buffer.value + + @callback_type + def inspect(hwnd, _): + if "security warning" not in text(hwnd).lower(): + return True + labels = [text(hwnd)] + + @callback_type + def child(child_hwnd, _): + labels.append(text(child_hwnd)) + return True + + user32.EnumChildWindows(hwnd, child, 0) + combined = " ".join(labels) + normalized = re.sub(r"[^0-9A-F]", "", combined.upper()) + if "Moshpit Root CA" in combined and thumbprint.upper() in normalized: + yes = user32.GetDlgItem(hwnd, 6) # IDYES, never an arbitrary dialog button. + if yes: + events.append({"thumbprint": thumbprint, "nativeWarningAccepted": True}) + user32.SendMessageW(yes, 0x00F5, 0, 0) # BM_CLICK + return True + + def watch(): + while not stop.wait(0.2): + user32.EnumWindows(inspect, 0) + + thread = threading.Thread(target=watch, daemon=True) + thread.start() + try: + run(command_line(cmd, "--setup-pit-https"), env=env, input="TRUST\n") + finally: + stop.set() + thread.join(timeout=3) + (evidence / "native-consent.json").write_text(json.dumps(events), encoding="utf8") + + def main(): if not (sys.platform == "win32" and os.environ.get("GITHUB_ACTIONS") == "true" and os.environ.get("RUNNER_ENVIRONMENT") == "github-hosted" @@ -81,6 +165,7 @@ def main(): for test in (bundle / "extensions").rglob("*.test.js"): test.unlink() profile = root / "profile space !" + helper_log = profile / "tor-helper.log" pidfile = root / "owned-helper.pid" env = os.environ.copy() env.update(TRONBROWSER_BROWSER=str(browser), TRONBROWSER_DATA=str(profile), @@ -116,11 +201,22 @@ def log_message(self, *_): run(command_line(cmd, "--setup-pit-https"), env=env, input="CANCEL\n") assert not windows.certificate_action(cert, pin, "inspect")["alreadyTrusted"] print("PASS: cancelling real setup leaves trust unchanged", flush=True) - for phase in ("before-trust", "after-trust"): + for phase in ("before-trust", "after-trust", "after-trust-fresh", "after-removal"): if phase == "after-trust": - run(command_line(cmd, "--setup-pit-https"), env=env, input="TRUST\n") + consent_to_test_root(cmd, env, initial["thumbprint"], evidence) assert windows.certificate_action(cert, pin, "inspect")["alreadyTrusted"] print("PASS: explicit real setup imports the pinned root", flush=True) + already = run(command_line(cmd, "--setup-pit-https"), env=env, input="") + assert "already trusted; no changes made" in already.stdout + if phase == "after-removal": + run(command_line(cmd, "--remove-pit-https"), env=env, input="CANCEL\n") + assert windows.certificate_action(cert, pin, "inspect")["alreadyTrusted"] + run(command_line(cmd, "--remove-pit-https"), env=env, input="REMOVE\n") + assert not windows.certificate_action(cert, pin, "inspect")["alreadyTrusted"] + print("PASS: supported offline removal revokes the pinned root", flush=True) + if phase in ("after-trust-fresh", "after-removal"): + profile = root / phase + env["TRONBROWSER_DATA"] = str(profile) active_port = profile / "DevToolsActivePort" active_port.unlink(missing_ok=True) with (evidence / (phase + "-launcher.log")).open("w", encoding="utf8") as log: @@ -143,21 +239,19 @@ def log_message(self, *_): child.wait(timeout=10) print("PASS: Windows browser acceptance complete", flush=True) finally: - server.shutdown() - server.server_close() - thread.join(timeout=3) - # This file was written only by the helper spawned in our isolated - # bundle; never take ownership of a pre-existing machine PID. - if pidfile.is_file(): - pid = int(pidfile.read_text()) - subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True) - log = profile / "tor-helper.log" - if log.is_file(): - shutil.copy2(log, evidence / "tor-helper.log") - remove_test_root(cert, pin) - assert not windows.certificate_action(cert, pin, "inspect")["alreadyTrusted"] - (evidence / "cleanup.json").write_text(json.dumps({"removedTestRoot": True, "sha256": pin}), encoding="utf8") - print("PASS: exact test root removed from disposable runner", flush=True) + # Emergency cleanup must run even if a process/PID cleanup fails. + try: + remove_test_root(cert, pin) + assert not windows.certificate_action(cert, pin, "inspect")["alreadyTrusted"] + (evidence / "cleanup.json").write_text(json.dumps({"removedTestRoot": True, "sha256": pin}), encoding="utf8") + print("PASS: exact test root removed from disposable runner", flush=True) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) + stop_owned_helper(pidfile, bundle) + if helper_log.is_file(): + shutil.copy2(helper_log, evidence / "tor-helper.log") if __name__ == "__main__": diff --git a/docs/moshpit-pit-toggle.md b/docs/moshpit-pit-toggle.md index c41ec07c..5a3e17a9 100644 --- a/docs/moshpit-pit-toggle.md +++ b/docs/moshpit-pit-toggle.md @@ -251,9 +251,15 @@ HTTPS certificate warnings remain in place. Never bypass those warnings. After setup, fully restart TronBrowser and enable Pit. HTTPS still requires a valid certificate for the requested hostname; an arbitrary self-signed origin -will not become trusted. To undo a newly installed root, use `certmgr.msc` and -remove only the certificate whose exact thumbprint the setup printed. The setup -does not change or claim ownership of a root that was already trusted. +will not become trusted. To undo, run `tronbrowser.cmd --remove-pit-https` and +type `REMOVE`. This works offline, selects only the release-pinned SHA-256 in +CurrentUser roots, and warns that other applications using this root are affected. +It can remove a matching root installed by another tool, so ownership is not +assumed. Fully restart applications afterwards to clear cached trust. If device +policy or a machine-level root keeps it trusted, ask your administrator; this +command does not modify LocalMachine roots. Alternatively, use `certmgr.msc` +and remove only the certificate whose exact thumbprint setup printed. Installing +an already trusted root is a no-op. Regression tests (no CA imports or public-network calls): From c1b58f54c12172c370e48871ac1f1170416ad65f Mon Sep 17 00:00:00 2001 From: Phuc Nguyen Date: Wed, 23 Sep 2026 14:10:39 +0700 Subject: [PATCH 08/12] test(desktop): bound browser teardown and require native consent evidence --- apps/desktop/test/windows-pit-browser.mjs | 4 +++ apps/desktop/test/windows_pit_acceptance.py | 28 ++++++++++++++++----- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/apps/desktop/test/windows-pit-browser.mjs b/apps/desktop/test/windows-pit-browser.mjs index 347d728c..ede9f4c2 100644 --- a/apps/desktop/test/windows-pit-browser.mjs +++ b/apps/desktop/test/windows-pit-browser.mjs @@ -96,5 +96,9 @@ try { throw error; } finally { fs.writeFileSync(path.join(evidence, `${phase}.json`), JSON.stringify(results, null, 2)); + // A CDP attachment's close can only disconnect. Close the owned browser + // explicitly so the next phase is a new process with a fresh trust cache. + const session = await browser.newBrowserCDPSession().catch(() => null); + await session?.send('Browser.close').catch(() => {}); await browser.close(); } diff --git a/apps/desktop/test/windows_pit_acceptance.py b/apps/desktop/test/windows_pit_acceptance.py index 84c98c82..251cc2f8 100644 --- a/apps/desktop/test/windows_pit_acceptance.py +++ b/apps/desktop/test/windows_pit_acceptance.py @@ -77,7 +77,8 @@ def stop_owned_helper(pidfile, bundle): $p = Get-CimInstance Win32_Process -Filter "ProcessId=$env:TRON_OWNED_PID" if ($null -eq $p) { exit 0 } if ($p.Name -notmatch '^python([0-9.]+)?\.exe$' -or $p.CommandLine.IndexOf($env:TRON_HELPER_SCRIPT, [StringComparison]::OrdinalIgnoreCase) -lt 0) { throw 'Refusing to stop a process outside our unique test bundle' } -Stop-Process -Id $p.ProcessId -Force +& "$env:SystemRoot\System32\taskkill.exe" /PID $p.ProcessId /T /F | Out-Null +if ($LASTEXITCODE -ne 0) { throw 'Owned helper cleanup failed' } '''], env=env) @@ -101,6 +102,8 @@ def consent_to_test_root(cmd, env, thumbprint, evidence): user32.SendMessageW.restype = wintypes.LPARAM stop = threading.Event() events = [] + clicked = set() + observed = {} def text(hwnd): buffer = ctypes.create_unicode_buffer(user32.GetWindowTextLengthW(hwnd) + 1) @@ -120,10 +123,12 @@ def child(child_hwnd, _): user32.EnumChildWindows(hwnd, child, 0) combined = " ".join(labels) + observed[str(hwnd)] = combined normalized = re.sub(r"[^0-9A-F]", "", combined.upper()) if "Moshpit Root CA" in combined and thumbprint.upper() in normalized: yes = user32.GetDlgItem(hwnd, 6) # IDYES, never an arbitrary dialog button. - if yes: + if yes and hwnd not in clicked: + clicked.add(hwnd) events.append({"thumbprint": thumbprint, "nativeWarningAccepted": True}) user32.SendMessageW(yes, 0x00F5, 0, 0) # BM_CLICK return True @@ -136,10 +141,12 @@ def watch(): thread.start() try: run(command_line(cmd, "--setup-pit-https"), env=env, input="TRUST\n") + if not events: + raise RuntimeError("No verified native root-consent event was recorded") finally: stop.set() thread.join(timeout=3) - (evidence / "native-consent.json").write_text(json.dumps(events), encoding="utf8") + (evidence / "native-consent.json").write_text(json.dumps({"accepted": events, "observedWarnings": observed}), encoding="utf8") def main(): @@ -191,10 +198,19 @@ def do_GET(self): def log_message(self, *_): pass - server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler) tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) tls.load_cert_chain(root / "bad.crt", root / "bad.key") - server.socket = tls.wrap_socket(server.socket, server_side=True) + class BoundedTlsServer(http.server.ThreadingHTTPServer): + def get_request(self): + sock, address = super().get_request() + sock.settimeout(3) + try: + return tls.wrap_socket(sock, server_side=True), address + except Exception: + sock.close() + raise + + server = BoundedTlsServer(("127.0.0.1", 0), QuietHandler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: @@ -249,9 +265,9 @@ def log_message(self, *_): server.shutdown() server.server_close() thread.join(timeout=3) - stop_owned_helper(pidfile, bundle) if helper_log.is_file(): shutil.copy2(helper_log, evidence / "tor-helper.log") + stop_owned_helper(pidfile, bundle) if __name__ == "__main__": From d14d6a01ff83debe89274700fa5639bf0aa72737 Mon Sep 17 00:00:00 2001 From: Phuc Nguyen Date: Wed, 23 Sep 2026 14:14:21 +0700 Subject: [PATCH 09/12] test(desktop): handle native confirmation for CA removal too --- apps/desktop/launcher/tron-windows.py | 4 +++- apps/desktop/test/test_windows_pit.py | 2 +- apps/desktop/test/windows_pit_acceptance.py | 21 +++++++++++++-------- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/apps/desktop/launcher/tron-windows.py b/apps/desktop/launcher/tron-windows.py index 8273fadc..9a31c6d3 100644 --- a/apps/desktop/launcher/tron-windows.py +++ b/apps/desktop/launcher/tron-windows.py @@ -211,7 +211,7 @@ def run_certificate_command(command, **values): }) if ($remaining.Count -gt 0) { throw 'Root is still trusted; device policy or a machine-level root may require your administrator' } } - @{count=$matches.Count; fingerprint=$env:TRON_CA_SHA256} | ConvertTo-Json -Compress + @{count=$matches.Count; fingerprint=$env:TRON_CA_SHA256; thumbprints=@($matches | ForEach-Object { $_.Thumbprint })} | ConvertTo-Json -Compress } finally { $sha.Dispose(); $store.Close() } ''' @@ -228,6 +228,8 @@ def remove_https(): if input("Type REMOVE to continue (anything else cancels): ").strip() != "REMOVE": print("Cancelled; trust was not changed.") return + print("Windows may request confirmation. Match this thumbprint before approving:") + print(", ".join(info["thumbprints"]), flush=True) run_certificate_command(ROOT_REMOVAL_COMMAND, TRON_CA_SHA256=ROOT_SHA256, TRON_CA_MODE="remove") print("Pinned root removed. Fully restart applications to clear cached trust.") diff --git a/apps/desktop/test/test_windows_pit.py b/apps/desktop/test/test_windows_pit.py index cf52571c..61d4b2e0 100644 --- a/apps/desktop/test/test_windows_pit.py +++ b/apps/desktop/test/test_windows_pit.py @@ -182,7 +182,7 @@ def test_removal_absent_or_cancelled_never_writes(self): self.assertEqual(command.call_args.kwargs["TRON_CA_MODE"], "inspect") def test_removal_requires_explicit_consent_and_pinned_fingerprint(self): - with mock.patch.object(windows, "run_certificate_command", return_value={"count": 1}) as command, mock.patch("builtins.input", return_value="REMOVE"), contextlib.redirect_stdout(io.StringIO()) as output, mock.patch.object(windows, "read_url") as network: + with mock.patch.object(windows, "run_certificate_command", return_value={"count": 1, "thumbprints": ["A" * 40]}) as command, mock.patch("builtins.input", return_value="REMOVE"), contextlib.redirect_stdout(io.StringIO()) as output, mock.patch.object(windows, "read_url") as network: windows.remove_https() self.assertEqual([call.kwargs["TRON_CA_MODE"] for call in command.call_args_list], ["inspect", "remove"]) self.assertEqual(command.call_args.kwargs["TRON_CA_SHA256"], windows.ROOT_SHA256) diff --git a/apps/desktop/test/windows_pit_acceptance.py b/apps/desktop/test/windows_pit_acceptance.py index 251cc2f8..08f77ba1 100644 --- a/apps/desktop/test/windows_pit_acceptance.py +++ b/apps/desktop/test/windows_pit_acceptance.py @@ -4,6 +4,7 @@ Only the exact root absent before this test may be removed during cleanup. """ import http.server +import contextlib import importlib.util import json import os @@ -82,7 +83,8 @@ def stop_owned_helper(pidfile, bundle): '''], env=env) -def consent_to_test_root(cmd, env, thumbprint, evidence): +@contextlib.contextmanager +def native_root_consent(thumbprint, evidence, label, required=True): """Click the native Windows warning ONLY for the already-verified test root. This UI automation stays inside the guarded disposable-runner harness. @@ -112,7 +114,7 @@ def text(hwnd): @callback_type def inspect(hwnd, _): - if "security warning" not in text(hwnd).lower(): + if not any(title in text(hwnd).lower() for title in ("security warning", "root certificate store", "delete certificate")): return True labels = [text(hwnd)] @@ -140,13 +142,13 @@ def watch(): thread = threading.Thread(target=watch, daemon=True) thread.start() try: - run(command_line(cmd, "--setup-pit-https"), env=env, input="TRUST\n") - if not events: + yield + if required and not events: raise RuntimeError("No verified native root-consent event was recorded") finally: stop.set() thread.join(timeout=3) - (evidence / "native-consent.json").write_text(json.dumps({"accepted": events, "observedWarnings": observed}), encoding="utf8") + (evidence / ("native-" + label + "-consent.json")).write_text(json.dumps({"accepted": events, "observedWarnings": observed}), encoding="utf8") def main(): @@ -219,7 +221,8 @@ def get_request(self): print("PASS: cancelling real setup leaves trust unchanged", flush=True) for phase in ("before-trust", "after-trust", "after-trust-fresh", "after-removal"): if phase == "after-trust": - consent_to_test_root(cmd, env, initial["thumbprint"], evidence) + with native_root_consent(initial["thumbprint"], evidence, "install"): + run(command_line(cmd, "--setup-pit-https"), env=env, input="TRUST\n") assert windows.certificate_action(cert, pin, "inspect")["alreadyTrusted"] print("PASS: explicit real setup imports the pinned root", flush=True) already = run(command_line(cmd, "--setup-pit-https"), env=env, input="") @@ -227,7 +230,8 @@ def get_request(self): if phase == "after-removal": run(command_line(cmd, "--remove-pit-https"), env=env, input="CANCEL\n") assert windows.certificate_action(cert, pin, "inspect")["alreadyTrusted"] - run(command_line(cmd, "--remove-pit-https"), env=env, input="REMOVE\n") + with native_root_consent(initial["thumbprint"], evidence, "remove"): + run(command_line(cmd, "--remove-pit-https"), env=env, input="REMOVE\n") assert not windows.certificate_action(cert, pin, "inspect")["alreadyTrusted"] print("PASS: supported offline removal revokes the pinned root", flush=True) if phase in ("after-trust-fresh", "after-removal"): @@ -257,7 +261,8 @@ def get_request(self): finally: # Emergency cleanup must run even if a process/PID cleanup fails. try: - remove_test_root(cert, pin) + with native_root_consent(initial["thumbprint"], evidence, "cleanup", required=False): + remove_test_root(cert, pin) assert not windows.certificate_action(cert, pin, "inspect")["alreadyTrusted"] (evidence / "cleanup.json").write_text(json.dumps({"removedTestRoot": True, "sha256": pin}), encoding="utf8") print("PASS: exact test root removed from disposable runner", flush=True) From 1c615bb872f6178c31036167afd1a32232986abb Mon Sep 17 00:00:00 2001 From: Phuc Nguyen Date: Wed, 23 Sep 2026 14:17:24 +0700 Subject: [PATCH 10/12] test(desktop): canonicalize Windows helper ownership path --- apps/desktop/test/windows_pit_acceptance.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/test/windows_pit_acceptance.py b/apps/desktop/test/windows_pit_acceptance.py index 08f77ba1..057c5db3 100644 --- a/apps/desktop/test/windows_pit_acceptance.py +++ b/apps/desktop/test/windows_pit_acceptance.py @@ -71,7 +71,9 @@ def stop_owned_helper(pidfile, bundle): raise RuntimeError("Invalid owned helper PID") env = os.environ.copy() env.pop("PSModulePath", None) - env.update(TRON_OWNED_PID=str(pid), TRON_HELPER_SCRIPT=str(bundle / "tron-tor-helper")) + # Product startup resolves __file__; Windows may expand RUNNER~1 to the + # long user path. Compare the same canonical path, not the 8.3 spelling. + env.update(TRON_OWNED_PID=str(pid), TRON_HELPER_SCRIPT=str((bundle / "tron-tor-helper").resolve())) powershell = Path(os.environ["SystemRoot"]) / "System32/WindowsPowerShell/v1.0/powershell.exe" run([str(powershell), "-NoProfile", "-NonInteractive", "-Command", r''' $ErrorActionPreference = 'Stop' From da7b28d363691fd684c18ffdfd377883e650301c Mon Sep 17 00:00:00 2001 From: Phuc Nguyen Date: Wed, 23 Sep 2026 14:18:11 +0700 Subject: [PATCH 11/12] test(desktop): include headed Windows browser acceptance --- apps/desktop/test/windows-pit-browser.mjs | 2 +- apps/desktop/test/windows_pit_acceptance.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/desktop/test/windows-pit-browser.mjs b/apps/desktop/test/windows-pit-browser.mjs index ede9f4c2..701ddb37 100644 --- a/apps/desktop/test/windows-pit-browser.mjs +++ b/apps/desktop/test/windows-pit-browser.mjs @@ -9,7 +9,7 @@ const { chromium } = require('playwright-core'); const [profile, phase, evidence, invalidTlsUrl] = process.argv.slice(2); const [port] = fs.readFileSync(path.join(profile, 'DevToolsActivePort'), 'utf8').split('\n'); const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`); -const results = { phase, browser: browser.version(), checks: [] }; +const results = { phase, mode: process.env.PIT_BROWSER_MODE, browser: browser.version(), checks: [] }; const check = (name) => { results.checks.push(name); console.log(`PASS: ${name}`); }; let context; try { diff --git a/apps/desktop/test/windows_pit_acceptance.py b/apps/desktop/test/windows_pit_acceptance.py index 057c5db3..3a06edc0 100644 --- a/apps/desktop/test/windows_pit_acceptance.py +++ b/apps/desktop/test/windows_pit_acceptance.py @@ -241,8 +241,10 @@ def get_request(self): env["TRONBROWSER_DATA"] = str(profile) active_port = profile / "DevToolsActivePort" active_port.unlink(missing_ok=True) + env["PIT_BROWSER_MODE"] = "headed" if phase == "after-trust-fresh" else "headless" + flags = "" if env["PIT_BROWSER_MODE"] == "headed" else "--headless=new " with (evidence / (phase + "-launcher.log")).open("w", encoding="utf8") as log: - child = subprocess.Popen(command_line(cmd, "--headless=new --remote-debugging-port=0 about:blank"), + child = subprocess.Popen(command_line(cmd, flags + "--remote-debugging-port=0 about:blank"), env=env, stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT) try: deadline = time.monotonic() + 45 From 7092646150f30168bb412a5c010a57683a7373ac Mon Sep 17 00:00:00 2001 From: Phuc Nguyen Date: Wed, 23 Sep 2026 14:20:24 +0700 Subject: [PATCH 12/12] docs(desktop): document verified Windows browser acceptance and rollback --- docs/moshpit-pit-toggle.md | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/moshpit-pit-toggle.md b/docs/moshpit-pit-toggle.md index 5a3e17a9..4048cade 100644 --- a/docs/moshpit-pit-toggle.md +++ b/docs/moshpit-pit-toggle.md @@ -174,6 +174,8 @@ would leak every lookup outside Tor, so: | --- | --- | | `apps/desktop/launcher/tron-tor-helper` | `/pit/*` routes, the SOCKS5 resolver, the DoH client, per-name leaf trust | | `apps/desktop/launcher/tronbrowser` | starts the helper; `HELPER_VERSION` must match the helper's so a stale one is replaced | +| `apps/desktop/launcher/tronbrowser.cmd` | Windows launcher, helper startup and explicit HTTPS setup/removal commands | +| `apps/desktop/launcher/tron-windows.py` | bounded Windows readiness, pinned opt-in CA setup and offline rollback | | `apps/desktop/extensions/ai-sidebar/pit-proxy.js` | the PAC + proxy config (pure, tested in `pit-proxy.test.js`) | | `apps/desktop/extensions/ai-sidebar/background.js` | `pit-set` / `pit-status` messages, badge, session-scoped state | | `apps/desktop/extensions/ai-sidebar/sidepanel.*` | the button and its status copy | @@ -241,6 +243,8 @@ usage and dates with Windows, and requires typing `TRUST` before adding it to **Current User / Trusted Root Certification Authorities**. It never changes Local Machine roots or DNS and never disables TLS verification. A root rotation requires a reviewed code update, not just new metadata from the registry. +Windows can also display its native Security Warning. Match the printed +thumbprint before approving; this OS confirmation is not suppressed. **Trust boundary:** this root is unconstrained. It can vouch for arbitrary DNS names in **all apps that use this Windows user store**, not only Moshpit names or @@ -267,7 +271,23 @@ Regression tests (no CA imports or public-network calls): python -B -m unittest discover -s apps/desktop/test -p test_windows_pit.py -v ``` -Run on Windows as well as Linux: the native `.cmd` argument/path test is skipped -on other systems. Real Ungoogled Chromium/Pit HTTPS acceptance still needs a -Windows machine with the intended trust policy. Automated socket/TLS tests alone -do not establish that a specific browser build honors the Windows trust store. +Run on Windows as well as Linux: native `.cmd` and certificate-inspection tests +are skipped on other systems. + +The separate `windows-browser` CI job runs official checksum-pinned Ungoogled +Chromium 153.0.8010.52 on a disposable Windows x64 runner, in both headed and +headless modes. It tests the actual launcher/extension/PAC, registry HTTP and +HTTPS, native Windows CA consent, cancellation, existing-root no-op, fresh +profiles, proxy reset, offline CA removal, and rejection of unrelated +self-signed certificates. After removal, a new browser process must reject the +registry certificate again. The test saves screenshots and TLS details. + +This acceptance harness temporarily imports the pinned root on its disposable +runner and removes it afterwards. It refuses to run without the explicit +GitHub-hosted Windows guard. Do not run it on a developer or company machine. +Its UI automation confirms only the expected native warning matching the +verified test root; production code does not automate Windows consent. + +Passing this pinned-build test is not a guarantee for every browser version, +Windows architecture, or enterprise trust policy. macOS trust remains outside +this patch's acceptance scope.