From e0961ed2db53da0eae8d601c37311b803ece053a Mon Sep 17 00:00:00 2001 From: mahan-bst Date: Thu, 23 Apr 2026 11:03:27 +0330 Subject: [PATCH] feat: add unistall CA cert feature --- README.md | 3 + README_FA.md | 1 + main.py | 19 ++++- src/cert_installer.py | 192 ++++++++++++++++++++++++++++++++++++++++++ start.bat | 11 ++- 5 files changed, 224 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ceff8f3..ad1fbb8 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,8 @@ Firefox uses its own certificate store, so even after OS-level install you need > **Auto-install on startup:** When running in `apps_script` mode the proxy will automatically detect if the CA is not yet trusted and attempt to install it for you. If it succeeds you'll see a confirmation in the log; if it fails (e.g. needs administrator rights) it will print instructions. You can also run `python main.py --install-cert` at any time to (re-)install the certificate. +> **Uninstalling:** To remove the certificate from your system's trust stores, run `python main.py --uninstall-cert` or use `python start.bat --uninstall-cert` on Windows. This removes the certificate from all system trust stores and Firefox profiles. + > ⚠️ **Security note:** This certificate only works locally on your machine. Don't share the `ca/` folder with anyone. If you want to start fresh, delete the `ca/` folder and the tool will generate a new one. --- @@ -312,6 +314,7 @@ python3 main.py --disable-socks5 # Disable SOCKS5 listener python3 main.py --log-level DEBUG # Show detailed logs python3 main.py -c /path/to/config.json # Use a different config file python3 main.py --install-cert # Install MITM CA certificate and exit +python3 main.py --uninstall-cert # Remove MITM CA certificate and exit python3 main.py --no-cert-check # Skip automatic CA install check on startup ``` diff --git a/README_FA.md b/README_FA.md index 5af49ad..09762bb 100644 --- a/README_FA.md +++ b/README_FA.md @@ -260,6 +260,7 @@ python3 main.py --disable-socks5 python3 main.py --log-level DEBUG python3 main.py -c /path/to/config.json python3 main.py --install-cert # نصب گواهی CA و خروج +python3 main.py --uninstall-cert # حذف گراهی CA و خروج python3 main.py --no-cert-check # رد شدن از بررسی خودکار گواهی ``` diff --git a/main.py b/main.py index adad513..abb2427 100644 --- a/main.py +++ b/main.py @@ -20,7 +20,7 @@ if _SRC_DIR not in sys.path: sys.path.insert(0, _SRC_DIR) -from cert_installer import install_ca, is_ca_trusted +from cert_installer import install_ca, uninstall_ca, is_ca_trusted from constants import __version__ from lan_utils import log_lan_access from logging_utils import configure as configure_logging, print_banner @@ -87,6 +87,11 @@ def parse_args(): action="store_true", help="Install the MITM CA certificate as a trusted root and exit.", ) + parser.add_argument( + "--uninstall-cert", + action="store_true", + help="Remove the MITM CA certificate from trusted roots and exit.", + ) parser.add_argument( "--no-cert-check", action="store_true", @@ -192,6 +197,18 @@ def main(): ok = install_ca(CA_CERT_FILE) sys.exit(0 if ok else 1) + # ── Certificate uninstallation ─────────────────────────────────────────── + if args.uninstall_cert: + setup_logging("INFO") + _log = logging.getLogger("Main") + _log.info("Removing CA certificate…") + ok = uninstall_ca(CA_CERT_FILE) + if ok: + _log.info("CA certificate removed successfully.") + else: + _log.warning("CA certificate removal may have failed. Check logs above.") + sys.exit(0 if ok else 1) + setup_logging(config.get("log_level", "INFO")) log = logging.getLogger("Main") diff --git a/src/cert_installer.py b/src/cert_installer.py index 71ce9c4..ffd1f75 100644 --- a/src/cert_installer.py +++ b/src/cert_installer.py @@ -358,6 +358,172 @@ def _install_firefox(cert_path: str, cert_name: str): log.warning("Firefox profile %s: %s", os.path.basename(profile), exc) +def _uninstall_firefox(cert_name: str): + """Remove certificate from all detected Firefox profile NSS databases.""" + if not _has_cmd("certutil"): + log.debug("NSS certutil not found — skipping Firefox uninstall.") + return + + profile_dirs: list[str] = [] + system = platform.system() + + if system == "Windows": + appdata = os.environ.get("APPDATA", "") + profile_dirs += glob.glob(os.path.join(appdata, r"Mozilla\Firefox\Profiles\*")) + elif system == "Darwin": + profile_dirs += glob.glob(os.path.expanduser("~/Library/Application Support/Firefox/Profiles/*")) + else: + profile_dirs += glob.glob(os.path.expanduser("~/.mozilla/firefox/*.default*")) + profile_dirs += glob.glob(os.path.expanduser("~/.mozilla/firefox/*.release*")) + + if not profile_dirs: + log.debug("No Firefox profiles found.") + return + + for profile in profile_dirs: + db = f"sql:{profile}" if os.path.exists(os.path.join(profile, "cert9.db")) else f"dbm:{profile}" + try: + _run(["certutil", "-D", "-n", cert_name, "-d", db], check=False) + log.info("Removed from Firefox profile: %s", os.path.basename(profile)) + except (subprocess.CalledProcessError, FileNotFoundError) as exc: + log.debug("Firefox profile %s: %s", os.path.basename(profile), exc) + + +# ───────────────────────────────────────────────────────────────────────────── +# Uninstall functions +# ───────────────────────────────────────────────────────────────────────────── + +def _uninstall_windows(cert_name: str) -> bool: + """Remove certificate from the Windows Trusted Root store.""" + # Try per-user store first (no admin required) + try: + _run(["certutil", "-delstore", "-user", "Root", cert_name]) + log.info("Certificate removed from Windows user Trusted Root store.") + return True + except (subprocess.CalledProcessError, FileNotFoundError) as exc: + log.warning("certutil user store removal failed: %s", exc) + + # Try system store (requires admin) + try: + _run(["certutil", "-delstore", "Root", cert_name]) + log.info("Certificate removed from Windows system Trusted Root store.") + return True + except (subprocess.CalledProcessError, FileNotFoundError) as exc: + log.warning("certutil system store removal failed: %s", exc) + + # Fallback: use PowerShell + try: + ps_cmd = ( + f"Remove-Item -Path Cert:\\CurrentUser\\Root\\{cert_name} -Force -ErrorAction SilentlyContinue" + ) + _run(["powershell", "-NoProfile", "-Command", ps_cmd], check=False) + log.info("Attempted certificate removal via PowerShell.") + return True + except (subprocess.CalledProcessError, FileNotFoundError) as exc: + log.error("PowerShell removal failed: %s", exc) + + return False + + +def _uninstall_macos(cert_name: str) -> bool: + """Remove certificate from the macOS keychains.""" + login_keychain = os.path.expanduser("~/Library/Keychains/login.keychain-db") + if not os.path.exists(login_keychain): + login_keychain = os.path.expanduser("~/Library/Keychains/login.keychain") + + try: + _run([ + "security", "delete-certificate", + "-c", cert_name, + login_keychain, + ], check=False) + log.info("Certificate removed from macOS login keychain.") + return True + except (subprocess.CalledProcessError, FileNotFoundError) as exc: + log.warning("login keychain removal failed: %s", exc) + + # Try system keychain (needs sudo) + try: + _run([ + "sudo", "security", "delete-certificate", + "-c", cert_name, + "/Library/Keychains/System.keychain", + ], check=False) + log.info("Certificate removed from macOS system keychain.") + return True + except (subprocess.CalledProcessError, FileNotFoundError) as exc: + log.debug("System keychain removal failed: %s", exc) + + return False + + +def _uninstall_linux(cert_path: str, cert_name: str) -> bool: + """Remove certificate from Linux trust stores.""" + distro = _detect_linux_distro() + log.info("Detected Linux distro family: %s", distro) + + removed = False + + if distro == "debian": + dest_file = f"/usr/local/share/ca-certificates/{cert_name.replace(' ', '_')}.crt" + try: + if os.path.exists(dest_file): + os.remove(dest_file) + _run(["update-ca-certificates"]) + log.info("Certificate removed via update-ca-certificates.") + removed = True + except (OSError, subprocess.CalledProcessError) as exc: + log.warning("Debian removal failed (needs sudo?): %s", exc) + try: + _run(["sudo", "rm", "-f", dest_file]) + _run(["sudo", "update-ca-certificates"]) + log.info("Certificate removed via sudo update-ca-certificates.") + removed = True + except (subprocess.CalledProcessError, FileNotFoundError) as exc2: + log.warning("sudo Debian removal failed: %s", exc2) + + elif distro == "rhel": + dest_file = f"/etc/pki/ca-trust/source/anchors/{cert_name.replace(' ', '_')}.crt" + try: + if os.path.exists(dest_file): + os.remove(dest_file) + _run(["update-ca-trust", "extract"]) + log.info("Certificate removed via update-ca-trust.") + removed = True + except (OSError, subprocess.CalledProcessError) as exc: + log.warning("RHEL removal failed (needs sudo?): %s", exc) + try: + _run(["sudo", "rm", "-f", dest_file]) + _run(["sudo", "update-ca-trust", "extract"]) + log.info("Certificate removed via sudo update-ca-trust.") + removed = True + except (subprocess.CalledProcessError, FileNotFoundError) as exc2: + log.warning("sudo RHEL removal failed: %s", exc2) + + elif distro == "arch": + dest_file = f"/etc/ca-certificates/trust-source/anchors/{cert_name.replace(' ', '_')}.crt" + try: + if os.path.exists(dest_file): + os.remove(dest_file) + _run(["trust", "extract-compat"]) + log.info("Certificate removed via trust extract-compat.") + removed = True + except (OSError, subprocess.CalledProcessError) as exc: + log.warning("Arch removal failed (needs sudo?): %s", exc) + try: + _run(["sudo", "rm", "-f", dest_file]) + _run(["sudo", "trust", "extract-compat"]) + log.info("Certificate removed via sudo trust extract-compat.") + removed = True + except (subprocess.CalledProcessError, FileNotFoundError) as exc2: + log.warning("sudo Arch removal failed: %s", exc2) + + else: + log.warning("Unknown Linux distro. Manually remove %s from trusted CAs.", cert_name) + + return removed + + # ───────────────────────────────────────────────────────────────────────────── # Public API # ───────────────────────────────────────────────────────────────────────────── @@ -403,3 +569,29 @@ def install_ca(cert_path: str, cert_name: str = CERT_NAME) -> bool: _install_firefox(cert_path, cert_name) return ok + + +def uninstall_ca(cert_path: str, cert_name: str = CERT_NAME) -> bool: + """ + Remove *cert_name* from the system's trusted root CAs on the current platform. + Also attempts Firefox NSS removal. + + Returns True if the system store removal succeeded. + """ + system = platform.system() + log.info("Removing CA certificate from %s…", system) + + if system == "Windows": + ok = _uninstall_windows(cert_name) + elif system == "Darwin": + ok = _uninstall_macos(cert_name) + elif system == "Linux": + ok = _uninstall_linux(cert_path, cert_name) + else: + log.error("Unsupported platform: %s", system) + return False + + # Best-effort Firefox uninstall on all platforms + _uninstall_firefox(cert_name) + + return ok diff --git a/start.bat b/start.bat index 69dbfbb..cd59a20 100644 --- a/start.bat +++ b/start.bat @@ -4,7 +4,8 @@ cd /d "%~dp0" REM -------- MasterHttpRelayVPN one-click launcher (Windows) -------- REM Creates a local virtualenv, installs deps, runs the setup wizard -REM if needed, then starts the proxy. +REM if needed, then starts the proxy. Also checks and installs CA cert +REM if not already trusted. set "VENV_DIR=.venv" set "PY=" @@ -63,6 +64,14 @@ if not exist "config.json" ( ) ) +REM -------- Check for uninstall flag -------- +if "%~1"=="--uninstall-cert" ( + echo [*] Uninstalling CA certificate ... + "%VPY%" main.py --uninstall-cert + exit /b %errorlevel% +) + + echo. echo [*] Starting MasterHttpRelayVPN ... echo.