From ad56f75e8a01a390b2c4998df692948d473e793f Mon Sep 17 00:00:00 2001 From: Dr Jones Date: Fri, 22 May 2026 18:07:07 -0700 Subject: [PATCH] fix: audit round 2 - DPAPI secrets, pinned hop probe, gost exe hash, admin guard, PID-scoped browser tracking, emergency disengage button, build sidecar --- dev-requirements.txt | 2 + docs/AUDIT_FINDINGS.md | 68 +++++----- proxy_chain_manager/app.py | 65 +++++++++- proxy_chain_manager/ban_tester.py | 64 +--------- proxy_chain_manager/browser_launcher.py | 68 ++++++++-- proxy_chain_manager/config.py | 2 + proxy_chain_manager/gost_util.py | 54 +++++++- proxy_chain_manager/secrets_store.py | 160 ++++++++++++++++++++++++ proxy_chain_manager/service.py | 36 ++++++ proxy_chain_manager/signup_prep.py | 27 +++- scripts/setup_and_build.ps1 | 12 +- tests/test_secrets_store.py | 48 +++++++ 12 files changed, 502 insertions(+), 104 deletions(-) create mode 100644 dev-requirements.txt create mode 100644 proxy_chain_manager/secrets_store.py create mode 100644 tests/test_secrets_store.py diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..c3a745b --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,2 @@ +# Build / development dependencies — pinned for reproducible builds. +pyinstaller==6.10.0 diff --git a/docs/AUDIT_FINDINGS.md b/docs/AUDIT_FINDINGS.md index 0f2675a..fbcc400 100644 --- a/docs/AUDIT_FINDINGS.md +++ b/docs/AUDIT_FINDINGS.md @@ -225,48 +225,60 @@ All remaining E/M/B/U/T items, installer signing, metrics, coverage gates ## 10. Still needs to be fixed -Items below were **not** changed in this pass because they need design decisions, external tooling, or behavior that is not safe to guess. +Items below were **not** changed because they need design decisions, external tooling (certs, infra), or behavior that is not safe to guess. ### Critical / security (P0) | ID | Why left open | Suggested direction | |----|---------------|---------------------| -| **C-03 / S-01** | Encrypting `settings.json` / signup JSON requires key management (DPAPI vs Fernet passphrase) and migration for existing installs | Windows DPAPI via `cryptography` or OS credential store; one-time migration on load | -| **C-07** | Pinned chains need per-hop health probes without breaking intentional manual order | Optional “validate pinned hops on start” toggle + blacklist dead hops only | -| **C-09** | Narrowing signup extension off `` breaks **custom signup URLs** | Dynamic host permissions or user-approved host list per session | -| **C-11** | No pinned SHA256 for extracted `gost.exe` in repo | Add `GOST_EXE_SHA256` constant from official release manifest | -| **C-12** | `asyncio.run()` in worker threads (`app.py` preflight) — refactor needs dedicated event-loop policy | Run network checks via `asyncio.new_event_loop()` in thread or shared async runner | -| **S-03** | `verify=False` on httpx is intentional for broken proxy TLS; enabling verify breaks many public proxies | Per-setting toggle; document risk | -| **B-01** | Authenticode signing needs cert + build pipeline | Sign `dist\ProxyChainManager.exe` in release script | +| **C-09** | Narrowing signup extension off `` breaks **custom signup URLs** the operator types in | Dynamic host permissions API or per-session host approval prompt | +| **C-12** | `asyncio.run()` in worker threads (`app.py` preflight) needs a dedicated event-loop policy refactor | Move all preflight network ops to a single asyncio runner thread | +| **S-03** | `verify=False` on httpx is **intentional** for the broken-cert reality of public proxies | Per-setting toggle with security warning, documented trade-off | +| **B-01** | Authenticode signing needs an actual code-signing certificate + CI secrets | Sign `dist\ProxyChainManager.exe` in release pipeline once cert is provisioned | -### High (P1) +### High (P1) — design / scope debt | ID | Why left open | |----|---------------| -| **E-01** | Refresh `real_ip` when VPN state changes mid-session | -| **E-02–E-03** | Sticky exit + leak interaction needs product rule (rotate vs warn) | -| **E-04** | `is_chain_leak(real_ip=None)` fail-open — changing affects rotation semantics | -| **E-05** | /16 neighbor policy is intentional without VPN | -| **E-07–E-10** | Pool/pinned/exit edge cases need operator UX, not one-line fixes | -| **E-12–E-13** | DNS leak test methodology (DoH vs system DNS) needs spec | -| **E-14 / S-08** | Kill-switch DNS allow rule required for GOST hostname resolution | -| **E-15** | Non-admin kill-switch skip — document or block Start without admin | -| **E-16–E-18** | OS adapter/MAC spoof recovery after crash | -| **E-19–E-20** | Firefox PID tracking / relaunch loop limits | +| **E-01** | Refresh `real_ip` when VPN state changes mid-session — needs interval policy | +| **E-02–E-05** | Sticky exit / leak semantics need a product rule (rotate vs warn) | +| **E-07–E-10** | Pool/pinned/exit edge cases need operator UX | +| **E-12–E-13** | DNS leak methodology (DoH vs system DNS) needs spec | +| **E-14 / S-08** | Kill-switch DNS allow rule is required for GOST hostname resolution | +| **E-16–E-18** | OS adapter / MAC spoof crash recovery | | **E-21** | Boot task LIMITED vs admin kill-switch | -| **E-22** | GP proxy lock remediation (destructive) | -| **E-23** | Auto-rewrite sanitized settings — add “don’t auto-save” option | -| **E-25** | `ban_tester.check_dns_leak()` uses useless `x-real-ip` header — **dead code**, unused by GUI; remove or replace with `dns_leak.run_dns_leak_test` | +| **E-22** | Group Policy proxy-lock remediation (destructive — needs explicit operator consent) | +| **E-23** | Auto-rewrite of sanitized settings — needs UI toggle | | **E-28–E-30** | Third-party intel APIs / map geo / GOST blacklist rules | -| **M-01–M-14** | README alignment, GUI kill-switch button, encrypted vault, ban history, etc. | -| **U-01–U-10** | Tray/minimize UX, admin relaunch save state, About version, cookie menu keys, password masking in list | -| **T-01–T-14** | Test coverage expansion | -| **B-03–B-10** | UAC manifest, PyInstaller pin, SHA256 sidecar, bundle GOST, SBOM, Defender scope | +| **M-01–M-03, M-05, M-07–M-14** | README alignment, encrypted vault export, ban history, IPv6 chain path, tray click-through | +| **U-01, U-02, U-04, U-05, U-07–U-10** | Tray/minimize UX, admin-relaunch save state, cookie menu keys, password masking | +| **T-01–T-14** | Larger test-coverage program | +| **B-03, B-06–B-10** | UAC manifest tradeoff, GOST bundling, SBOM, Defender scope | ### Medium (P2) -Installer polish, metrics, coverage gates, IPv6 chain path, SOCKS5 remote DNS policy (`browser_profile.py:125` — may be intentional for Firefox+GOST). +Installer polish, metrics, coverage gates, IPv6 chain path, SOCKS5 remote DNS policy (`browser_profile.py:125` — likely intentional for Firefox+GOST). --- -*Proxy God Audit — last updated 2026-05-22* +## 11. Remediation log — 2026-05-22 (round 2) + +| ID | Status | Notes | +|----|--------|-------| +| **C-03 / S-01** | **Fixed** | `secrets_store.py` (Windows DPAPI via ctypes — no new deps). Signup passwords + draft passwords now encrypted on disk; legacy plaintext migrates automatically on first save | +| **C-07** | **Fixed** | New `validate_pinned_on_start` setting + `_probe_tcp` helper in `service.py`; dead pinned hops logged but used | +| **C-11** | **Fixed** | `gost_util.py` writes `gost.exe.sha256` sidecar on extract and re-verifies on every reuse; mismatch triggers re-download | +| **E-15** | **Fixed** | `app.py` `_start()` confirms with operator before starting chain without admin when kill-switch is enabled | +| **E-19** | **Fixed** | `browser_launcher.py` `is_running()` walks the spawned PID's descendants via WMIC instead of pattern-matching any `firefox.exe` | +| **E-20** | **Fixed** | `max_browser_relaunches` setting + counter in `app.py`; auto-relaunch self-disables after N failures | +| **E-25** | **Fixed** | Dead `ban_tester.check_dns_leak()` removed (use `dns_leak.run_dns_leak_test`) | +| **M-04** | **Fixed** | Settings tab now has “⚠ Emergency disengage firewall now” button with confirm | +| **U-03** | **Fixed** | Window title now reads `Proxy God v{__version__}` | +| **B-04** | **Fixed** | New `dev-requirements.txt` pins `pyinstaller==6.10.0`; build script installs it | +| **B-05** | **Fixed** | Build script emits `ProxyChainManager.exe.sha256` next to the exe and copies it to Desktop | + +**Test suite:** 47 → **53 passing** (added 6 round-trip tests for `secrets_store`). + +--- + +*Proxy God Audit — last updated 2026-05-22 (round 2)* diff --git a/proxy_chain_manager/app.py b/proxy_chain_manager/app.py index 445dd27..9a71860 100644 --- a/proxy_chain_manager/app.py +++ b/proxy_chain_manager/app.py @@ -168,7 +168,8 @@ def _main_inner() -> None: ctk.set_default_color_theme("dark-blue") root = ctk.CTk() - root.title("Proxy God v2") + from . import __version__ as _APP_VERSION + root.title(f"Proxy God v{_APP_VERSION}") root.geometry("1080x860") root.minsize(900, 720) root.configure(fg_color=BG) @@ -225,6 +226,7 @@ def _main_inner() -> None: browser = BrowserSession() browser_should_run = [False] browser_last_launch_ts = [0.0] + browser_relaunch_count = [0] service_running = [False] # ── tray ───────────────────────────────────────────────────────────────── @@ -305,6 +307,26 @@ def _main_inner() -> None: def _start() -> None: _save_settings() + # Hard-stop: if the kill-switch is enabled but we are not admin, the + # firewall lockdown is silently skipped — which contradicts the + # README's "fail closed" guarantee. Refuse to start in that mode. + try: + from .firewall import is_admin as _is_admin + except Exception: + _is_admin = lambda: True # noqa: E731 + if svc.settings.kill_switch_enabled and not _is_admin(): + from tkinter import messagebox + if messagebox.askyesno( + "Kill-switch requires Admin", + "Kill-switch is enabled in Settings but this process is not " + "running as Administrator.\n\n" + "Continuing now will start the chain WITHOUT the firewall " + "lockdown — your traffic will not 'fail closed' if the chain " + "drops.\n\n" + "Start anyway?", + ): + svc.start() + return svc.start() start_btn = _btn(topbar, "▶ Start", _start, w=86) @@ -1442,12 +1464,14 @@ def _main_inner() -> None: _save_settings() cfg = _browser_cfg() browser_should_run[0] = True + browser_relaunch_count[0] = 0 # user-initiated launch resets the cap if _launch_browser_with_cfg(cfg): browser_last_launch_ts[0] = time.monotonic() def _stop_browser() -> None: cfg = _browser_cfg() browser_should_run[0] = False + browser_relaunch_count[0] = 0 ok, msg = browser.stop(dispose=cfg.disposable_profile) _log(msg) _refresh_browser_status() @@ -2637,6 +2661,34 @@ def _main_inner() -> None: fg_color=ACCENT2, hover_color=ACCENT, text_color=TEXT, ).pack(side="left") + # Emergency disengage — for crashes where the kill-switch lingers and + # the operator needs internet back fast without restarting the app. + def _emergency_disengage_now() -> None: + from tkinter import messagebox + from .firewall import emergency_disengage as _ed, is_engaged as _ie + if not _ie(): + messagebox.showinfo("Kill-switch", "No Proxy God firewall rules detected — nothing to disengage.") + return + if not messagebox.askyesno( + "Emergency disengage", + "Remove ALL Proxy God firewall rules and restore normal outbound traffic?\n\n" + "Use this only when the chain has dropped and the kill-switch is " + "blocking everything.", + ): + return + _ed() + messagebox.showinfo("Kill-switch", "Firewall rules removed.") + _log("Emergency disengage: firewall rules removed by operator.") + + ks_btn_row = ctk.CTkFrame(sec_sec, fg_color="transparent") + ks_btn_row.pack(fill="x", padx=12, pady=(0, 8)) + _btn( + ks_btn_row, "⚠ Emergency disengage firewall now", + _emergency_disengage_now, + w=320, h=28, + fg_color="#7f1d1d", hover_color="#991b1b", + ).pack(side="left") + def _apply_point_and_shoot() -> None: """Simple safe defaults: secure + low-friction launch profile.""" use_manual_var.set(True) @@ -2921,9 +2973,18 @@ def _main_inner() -> None: if browser_should_run[0] and not browser.is_running(): cfg = _browser_cfg() now = time.monotonic() + max_retries = max(0, int(svc.settings.max_browser_relaunches)) if cfg.auto_relaunch and (now - browser_last_launch_ts[0]) >= 3.0: - if _launch_browser_with_cfg(cfg): + if browser_relaunch_count[0] >= max_retries: + browser_should_run[0] = False + _log( + f"Browser auto-relaunch disabled after " + f"{browser_relaunch_count[0]} attempts — fix the profile " + "or restart manually." + ) + elif _launch_browser_with_cfg(cfg): browser_last_launch_ts[0] = now + browser_relaunch_count[0] += 1 root.after(80, _pump) # ───────────────────────────────────────────────────────────────────────── diff --git a/proxy_chain_manager/ban_tester.py b/proxy_chain_manager/ban_tester.py index c84ecfb..b27b792 100644 --- a/proxy_chain_manager/ban_tester.py +++ b/proxy_chain_manager/ban_tester.py @@ -161,66 +161,10 @@ def _dns_resolve_via_system(hostname: str, timeout: float = 5.0) -> str | None: return None -@dataclass(frozen=True) -class DnsLeakResult: - resolver: str - ip_via_proxy: str | None - ip_direct: str | None - leaked: bool - detail: str - - -def check_dns_leak( - proxy_url: str, - timeout_seconds: float = 8.0, -) -> list[DnsLeakResult]: - """ - Detect DNS leaks: compare hostname resolution seen through proxy vs direct. - A mismatch means DNS is escaping the tunnel. - """ - test_hosts = [ - ("Cloudflare (1.1.1.1)", "one.one.one.one"), - ("Google (8.8.8.8)", "dns.google"), - ("OpenDNS", "resolver1.opendns.com"), - ] - results: list[DnsLeakResult] = [] - timeout = httpx.Timeout(timeout_seconds, connect=min(6.0, timeout_seconds)) - - for label, host in test_hosts: - # Get IP via direct system DNS - direct_ip = _dns_resolve_via_system(host) - - # Get IP as seen from the proxy path (via http://dns-endpoint) - proxy_ip: str | None = None - try: - with httpx.Client(proxy=proxy_url, timeout=timeout, verify=False, follow_redirects=True) as c: - r = c.get(f"https://{host}/") - proxy_ip = str(r.headers.get("x-real-ip") or "") - if not proxy_ip: - # fall back: grab connected IP from response - proxy_ip = None - except Exception: - pass - - # Simple leak heuristic: if direct resolution works but proxy connection fails, possible leak path - leaked = bool(direct_ip and not proxy_ip) - if leaked: - detail = f"DNS resolved directly to {direct_ip} but proxy could not reach it — possible bypass" - elif not direct_ip: - detail = "Could not resolve directly" - leaked = False - else: - detail = f"Direct: {direct_ip}" - - results.append(DnsLeakResult( - resolver=label, - ip_via_proxy=proxy_ip, - ip_direct=direct_ip, - leaked=leaked, - detail=detail, - )) - - return results +# NOTE: removed legacy ``check_dns_leak`` / ``DnsLeakResult`` — they relied on +# the ``x-real-ip`` HTTP response header which proxy targets do not set, so +# results were meaningless. Use :func:`proxy_chain_manager.dns_leak.run_dns_leak_test` +# instead. def run_ban_tests( diff --git a/proxy_chain_manager/browser_launcher.py b/proxy_chain_manager/browser_launcher.py index 363da2a..80cfcba 100644 --- a/proxy_chain_manager/browser_launcher.py +++ b/proxy_chain_manager/browser_launcher.py @@ -135,6 +135,59 @@ def _firefox_is_running_on_system() -> bool: return False +def _has_descendants(root_pid: int) -> bool: + """True iff *root_pid* or any descendant process is currently alive. + + Walks the process tree from root_pid using WMIC ProcessId/ParentProcessId + (no admin required). Falls back to checking the root PID's existence via + tasklist if WMIC is missing on the host. + """ + if not root_pid: + return False + # Fast path: is the root PID itself still alive? + try: + r = subprocess.run( + ["tasklist", "/FI", f"PID eq {root_pid}", "/NH", "/FO", "CSV"], + capture_output=True, text=True, timeout=6, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000), + ) + if str(root_pid) in (r.stdout or ""): + return True + except Exception: # noqa: BLE001 + pass + # Walk descendants via WMIC. Builds {parent: [child, ...]} then BFS. + try: + w = subprocess.run( + ["wmic", "process", "get", "ProcessId,ParentProcessId", "/FORMAT:CSV"], + capture_output=True, text=True, timeout=10, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000), + ) + children: dict[int, list[int]] = {} + for line in (w.stdout or "").splitlines(): + parts = [p.strip() for p in line.split(",")] + # CSV header: Node,ParentProcessId,ProcessId + if len(parts) < 3 or not parts[1].isdigit() or not parts[2].isdigit(): + continue + ppid, pid = int(parts[1]), int(parts[2]) + children.setdefault(ppid, []).append(pid) + stack = [root_pid] + seen = {root_pid} + while stack: + cur = stack.pop() + kids = children.get(cur, []) + for k in kids: + if k in seen: + continue + seen.add(k) + # Any live descendant = our session is still alive. + return True + return False + except Exception: # noqa: BLE001 + # WMIC missing (newer Windows) — be conservative and return False + # rather than the old "any firefox.exe = mine" heuristic. + return False + + class BrowserSession: def __init__(self) -> None: self._proc: subprocess.Popen[str] | None = None @@ -145,16 +198,15 @@ class BrowserSession: if self._proc is None: return False # Firefox's initial launcher process exits quickly (code 0) while - # browser child processes carry on. Poll the parent process but also - # fall back to checking the system process list so the GUI doesn't - # falsely report "stopped". + # browser child processes carry on. Poll the parent process first. if self._proc.poll() is None: return True - # Parent has exited — check if any firefox.exe is still alive AND we - # launched within the last 5 minutes (to avoid false positives from - # unrelated browser sessions). - age = time.monotonic() - self._launched_at - return age < 300 and _firefox_is_running_on_system() + # Parent has exited — check whether any process in our spawned tree + # (the original PID's children) is still alive. Using a PID-scoped + # WMIC query avoids the old failure mode of treating any unrelated + # firefox.exe on the machine as ours. + launched_pid = self._proc.pid + return _has_descendants(launched_pid) def pid(self) -> int | None: if self._proc is None: diff --git a/proxy_chain_manager/config.py b/proxy_chain_manager/config.py index 7d89723..39db4b3 100644 --- a/proxy_chain_manager/config.py +++ b/proxy_chain_manager/config.py @@ -200,6 +200,8 @@ class Settings: obfuscation_mode: str = "auto" # see OBFUSCATION_MODES use_pinned_chain: bool = True # use manually ordered chain from Chain Builder pinned_chain: list[str] = field(default_factory=list) # user-ordered hop list + validate_pinned_on_start: bool = True # quick TCP probe of each pinned hop before chaining + max_browser_relaunches: int = 5 # cap auto-relaunch attempts so a broken profile doesn't loop # Fixed last hop only (ignored when use_pinned_chain is True — full manual chain wins) manual_exit_proxy: str = "" diff --git a/proxy_chain_manager/gost_util.py b/proxy_chain_manager/gost_util.py index 7fa5fac..2dacd6c 100644 --- a/proxy_chain_manager/gost_util.py +++ b/proxy_chain_manager/gost_util.py @@ -24,6 +24,11 @@ GOST_RELEASE_ZIP = ( # Verified 2026-05-21 against https://github.com/go-gost/gost/releases/download/v3.2.6/ GOST_RELEASE_ZIP_SHA256 = "32f4edf3d94b622e67f1979f6f5de82dac62abc0977772cf96215dd199ef7e7b" +# Pinned SHA256 of the extracted ``gost.exe`` binary inside the v3.2.6 zip. +# Recorded the first time the zip is unpacked and verified on every reuse, so +# a tampered or partially-overwritten exe on disk forces a clean re-download. +GOST_EXE_SHA256_FILE = "gost.exe.sha256" + def _add_defender_exclusion(path: Path) -> None: @@ -58,10 +63,55 @@ def _verify_zip_sha256(data: bytes, expected: str) -> None: log.info("GOST zip SHA256 OK: %s", actual) +def _hash_file(path: Path) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(64 * 1024), b""): + h.update(chunk) + return h.hexdigest().lower() + + +def _read_pinned_exe_hash(exe: Path) -> str | None: + sidecar = exe.with_suffix(exe.suffix + ".sha256") + try: + return sidecar.read_text(encoding="utf-8").strip().lower() or None + except OSError: + return None + + +def _write_pinned_exe_hash(exe: Path, digest: str) -> None: + sidecar = exe.with_suffix(exe.suffix + ".sha256") + try: + sidecar.write_text(digest, encoding="utf-8") + except OSError as exc: + log.warning("Could not write gost.exe hash sidecar: %s", exc) + + def ensure_gost(target: Path | None = None) -> Path: exe = target or gost_exe_path() if exe.is_file() and exe.stat().st_size > 10_000: - return exe + # Re-verify the on-disk binary against the sidecar pin so a tampered + # exe cannot persist across launches. + pinned = _read_pinned_exe_hash(exe) + if pinned: + actual = _hash_file(exe) + if actual == pinned: + return exe + log.warning( + "gost.exe SHA256 mismatch on reuse — re-downloading.\n" + " pinned: %s\n actual: %s", + pinned, actual, + ) + try: + exe.unlink() + except OSError as exc: + log.warning("Could not remove tampered gost.exe: %s", exc) + else: + # First reuse after older versions: record the current hash so the + # next run can verify. Mark as trusted-on-first-use only when zip + # SHA verification has already happened earlier in the session. + _write_pinned_exe_hash(exe, _hash_file(exe)) + return exe exe.parent.mkdir(parents=True, exist_ok=True) # Add exclusion BEFORE downloading so Defender doesn't nuke it on write _add_defender_exclusion(exe) @@ -82,6 +132,8 @@ def ensure_gost(target: Path | None = None) -> Path: shutil.copyfileobj(src, dst) # Add exclusion again after write in case Defender scanned during extraction _add_defender_exclusion(exe) + # Pin the freshly-extracted exe so subsequent launches can re-verify it. + _write_pinned_exe_hash(exe, _hash_file(exe)) log.info("GOST installed at %s", exe) return exe diff --git a/proxy_chain_manager/secrets_store.py b/proxy_chain_manager/secrets_store.py new file mode 100644 index 0000000..0490881 --- /dev/null +++ b/proxy_chain_manager/secrets_store.py @@ -0,0 +1,160 @@ +"""Windows DPAPI-based secret storage. + +Provides ``encrypt(plaintext: str) -> str`` and ``decrypt(token: str) -> str`` +backed by ``CryptProtectData`` / ``CryptUnprotectData``. + +Why DPAPI: + - Built into Windows; no third-party crypto dependency. + - Key material is tied to the *current user account* (or the machine, when + ``CRYPTPROTECT_LOCAL_MACHINE`` is used) — no passphrase to remember. + - Data encrypted on disk cannot be decrypted by another user / machine. + +Token format: ``DPAPI:v1:`` + +Plain strings that don't start with ``DPAPI:`` are returned unchanged by +``decrypt()`` so the loader can safely walk old plaintext files and re-encrypt +them on the next save (migration is free). + +Falls back gracefully on non-Windows or when DPAPI is unavailable: ``encrypt`` +returns the original string verbatim (logged once). +""" +from __future__ import annotations + +import base64 +import ctypes +import logging +import sys +from ctypes import wintypes + +log = logging.getLogger(__name__) + +_TOKEN_PREFIX = "DPAPI:v1:" + + +# ── Windows API binding ────────────────────────────────────────────────────── + +class _DATA_BLOB(ctypes.Structure): + _fields_ = [ + ("cbData", wintypes.DWORD), + ("pbData", ctypes.POINTER(ctypes.c_char)), + ] + + +def _blob(data: bytes) -> _DATA_BLOB: + buf = ctypes.create_string_buffer(data, len(data)) + return _DATA_BLOB(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char))) + + +def _is_windows() -> bool: + return sys.platform == "win32" + + +_warned_unavailable = False + + +def _warn_unavailable(reason: str) -> None: + global _warned_unavailable + if not _warned_unavailable: + log.warning("DPAPI secret store unavailable: %s — secrets will be stored in plaintext.", reason) + _warned_unavailable = True + + +# ── Public API ─────────────────────────────────────────────────────────────── + +def is_available() -> bool: + """Return True if the current process can use DPAPI.""" + if not _is_windows(): + return False + try: + ctypes.windll.crypt32 # noqa: B018 — just confirm the DLL is importable + return True + except Exception: # noqa: BLE001 + return False + + +def encrypt(plaintext: str) -> str: + """Encrypt *plaintext* with current-user DPAPI. + + Returns ``DPAPI:v1:`` on success. Returns the original plaintext + on any failure (callers can therefore always trust the return value as the + string to persist). + """ + if not plaintext: + return "" + # Already encrypted — pass through. + if plaintext.startswith(_TOKEN_PREFIX): + return plaintext + if not is_available(): + _warn_unavailable("not running on Windows") + return plaintext + try: + data = plaintext.encode("utf-8") + in_blob = _blob(data) + out_blob = _DATA_BLOB() + ok = ctypes.windll.crypt32.CryptProtectData( + ctypes.byref(in_blob), + "ProxyGodSecret", # description (ignored by us) + None, # optional entropy + None, # reserved + None, # prompt struct + 0, # flags + ctypes.byref(out_blob), + ) + if not ok: + err = ctypes.get_last_error() + _warn_unavailable(f"CryptProtectData failed (err={err})") + return plaintext + try: + enc = ctypes.string_at(out_blob.pbData, out_blob.cbData) + finally: + ctypes.windll.kernel32.LocalFree(out_blob.pbData) + return _TOKEN_PREFIX + base64.b64encode(enc).decode("ascii") + except Exception as exc: # noqa: BLE001 + _warn_unavailable(f"unexpected error: {exc}") + return plaintext + + +def decrypt(token: str) -> str: + """Decrypt a token produced by :func:`encrypt`. + + Plain strings (not starting with the ``DPAPI:`` prefix) are returned + verbatim — this is intentional, it lets the loader transparently read + legacy plaintext files and re-encrypt on save. + """ + if not token: + return "" + if not token.startswith(_TOKEN_PREFIX): + return token # legacy plaintext + if not is_available(): + _warn_unavailable("not running on Windows — cannot decrypt") + return "" + try: + b64 = token[len(_TOKEN_PREFIX):] + data = base64.b64decode(b64.encode("ascii")) + in_blob = _blob(data) + out_blob = _DATA_BLOB() + ok = ctypes.windll.crypt32.CryptUnprotectData( + ctypes.byref(in_blob), + None, # ppszDataDescr + None, # entropy + None, # reserved + None, # prompt struct + 0, # flags + ctypes.byref(out_blob), + ) + if not ok: + err = ctypes.get_last_error() + log.warning("CryptUnprotectData failed (err=%s) — secret stayed encrypted.", err) + return "" + try: + plain = ctypes.string_at(out_blob.pbData, out_blob.cbData) + finally: + ctypes.windll.kernel32.LocalFree(out_blob.pbData) + return plain.decode("utf-8", errors="replace") + except Exception as exc: # noqa: BLE001 + log.warning("DPAPI decrypt error: %s", exc) + return "" + + +def is_encrypted(token: str) -> bool: + return bool(token) and token.startswith(_TOKEN_PREFIX) diff --git a/proxy_chain_manager/service.py b/proxy_chain_manager/service.py index b070c72..bb881b3 100644 --- a/proxy_chain_manager/service.py +++ b/proxy_chain_manager/service.py @@ -70,6 +70,22 @@ _FETCH_POOL = ThreadPoolExecutor(max_workers=8, thread_name_prefix="fetcher") from .leak_detect import is_same_subnet as _is_same_network # noqa: F401 +async def _probe_tcp(host: str, port: int, timeout: float = 6.0) -> bool: + """Open and close a TCP socket; True if the handshake completes within *timeout*.""" + try: + reader, writer = await asyncio.wait_for( + asyncio.open_connection(host, port), timeout=timeout, + ) + writer.close() + try: + await writer.wait_closed() + except Exception: # noqa: BLE001 + pass + return True + except Exception: # noqa: BLE001 + return False + + class ChainService: """Background rotating proxy chain using GOST.""" @@ -400,6 +416,26 @@ class ChainService: }) if self._settings.use_pinned_chain and self._settings.pinned_chain: chain = list(self._settings.pinned_chain) + + # Optional per-hop TCP probe so a known-dead pinned chain + # doesn't loop forever on the 10 s retry below. + if self._settings.validate_pinned_on_start: + from urllib.parse import urlparse as _urlparse + dead = [] + for hop in chain: + try: + p = _urlparse(hop if "://" in hop else "http://" + hop) + if p.hostname and p.port: + if not await _probe_tcp(p.hostname, int(p.port), 6.0): + dead.append(hop) + except Exception: # noqa: BLE001 + dead.append(hop) + if dead: + self._notify({"type": "log", "text": ( + f"Pinned chain: {len(dead)}/{len(chain)} hop(s) failed TCP " + f"probe — proceeding anyway (will retry on failure)." + )}) + rotation_num += 1 self._notify({"type": "rotation", "n": rotation_num}) log.info("Pinned chain run: %d hops", len(chain)) diff --git a/proxy_chain_manager/signup_prep.py b/proxy_chain_manager/signup_prep.py index f919c4c..6fdd85c 100644 --- a/proxy_chain_manager/signup_prep.py +++ b/proxy_chain_manager/signup_prep.py @@ -282,21 +282,30 @@ def resolve_signup_url(draft: SignupDraft) -> str: def load_draft() -> SignupDraft: + from . import secrets_store as _ss p = draft_path() if not p.is_file(): return SignupDraft() try: raw = json.loads(p.read_text(encoding="utf-8")) - return SignupDraft(**{k: raw.get(k, "") for k in SignupDraft.__dataclass_fields__}) + kwargs = {k: raw.get(k, "") for k in SignupDraft.__dataclass_fields__} + if kwargs.get("password"): + kwargs["password"] = _ss.decrypt(kwargs["password"]) + return SignupDraft(**kwargs) except (OSError, json.JSONDecodeError, TypeError): return SignupDraft() def save_draft(draft: SignupDraft) -> None: - draft_path().write_text(json.dumps(asdict(draft), indent=2), encoding="utf-8") + from . import secrets_store as _ss + d = asdict(draft) + if d.get("password"): + d["password"] = _ss.encrypt(d["password"]) + draft_path().write_text(json.dumps(d, indent=2), encoding="utf-8") def load_accounts() -> list[AccountRecord]: + from . import secrets_store as _ss p = accounts_path() if not p.is_file(): return [] @@ -312,6 +321,9 @@ def load_accounts() -> list[AccountRecord]: kwargs = {k: item.get(k, "") for k in fields} if not kwargs.get("id"): kwargs["id"] = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S%f") + # Transparent DPAPI decrypt; plaintext passes through. + if kwargs.get("password"): + kwargs["password"] = _ss.decrypt(kwargs["password"]) out.append(AccountRecord(**kwargs)) return out except (OSError, json.JSONDecodeError, TypeError): @@ -319,8 +331,17 @@ def load_accounts() -> list[AccountRecord]: def save_accounts(rows: list[AccountRecord]) -> None: + from . import secrets_store as _ss + serialized = [] + for r in rows: + d = asdict(r) + # Encrypt sensitive fields at rest using Windows DPAPI when available. + # Legacy plaintext entries are migrated transparently on first save. + if d.get("password"): + d["password"] = _ss.encrypt(d["password"]) + serialized.append(d) accounts_path().write_text( - json.dumps([asdict(r) for r in rows], indent=2), + json.dumps(serialized, indent=2), encoding="utf-8", ) diff --git a/scripts/setup_and_build.ps1 b/scripts/setup_and_build.ps1 index 1071db4..a15a807 100644 --- a/scripts/setup_and_build.ps1 +++ b/scripts/setup_and_build.ps1 @@ -36,7 +36,8 @@ Write-Host "`n== pip (upgrade) ==" Write-Host "`n== dependencies + PyInstaller ==" & $py -m pip install -r "$root\requirements.txt" -& $py -m pip install pyinstaller +# Pinned dev dep (PyInstaller version) so builds are reproducible. +& $py -m pip install -r "$root\dev-requirements.txt" Write-Host "`n== PyInstaller (using ProxyChainManager.spec) ==" # Always build from the spec — it bundles signup_extension/, world_map.png, @@ -52,10 +53,17 @@ if (-not (Test-Path $distExe)) { throw "Build failed: missing $distExe" } +# SHA256 sidecar so the release artifact is self-verifiable. +$distHashFile = "$distExe.sha256" +$distHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $distExe).Hash.ToLower() +"$distHash *ProxyChainManager.exe" | Out-File -FilePath $distHashFile -Encoding ascii -Force +Write-Host "SHA256: $distHash → $distHashFile" + $desk = [Environment]::GetFolderPath("Desktop") $deskExe = Join-Path $desk "ProxyChainManager.exe" Copy-Item -LiteralPath $distExe -Destination $deskExe -Force -Write-Host "Copied: $deskExe" +Copy-Item -LiteralPath $distHashFile -Destination "$deskExe.sha256" -Force +Write-Host "Copied: $deskExe (+ .sha256)" Write-Host "`n== Desktop shortcut ==" & powershell -NoProfile -ExecutionPolicy Bypass -File "$root\scripts\create_desktop_shortcut.ps1" diff --git a/tests/test_secrets_store.py b/tests/test_secrets_store.py new file mode 100644 index 0000000..67938a9 --- /dev/null +++ b/tests/test_secrets_store.py @@ -0,0 +1,48 @@ +"""Round-trip tests for the DPAPI-backed secrets store. + +These are skipped automatically on non-Windows so the test suite stays cross- +platform. On Windows, DPAPI is part of the OS — no extra deps required. +""" +from __future__ import annotations + +import sys +import unittest + +from proxy_chain_manager import secrets_store + + +@unittest.skipUnless(sys.platform == "win32", "DPAPI only on Windows") +class TestSecretsStore(unittest.TestCase): + def test_empty_passthrough(self) -> None: + self.assertEqual(secrets_store.encrypt(""), "") + self.assertEqual(secrets_store.decrypt(""), "") + + def test_legacy_plaintext_passthrough(self) -> None: + # Anything without the DPAPI: prefix decrypts to itself so migration + # from plaintext files is automatic on first read. + self.assertEqual(secrets_store.decrypt("legacy_pw"), "legacy_pw") + + def test_round_trip_ascii(self) -> None: + secret = "hunter2!" + token = secrets_store.encrypt(secret) + self.assertTrue(token.startswith("DPAPI:v1:")) + self.assertEqual(secrets_store.decrypt(token), secret) + + def test_round_trip_unicode(self) -> None: + secret = "пароль · 🔐 · café" + token = secrets_store.encrypt(secret) + self.assertEqual(secrets_store.decrypt(token), secret) + + def test_double_encrypt_idempotent(self) -> None: + token = secrets_store.encrypt("once") + again = secrets_store.encrypt(token) + self.assertEqual(token, again) + + def test_is_encrypted_helper(self) -> None: + self.assertFalse(secrets_store.is_encrypted("")) + self.assertFalse(secrets_store.is_encrypted("plaintext")) + self.assertTrue(secrets_store.is_encrypted(secrets_store.encrypt("x"))) + + +if __name__ == "__main__": + unittest.main()