from __future__ import annotations import json import secrets import shutil import string import uuid from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import Any from .paths import app_data_dir _EXT_ID = "signup-autofill@proxygod" def _ext_src_path() -> Path: """Locate the signup_extension dir whether running from source or frozen.""" import sys if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): return Path(sys._MEIPASS) / "proxy_chain_manager" / "signup_extension" return Path(__file__).resolve().parent / "signup_extension" @dataclass(frozen=True) class SignupSitePreset: key: str label: str url: str hosts: tuple[str, ...] extra_selectors: dict[str, list[str]] = field(default_factory=dict) SIGNUP_PRESETS: dict[str, SignupSitePreset] = { "google": SignupSitePreset( key="google", label="Google", url="https://accounts.google.com/signup/v2/createaccount?flowName=GlifWebSignIn&flowEntry=SignUp", hosts=("accounts.google.com", "google.com"), extra_selectors={ "first_name": ['input[name="firstName"]'], "last_name": ['input[name="lastName"]'], "email": ['input[name="Username"]', 'input[type="email"]'], "password": ['input[name="Passwd"]'], "password_confirm": ['input[name="PasswdAgain"]'], }, ), "protonmail": SignupSitePreset( key="protonmail", label="Proton Mail", url="https://account.proton.me/signup", hosts=("account.proton.me", "proton.me"), extra_selectors={ "email": ['input[id="email"]', 'input[name="email"]', 'input[type="email"]'], "password": ['input[id="password"]', 'input[name="password"]'], "password_confirm": ['input[id="password-confirm"]', 'input[name="passwordConfirmation"]'], }, ), "hydraproxy": SignupSitePreset( key="hydraproxy", label="HydraProxy", url="https://dashboard.hydraproxy.com/register", hosts=("dashboard.hydraproxy.com", "hydraproxy.com"), extra_selectors={ "email": ['input[name="email"]', 'input[type="email"]', 'input[id="email"]'], "password": ['input[name="password"]', 'input[type="password"]'], "password_confirm": ['input[name="password_confirmation"]', 'input[name="confirmPassword"]'], "username": ['input[name="username"]', 'input[name="name"]'], }, ), "outlook": SignupSitePreset( key="outlook", label="Outlook / Microsoft", url="https://signup.live.com/signup", hosts=("signup.live.com", "login.live.com", "account.microsoft.com"), extra_selectors={ "email": ['input[name="MemberName"]', 'input[type="email"]'], "password": ['input[name="Password"]', 'input[name="PasswordInput"]'], "first_name": ['input[name="FirstName"]'], "last_name": ['input[name="LastName"]'], }, ), "github": SignupSitePreset( key="github", label="GitHub", url="https://github.com/signup", hosts=("github.com",), extra_selectors={ "email": ['input[name="user[email]"]', 'input[autocomplete="email"]'], "password": ['input[name="user[password]"]', 'input[autocomplete="new-password"]'], "username": ['input[name="user[login]"]'], }, ), "reddit": SignupSitePreset( key="reddit", label="Reddit", url="https://www.reddit.com/register", hosts=("www.reddit.com", "reddit.com"), extra_selectors={ "email": ['input[name="email"]', 'input[type="email"]'], "username": ['input[name="username"]'], "password": ['input[name="password"]'], }, ), "discord": SignupSitePreset( key="discord", label="Discord", url="https://discord.com/register", hosts=("discord.com",), extra_selectors={ "email": ['input[name="email"]', 'input[type="email"]'], "username": ['input[name="username"]'], "password": ['input[name="password"]'], }, ), "x_twitter": SignupSitePreset( key="x_twitter", label="X / Twitter", url="https://x.com/i/flow/signup", hosts=("x.com", "twitter.com"), extra_selectors={ "email": ['input[name="email"]', 'input[type="email"]'], "username": ['input[name="username"]'], "password": ['input[name="password"]'], "first_name": ['input[name="name"]'], }, ), "telegram": SignupSitePreset( key="telegram", label="Telegram Web", url="https://web.telegram.org/", hosts=("web.telegram.org", "telegram.org"), ), "tutanota": SignupSitePreset( key="tutanota", label="Tuta Mail", url="https://app.tuta.com/signup", hosts=("app.tuta.com", "tutanota.com", "tuta.com"), extra_selectors={ "email": ['input[id="mailAddress"]', 'input[name="mailAddress"]'], "password": ['input[id="password1"]', 'input[type="password"]'], "password_confirm": ['input[id="password2"]'], }, ), "mailcom": SignupSitePreset( key="mailcom", label="mail.com", url="https://signup.mail.com/", hosts=("signup.mail.com", "mail.com"), extra_selectors={ "username": ['input[name="email_user"]', 'input[name="username"]'], "password": ['input[name="password"]'], "first_name": ['input[name="firstName"]'], "last_name": ['input[name="lastName"]'], }, ), } # Public site-key list in display order. Useful for building dropdowns. SIGNUP_PRESET_KEYS: tuple[str, ...] = ( "google", "protonmail", "outlook", "tutanota", "mailcom", "github", "reddit", "discord", "x_twitter", "telegram", "hydraproxy", ) _FIRST_NAMES: tuple[str, ...] = ( "Alex", "Jordan", "Casey", "Morgan", "Taylor", "Riley", "Cameron", "Sam", "Jamie", "Quinn", "Avery", "Drew", "Reese", "Skyler", "Logan", "Hayden", "Parker", "Rowan", "Sage", "Emerson", "Finley", "Harper", "Kai", "Micah", "Nico", "Phoenix", "River", "Shawn", "Sutton", "Wren", ) _LAST_NAMES: tuple[str, ...] = ( "Reed", "Hayes", "Brooks", "Bennett", "Carter", "Foster", "Gray", "Hughes", "Jensen", "Knight", "Lambert", "Mason", "Nash", "Owens", "Porter", "Quincy", "Reyes", "Sutton", "Tate", "Underwood", "Vance", "Walsh", "Yates", "Zimmerman", "Abbott", "Barker", "Cohen", "Dixon", "Ellis", "Fitch", ) def random_first_name() -> str: return secrets.choice(_FIRST_NAMES) def random_last_name() -> str: return secrets.choice(_LAST_NAMES) def random_full_name() -> tuple[str, str]: return random_first_name(), random_last_name() def random_username(first: str = "", last: str = "") -> str: """Generate a plausible-looking username. If a name is provided we mix it with digits; otherwise we use two short name fragments. Always lowercase, always 8-18 chars. """ f = (first or random_first_name()).lower() l = (last or random_last_name()).lower() seps = ("", ".", "_", "") sep = secrets.choice(seps) digits = "".join(secrets.choice(string.digits) for _ in range(secrets.choice([2, 3, 4]))) style = secrets.randbelow(4) if style == 0: base = f + sep + l + digits elif style == 1: base = f + digits elif style == 2: base = l + sep + f[0] + digits else: base = f[0] + sep + l + digits base = base.strip(".-_") return base[:18] @dataclass class SignupDraft: site_key: str = "google" custom_url: str = "" email: str = "" password: str = "" username: str = "" first_name: str = "" last_name: str = "" notes: str = "" @dataclass class AccountRecord: id: str site: str url: str email: str password: str username: str = "" first_name: str = "" last_name: str = "" exit_ip: str = "" exit_hop: str = "" notes: str = "" status: str = "created" # pending | created created_at: str = "" def accounts_path() -> Path: return app_data_dir() / "signup_accounts.json" def draft_path() -> Path: return app_data_dir() / "signup_draft.json" def generate_password(length: int = 18) -> str: n = max(12, min(64, int(length))) alphabet = string.ascii_letters + string.digits + "!@#$%^&*-_=+" while True: pw = "".join(secrets.choice(alphabet) for _ in range(n)) if ( any(c.islower() for c in pw) and any(c.isupper() for c in pw) and any(c.isdigit() for c in pw) ): return pw def preset_for(key: str) -> SignupSitePreset | None: return SIGNUP_PRESETS.get(key) def resolve_signup_url(draft: SignupDraft) -> str: if draft.site_key == "custom": return (draft.custom_url or "").strip() p = preset_for(draft.site_key) return p.url if p else "" 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")) 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: 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 [] try: raw = json.loads(p.read_text(encoding="utf-8")) if not isinstance(raw, list): return [] out: list[AccountRecord] = [] for item in raw: if not isinstance(item, dict): continue fields = AccountRecord.__dataclass_fields__ 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): return [] 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(serialized, indent=2), encoding="utf-8", ) def append_account(record: AccountRecord) -> None: rows = load_accounts() rows.insert(0, record) save_accounts(rows) def build_autofill_config(draft: SignupDraft) -> dict[str, Any]: preset = preset_for(draft.site_key) hosts = list(preset.hosts) if preset else [] selectors: dict[str, list[str]] = {} if preset: selectors.update(preset.extra_selectors) fields: dict[str, str] = {} if draft.first_name: fields["first_name"] = draft.first_name if draft.last_name: fields["last_name"] = draft.last_name if draft.username: fields["username"] = draft.username if draft.email: fields["email"] = draft.email if draft.password: fields["password"] = draft.password fields["password_confirm"] = draft.password out_fields: dict[str, Any] = {} for key, val in fields.items(): out_fields[key] = val sel_key = f"{key}_selectors" if key in selectors: out_fields[sel_key] = selectors[key] return { "site": draft.site_key, "hosts": hosts, "fields": out_fields, "updated_at": datetime.now(timezone.utc).isoformat(), } def _extensions_json_entry(ext_dir_name: str) -> dict[str, Any]: now = int(datetime.now(timezone.utc).timestamp()) * 1000 return { "id": _EXT_ID, "syncGUID": str(uuid.uuid4()), "version": "1.0.0", "type": "extension", "location": { "appProfile": { "root": "app-profile", "path": f"extensions/{ext_dir_name}", } }, "internalName": None, "updateURL": None, "webExtensionConverted": False, "webExtensionId": _EXT_ID, "active": True, "userDisabled": False, "appDisabled": False, "installDate": now, "scope": 1, } def install_signup_extension(profile_dir: Path, draft: SignupDraft) -> None: """Copy autofill WebExtension into the Firefox profile and write current field values.""" profile_dir.mkdir(parents=True, exist_ok=True) ext_name = _EXT_ID dest = profile_dir / "extensions" / ext_name if dest.exists(): shutil.rmtree(dest, ignore_errors=True) shutil.copytree(_ext_src_path(), dest) cfg = build_autofill_config(draft) (dest / "autofill_config.json").write_text(json.dumps(cfg, indent=2), encoding="utf-8") ext_json_path = profile_dir / "extensions.json" entry = _extensions_json_entry(ext_name) data: dict[str, Any] if ext_json_path.is_file(): try: data = json.loads(ext_json_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): data = {"schemaVersion": 35, "addons": []} else: data = {"schemaVersion": 35, "addons": []} addons = data.get("addons") if not isinstance(addons, list): addons = [] addons = [a for a in addons if isinstance(a, dict) and a.get("id") != _EXT_ID] addons.append(entry) data["addons"] = addons ext_json_path.write_text(json.dumps(data, indent=2), encoding="utf-8")