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)
+26 -6
View File
@@ -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
View File
@@ -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
}
})();
}
+4
View File
@@ -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";
+218
View File
@@ -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
}
}
+2 -2
View File
@@ -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
View File
@@ -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;
+5
View File
@@ -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
View File
@@ -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", () => {
+312
View File
@@ -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
View File
@@ -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(
+408
View File
@@ -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")
+118
View File
@@ -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."""