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
+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;