mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
feat: add Pro tier license validation and download routing
This commit is contained in:
@@ -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__",
|
||||
|
||||
+14
-6
@@ -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:
|
||||
|
||||
+22
-6
@@ -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():
|
||||
|
||||
+260
-8
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
+26
-6
@@ -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 {
|
||||
|
||||
+264
-7
@@ -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<string> {
|
||||
export async function ensureBinary(licenseKey?: string): Promise<string> {
|
||||
// Check for local override
|
||||
const localOverride = getLocalBinaryOverride();
|
||||
if (localOverride) {
|
||||
@@ -58,6 +74,37 @@ export async function ensureBinary(): Promise<string> {
|
||||
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<v
|
||||
console.log("[cloakbrowser] Checksum verified: SHA-256 OK");
|
||||
}
|
||||
|
||||
async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
async function downloadFile(url: string, dest: string, headers?: Record<string, string>): Promise<void> {
|
||||
console.log(`[cloakbrowser] Downloading from ${url}`);
|
||||
|
||||
const controller = new AbortController();
|
||||
@@ -456,6 +517,7 @@ async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
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<void> {
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pro binary download
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function ensureProBinary(licenseKey: string): Promise<string> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<LicenseInfo | null> {
|
||||
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<string, unknown>;
|
||||
|
||||
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<string | null> {
|
||||
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<string, unknown>;
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,7 @@ export function buildContextOptions(
|
||||
export async function buildLaunchOptions(
|
||||
options: LaunchOptions = {}
|
||||
): Promise<PlaywrightLaunchOptions> {
|
||||
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);
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
/** 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;
|
||||
|
||||
+39
-1
@@ -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", () => {
|
||||
|
||||
@@ -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$/);
|
||||
});
|
||||
});
|
||||
+103
-1
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
@@ -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=<v>' line: read by new wrappers, ignored by old parsers."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user