From 10f492e95b31df231253d03085fca4cf2124edca Mon Sep 17 00:00:00 2001 From: CloakHQ Date: Tue, 19 May 2026 23:16:25 +0200 Subject: [PATCH] feat: add Pro tier license validation and download routing --- cloakbrowser/__init__.py | 3 + cloakbrowser/browser.py | 20 +- cloakbrowser/config.py | 28 ++- cloakbrowser/download.py | 268 +++++++++++++++++++++++- cloakbrowser/license.py | 188 +++++++++++++++++ js/src/config.ts | 32 ++- js/src/download.ts | 271 +++++++++++++++++++++++- js/src/index.ts | 4 + js/src/license.ts | 218 ++++++++++++++++++++ js/src/playwright.ts | 4 +- js/src/puppeteer.ts | 2 +- js/src/types.ts | 5 + js/tests/launch.test.ts | 40 +++- js/tests/license.test.ts | 312 ++++++++++++++++++++++++++++ js/tests/signature.test.ts | 104 +++++++++- tests/test_license.py | 408 +++++++++++++++++++++++++++++++++++++ tests/test_update.py | 118 +++++++++++ 17 files changed, 1987 insertions(+), 38 deletions(-) create mode 100644 cloakbrowser/license.py create mode 100644 js/src/license.ts create mode 100644 js/tests/license.test.ts create mode 100644 tests/test_license.py diff --git a/cloakbrowser/__init__.py b/cloakbrowser/__init__.py index d8de5bb..740a551 100644 --- a/cloakbrowser/__init__.py +++ b/cloakbrowser/__init__.py @@ -14,6 +14,7 @@ Usage: from .browser import launch, launch_async, launch_context, launch_context_async, launch_persistent_context, launch_persistent_context_async, ProxySettings, build_args, maybe_resolve_geoip from .config import CHROMIUM_VERSION, get_default_stealth_args from .download import binary_info, check_for_update, clear_cache, ensure_binary +from .license import LicenseInfo, validate_license from ._version import __version__ # Human-like behavioral layer (optional) @@ -44,6 +45,8 @@ __all__ = [ "build_args", "maybe_resolve_geoip", "ProxySettings", + "validate_license", + "LicenseInfo", "HumanConfig", "resolve_human_config", "__version__", diff --git a/cloakbrowser/browser.py b/cloakbrowser/browser.py index b985d4c..68c81d6 100644 --- a/cloakbrowser/browser.py +++ b/cloakbrowser/browser.py @@ -147,6 +147,7 @@ def launch( human_preset: HumanPreset = "default", human_config: HumanConfigOverrides | None = None, extension_paths: list[str] | None = None, + license_key: str | None = None, **kwargs: Any, ) -> Any: """Launch stealth Chromium browser. Returns a Playwright Browser object. @@ -187,7 +188,7 @@ def launch( from playwright.sync_api import sync_playwright - binary_path = ensure_binary() + binary_path = ensure_binary(license_key=license_key) timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale) proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy) args = _resolve_webrtc_args(args, proxy) @@ -248,6 +249,7 @@ async def launch_async( # noqa: C901 human_preset: HumanPreset = "default", human_config: HumanConfigOverrides | None = None, extension_paths: list[str] | None = None, + license_key: str | None = None, **kwargs: Any, ) -> Any: """Async version of launch(). Returns a Playwright Browser object. @@ -286,7 +288,7 @@ async def launch_async( # noqa: C901 from playwright.async_api import async_playwright - binary_path = ensure_binary() + binary_path = ensure_binary(license_key=license_key) timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale) proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy) args = _resolve_webrtc_args(args, proxy) @@ -348,6 +350,7 @@ def launch_persistent_context( human_preset: HumanPreset = "default", human_config: HumanConfigOverrides | None = None, extension_paths: list[str] | None = None, + license_key: str | None = None, **kwargs: Any, ) -> Any: """Launch stealth browser with a persistent profile and return a BrowserContext. @@ -396,7 +399,7 @@ def launch_persistent_context( timezone = _resolve_timezone(timezone, kwargs) - binary_path = ensure_binary() + binary_path = ensure_binary(license_key=license_key) timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale) proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy) args = _resolve_webrtc_args(args, proxy) @@ -472,6 +475,7 @@ async def launch_persistent_context_async( human_preset: HumanPreset = "default", human_config: HumanConfigOverrides | None = None, extension_paths: list[str] | None = None, + license_key: str | None = None, **kwargs: Any, ) -> Any: """Async version of launch_persistent_context(). @@ -522,7 +526,7 @@ async def launch_persistent_context_async( timezone = _resolve_timezone(timezone, kwargs) - binary_path = ensure_binary() + binary_path = ensure_binary(license_key=license_key) timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale) proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy) args = _resolve_webrtc_args(args, proxy) @@ -597,6 +601,7 @@ def launch_context( human_preset: HumanPreset = "default", human_config: HumanConfigOverrides | None = None, extension_paths: list[str] | None = None, + license_key: str | None = None, **kwargs: Any, ) -> Any: """Launch stealth browser and return a BrowserContext with common options pre-set. @@ -641,7 +646,8 @@ def launch_context( # so it applies to ALL contexts, not just the default one. # locale and timezone are set via binary flags only — no CDP emulation. browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args, - timezone=timezone, locale=locale, extension_paths=extension_paths) + timezone=timezone, locale=locale, extension_paths=extension_paths, + license_key=license_key) context_kwargs: dict[str, Any] = {} if user_agent: @@ -694,6 +700,7 @@ async def launch_context_async( human_preset: HumanPreset = "default", human_config: HumanConfigOverrides | None = None, extension_paths: list[str] | None = None, + license_key: str | None = None, **kwargs: Any, ) -> Any: """Async version of launch_context(). @@ -757,7 +764,8 @@ async def launch_context_async( # so it applies to ALL contexts, not just the default one. # locale and timezone are set via binary flags only — no CDP emulation. browser = await launch_async(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args, - timezone=timezone, locale=locale, extension_paths=extension_paths) + timezone=timezone, locale=locale, extension_paths=extension_paths, + license_key=license_key) context_kwargs: dict[str, Any] = {} if user_agent: diff --git a/cloakbrowser/config.py b/cloakbrowser/config.py index a8ee943..84b8ec7 100644 --- a/cloakbrowser/config.py +++ b/cloakbrowser/config.py @@ -134,15 +134,16 @@ def get_cache_dir() -> Path: return Path.home() / ".cloakbrowser" -def get_binary_dir(version: str | None = None) -> Path: +def get_binary_dir(version: str | None = None, pro: bool = False) -> Path: """Return the directory for a Chromium version binary.""" v = version or get_chromium_version() - return get_cache_dir() / f"chromium-{v}" + suffix = "-pro" if pro else "" + return get_cache_dir() / f"chromium-{v}{suffix}" -def get_binary_path(version: str | None = None) -> Path: +def get_binary_path(version: str | None = None, pro: bool = False) -> Path: """Return the expected path to the chrome executable.""" - binary_dir = get_binary_dir(version) + binary_dir = get_binary_dir(version, pro=pro) if platform.system() == "Darwin": # macOS: Chromium.app bundle @@ -172,15 +173,30 @@ def check_platform_available() -> None: ) -def get_effective_version() -> str: +def get_effective_version(pro: bool = False) -> str: """Return the best available version: auto-updated if available, else platform default. Reads a platform-scoped marker file from the cache directory. Returns the platform's hardcoded version if no update has been downloaded. + When pro=True, reads from the Pro-specific marker files. """ base = get_chromium_version() - # Try platform-scoped marker first, fall back to legacy marker for upgrades from <0.3.0 cache = get_cache_dir() + + if pro: + marker = cache / f"latest_pro_version_{get_platform_tag()}" + if marker.exists(): + try: + version = marker.read_text().strip() + if version: + binary = get_binary_path(version, pro=True) + if binary.exists(): + return version + except (ValueError, OSError): + pass + return base + + # Free tier: try platform-scoped marker first, fall back to legacy marker for name in (f"latest_version_{get_platform_tag()}", "latest_version"): marker = cache / name if marker.exists(): diff --git a/cloakbrowser/download.py b/cloakbrowser/download.py index 725c046..2a9989b 100644 --- a/cloakbrowser/download.py +++ b/cloakbrowser/download.py @@ -45,6 +45,17 @@ from .config import ( logger = logging.getLogger("cloakbrowser") + +class BinaryVerificationError(RuntimeError): + """A downloaded binary could not be authenticated (bad/missing signature, + version mismatch, or checksum failure). + + Distinct from transient download/network errors: a verification failure is + a tampering signal and MUST surface, never silently fall back to another + binary. The Pro routing in ensure_binary re-raises this rather than + downgrading to the free tier. + """ + # Timeout for download (large binary, allow 10 min) DOWNLOAD_TIMEOUT = httpx.Timeout(connect=10.0, read=60.0, write=10.0, pool=10.0) @@ -71,11 +82,14 @@ def _show_welcome() -> None: pass -def ensure_binary() -> str: +def ensure_binary(license_key: str | None = None) -> str: """Ensure the stealth Chromium binary is available. Download if needed. Returns the path to the chrome executable as a string. + Args: + license_key: Pro license key. Also reads from CLOAKBROWSER_LICENSE_KEY env var. + Set CLOAKBROWSER_BINARY_PATH to skip download and use a local build. """ # Check for local override first @@ -89,6 +103,39 @@ def ensure_binary() -> str: logger.info("Using local binary override: %s", local_override) return str(path) + # Pro license key check (custom download URL overrides Pro path) + from .license import resolve_license_key, validate_license + + key = resolve_license_key(license_key) + if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"): + key = None + + if key: + info = validate_license(key) + if info and info.valid: + # A valid license is entitled to Pro, so Pro failures surface loudly + # rather than silently substituting the older free binary. (A blip + # during a routine update never reaches here: _ensure_pro_binary + # returns the cached Pro binary and updates in the background.) + try: + return _ensure_pro_binary(key) + except BinaryVerificationError: + # Authenticity could not be confirmed — surface verbatim. + raise + except Exception as e: + # Transient failure with no cached Pro binary to use — surface a + # clear error rather than silently downloading the free binary. + raise RuntimeError( + f"Pro binary unavailable: {e}. Your license is valid but the " + f"Pro binary could not be downloaded right now. Retry in a " + f"moment. To use the free binary instead, unset " + f"CLOAKBROWSER_LICENSE_KEY." + ) from e + elif info: + logger.warning("License validation failed (plan=%s), using free tier", info.plan) + else: + logger.warning("License validation unavailable, using free tier") + # Fail fast if no binary available for this platform check_platform_available() @@ -176,6 +223,154 @@ def _download_and_extract(version: str | None = None) -> None: tmp_path.unlink(missing_ok=True) +def _ensure_pro_binary(license_key: str) -> str: + """Ensure the Pro binary is downloaded and cached. Returns the binary path.""" + from .license import get_pro_latest_version + + effective = get_effective_version(pro=True) + binary_path = get_binary_path(effective, pro=True) + + if binary_path.exists() and _is_executable(binary_path): + logger.debug("Pro binary found in cache: %s (version %s)", binary_path, effective) + _show_welcome() + _maybe_trigger_pro_update_check(license_key) + return str(binary_path) + + version = get_pro_latest_version() + if not version: + raise RuntimeError("Could not determine latest Pro version from server") + + binary_path = get_binary_path(version, pro=True) + if binary_path.exists() and _is_executable(binary_path): + logger.debug("Pro binary found in cache: %s (version %s)", binary_path, version) + _show_welcome() + return str(binary_path) + + logger.info("Downloading Pro Chromium %s for %s...", version, get_platform_tag()) + _download_pro_binary(version, license_key) + + binary_path = get_binary_path(version, pro=True) + if not binary_path.exists(): + raise RuntimeError( + f"Pro download completed but binary not found at: {binary_path}" + ) + + # Write Pro version marker (atomic) + marker = get_cache_dir() / f"latest_pro_version_{get_platform_tag()}" + try: + tmp = marker.with_suffix(".tmp") + tmp.write_text(version) + os.replace(str(tmp), str(marker)) + except OSError: + pass + + _show_welcome() + return str(binary_path) + + +def _download_pro_binary(version: str, license_key: str) -> None: + """Download a Pro binary from cloakbrowser.dev with license key auth. + + Requests the explicit version so the served archive matches the signed + manifest verified in _verify_pro_download. + """ + download_url = f"{DOWNLOAD_BASE_URL}/api/download/{version}" + binary_dir = get_binary_dir(version, pro=True) + binary_path = get_binary_path(version, pro=True) + platform_tag = get_platform_tag() + + binary_dir.parent.mkdir(parents=True, exist_ok=True) + + with tempfile.NamedTemporaryFile(suffix=get_archive_ext(), delete=False) as tmp: + tmp_path = Path(tmp.name) + + try: + _download_file( + download_url, + tmp_path, + headers={ + "Authorization": f"Bearer {license_key}", + "X-Platform": platform_tag, + }, + ) + + # Pro binaries come from cloakbrowser.dev — the same origin as free + # downloads — so the M1 attack the Ed25519 signature defends against + # applies equally. Verify with the same non-bypassable signature check; + # CLOAKBROWSER_SKIP_CHECKSUM does NOT bypass it (parity with the + # official free path). + _verify_pro_download(tmp_path, version) + + _extract_archive(tmp_path, binary_dir, binary_path) + finally: + tmp_path.unlink(missing_ok=True) + + +def _verify_pro_download(file_path: Path, version: str) -> None: + """Verify a Pro archive with the same non-bypassable Ed25519 signature check + as official free downloads. + + Pro binaries are served from cloakbrowser.dev (same origin as the free + tier), so a tampered same-origin SHA256SUMS could otherwise certify a + tampered binary (M1, #308). Fetch the Pro SHA256SUMS + detached + SHA256SUMS.sig, verify the signature against the pinned keys FIRST, bind the + manifest to the requested version, then verify the archive's SHA-256. + + An invalid signature, checksum, or version mismatch raises + BinaryVerificationError (a tampering signal the router surfaces verbatim); + CLOAKBROWSER_SKIP_CHECKSUM cannot bypass it. A failed manifest FETCH is + transient — nothing was validated — and raises a plain RuntimeError. A + valid-license user is never silently downgraded to the free binary. + """ + base = f"{DOWNLOAD_BASE_URL}/releases/pro/chromium-v{version}" + try: + manifest_resp = httpx.get( + f"{base}/SHA256SUMS", follow_redirects=True, timeout=10.0 + ) + manifest_resp.raise_for_status() + sig_resp = httpx.get( + f"{base}/SHA256SUMS.sig", follow_redirects=True, timeout=10.0 + ) + sig_resp.raise_for_status() + except Exception as exc: + # Fetch failure is transient, not tampering — raise a plain RuntimeError + # (the router reports it as "unavailable, retry") rather than a + # BinaryVerificationError (which it surfaces as a tampering signal). + raise RuntimeError( + f"Could not fetch the signed SHA256SUMS for Pro {version} ({exc})" + ) + + manifest_bytes = manifest_resp.content + # _verify_signature / _verify_checksum raise plain RuntimeError; convert to + # BinaryVerificationError so the Pro router treats them as tampering signals + # (re-raise) rather than transient failures (fall back to free). + try: + _verify_signature(manifest_bytes, sig_resp.content) + except RuntimeError as exc: + raise BinaryVerificationError(str(exc)) from exc + manifest_text = manifest_bytes.decode("utf-8") + + # Version binding: same forced-downgrade defense as the official path. + declared = _parse_manifest_version(manifest_text) + if declared != version: + raise BinaryVerificationError( + f"Version mismatch in signed Pro SHA256SUMS: requested {version}, " + f"manifest declares {declared or 'none'}. Refusing (possible downgrade)." + ) + + tarball_name = get_archive_name() + expected = _parse_checksums(manifest_text).get(tarball_name) + if expected is None: + raise BinaryVerificationError( + f"Signature-verified Pro SHA256SUMS has no entry for {tarball_name} — " + f"cannot confirm binary integrity." + ) + try: + _verify_checksum(file_path, expected) + except RuntimeError as exc: + raise BinaryVerificationError(str(exc)) from exc + + def _verify_download_checksum(file_path: Path, version: str | None = None) -> None: """Verify the downloaded archive's integrity and authenticity. @@ -388,11 +583,11 @@ def _verify_checksum(file_path: Path, expected_hash: str) -> None: logger.info("Checksum verified: SHA-256 OK") -def _download_file(url: str, dest: Path) -> None: +def _download_file(url: str, dest: Path, headers: dict[str, str] | None = None) -> None: """Download a file with progress logging.""" logger.info("Downloading from %s", url) - with httpx.stream("GET", url, follow_redirects=True, timeout=DOWNLOAD_TIMEOUT) as response: + with httpx.stream("GET", url, follow_redirects=True, timeout=DOWNLOAD_TIMEOUT, headers=headers or {}) as response: response.raise_for_status() total = int(response.headers.get("content-length", 0)) @@ -546,17 +741,34 @@ def clear_cache() -> None: def binary_info() -> dict: - """Return info about the current binary installation.""" - effective = get_effective_version() - binary_path = get_binary_path(effective) + """Return info about the current binary installation. + + tier reflects what is actually installed on disk, not merely whether a + license is cached — a cached license with no Pro binary downloaded yet is + still effectively running the free binary, and the active key may differ + from the cached one. + """ + # Prefer Pro only if a Pro binary actually exists on disk. + pro_version = get_effective_version(pro=True) + pro_path = get_binary_path(pro_version, pro=True) + pro = pro_path.exists() and _is_executable(pro_path) + + if pro: + effective = pro_version + binary_path = pro_path + else: + effective = get_effective_version() + binary_path = get_binary_path(effective) + download_url = f"{DOWNLOAD_BASE_URL}/api/download/latest" if pro else get_download_url(effective) return { "version": effective, + "tier": "pro" if pro else "free", "bundled_version": CHROMIUM_VERSION, "platform": get_platform_tag(), "binary_path": str(binary_path), "installed": binary_path.exists(), - "cache_dir": str(get_binary_dir(effective)), - "download_url": get_download_url(effective), + "cache_dir": str(get_binary_dir(effective, pro=pro)), + "download_url": download_url, } @@ -721,3 +933,43 @@ def _maybe_trigger_update_check() -> None: return t = threading.Thread(target=_check_and_download_update, daemon=True) t.start() + + +def _maybe_trigger_pro_update_check(license_key: str) -> None: + """Fire-and-forget Pro binary update check in a daemon thread.""" + check_file = get_cache_dir() / ".last_pro_update_check" + if check_file.exists(): + try: + last_check = float(check_file.read_text().strip()) + if time.time() - last_check < UPDATE_CHECK_INTERVAL: + return + except (ValueError, OSError): + pass + + def _check(): + try: + from .license import get_pro_latest_version + + check_file.parent.mkdir(parents=True, exist_ok=True) + check_file.write_text(str(time.time())) + + latest = get_pro_latest_version() + if not latest: + return + + if get_binary_path(latest, pro=True).exists(): + return + + logger.info("Newer Pro binary available: %s. Downloading in background...", latest) + _download_pro_binary(latest, license_key) + + marker = get_cache_dir() / f"latest_pro_version_{get_platform_tag()}" + tmp = marker.with_suffix(".tmp") + tmp.write_text(latest) + os.replace(str(tmp), str(marker)) + logger.info("Pro background update complete: %s ready. Will use on next launch.", latest) + except Exception: + logger.debug("Pro background update failed", exc_info=True) + + t = threading.Thread(target=_check, daemon=True) + t.start() diff --git a/cloakbrowser/license.py b/cloakbrowser/license.py new file mode 100644 index 0000000..a85c459 --- /dev/null +++ b/cloakbrowser/license.py @@ -0,0 +1,188 @@ +"""License validation and caching for CloakBrowser Pro. + +Handles license key resolution, server validation with local caching, +and Pro version checks. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +import time +from dataclasses import dataclass +from pathlib import Path + +import httpx + +from .config import get_cache_dir + +logger = logging.getLogger("cloakbrowser") + +VALIDATE_URL = "https://cloakbrowser.dev/api/license/validate" +PRO_VERSION_URL = "https://cloakbrowser.dev/api/download/version" + +LICENSE_CACHE_TTL = 86400 # 24 hours +PRO_VERSION_CHECK_INTERVAL = 3600 # 1 hour + + +@dataclass +class LicenseInfo: + valid: bool + plan: str + expires: str | None + + +def resolve_license_key(license_key: str | None = None) -> str | None: + """Resolve the license key: explicit param > env var > file > None.""" + if license_key and license_key.strip(): + return license_key.strip() + env_key = os.environ.get("CLOAKBROWSER_LICENSE_KEY", "").strip() + if env_key: + return env_key + key_file = get_cache_dir() / "license.key" + try: + content = key_file.read_text().strip() + if content: + return content + except OSError: + pass + return None + + +def validate_license(license_key: str) -> LicenseInfo | None: + """Validate a license key with the CloakBrowser server. + + Checks a local file cache first (24h TTL). Falls back to stale + cache if the server is unreachable. + + Returns LicenseInfo if validation succeeded, None on total failure. + """ + cache_path = get_cache_dir() / ".license_cache" + key_sha = hashlib.sha256(license_key.encode()).hexdigest() + + cached = _read_cache(cache_path, key_sha) + if cached: + return cached + + try: + resp = httpx.post( + VALIDATE_URL, + json={"license_key": license_key}, + timeout=10.0, + ) + resp.raise_for_status() + data = resp.json() + + info = LicenseInfo( + valid=data.get("valid", False), + plan=data.get("plan", "solo"), + expires=data.get("expires"), + ) + + if info.valid: + _write_cache(cache_path, key_sha, info) + return info + + except Exception as e: + logger.warning("License validation request failed: %s", e) + + stale = _read_cache(cache_path, key_sha, ignore_ttl=True) + if stale: + logger.warning("Using cached license validation (server unreachable)") + return stale + + return None + + +def get_pro_latest_version() -> str | None: + """Get the latest Pro binary version from the server. + + Rate-limited to 1 call per hour via a marker file. + """ + marker = get_cache_dir() / ".last_pro_version_check" + + if marker.exists(): + try: + age = time.time() - marker.stat().st_mtime + if age < PRO_VERSION_CHECK_INTERVAL: + content = marker.read_text().strip() + return content if content else None + except OSError: + pass + + try: + resp = httpx.get(PRO_VERSION_URL, timeout=10.0) + resp.raise_for_status() + version = resp.json().get("version") + if not version: + return None + + marker.parent.mkdir(parents=True, exist_ok=True) + tmp = marker.with_suffix(".tmp") + tmp.write_text(version) + os.replace(str(tmp), str(marker)) + return version + + except Exception as e: + logger.debug("Pro version check failed: %s", e) + return None + + +def _read_cache( + cache_path: Path, key_sha: str, ignore_ttl: bool = False +) -> LicenseInfo | None: + """Read cached license validation if it exists and is fresh.""" + try: + if not cache_path.exists(): + return None + + data = json.loads(cache_path.read_text()) + + if data.get("key_sha256") != key_sha: + return None + + if not ignore_ttl: + validated_at = data.get("validated_at", 0) + if time.time() - validated_at > LICENSE_CACHE_TTL: + return None + + expires = data.get("expires") + if expires: + try: + from datetime import datetime, timezone + exp_dt = datetime.fromisoformat(expires) + if exp_dt.tzinfo is None: + exp_dt = exp_dt.replace(tzinfo=timezone.utc) + if exp_dt < datetime.now(timezone.utc): + return LicenseInfo(valid=False, plan=data.get("plan", "solo"), expires=expires) + except (ValueError, TypeError): + pass + + return LicenseInfo( + valid=data.get("valid", False), + plan=data.get("plan", "solo"), + expires=expires, + ) + except (json.JSONDecodeError, OSError, KeyError, TypeError): + # TypeError: a corrupted cache with a non-numeric validated_at. Treat any + # unreadable cache as absent rather than crashing the caller. + return None + + +def _write_cache(cache_path: Path, key_sha: str, info: LicenseInfo) -> None: + """Write license validation result to local cache (atomic via tmp+rename).""" + try: + cache_path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = cache_path.with_suffix(".tmp") + tmp_path.write_text(json.dumps({ + "key_sha256": key_sha, + "valid": info.valid, + "plan": info.plan, + "expires": info.expires, + "validated_at": time.time(), + })) + os.replace(str(tmp_path), str(cache_path)) + except OSError as e: + logger.debug("Failed to write license cache: %s", e) diff --git a/js/src/config.ts b/js/src/config.ts index 991b99f..aaa513d 100644 --- a/js/src/config.ts +++ b/js/src/config.ts @@ -99,12 +99,13 @@ export function getCacheDir(): string { return path.join(os.homedir(), ".cloakbrowser"); } -export function getBinaryDir(version?: string): string { - return path.join(getCacheDir(), `chromium-${version || getChromiumVersion()}`); +export function getBinaryDir(version?: string, pro = false): string { + const suffix = pro ? "-pro" : ""; + return path.join(getCacheDir(), `chromium-${version || getChromiumVersion()}${suffix}`); } -export function getBinaryPath(version?: string): string { - const binaryDir = getBinaryDir(version); +export function getBinaryPath(version?: string, pro = false): string { + const binaryDir = getBinaryDir(version, pro); if (process.platform === "darwin") { return path.join(binaryDir, "Chromium.app", "Contents", "MacOS", "Chromium"); } @@ -158,10 +159,29 @@ export function getFallbackDownloadUrl(version?: string): string { return `${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}/${getArchiveName()}`; } -export function getEffectiveVersion(): string { +export function getEffectiveVersion(pro = false): string { const base = getChromiumVersion(); const cacheDir = getCacheDir(); - // Try platform-scoped marker first, fall back to legacy marker for upgrades from <0.3.0 + + if (pro) { + const marker = path.join(cacheDir, `latest_pro_version_${getPlatformTag()}`); + try { + if (fs.existsSync(marker)) { + const version = fs.readFileSync(marker, "utf-8").trim(); + if (version) { + const binary = getBinaryPath(version, true); + if (fs.existsSync(binary)) { + return version; + } + } + } + } catch { + // Marker unreadable + } + return base; + } + + // Free tier: try platform-scoped marker first, fall back to legacy marker for upgrades from <0.3.0 for (const name of [`latest_version_${getPlatformTag()}`, "latest_version"]) { const marker = path.join(cacheDir, name); try { diff --git a/js/src/download.ts b/js/src/download.ts index b28e237..eb114a3 100644 --- a/js/src/download.ts +++ b/js/src/download.ts @@ -15,6 +15,7 @@ import { extract as tarExtract } from "tar"; import type { BinaryInfo } from "./types.js"; import { BINARY_SIGNING_PUBKEYS, + CHROMIUM_VERSION, DOWNLOAD_BASE_URL, GITHUB_API_URL, GITHUB_DOWNLOAD_BASE_URL, @@ -33,10 +34,25 @@ import { getPlatformTag, versionNewer, } from "./config.js"; +import { resolveLicenseKey, validateLicense, getProLatestVersion } from "./license.js"; const DOWNLOAD_TIMEOUT_MS = 600_000; // 10 minutes const UPDATE_CHECK_INTERVAL_MS = 3_600_000; // 1 hour +/** + * A downloaded binary could not be authenticated (bad/missing signature, + * version mismatch, or checksum failure). Distinct from transient + * download/network errors: a verification failure is a tampering signal and + * MUST surface, never silently fall back to another binary. The Pro routing in + * ensureBinary re-throws this rather than downgrading to the free tier. + */ +export class BinaryVerificationError extends Error { + constructor(message: string) { + super(message); + this.name = "BinaryVerificationError"; + } +} + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -45,7 +61,7 @@ const UPDATE_CHECK_INTERVAL_MS = 3_600_000; // 1 hour * Ensure the stealth Chromium binary is available. Download if needed. * Returns the path to the chrome executable. */ -export async function ensureBinary(): Promise { +export async function ensureBinary(licenseKey?: string): Promise { // Check for local override const localOverride = getLocalBinaryOverride(); if (localOverride) { @@ -58,6 +74,37 @@ export async function ensureBinary(): Promise { return localOverride; } + // Pro license key check (custom download URL overrides Pro path) + const key = resolveLicenseKey(licenseKey); + const effectiveKey = process.env.CLOAKBROWSER_DOWNLOAD_URL ? undefined : key; + if (effectiveKey) { + const info = await validateLicense(effectiveKey); + if (info?.valid) { + // A valid license is entitled to Pro, so Pro failures surface loudly + // rather than silently substituting the older free binary. (A blip during + // a routine update never reaches here: ensureProBinary returns the cached + // Pro binary and updates in the background.) + try { + return await ensureProBinary(effectiveKey); + } catch (e) { + // Authenticity could not be confirmed — surface verbatim. + if (e instanceof BinaryVerificationError) throw e; + // Transient failure with no cached Pro binary to use — surface a clear + // error rather than silently downloading the free binary. + throw new Error( + `Pro binary unavailable: ${e}. Your license is valid but the Pro ` + + `binary could not be downloaded right now. Retry in a moment. To use ` + + `the free binary instead, unset CLOAKBROWSER_LICENSE_KEY.`, + { cause: e } + ); + } + } else if (info) { + console.log(`[cloakbrowser] License validation failed (plan=${info.plan}), using free tier`); + } else { + console.log("[cloakbrowser] License validation unavailable, using free tier"); + } + } + // Fail fast if no binary available for this platform checkPlatformAvailable(); @@ -109,17 +156,31 @@ export function clearCache(): void { } } -/** Return info about the current binary installation. */ +/** + * Return info about the current binary installation. + * + * tier reflects what is actually installed on disk, not merely whether a license + * is cached — a cached license with no Pro binary downloaded yet is still + * effectively running the free binary, and the active key may differ from the + * cached one. + */ export function binaryInfo(): BinaryInfo { - const effective = getEffectiveVersion(); - const binaryPath = getBinaryPath(effective); + // Prefer Pro only if a Pro binary actually exists on disk. + const proVersion = getEffectiveVersion(true); + const proPath = getBinaryPath(proVersion, true); + const isPro = fs.existsSync(proPath) && isExecutable(proPath); + + const effective = isPro ? proVersion : getEffectiveVersion(false); + const binaryPath = isPro ? proPath : getBinaryPath(effective, false); return { version: effective, + bundledVersion: CHROMIUM_VERSION, + tier: isPro ? "pro" : "free", platform: getPlatformTag(), binaryPath, installed: fs.existsSync(binaryPath), - cacheDir: getBinaryDir(effective), - downloadUrl: getDownloadUrl(effective), + cacheDir: getBinaryDir(effective, isPro), + downloadUrl: isPro ? `${DOWNLOAD_BASE_URL}/api/download/latest` : getDownloadUrl(effective), }; } @@ -443,7 +504,7 @@ async function verifyChecksum(filePath: string, expectedHash: string): Promise { +async function downloadFile(url: string, dest: string, headers?: Record): Promise { console.log(`[cloakbrowser] Downloading from ${url}`); const controller = new AbortController(); @@ -456,6 +517,7 @@ async function downloadFile(url: string, dest: string): Promise { const response = await fetch(url, { signal: controller.signal, redirect: "follow", + ...(headers ? { headers } : {}), }); if (!response.ok) { @@ -519,6 +581,168 @@ async function downloadFile(url: string, dest: string): Promise { } +// --------------------------------------------------------------------------- +// Pro binary download +// --------------------------------------------------------------------------- + +async function ensureProBinary(licenseKey: string): Promise { + const effective = getEffectiveVersion(true); + const effectivePath = getBinaryPath(effective, true); + + if (fs.existsSync(effectivePath) && isExecutable(effectivePath)) { + showWelcome(); + maybeTriggerProUpdateCheck(licenseKey); + return effectivePath; + } + + const version = await getProLatestVersion(); + if (!version) { + throw new Error("Could not determine latest Pro version from server"); + } + + const versionPath = getBinaryPath(version, true); + if (fs.existsSync(versionPath) && isExecutable(versionPath)) { + showWelcome(); + return versionPath; + } + + console.log( + `[cloakbrowser] Downloading Pro Chromium ${version} for ${getPlatformTag()}...` + ); + await downloadProBinary(version, licenseKey); + + const downloadedPath = getBinaryPath(version, true); + if (!fs.existsSync(downloadedPath)) { + throw new Error( + `Pro download completed but binary not found at: ${downloadedPath}` + ); + } + + // Write Pro version marker + try { + const cacheDir = getCacheDir(); + fs.mkdirSync(cacheDir, { recursive: true }); + const marker = path.join(cacheDir, `latest_pro_version_${getPlatformTag()}`); + fs.writeFileSync(marker, version); + } catch { + // Non-fatal + } + + showWelcome(); + return downloadedPath; +} + +/** @internal Exported for testing only. */ +export async function downloadProBinary(version: string, licenseKey: string): Promise { + // Request the explicit version so the served archive matches the signed + // manifest verified in verifyProDownload. + const downloadUrl = `${DOWNLOAD_BASE_URL}/api/download/${version}`; + const binaryDir = getBinaryDir(version, true); + const binaryPath = getBinaryPath(version, true); + const platformTag = getPlatformTag(); + + fs.mkdirSync(path.dirname(binaryDir), { recursive: true }); + + const tmpPath = path.join( + path.dirname(binaryDir), + `_download_${Date.now()}${getArchiveExt()}` + ); + + try { + await downloadFile(downloadUrl, tmpPath, { + Authorization: `Bearer ${licenseKey}`, + "X-Platform": platformTag, + }); + + // Pro binaries come from cloakbrowser.dev — the same origin as free + // downloads — so the M1 attack the Ed25519 signature defends against + // applies equally. Verify with the same non-bypassable signature check; + // CLOAKBROWSER_SKIP_CHECKSUM does NOT bypass it (parity with the official + // free path). + await verifyProDownload(tmpPath, version); + + await extractArchive(tmpPath, binaryDir, binaryPath); + } finally { + if (fs.existsSync(tmpPath)) { + fs.unlinkSync(tmpPath); + } + } +} + +/** + * Verify a Pro archive with the same non-bypassable Ed25519 signature check as + * official free downloads. Pro binaries are served from cloakbrowser.dev (same + * origin as the free tier), so a tampered same-origin SHA256SUMS could + * otherwise certify a tampered binary (M1, #308). Fetch the Pro SHA256SUMS + + * detached SHA256SUMS.sig, verify the signature against the pinned keys FIRST, + * bind the manifest to the requested version, then verify the archive's + * SHA-256. + * + * An invalid signature, checksum, or version mismatch throws + * BinaryVerificationError (a tampering signal the router surfaces verbatim); + * CLOAKBROWSER_SKIP_CHECKSUM cannot bypass it. A failed manifest FETCH is + * transient — nothing was validated — and throws a plain Error. A valid-license + * user is never silently downgraded to the free binary. + * @internal Exported for testing only. + */ +export async function verifyProDownload(filePath: string, version: string): Promise { + const base = `${DOWNLOAD_BASE_URL}/releases/pro/chromium-v${version}`; + let manifestBytes: Uint8Array; + let sigBytes: Uint8Array; + try { + const manifestResp = await fetch(`${base}/SHA256SUMS`, { + redirect: "follow", + signal: AbortSignal.timeout(10_000), + }); + if (!manifestResp.ok) throw new Error(`HTTP ${manifestResp.status} for SHA256SUMS`); + const sigResp = await fetch(`${base}/SHA256SUMS.sig`, { + redirect: "follow", + signal: AbortSignal.timeout(10_000), + }); + if (!sigResp.ok) throw new Error(`HTTP ${sigResp.status} for SHA256SUMS.sig`); + manifestBytes = new Uint8Array(await manifestResp.arrayBuffer()); + sigBytes = new Uint8Array(await sigResp.arrayBuffer()); + } catch (e) { + // Fetch failure is transient, not tampering — throw a plain Error (the + // router reports it as "unavailable, retry") rather than a + // BinaryVerificationError (which it surfaces as a tampering signal). + throw new Error(`Could not fetch the signed SHA256SUMS for Pro ${version} (${e})`); + } + + // verifySignature / verifyChecksum throw a plain Error; convert to + // BinaryVerificationError so the Pro router treats them as tampering signals + // (re-throw) rather than transient failures (fall back to free). + try { + verifySignature(manifestBytes, sigBytes); + } catch (e) { + throw new BinaryVerificationError(e instanceof Error ? e.message : String(e)); + } + const manifestText = new TextDecoder().decode(manifestBytes); + + // Version binding: same forced-downgrade defense as the official path. + const declared = parseManifestVersion(manifestText); + if (declared !== version) { + throw new BinaryVerificationError( + `Version mismatch in signed Pro SHA256SUMS: requested ${version}, ` + + `manifest declares ${declared ?? "none"}. Refusing (possible downgrade).` + ); + } + + const tarballName = getArchiveName(); + const expected = parseChecksums(manifestText).get(tarballName); + if (!expected) { + throw new BinaryVerificationError( + `Signature-verified Pro SHA256SUMS has no entry for ${tarballName} — ` + + `cannot confirm binary integrity.` + ); + } + try { + await verifyChecksum(filePath, expected); + } catch (e) { + throw new BinaryVerificationError(e instanceof Error ? e.message : String(e)); + } +} + async function extractArchive( archivePath: string, destDir: string, @@ -771,3 +995,36 @@ function maybeTriggerUpdateCheck(): void { if (!shouldCheckForUpdate()) return; checkAndDownloadUpdate().catch(() => { }); } + +function maybeTriggerProUpdateCheck(licenseKey: string): void { + const checkFile = path.join(getCacheDir(), ".last_pro_update_check"); + try { + if (fs.existsSync(checkFile)) { + const lastCheck = parseFloat(fs.readFileSync(checkFile, "utf-8").trim()); + if (Date.now() - lastCheck * 1000 < UPDATE_CHECK_INTERVAL_MS) return; + } + } catch { + // unreadable — proceed + } + + (async () => { + try { + fs.mkdirSync(path.dirname(checkFile), { recursive: true }); + fs.writeFileSync(checkFile, String(Date.now() / 1000)); + + const latest = await getProLatestVersion(); + if (!latest) return; + + if (fs.existsSync(getBinaryPath(latest, true))) return; + + console.log(`[cloakbrowser] Newer Pro binary available: ${latest}. Downloading in background...`); + await downloadProBinary(latest, licenseKey); + + const marker = path.join(getCacheDir(), `latest_pro_version_${getPlatformTag()}`); + fs.writeFileSync(marker, latest); + console.log(`[cloakbrowser] Pro background update complete: ${latest} ready. Will use on next launch.`); + } catch (err) { + // non-fatal + } + })(); +} diff --git a/js/src/index.ts b/js/src/index.ts index f824f1b..5d9e929 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -24,5 +24,9 @@ export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download // Config export { CHROMIUM_VERSION, getDefaultStealthArgs } from "./config.js"; +// License +export { validateLicense } from "./license.js"; + // Types export type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions, BinaryInfo } from "./types.js"; +export type { LicenseInfo } from "./license.js"; diff --git a/js/src/license.ts b/js/src/license.ts new file mode 100644 index 0000000..3674507 --- /dev/null +++ b/js/src/license.ts @@ -0,0 +1,218 @@ +/** + * License validation and caching for CloakBrowser Pro. + * Mirrors Python cloakbrowser/license.py. + * + * Handles license key resolution, server validation with local caching, + * and Pro version checks. + */ + +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +import { getCacheDir } from "./config.js"; + +const VALIDATE_URL = "https://cloakbrowser.dev/api/license/validate"; +const PRO_VERSION_URL = "https://cloakbrowser.dev/api/download/version"; + +const LICENSE_CACHE_TTL_MS = 86_400_000; // 24 hours +const PRO_VERSION_CHECK_INTERVAL_MS = 3_600_000; // 1 hour + +export interface LicenseInfo { + valid: boolean; + plan: string; + expires: string | null; +} + +/** + * Resolve the license key: explicit param > env var > file > undefined. + */ +export function resolveLicenseKey(licenseKey?: string): string | undefined { + const trimmed = licenseKey?.trim(); + if (trimmed) return trimmed; + const envKey = (process.env.CLOAKBROWSER_LICENSE_KEY ?? "").trim(); + if (envKey) return envKey; + try { + const keyFile = path.join(getCacheDir(), "license.key"); + const content = fs.readFileSync(keyFile, "utf-8").trim(); + if (content) return content; + } catch { + // File doesn't exist or unreadable + } + return undefined; +} + +/** + * Validate a license key with the CloakBrowser server. + * + * Checks a local file cache first (24h TTL). Falls back to stale + * cache if the server is unreachable. + * + * Returns LicenseInfo if validation succeeded, null on total failure. + */ +export async function validateLicense(licenseKey: string): Promise { + const cachePath = path.join(getCacheDir(), ".license_cache"); + const keySha = createHash("sha256").update(licenseKey).digest("hex"); + + const cached = readCache(cachePath, keySha); + if (cached) return cached; + + try { + const resp = await fetch(VALIDATE_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ license_key: licenseKey }), + signal: AbortSignal.timeout(10_000), + }); + + if (!resp.ok) { + throw new Error(`HTTP ${resp.status} ${resp.statusText}`); + } + + const data = (await resp.json()) as Record; + + const info: LicenseInfo = { + valid: Boolean(data.valid ?? false), + plan: String(data.plan ?? "solo"), + expires: data.expires != null ? String(data.expires) : null, + }; + + if (info.valid) { + writeCache(cachePath, keySha, info); + } + return info; + } catch (e) { + console.warn( + `[cloakbrowser] License validation request failed: ${e instanceof Error ? e.message : e}` + ); + + // Fall back to stale cache + const stale = readCache(cachePath, keySha, true); + if (stale) { + console.warn("[cloakbrowser] Using cached license validation (server unreachable)"); + return stale; + } + + return null; + } +} + +/** + * Get the latest Pro binary version from the server. + * Rate-limited to 1 call per hour via a marker file. + */ +export async function getProLatestVersion(): Promise { + const marker = path.join(getCacheDir(), ".last_pro_version_check"); + + try { + if (fs.existsSync(marker)) { + const stats = fs.statSync(marker); + const age = Date.now() - stats.mtimeMs; + if (age < PRO_VERSION_CHECK_INTERVAL_MS) { + const content = fs.readFileSync(marker, "utf-8").trim(); + return content || null; + } + } + } catch { + // Marker unreadable — proceed with fetch + } + + try { + const resp = await fetch(PRO_VERSION_URL, { + signal: AbortSignal.timeout(10_000), + }); + + if (!resp.ok) { + throw new Error(`HTTP ${resp.status} ${resp.statusText}`); + } + + const data = (await resp.json()) as Record; + const version = data.version != null ? String(data.version) : null; + if (!version) return null; + + try { + fs.mkdirSync(path.dirname(marker), { recursive: true }); + fs.writeFileSync(marker, version); + } catch { + // Non-fatal + } + + return version; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Cache helpers +// --------------------------------------------------------------------------- + +interface CacheData { + key_sha256: string; + valid: boolean; + plan: string; + expires: string | null; + validated_at: number; +} + +function readCache( + cachePath: string, + keySha: string, + ignoreTtl = false, +): LicenseInfo | null { + try { + if (!fs.existsSync(cachePath)) return null; + + const data = JSON.parse(fs.readFileSync(cachePath, "utf-8")) as CacheData; + + if (data.key_sha256 !== keySha) return null; + + if (!ignoreTtl) { + const validatedAt = data.validated_at ?? 0; + // A non-numeric validated_at (corrupted cache) is treated as absent rather + // than coercing to NaN and silently trusting the entry. + if (!Number.isFinite(validatedAt) || Date.now() - validatedAt * 1000 > LICENSE_CACHE_TTL_MS) { + return null; + } + } + + if (data.expires) { + try { + if (new Date(data.expires).getTime() < Date.now()) { + return { valid: false, plan: String(data.plan ?? "solo"), expires: data.expires }; + } + } catch { + // unparseable date — skip check + } + } + + return { + valid: Boolean(data.valid ?? false), + plan: String(data.plan ?? "solo"), + expires: data.expires ?? null, + }; + } catch { + return null; + } +} + +function writeCache(cachePath: string, keySha: string, info: LicenseInfo): void { + try { + const dir = path.dirname(cachePath); + fs.mkdirSync(dir, { recursive: true }); + const tmpPath = cachePath + ".tmp"; + fs.writeFileSync( + tmpPath, + JSON.stringify({ + key_sha256: keySha, + valid: info.valid, + plan: info.plan, + expires: info.expires, + validated_at: Date.now() / 1000, + }), + ); + fs.renameSync(tmpPath, cachePath); + } catch { + // Non-fatal + } +} diff --git a/js/src/playwright.ts b/js/src/playwright.ts index 6f8d68b..20c6012 100644 --- a/js/src/playwright.ts +++ b/js/src/playwright.ts @@ -102,7 +102,7 @@ export function buildContextOptions( export async function buildLaunchOptions( options: LaunchOptions = {} ): Promise { - const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary()); + const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary(options.licenseKey)); const { exitIp, ...resolved } = await maybeResolveGeoip(options); const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy); let resolvedArgs = await resolveWebrtcArgs(options); @@ -272,7 +272,7 @@ export async function launchPersistentContext( options = resolveTimezone(options); const { chromium } = await import("playwright-core"); - const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary()); + const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary(options.licenseKey)); const { exitIp, ...resolved } = await maybeResolveGeoip(options); const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy); let resolvedArgs = await resolveWebrtcArgs(options); diff --git a/js/src/puppeteer.ts b/js/src/puppeteer.ts index 0b1c8e0..1c7687c 100644 --- a/js/src/puppeteer.ts +++ b/js/src/puppeteer.ts @@ -33,7 +33,7 @@ function resolveDefaultViewport(options: LaunchOptions): { width: number; height /** Resolve binary path, geoip, webrtc, and build final Chrome args. */ async function resolveArgs(options: LaunchOptions): Promise<{ binaryPath: string; args: string[] }> { - const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary()); + const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary(options.licenseKey)); const { exitIp, ...resolved } = (await maybeResolveGeoip(options)) ?? {}; let resolvedArgs = (await resolveWebrtcArgs(options)) ?? options.args; diff --git a/js/src/types.ts b/js/src/types.ts index 6e9207b..65e0fd6 100644 --- a/js/src/types.ts +++ b/js/src/types.ts @@ -27,6 +27,8 @@ export interface LaunchOptions { locale?: string; /** Auto-detect timezone/locale from proxy IP (requires: npm install mmdb-lib). */ geoip?: boolean; + /** Pro license key. Also reads from CLOAKBROWSER_LICENSE_KEY env var. */ + licenseKey?: string; /** Raw options passed directly to playwright/puppeteer launch(). */ launchOptions?: Record; /** Enable human-like mouse, keyboard, and scroll behavior. */ @@ -66,7 +68,10 @@ export interface LaunchPersistentContextOptions extends LaunchContextOptions { export interface BinaryInfo { version: string; + /** The wrapper's bundled baseline Chromium version (CHROMIUM_VERSION). */ + bundledVersion: string; platform: string; + tier: "pro" | "free"; binaryPath: string; installed: boolean; cacheDir: string; diff --git a/js/tests/launch.test.ts b/js/tests/launch.test.ts index addf32e..083f830 100644 --- a/js/tests/launch.test.ts +++ b/js/tests/launch.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; import { binaryInfo } from "../src/download.js"; -import { DEFAULT_VIEWPORT, getChromiumVersion } from "../src/config.js"; +import { DEFAULT_VIEWPORT, getBinaryPath, getChromiumVersion, getPlatformTag } from "../src/config.js"; import * as config from "../src/config.js"; describe("binaryInfo", () => { @@ -11,6 +13,7 @@ describe("binaryInfo", () => { const info = binaryInfo(); expect(info.version).toBe(getChromiumVersion()); + expect(info.bundledVersion).toBeTruthy(); expect(info.platform).toMatch(/^(linux|darwin|windows)-(x64|arm64)$/); expect(info.binaryPath).toBeTruthy(); expect(typeof info.installed).toBe("boolean"); @@ -20,6 +23,41 @@ describe("binaryInfo", () => { else delete process.env.CLOAKBROWSER_CACHE_DIR; } }); + + it("reports tier from the installed binary, not a cached license", () => { + // A valid, fresh license is cached but NO Pro binary is on disk → free. + const orig = process.env.CLOAKBROWSER_CACHE_DIR; + const dir = `/tmp/cloakbrowser-test-${Date.now()}-tier`; + fs.mkdirSync(dir, { recursive: true }); + process.env.CLOAKBROWSER_CACHE_DIR = dir; + try { + fs.writeFileSync( + path.join(dir, ".license_cache"), + JSON.stringify({ + key_sha256: "abc", + valid: true, + plan: "solo", + expires: null, + validated_at: Date.now() / 1000, + }) + ); + expect(binaryInfo().tier).toBe("free"); + + // Now drop a Pro binary on disk → pro. + fs.writeFileSync(path.join(dir, `latest_pro_version_${getPlatformTag()}`), "147.0.5555.1"); + const bp = getBinaryPath("147.0.5555.1", true); + fs.mkdirSync(path.dirname(bp), { recursive: true }); + fs.writeFileSync(bp, "fake"); + fs.chmodSync(bp, 0o755); + const info = binaryInfo(); + expect(info.tier).toBe("pro"); + expect(info.version).toBe("147.0.5555.1"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + if (orig) process.env.CLOAKBROWSER_CACHE_DIR = orig; + else delete process.env.CLOAKBROWSER_CACHE_DIR; + } + }); }); describe("composable Playwright launch helpers", () => { diff --git a/js/tests/license.test.ts b/js/tests/license.test.ts new file mode 100644 index 0000000..3f554ca --- /dev/null +++ b/js/tests/license.test.ts @@ -0,0 +1,312 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; + +import { + resolveLicenseKey, + validateLicense, + getProLatestVersion, +} from "../src/license.js"; + +import * as config from "../src/config.js"; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = path.join("/tmp", `cloakbrowser-test-${Date.now()}`); + fs.mkdirSync(tmpDir, { recursive: true }); + vi.spyOn(config, "getCacheDir").mockReturnValue(tmpDir); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch {} +}); + +// ── resolveLicenseKey ───────────────────────────────── + +describe("resolveLicenseKey", () => { + it("explicit param wins over env", () => { + process.env.CLOAKBROWSER_LICENSE_KEY = "env-key"; + expect(resolveLicenseKey("explicit")).toBe("explicit"); + delete process.env.CLOAKBROWSER_LICENSE_KEY; + }); + + it("env var fallback", () => { + process.env.CLOAKBROWSER_LICENSE_KEY = "env-key"; + expect(resolveLicenseKey()).toBe("env-key"); + delete process.env.CLOAKBROWSER_LICENSE_KEY; + }); + + it("returns undefined when absent", () => { + delete process.env.CLOAKBROWSER_LICENSE_KEY; + expect(resolveLicenseKey()).toBeUndefined(); + }); + + it("file fallback when no param or env", () => { + delete process.env.CLOAKBROWSER_LICENSE_KEY; + const keyFile = path.join(tmpDir, "license.key"); + fs.writeFileSync(keyFile, "file-key-123\n"); + expect(resolveLicenseKey()).toBe("file-key-123"); + }); + + it("env takes precedence over file", () => { + process.env.CLOAKBROWSER_LICENSE_KEY = "env-key"; + const keyFile = path.join(tmpDir, "license.key"); + fs.writeFileSync(keyFile, "file-key"); + expect(resolveLicenseKey()).toBe("env-key"); + delete process.env.CLOAKBROWSER_LICENSE_KEY; + }); + + it("returns undefined when file missing", () => { + delete process.env.CLOAKBROWSER_LICENSE_KEY; + expect(resolveLicenseKey()).toBeUndefined(); + }); +}); + +// ── validateLicense ─────────────────────────────────── + +describe("validateLicense", () => { + const keySha = crypto.createHash("sha256").update("test-key").digest("hex"); + + it("fresh cache skips server call", async () => { + const cachePath = path.join(tmpDir, ".license_cache"); + fs.writeFileSync( + cachePath, + JSON.stringify({ + key_sha256: keySha, + valid: true, + plan: "team", + expires: "2026-12-01", + validated_at: Date.now() / 1000, + }) + ); + + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const result = await validateLicense("test-key"); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(result).not.toBeNull(); + expect(result!.valid).toBe(true); + expect(result!.plan).toBe("team"); + }); + + it("stale cache triggers server call", async () => { + const cachePath = path.join(tmpDir, ".license_cache"); + fs.writeFileSync( + cachePath, + JSON.stringify({ + key_sha256: keySha, + valid: true, + plan: "solo", + expires: null, + validated_at: Date.now() / 1000 - 90000, // 25 hours ago + }) + ); + + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ valid: true, plan: "solo", expires: null }), + } as Response); + + const result = await validateLicense("test-key"); + expect(globalThis.fetch).toHaveBeenCalledOnce(); + expect(result!.valid).toBe(true); + }); + + it("server success returns LicenseInfo", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ valid: true, plan: "business", expires: "2026-07-13" }), + } as Response); + + const result = await validateLicense("pro-key"); + expect(result).not.toBeNull(); + expect(result!.valid).toBe(true); + expect(result!.plan).toBe("business"); + expect(result!.expires).toBe("2026-07-13"); + }); + + it("server rejection returns invalid", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ valid: false, plan: "solo", expires: null }), + } as Response); + + const result = await validateLicense("bad-key"); + expect(result).not.toBeNull(); + expect(result!.valid).toBe(false); + }); + + it("server unreachable uses stale cache", async () => { + const cachePath = path.join(tmpDir, ".license_cache"); + fs.writeFileSync( + cachePath, + JSON.stringify({ + key_sha256: keySha, + valid: true, + plan: "solo", + expires: "2026-12-01", + validated_at: Date.now() / 1000 - 90000, + }) + ); + + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout")); + + const result = await validateLicense("test-key"); + expect(result).not.toBeNull(); + expect(result!.valid).toBe(true); + }); + + it("server unreachable no cache returns null", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout")); + + const result = await validateLicense("test-key"); + expect(result).toBeNull(); + }); + + it("cache stores hash not raw key", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ valid: true, plan: "solo", expires: null }), + } as Response); + + await validateLicense("secret-key-123"); + + const cachePath = path.join(tmpDir, ".license_cache"); + const content = fs.readFileSync(cachePath, "utf-8"); + expect(content).not.toContain("secret-key-123"); + const expectedSha = crypto + .createHash("sha256") + .update("secret-key-123") + .digest("hex"); + expect(content).toContain(expectedSha); + }); + + it("wrong key cache ignored", async () => { + const cachePath = path.join(tmpDir, ".license_cache"); + fs.writeFileSync( + cachePath, + JSON.stringify({ + key_sha256: "other-hash", + valid: true, + plan: "solo", + expires: null, + validated_at: Date.now() / 1000, + }) + ); + + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ valid: true, plan: "solo", expires: null }), + } as Response); + + await validateLicense("different-key"); + expect(globalThis.fetch).toHaveBeenCalledOnce(); + }); + + it("expired license rejected from cache", async () => { + const cachePath = path.join(tmpDir, ".license_cache"); + const keySha = crypto.createHash("sha256").update("test-key").digest("hex"); + fs.writeFileSync( + cachePath, + JSON.stringify({ + key_sha256: keySha, + valid: true, + plan: "solo", + expires: "2020-01-01T00:00:00+00:00", + validated_at: Date.now() / 1000, + }) + ); + + const result = await validateLicense("test-key"); + expect(result).not.toBeNull(); + expect(result!.valid).toBe(false); + }); + + it("does not cache invalid responses", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ valid: false, plan: "solo", expires: null }), + } as Response); + + await validateLicense("bad-key"); + + const cachePath = path.join(tmpDir, ".license_cache"); + expect(fs.existsSync(cachePath)).toBe(false); + }); + + it("corrupted validated_at is treated as absent cache, not trusted", async () => { + const keySha = crypto.createHash("sha256").update("test-key").digest("hex"); + fs.writeFileSync( + path.join(tmpDir, ".license_cache"), + JSON.stringify({ + key_sha256: keySha, + valid: true, + plan: "solo", + expires: null, + validated_at: "not-a-number", + }) + ); + + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ valid: true, plan: "solo", expires: null }), + } as Response); + + const result = await validateLicense("test-key"); + expect(globalThis.fetch).toHaveBeenCalledOnce(); // corrupted cache ignored → server hit + expect(result!.valid).toBe(true); + }); +}); + +// ── getProLatestVersion ─────────────────────────────── + +describe("getProLatestVersion", () => { + it("fetches version from server", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ version: "147.0.1234.5" }), + } as Response); + + const version = await getProLatestVersion(); + expect(version).toBe("147.0.1234.5"); + }); + + it("rate limited by marker file", async () => { + const marker = path.join(tmpDir, ".last_pro_version_check"); + fs.writeFileSync(marker, "147.0.1234.5"); + + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const version = await getProLatestVersion(); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(version).toBe("147.0.1234.5"); + }); + + it("network error returns null", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network")); + const version = await getProLatestVersion(); + expect(version).toBeNull(); + }); +}); + +// ── Config pro parameter ────────────────────────────── + +describe("config pro parameter", () => { + it("getBinaryDir adds -pro suffix", () => { + const normal = config.getBinaryDir("147.0.0.0"); + const pro = config.getBinaryDir("147.0.0.0", true); + expect(normal).toMatch(/chromium-147\.0\.0\.0$/); + expect(pro).toMatch(/chromium-147\.0\.0\.0-pro$/); + }); + + it("getBinaryDir default has no suffix", () => { + const normal = config.getBinaryDir("147.0.0.0"); + expect(normal).not.toMatch(/-pro$/); + }); +}); diff --git a/js/tests/signature.test.ts b/js/tests/signature.test.ts index 169eaf0..b6981e2 100644 --- a/js/tests/signature.test.ts +++ b/js/tests/signature.test.ts @@ -26,13 +26,16 @@ vi.mock("../src/config.js", async (importActual) => { }); import { + BinaryVerificationError, + downloadProBinary, fetchSignedManifest, parseChecksums, parseManifestVersion, verifyDownloadChecksum, + verifyProDownload, verifySignature, } from "../src/download.js"; -import { getArchiveName, getChromiumVersion } from "../src/config.js"; +import { DOWNLOAD_BASE_URL, getArchiveName, getChromiumVersion } from "../src/config.js"; /** Produce SHA256SUMS.sig content (base64 text bytes) for a manifest. */ function sign(manifest: Uint8Array): Uint8Array { @@ -173,6 +176,105 @@ describe("verifyDownloadChecksum (official path, fail-closed)", () => { }); }); +describe("downloadProBinary (version-pinned URL)", () => { + afterEach(() => vi.restoreAllMocks()); + + it("requests the explicit version, not /latest", async () => { + let capturedUrl = ""; + // First fetch is the binary download; capture its URL then abort the flow + // before verify/extract by returning a non-ok response. + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + capturedUrl = typeof input === "string" ? input : (input as URL).toString(); + return { ok: false, status: 500, statusText: "stop" } as Response; + }); + + await downloadProBinary("147.0.1.0", "cb_key").catch(() => {}); + + expect(capturedUrl).toBe(`${DOWNLOAD_BASE_URL}/api/download/147.0.1.0`); + expect(capturedUrl.endsWith("/latest")).toBe(false); + }); +}); + +describe("verifyProDownload (Pro path, fail-closed parity)", () => { + const PRO_VERSION = "147.0.1.0"; + + afterEach(() => { + vi.restoreAllMocks(); + delete process.env.CLOAKBROWSER_SKIP_CHECKSUM; + }); + + function tmpFile(bytes: Buffer): string { + const p = path.join(os.tmpdir(), `cloak-pro-${process.pid}-${bytes.length}-${bytes[0]}`); + fs.writeFileSync(p, bytes); + return p; + } + + /** Mock fetch: serve `manifestBytes` for SHA256SUMS, its signature for *.sig. */ + function mockManifest(manifestBytes: Uint8Array, sigBytes = sign(manifestBytes)) { + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = typeof input === "string" ? input : (input as URL).toString(); + const out = url.endsWith(".sig") ? sigBytes : manifestBytes; + return { ok: true, arrayBuffer: async () => out.buffer } as Response; + }); + } + + const body = (lines: string, version = PRO_VERSION) => + enc(`version=${version}\n${lines}`); + + it("passes when signature is valid and hash matches", async () => { + const data = Buffer.from("the real pro binary"); + const file = tmpFile(data); + const hash = createHash("sha256").update(data).digest("hex"); + mockManifest(body(`${hash} ${getArchiveName()}\n`)); + await expect(verifyProDownload(file, PRO_VERSION)).resolves.toBeUndefined(); + }); + + it("CLOAKBROWSER_SKIP_CHECKSUM does NOT bypass Pro verification", async () => { + const file = tmpFile(Buffer.from("a malicious pro binary")); + const goodHash = createHash("sha256").update(Buffer.from("the real pro binary")).digest("hex"); + process.env.CLOAKBROWSER_SKIP_CHECKSUM = "true"; + mockManifest(body(`${goodHash} ${getArchiveName()}\n`)); + const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e); + // The error TYPE is the contract the ensureBinary router branches on: + // BinaryVerificationError => re-throw (never downgrade to free). + expect(err).toBeInstanceOf(BinaryVerificationError); + expect(err.message).toMatch(/Checksum verification failed/); + }); + + it("treats a failed manifest fetch as transient, not tampering", async () => { + // A failed manifest FETCH must be a plain Error (router falls back to free), + // NOT a BinaryVerificationError (which the router re-throws as a hard fail). + const file = tmpFile(Buffer.from("x")); + vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: false, status: 404 } as Response); + const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e); + expect(err).toBeInstanceOf(Error); + expect(err).not.toBeInstanceOf(BinaryVerificationError); + }); + + it("fails on a signed manifest for the wrong version (downgrade)", async () => { + const data = Buffer.from("the real pro binary"); + const file = tmpFile(data); + const hash = createHash("sha256").update(data).digest("hex"); + mockManifest(body(`${hash} ${getArchiveName()}\n`, "1.0.0.0")); + const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e); + expect(err).toBeInstanceOf(BinaryVerificationError); + expect(err.message).toMatch(/Version mismatch/); + }); + + it("rejects a manifest tampered after signing", async () => { + const data = Buffer.from("the real pro binary"); + const file = tmpFile(data); + const hash = createHash("sha256").update(data).digest("hex"); + const good = body(`${hash} ${getArchiveName()}\n`); + const sig = sign(good); + const tampered = enc(new TextDecoder().decode(good).replace(getArchiveName(), "evil.tar.gz")); + mockManifest(tampered, sig); + const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e); + expect(err).toBeInstanceOf(BinaryVerificationError); + expect(err.message).toMatch(/signature verification failed/); + }); +}); + describe("version binding", () => { it("reads the version= line", () => { expect( diff --git a/tests/test_license.py b/tests/test_license.py new file mode 100644 index 0000000..8801566 --- /dev/null +++ b/tests/test_license.py @@ -0,0 +1,408 @@ +"""Tests for the CloakBrowser Pro license module.""" + +import hashlib +import json +import os +import time +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from cloakbrowser.download import BinaryVerificationError, ensure_binary +from cloakbrowser.license import ( + LicenseInfo, + get_pro_latest_version, + resolve_license_key, + validate_license, +) + + +# ── resolve_license_key ─────────────────────────────── + + +class TestResolveLicenseKey: + def test_explicit_param_wins(self): + with patch.dict(os.environ, {"CLOAKBROWSER_LICENSE_KEY": "env-key"}): + assert resolve_license_key("explicit") == "explicit" + + def test_env_var_fallback(self): + with patch.dict(os.environ, {"CLOAKBROWSER_LICENSE_KEY": "env-key"}): + assert resolve_license_key() == "env-key" + + def test_returns_none_when_absent(self): + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("CLOAKBROWSER_LICENSE_KEY", None) + assert resolve_license_key() is None + + def test_empty_string_param_uses_env(self): + with patch.dict(os.environ, {"CLOAKBROWSER_LICENSE_KEY": "env-key"}): + assert resolve_license_key("") == "env-key" + + def test_file_fallback(self, tmp_path): + key_file = tmp_path / "license.key" + key_file.write_text("file-key-123\n") + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("CLOAKBROWSER_LICENSE_KEY", None) + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + assert resolve_license_key() == "file-key-123" + + def test_env_takes_precedence_over_file(self, tmp_path): + key_file = tmp_path / "license.key" + key_file.write_text("file-key") + with patch.dict(os.environ, {"CLOAKBROWSER_LICENSE_KEY": "env-key"}): + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + assert resolve_license_key() == "env-key" + + def test_no_file_returns_none(self, tmp_path): + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("CLOAKBROWSER_LICENSE_KEY", None) + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + assert resolve_license_key() is None + + +# ── validate_license ────────────────────────────────── + + +class TestValidateLicense: + def test_fresh_cache_skips_server(self, tmp_path): + cache_path = tmp_path / ".license_cache" + key_sha = hashlib.sha256(b"test-key").hexdigest() + cache_path.write_text(json.dumps({ + "key_sha256": key_sha, + "valid": True, + "plan": "team", + "expires": "2026-12-01", + "validated_at": time.time(), + })) + + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + with patch("cloakbrowser.license.httpx.post") as mock_post: + result = validate_license("test-key") + + mock_post.assert_not_called() + assert result is not None + assert result.valid is True + assert result.plan == "team" + + def test_stale_cache_calls_server(self, tmp_path): + cache_path = tmp_path / ".license_cache" + key_sha = hashlib.sha256(b"test-key").hexdigest() + cache_path.write_text(json.dumps({ + "key_sha256": key_sha, + "valid": True, + "plan": "solo", + "expires": None, + "validated_at": time.time() - 90000, # 25 hours ago + })) + + mock_resp = MagicMock() + mock_resp.json.return_value = {"valid": True, "plan": "solo", "expires": None} + mock_resp.raise_for_status = MagicMock() + + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + with patch("cloakbrowser.license.httpx.post", return_value=mock_resp) as mock_post: + result = validate_license("test-key") + + mock_post.assert_called_once() + assert result is not None + assert result.valid is True + + def test_server_success(self, tmp_path): + mock_resp = MagicMock() + mock_resp.json.return_value = {"valid": True, "plan": "business", "expires": "2026-07-13"} + mock_resp.raise_for_status = MagicMock() + + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + with patch("cloakbrowser.license.httpx.post", return_value=mock_resp): + result = validate_license("pro-key") + + assert result is not None + assert result.valid is True + assert result.plan == "business" + assert result.expires == "2026-07-13" + + def test_server_rejection(self, tmp_path): + mock_resp = MagicMock() + mock_resp.json.return_value = {"valid": False, "plan": "solo", "expires": None} + mock_resp.raise_for_status = MagicMock() + + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + with patch("cloakbrowser.license.httpx.post", return_value=mock_resp): + result = validate_license("bad-key") + + assert result is not None + assert result.valid is False + + def test_server_unreachable_uses_stale_cache(self, tmp_path): + cache_path = tmp_path / ".license_cache" + key_sha = hashlib.sha256(b"test-key").hexdigest() + cache_path.write_text(json.dumps({ + "key_sha256": key_sha, + "valid": True, + "plan": "solo", + "expires": "2026-12-01", + "validated_at": time.time() - 90000, + })) + + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + with patch("cloakbrowser.license.httpx.post", side_effect=Exception("timeout")): + result = validate_license("test-key") + + assert result is not None + assert result.valid is True + + def test_server_unreachable_no_cache_returns_none(self, tmp_path): + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + with patch("cloakbrowser.license.httpx.post", side_effect=Exception("timeout")): + result = validate_license("test-key") + + assert result is None + + def test_cache_stores_hash_not_raw_key(self, tmp_path): + mock_resp = MagicMock() + mock_resp.json.return_value = {"valid": True, "plan": "solo", "expires": None} + mock_resp.raise_for_status = MagicMock() + + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + with patch("cloakbrowser.license.httpx.post", return_value=mock_resp): + validate_license("secret-key-123") + + cache_path = tmp_path / ".license_cache" + content = cache_path.read_text() + assert "secret-key-123" not in content + expected_sha = hashlib.sha256(b"secret-key-123").hexdigest() + assert expected_sha in content + + def test_expired_license_rejected_from_cache(self, tmp_path): + cache_path = tmp_path / ".license_cache" + key_sha = hashlib.sha256(b"test-key").hexdigest() + cache_path.write_text(json.dumps({ + "key_sha256": key_sha, + "valid": True, + "plan": "solo", + "expires": "2020-01-01T00:00:00+00:00", + "validated_at": time.time(), + })) + + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + result = validate_license("test-key") + + assert result is not None + assert result.valid is False + + def test_expired_license_naive_date_rejected(self, tmp_path): + """Date-only string (naive datetime) should also be detected as expired.""" + cache_path = tmp_path / ".license_cache" + key_sha = hashlib.sha256(b"test-key").hexdigest() + cache_path.write_text(json.dumps({ + "key_sha256": key_sha, + "valid": True, + "plan": "solo", + "expires": "2020-01-01", + "validated_at": time.time(), + })) + + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + result = validate_license("test-key") + + assert result is not None + assert result.valid is False + + def test_wrong_key_cache_ignored(self, tmp_path): + cache_path = tmp_path / ".license_cache" + cache_path.write_text(json.dumps({ + "key_sha256": "other-hash", + "valid": True, + "plan": "solo", + "expires": None, + "validated_at": time.time(), + })) + + mock_resp = MagicMock() + mock_resp.json.return_value = {"valid": True, "plan": "solo", "expires": None} + mock_resp.raise_for_status = MagicMock() + + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + with patch("cloakbrowser.license.httpx.post", return_value=mock_resp) as mock_post: + validate_license("different-key") + + mock_post.assert_called_once() + + def test_corrupted_validated_at_does_not_crash(self, tmp_path): + """A non-numeric validated_at must be treated as an absent cache, not crash.""" + cache_path = tmp_path / ".license_cache" + key_sha = hashlib.sha256(b"test-key").hexdigest() + cache_path.write_text(json.dumps({ + "key_sha256": key_sha, + "valid": True, + "plan": "solo", + "expires": None, + "validated_at": "not-a-number", + })) + + mock_resp = MagicMock() + mock_resp.json.return_value = {"valid": True, "plan": "solo", "expires": None} + mock_resp.raise_for_status = MagicMock() + + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + with patch("cloakbrowser.license.httpx.post", return_value=mock_resp) as mock_post: + result = validate_license("test-key") + + mock_post.assert_called_once() # corrupted cache ignored → server hit + assert result is not None + assert result.valid is True + + +# ── get_pro_latest_version ──────────────────────────── + + +class TestGetProLatestVersion: + def test_fetches_version(self, tmp_path): + mock_resp = MagicMock() + mock_resp.json.return_value = {"version": "147.0.1234.5"} + mock_resp.raise_for_status = MagicMock() + + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + with patch("cloakbrowser.license.httpx.get", return_value=mock_resp): + version = get_pro_latest_version() + + assert version == "147.0.1234.5" + + def test_rate_limited(self, tmp_path): + marker = tmp_path / ".last_pro_version_check" + marker.write_text("147.0.1234.5") + + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + with patch("cloakbrowser.license.httpx.get") as mock_get: + version = get_pro_latest_version() + + mock_get.assert_not_called() + assert version == "147.0.1234.5" + + def test_network_error_returns_none(self, tmp_path): + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + with patch("cloakbrowser.license.httpx.get", side_effect=Exception("network")): + version = get_pro_latest_version() + + assert version is None + + +# ── Config pro parameter ────────────────────────────── + + +class TestConfigPro: + def test_binary_dir_pro_suffix(self, tmp_path): + from cloakbrowser.config import get_binary_dir + + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + normal = get_binary_dir("147.0.0.0") + pro = get_binary_dir("147.0.0.0", pro=True) + + assert str(normal).endswith("chromium-147.0.0.0") + assert str(pro).endswith("chromium-147.0.0.0-pro") + + def test_binary_dir_default_no_suffix(self, tmp_path): + from cloakbrowser.config import get_binary_dir + + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + normal = get_binary_dir("147.0.0.0") + + assert not str(normal).endswith("-pro") + + def test_effective_version_pro_marker(self, tmp_path): + from cloakbrowser.config import get_effective_version, get_platform_tag + + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + tag = get_platform_tag() + marker = tmp_path / f"latest_pro_version_{tag}" + marker.write_text("147.0.5555.1") + + # Create the binary so effective version returns it + from cloakbrowser.config import get_binary_path + bp = get_binary_path("147.0.5555.1", pro=True) + bp.parent.mkdir(parents=True, exist_ok=True) + bp.write_text("fake") + + version = get_effective_version(pro=True) + + assert version == "147.0.5555.1" + + +# ── binary_info tier reporting ──────────────────────── + + +class TestBinaryInfoTier: + """binary_info() reports tier from the binary actually on disk — NOT from a + cached license, which can disagree with what's installed or the active key.""" + + def test_free_when_no_pro_binary_even_if_license_cached(self, tmp_path): + from cloakbrowser.download import binary_info + + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}, clear=False): + # A valid, fresh license is cached... + (tmp_path / ".license_cache").write_text(json.dumps({ + "key_sha256": hashlib.sha256(b"cb_x").hexdigest(), + "valid": True, "plan": "solo", "expires": None, + "validated_at": time.time(), + })) + # ...but no Pro binary is on disk → must report free, not pro. + info = binary_info() + + assert info["tier"] == "free" + + def test_pro_when_pro_binary_installed(self, tmp_path): + from cloakbrowser.config import get_binary_path, get_platform_tag + from cloakbrowser.download import binary_info + + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}, clear=False): + tag = get_platform_tag() + (tmp_path / f"latest_pro_version_{tag}").write_text("147.0.5555.1") + bp = get_binary_path("147.0.5555.1", pro=True) + bp.parent.mkdir(parents=True, exist_ok=True) + bp.write_text("fake") + bp.chmod(0o755) + info = binary_info() + + assert info["tier"] == "pro" + assert info["version"] == "147.0.5555.1" + + +# ── ensure_binary Pro routing (fail-closed vs fall-back) ────────────────────── + + +class TestEnsureBinaryProRouting: + """A valid-license user is NEVER silently downgraded to the free binary. Both + a tampering signal (verification failure) and a transient failure + (network/server) surface a clear error — they differ only in the message: + tampering is re-raised verbatim (security, no 'retry'); transient is rewrapped + as an actionable 'Pro binary unavailable, retry' error carrying the cause.""" + + def test_verification_failure_propagates_verbatim(self): + """A BinaryVerificationError must surface verbatim — never reach free.""" + with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}, clear=False), \ + patch("cloakbrowser.download.get_local_binary_override", return_value=None), \ + patch("cloakbrowser.license.resolve_license_key", return_value="cb_x"), \ + patch("cloakbrowser.license.validate_license", + return_value=LicenseInfo(valid=True, plan="solo", expires=None)), \ + patch("cloakbrowser.download._ensure_pro_binary", + side_effect=BinaryVerificationError("bad signature")), \ + patch("cloakbrowser.download.check_platform_available", + side_effect=AssertionError("MUST NOT reach the free-tier path")): + with pytest.raises(BinaryVerificationError, match="bad signature"): + ensure_binary("cb_x") + + def test_transient_failure_hard_errors_not_free(self): + """A transient Pro failure must surface a clear, actionable error carrying + the underlying cause — NOT silently download the free binary.""" + with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}, clear=False), \ + patch("cloakbrowser.download.get_local_binary_override", return_value=None), \ + patch("cloakbrowser.license.resolve_license_key", return_value="cb_x"), \ + patch("cloakbrowser.license.validate_license", + return_value=LicenseInfo(valid=True, plan="solo", expires=None)), \ + patch("cloakbrowser.download._ensure_pro_binary", + side_effect=RuntimeError("network blip")), \ + patch("cloakbrowser.download.check_platform_available", + side_effect=AssertionError("MUST NOT reach the free-tier path")): + with pytest.raises(RuntimeError, match="Pro binary unavailable: network blip"): + ensure_binary("cb_x") diff --git a/tests/test_update.py b/tests/test_update.py index de0c9f6..5fd5a9c 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -21,8 +21,10 @@ from cloakbrowser.config import ( get_platform_tag, ) from cloakbrowser.download import ( + BinaryVerificationError, _check_wrapper_update, _download_and_extract, + _download_pro_binary, _fetch_checksums, _fetch_signed_manifest, _get_latest_chromium_version, @@ -31,6 +33,7 @@ from cloakbrowser.download import ( _should_check_for_update, _verify_checksum, _verify_download_checksum, + _verify_pro_download, _verify_signature, _write_version_marker, check_for_update, @@ -736,6 +739,121 @@ class TestVerifyDownloadChecksumSigned: mocked.assert_not_called() +class TestVerifyProDownloadSigned: + """_verify_pro_download: Pro binaries get the SAME non-bypassable signature + check as the free official path (parity — closes the Pro M1 gap).""" + + PRO_VERSION = "147.0.1.0" + + def _hash(self, data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + def _tarball(self) -> str: + return get_download_url().rsplit("/", 1)[-1] + + def _mock_fetch(self, manifest: bytes, sig: bytes): + """httpx.get stub: returns the .sig for *.sig URLs, manifest otherwise.""" + def mock_get(url, **kwargs): + resp = MagicMock() + resp.raise_for_status = MagicMock() + resp.content = sig if url.endswith(".sig") else manifest + return resp + return mock_get + + def test_valid_pro_manifest_passes(self, tmp_path): + priv, pub_b64 = _make_key() + archive = tmp_path / "binary" + archive.write_bytes(b"the real pro binary") + manifest = ( + f"version={self.PRO_VERSION}\n" + f"{self._hash(b'the real pro binary')} {self._tarball()}\n" + ).encode() + sig = _sign(priv, manifest) + with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), \ + patch("cloakbrowser.download.httpx.get", side_effect=self._mock_fetch(manifest, sig)): + _verify_pro_download(archive, self.PRO_VERSION) # no raise + + def test_skip_checksum_does_not_bypass(self, tmp_path): + """CLOAKBROWSER_SKIP_CHECKSUM must NOT weaken Pro verification (the point).""" + priv, pub_b64 = _make_key() + archive = tmp_path / "binary" + archive.write_bytes(b"a malicious pro binary") # bytes differ from manifest + manifest = ( + f"version={self.PRO_VERSION}\n" + f"{self._hash(b'the real pro binary')} {self._tarball()}\n" + ).encode() + sig = _sign(priv, manifest) + with patch.dict(os.environ, {"CLOAKBROWSER_SKIP_CHECKSUM": "true"}), \ + patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), \ + patch("cloakbrowser.download.httpx.get", side_effect=self._mock_fetch(manifest, sig)): + with pytest.raises(RuntimeError, match="Checksum verification failed"): + _verify_pro_download(archive, self.PRO_VERSION) + + def test_missing_manifest_is_transient_not_tampering(self, tmp_path): + """A failed manifest FETCH is transient (router falls back to free), so it + must be a plain RuntimeError — NOT a BinaryVerificationError, which the + router re-raises as a hard failure.""" + archive = tmp_path / "binary" + archive.write_bytes(b"x") + with patch("cloakbrowser.download.httpx.get", side_effect=Exception("404")): + with pytest.raises(RuntimeError) as ei: + _verify_pro_download(archive, self.PRO_VERSION) + assert not isinstance(ei.value, BinaryVerificationError) + + def test_wrong_version_fails_downgrade(self, tmp_path): + """A genuinely-signed Pro manifest for a DIFFERENT version is rejected.""" + priv, pub_b64 = _make_key() + archive = tmp_path / "binary" + archive.write_bytes(b"the real pro binary") + manifest = ( + f"version=1.0.0.0\n" # declares old version, we ask for PRO_VERSION + f"{self._hash(b'the real pro binary')} {self._tarball()}\n" + ).encode() + sig = _sign(priv, manifest) + with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), \ + patch("cloakbrowser.download.httpx.get", side_effect=self._mock_fetch(manifest, sig)): + with pytest.raises(RuntimeError, match="Version mismatch"): + _verify_pro_download(archive, self.PRO_VERSION) + + def test_tampered_manifest_fails_signature(self, tmp_path): + """A manifest tampered after signing fails the signature gate (not the hash).""" + priv, pub_b64 = _make_key() + archive = tmp_path / "binary" + archive.write_bytes(b"the real pro binary") + manifest = ( + f"version={self.PRO_VERSION}\n" + f"{self._hash(b'the real pro binary')} {self._tarball()}\n" + ).encode() + sig = _sign(priv, manifest) + tampered = manifest.replace(self._tarball().encode(), b"evil.tar.gz") + with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), \ + patch("cloakbrowser.download.httpx.get", side_effect=self._mock_fetch(tampered, sig)): + with pytest.raises(RuntimeError, match="signature verification failed"): + _verify_pro_download(archive, self.PRO_VERSION) + + +class TestProDownloadVersionPinned: + """The Pro download must request the explicit version, NOT /latest, so the + served artifact matches the version-pinned signed manifest it's verified + against (no latest-advances TOCTOU).""" + + def test_download_url_is_version_pinned(self): + from cloakbrowser.config import DOWNLOAD_BASE_URL + + captured = {} + + def fake_download_file(url, dest, headers=None): + captured["url"] = url + + with patch("cloakbrowser.download._download_file", side_effect=fake_download_file), \ + patch("cloakbrowser.download._verify_pro_download"), \ + patch("cloakbrowser.download._extract_archive"): + _download_pro_binary("147.0.1.0", "cb_key") + + assert captured["url"] == f"{DOWNLOAD_BASE_URL}/api/download/147.0.1.0" + assert not captured["url"].endswith("/latest") + + class TestVersionBinding: """The 'version=' line: read by new wrappers, ignored by old parsers."""