fix: audit round 2 - DPAPI secrets, pinned hop probe, gost exe hash, admin guard, PID-scoped browser tracking, emergency disengage button, build sidecar
This commit is contained in:
2
dev-requirements.txt
Normal file
2
dev-requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
# Build / development dependencies — pinned for reproducible builds.
|
||||
pyinstaller==6.10.0
|
||||
@@ -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 `<all_urls>` 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 `<all_urls>` 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)*
|
||||
|
||||
@@ -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)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
|
||||
@@ -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,9 +63,54 @@ 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:
|
||||
# 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
|
||||
@@ -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
|
||||
|
||||
|
||||
160
proxy_chain_manager/secrets_store.py
Normal file
160
proxy_chain_manager/secrets_store.py
Normal file
@@ -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:<base64-blob>``
|
||||
|
||||
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:<base64>`` 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)
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
48
tests/test_secrets_store.py
Normal file
48
tests/test_secrets_store.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user