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
|
## 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)
|
### Critical / security (P0)
|
||||||
|
|
||||||
| ID | Why left open | Suggested direction |
|
| 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-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-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-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 |
|
||||||
| **C-09** | Narrowing signup extension off `<all_urls>` breaks **custom signup URLs** | Dynamic host permissions or user-approved host list per session |
|
| **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 |
|
||||||
| **C-11** | No pinned SHA256 for extracted `gost.exe` in repo | Add `GOST_EXE_SHA256` constant from official release manifest |
|
| **B-01** | Authenticode signing needs an actual code-signing certificate + CI secrets | Sign `dist\ProxyChainManager.exe` in release pipeline once cert is provisioned |
|
||||||
| **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 |
|
|
||||||
|
|
||||||
### High (P1)
|
### High (P1) — design / scope debt
|
||||||
|
|
||||||
| ID | Why left open |
|
| ID | Why left open |
|
||||||
|----|---------------|
|
|----|---------------|
|
||||||
| **E-01** | Refresh `real_ip` when VPN state changes mid-session |
|
| **E-01** | Refresh `real_ip` when VPN state changes mid-session — needs interval policy |
|
||||||
| **E-02–E-03** | Sticky exit + leak interaction needs product rule (rotate vs warn) |
|
| **E-02–E-05** | Sticky exit / leak semantics need a product rule (rotate vs warn) |
|
||||||
| **E-04** | `is_chain_leak(real_ip=None)` fail-open — changing affects rotation semantics |
|
| **E-07–E-10** | Pool/pinned/exit edge cases need operator UX |
|
||||||
| **E-05** | /16 neighbor policy is intentional without VPN |
|
| **E-12–E-13** | DNS leak methodology (DoH vs system DNS) needs spec |
|
||||||
| **E-07–E-10** | Pool/pinned/exit edge cases need operator UX, not one-line fixes |
|
| **E-14 / S-08** | Kill-switch DNS allow rule is required for GOST hostname resolution |
|
||||||
| **E-12–E-13** | DNS leak test methodology (DoH vs system DNS) needs spec |
|
| **E-16–E-18** | OS adapter / MAC spoof crash recovery |
|
||||||
| **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-21** | Boot task LIMITED vs admin kill-switch |
|
| **E-21** | Boot task LIMITED vs admin kill-switch |
|
||||||
| **E-22** | GP proxy lock remediation (destructive) |
|
| **E-22** | Group Policy proxy-lock remediation (destructive — needs explicit operator consent) |
|
||||||
| **E-23** | Auto-rewrite sanitized settings — add “don’t auto-save” option |
|
| **E-23** | Auto-rewrite of sanitized settings — needs UI toggle |
|
||||||
| **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-28–E-30** | Third-party intel APIs / map geo / GOST blacklist rules |
|
| **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. |
|
| **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-10** | Tray/minimize UX, admin relaunch save state, About version, cookie menu keys, password masking in list |
|
| **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** | Test coverage expansion |
|
| **T-01–T-14** | Larger test-coverage program |
|
||||||
| **B-03–B-10** | UAC manifest, PyInstaller pin, SHA256 sidecar, bundle GOST, SBOM, Defender scope |
|
| **B-03, B-06–B-10** | UAC manifest tradeoff, GOST bundling, SBOM, Defender scope |
|
||||||
|
|
||||||
### Medium (P2)
|
### 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")
|
ctk.set_default_color_theme("dark-blue")
|
||||||
|
|
||||||
root = ctk.CTk()
|
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.geometry("1080x860")
|
||||||
root.minsize(900, 720)
|
root.minsize(900, 720)
|
||||||
root.configure(fg_color=BG)
|
root.configure(fg_color=BG)
|
||||||
@@ -225,6 +226,7 @@ def _main_inner() -> None:
|
|||||||
browser = BrowserSession()
|
browser = BrowserSession()
|
||||||
browser_should_run = [False]
|
browser_should_run = [False]
|
||||||
browser_last_launch_ts = [0.0]
|
browser_last_launch_ts = [0.0]
|
||||||
|
browser_relaunch_count = [0]
|
||||||
service_running = [False]
|
service_running = [False]
|
||||||
|
|
||||||
# ── tray ─────────────────────────────────────────────────────────────────
|
# ── tray ─────────────────────────────────────────────────────────────────
|
||||||
@@ -305,6 +307,26 @@ def _main_inner() -> None:
|
|||||||
|
|
||||||
def _start() -> None:
|
def _start() -> None:
|
||||||
_save_settings()
|
_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()
|
svc.start()
|
||||||
|
|
||||||
start_btn = _btn(topbar, "▶ Start", _start, w=86)
|
start_btn = _btn(topbar, "▶ Start", _start, w=86)
|
||||||
@@ -1442,12 +1464,14 @@ def _main_inner() -> None:
|
|||||||
_save_settings()
|
_save_settings()
|
||||||
cfg = _browser_cfg()
|
cfg = _browser_cfg()
|
||||||
browser_should_run[0] = True
|
browser_should_run[0] = True
|
||||||
|
browser_relaunch_count[0] = 0 # user-initiated launch resets the cap
|
||||||
if _launch_browser_with_cfg(cfg):
|
if _launch_browser_with_cfg(cfg):
|
||||||
browser_last_launch_ts[0] = time.monotonic()
|
browser_last_launch_ts[0] = time.monotonic()
|
||||||
|
|
||||||
def _stop_browser() -> None:
|
def _stop_browser() -> None:
|
||||||
cfg = _browser_cfg()
|
cfg = _browser_cfg()
|
||||||
browser_should_run[0] = False
|
browser_should_run[0] = False
|
||||||
|
browser_relaunch_count[0] = 0
|
||||||
ok, msg = browser.stop(dispose=cfg.disposable_profile)
|
ok, msg = browser.stop(dispose=cfg.disposable_profile)
|
||||||
_log(msg)
|
_log(msg)
|
||||||
_refresh_browser_status()
|
_refresh_browser_status()
|
||||||
@@ -2637,6 +2661,34 @@ def _main_inner() -> None:
|
|||||||
fg_color=ACCENT2, hover_color=ACCENT, text_color=TEXT,
|
fg_color=ACCENT2, hover_color=ACCENT, text_color=TEXT,
|
||||||
).pack(side="left")
|
).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:
|
def _apply_point_and_shoot() -> None:
|
||||||
"""Simple safe defaults: secure + low-friction launch profile."""
|
"""Simple safe defaults: secure + low-friction launch profile."""
|
||||||
use_manual_var.set(True)
|
use_manual_var.set(True)
|
||||||
@@ -2921,9 +2973,18 @@ def _main_inner() -> None:
|
|||||||
if browser_should_run[0] and not browser.is_running():
|
if browser_should_run[0] and not browser.is_running():
|
||||||
cfg = _browser_cfg()
|
cfg = _browser_cfg()
|
||||||
now = time.monotonic()
|
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 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_last_launch_ts[0] = now
|
||||||
|
browser_relaunch_count[0] += 1
|
||||||
root.after(80, _pump)
|
root.after(80, _pump)
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -161,66 +161,10 @@ def _dns_resolve_via_system(hostname: str, timeout: float = 5.0) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
# NOTE: removed legacy ``check_dns_leak`` / ``DnsLeakResult`` — they relied on
|
||||||
class DnsLeakResult:
|
# the ``x-real-ip`` HTTP response header which proxy targets do not set, so
|
||||||
resolver: str
|
# results were meaningless. Use :func:`proxy_chain_manager.dns_leak.run_dns_leak_test`
|
||||||
ip_via_proxy: str | None
|
# instead.
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def run_ban_tests(
|
def run_ban_tests(
|
||||||
|
|||||||
@@ -135,6 +135,59 @@ def _firefox_is_running_on_system() -> bool:
|
|||||||
return False
|
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:
|
class BrowserSession:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._proc: subprocess.Popen[str] | None = None
|
self._proc: subprocess.Popen[str] | None = None
|
||||||
@@ -145,16 +198,15 @@ class BrowserSession:
|
|||||||
if self._proc is None:
|
if self._proc is None:
|
||||||
return False
|
return False
|
||||||
# Firefox's initial launcher process exits quickly (code 0) while
|
# Firefox's initial launcher process exits quickly (code 0) while
|
||||||
# browser child processes carry on. Poll the parent process but also
|
# browser child processes carry on. Poll the parent process first.
|
||||||
# fall back to checking the system process list so the GUI doesn't
|
|
||||||
# falsely report "stopped".
|
|
||||||
if self._proc.poll() is None:
|
if self._proc.poll() is None:
|
||||||
return True
|
return True
|
||||||
# Parent has exited — check if any firefox.exe is still alive AND we
|
# Parent has exited — check whether any process in our spawned tree
|
||||||
# launched within the last 5 minutes (to avoid false positives from
|
# (the original PID's children) is still alive. Using a PID-scoped
|
||||||
# unrelated browser sessions).
|
# WMIC query avoids the old failure mode of treating any unrelated
|
||||||
age = time.monotonic() - self._launched_at
|
# firefox.exe on the machine as ours.
|
||||||
return age < 300 and _firefox_is_running_on_system()
|
launched_pid = self._proc.pid
|
||||||
|
return _has_descendants(launched_pid)
|
||||||
|
|
||||||
def pid(self) -> int | None:
|
def pid(self) -> int | None:
|
||||||
if self._proc is None:
|
if self._proc is None:
|
||||||
|
|||||||
@@ -200,6 +200,8 @@ class Settings:
|
|||||||
obfuscation_mode: str = "auto" # see OBFUSCATION_MODES
|
obfuscation_mode: str = "auto" # see OBFUSCATION_MODES
|
||||||
use_pinned_chain: bool = True # use manually ordered chain from Chain Builder
|
use_pinned_chain: bool = True # use manually ordered chain from Chain Builder
|
||||||
pinned_chain: list[str] = field(default_factory=list) # user-ordered hop list
|
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)
|
# Fixed last hop only (ignored when use_pinned_chain is True — full manual chain wins)
|
||||||
manual_exit_proxy: str = ""
|
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/
|
# Verified 2026-05-21 against https://github.com/go-gost/gost/releases/download/v3.2.6/
|
||||||
GOST_RELEASE_ZIP_SHA256 = "32f4edf3d94b622e67f1979f6f5de82dac62abc0977772cf96215dd199ef7e7b"
|
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:
|
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)
|
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:
|
def ensure_gost(target: Path | None = None) -> Path:
|
||||||
exe = target or gost_exe_path()
|
exe = target or gost_exe_path()
|
||||||
if exe.is_file() and exe.stat().st_size > 10_000:
|
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
|
return exe
|
||||||
exe.parent.mkdir(parents=True, exist_ok=True)
|
exe.parent.mkdir(parents=True, exist_ok=True)
|
||||||
# Add exclusion BEFORE downloading so Defender doesn't nuke it on write
|
# 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)
|
shutil.copyfileobj(src, dst)
|
||||||
# Add exclusion again after write in case Defender scanned during extraction
|
# Add exclusion again after write in case Defender scanned during extraction
|
||||||
_add_defender_exclusion(exe)
|
_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)
|
log.info("GOST installed at %s", exe)
|
||||||
return 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
|
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:
|
class ChainService:
|
||||||
"""Background rotating proxy chain using GOST."""
|
"""Background rotating proxy chain using GOST."""
|
||||||
|
|
||||||
@@ -400,6 +416,26 @@ class ChainService:
|
|||||||
})
|
})
|
||||||
if self._settings.use_pinned_chain and self._settings.pinned_chain:
|
if self._settings.use_pinned_chain and self._settings.pinned_chain:
|
||||||
chain = list(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
|
rotation_num += 1
|
||||||
self._notify({"type": "rotation", "n": rotation_num})
|
self._notify({"type": "rotation", "n": rotation_num})
|
||||||
log.info("Pinned chain run: %d hops", len(chain))
|
log.info("Pinned chain run: %d hops", len(chain))
|
||||||
|
|||||||
@@ -282,21 +282,30 @@ def resolve_signup_url(draft: SignupDraft) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def load_draft() -> SignupDraft:
|
def load_draft() -> SignupDraft:
|
||||||
|
from . import secrets_store as _ss
|
||||||
p = draft_path()
|
p = draft_path()
|
||||||
if not p.is_file():
|
if not p.is_file():
|
||||||
return SignupDraft()
|
return SignupDraft()
|
||||||
try:
|
try:
|
||||||
raw = json.loads(p.read_text(encoding="utf-8"))
|
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):
|
except (OSError, json.JSONDecodeError, TypeError):
|
||||||
return SignupDraft()
|
return SignupDraft()
|
||||||
|
|
||||||
|
|
||||||
def save_draft(draft: SignupDraft) -> None:
|
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]:
|
def load_accounts() -> list[AccountRecord]:
|
||||||
|
from . import secrets_store as _ss
|
||||||
p = accounts_path()
|
p = accounts_path()
|
||||||
if not p.is_file():
|
if not p.is_file():
|
||||||
return []
|
return []
|
||||||
@@ -312,6 +321,9 @@ def load_accounts() -> list[AccountRecord]:
|
|||||||
kwargs = {k: item.get(k, "") for k in fields}
|
kwargs = {k: item.get(k, "") for k in fields}
|
||||||
if not kwargs.get("id"):
|
if not kwargs.get("id"):
|
||||||
kwargs["id"] = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S%f")
|
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))
|
out.append(AccountRecord(**kwargs))
|
||||||
return out
|
return out
|
||||||
except (OSError, json.JSONDecodeError, TypeError):
|
except (OSError, json.JSONDecodeError, TypeError):
|
||||||
@@ -319,8 +331,17 @@ def load_accounts() -> list[AccountRecord]:
|
|||||||
|
|
||||||
|
|
||||||
def save_accounts(rows: list[AccountRecord]) -> None:
|
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(
|
accounts_path().write_text(
|
||||||
json.dumps([asdict(r) for r in rows], indent=2),
|
json.dumps(serialized, indent=2),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ Write-Host "`n== pip (upgrade) =="
|
|||||||
|
|
||||||
Write-Host "`n== dependencies + PyInstaller =="
|
Write-Host "`n== dependencies + PyInstaller =="
|
||||||
& $py -m pip install -r "$root\requirements.txt"
|
& $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) =="
|
Write-Host "`n== PyInstaller (using ProxyChainManager.spec) =="
|
||||||
# Always build from the spec — it bundles signup_extension/, world_map.png,
|
# 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"
|
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")
|
$desk = [Environment]::GetFolderPath("Desktop")
|
||||||
$deskExe = Join-Path $desk "ProxyChainManager.exe"
|
$deskExe = Join-Path $desk "ProxyChainManager.exe"
|
||||||
Copy-Item -LiteralPath $distExe -Destination $deskExe -Force
|
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 =="
|
Write-Host "`n== Desktop shortcut =="
|
||||||
& powershell -NoProfile -ExecutionPolicy Bypass -File "$root\scripts\create_desktop_shortcut.ps1"
|
& 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