445 lines
14 KiB
Python
445 lines
14 KiB
Python
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")
|