feat: add Pro tier license validation and download routing

This commit is contained in:
CloakHQ
2026-06-21 04:08:45 +02:00
parent 660b6bf58c
commit 10f492e95b
17 changed files with 1987 additions and 38 deletions
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+188
View File
@@ -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)