mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
feat: add Pro tier license validation and download routing
This commit is contained in:
+26
-6
@@ -99,12 +99,13 @@ export function getCacheDir(): string {
|
||||
return path.join(os.homedir(), ".cloakbrowser");
|
||||
}
|
||||
|
||||
export function getBinaryDir(version?: string): string {
|
||||
return path.join(getCacheDir(), `chromium-${version || getChromiumVersion()}`);
|
||||
export function getBinaryDir(version?: string, pro = false): string {
|
||||
const suffix = pro ? "-pro" : "";
|
||||
return path.join(getCacheDir(), `chromium-${version || getChromiumVersion()}${suffix}`);
|
||||
}
|
||||
|
||||
export function getBinaryPath(version?: string): string {
|
||||
const binaryDir = getBinaryDir(version);
|
||||
export function getBinaryPath(version?: string, pro = false): string {
|
||||
const binaryDir = getBinaryDir(version, pro);
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(binaryDir, "Chromium.app", "Contents", "MacOS", "Chromium");
|
||||
}
|
||||
@@ -158,10 +159,29 @@ export function getFallbackDownloadUrl(version?: string): string {
|
||||
return `${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}/${getArchiveName()}`;
|
||||
}
|
||||
|
||||
export function getEffectiveVersion(): string {
|
||||
export function getEffectiveVersion(pro = false): string {
|
||||
const base = getChromiumVersion();
|
||||
const cacheDir = getCacheDir();
|
||||
// Try platform-scoped marker first, fall back to legacy marker for upgrades from <0.3.0
|
||||
|
||||
if (pro) {
|
||||
const marker = path.join(cacheDir, `latest_pro_version_${getPlatformTag()}`);
|
||||
try {
|
||||
if (fs.existsSync(marker)) {
|
||||
const version = fs.readFileSync(marker, "utf-8").trim();
|
||||
if (version) {
|
||||
const binary = getBinaryPath(version, true);
|
||||
if (fs.existsSync(binary)) {
|
||||
return version;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Marker unreadable
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
// Free tier: try platform-scoped marker first, fall back to legacy marker for upgrades from <0.3.0
|
||||
for (const name of [`latest_version_${getPlatformTag()}`, "latest_version"]) {
|
||||
const marker = path.join(cacheDir, name);
|
||||
try {
|
||||
|
||||
+264
-7
@@ -15,6 +15,7 @@ import { extract as tarExtract } from "tar";
|
||||
import type { BinaryInfo } from "./types.js";
|
||||
import {
|
||||
BINARY_SIGNING_PUBKEYS,
|
||||
CHROMIUM_VERSION,
|
||||
DOWNLOAD_BASE_URL,
|
||||
GITHUB_API_URL,
|
||||
GITHUB_DOWNLOAD_BASE_URL,
|
||||
@@ -33,10 +34,25 @@ import {
|
||||
getPlatformTag,
|
||||
versionNewer,
|
||||
} from "./config.js";
|
||||
import { resolveLicenseKey, validateLicense, getProLatestVersion } from "./license.js";
|
||||
|
||||
const DOWNLOAD_TIMEOUT_MS = 600_000; // 10 minutes
|
||||
const UPDATE_CHECK_INTERVAL_MS = 3_600_000; // 1 hour
|
||||
|
||||
/**
|
||||
* A downloaded binary could not be authenticated (bad/missing signature,
|
||||
* version mismatch, or checksum failure). Distinct from transient
|
||||
* download/network errors: a verification failure is a tampering signal and
|
||||
* MUST surface, never silently fall back to another binary. The Pro routing in
|
||||
* ensureBinary re-throws this rather than downgrading to the free tier.
|
||||
*/
|
||||
export class BinaryVerificationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "BinaryVerificationError";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -45,7 +61,7 @@ const UPDATE_CHECK_INTERVAL_MS = 3_600_000; // 1 hour
|
||||
* Ensure the stealth Chromium binary is available. Download if needed.
|
||||
* Returns the path to the chrome executable.
|
||||
*/
|
||||
export async function ensureBinary(): Promise<string> {
|
||||
export async function ensureBinary(licenseKey?: string): Promise<string> {
|
||||
// Check for local override
|
||||
const localOverride = getLocalBinaryOverride();
|
||||
if (localOverride) {
|
||||
@@ -58,6 +74,37 @@ export async function ensureBinary(): Promise<string> {
|
||||
return localOverride;
|
||||
}
|
||||
|
||||
// Pro license key check (custom download URL overrides Pro path)
|
||||
const key = resolveLicenseKey(licenseKey);
|
||||
const effectiveKey = process.env.CLOAKBROWSER_DOWNLOAD_URL ? undefined : key;
|
||||
if (effectiveKey) {
|
||||
const info = await validateLicense(effectiveKey);
|
||||
if (info?.valid) {
|
||||
// A valid license is entitled to Pro, so Pro failures surface loudly
|
||||
// rather than silently substituting the older free binary. (A blip during
|
||||
// a routine update never reaches here: ensureProBinary returns the cached
|
||||
// Pro binary and updates in the background.)
|
||||
try {
|
||||
return await ensureProBinary(effectiveKey);
|
||||
} catch (e) {
|
||||
// Authenticity could not be confirmed — surface verbatim.
|
||||
if (e instanceof BinaryVerificationError) throw e;
|
||||
// Transient failure with no cached Pro binary to use — surface a clear
|
||||
// error rather than silently downloading the free binary.
|
||||
throw new Error(
|
||||
`Pro binary unavailable: ${e}. Your license is valid but the Pro ` +
|
||||
`binary could not be downloaded right now. Retry in a moment. To use ` +
|
||||
`the free binary instead, unset CLOAKBROWSER_LICENSE_KEY.`,
|
||||
{ cause: e }
|
||||
);
|
||||
}
|
||||
} else if (info) {
|
||||
console.log(`[cloakbrowser] License validation failed (plan=${info.plan}), using free tier`);
|
||||
} else {
|
||||
console.log("[cloakbrowser] License validation unavailable, using free tier");
|
||||
}
|
||||
}
|
||||
|
||||
// Fail fast if no binary available for this platform
|
||||
checkPlatformAvailable();
|
||||
|
||||
@@ -109,17 +156,31 @@ export function clearCache(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Return info about the current binary installation. */
|
||||
/**
|
||||
* Return info about the current binary installation.
|
||||
*
|
||||
* tier reflects what is actually installed on disk, not merely whether a license
|
||||
* is cached — a cached license with no Pro binary downloaded yet is still
|
||||
* effectively running the free binary, and the active key may differ from the
|
||||
* cached one.
|
||||
*/
|
||||
export function binaryInfo(): BinaryInfo {
|
||||
const effective = getEffectiveVersion();
|
||||
const binaryPath = getBinaryPath(effective);
|
||||
// Prefer Pro only if a Pro binary actually exists on disk.
|
||||
const proVersion = getEffectiveVersion(true);
|
||||
const proPath = getBinaryPath(proVersion, true);
|
||||
const isPro = fs.existsSync(proPath) && isExecutable(proPath);
|
||||
|
||||
const effective = isPro ? proVersion : getEffectiveVersion(false);
|
||||
const binaryPath = isPro ? proPath : getBinaryPath(effective, false);
|
||||
return {
|
||||
version: effective,
|
||||
bundledVersion: CHROMIUM_VERSION,
|
||||
tier: isPro ? "pro" : "free",
|
||||
platform: getPlatformTag(),
|
||||
binaryPath,
|
||||
installed: fs.existsSync(binaryPath),
|
||||
cacheDir: getBinaryDir(effective),
|
||||
downloadUrl: getDownloadUrl(effective),
|
||||
cacheDir: getBinaryDir(effective, isPro),
|
||||
downloadUrl: isPro ? `${DOWNLOAD_BASE_URL}/api/download/latest` : getDownloadUrl(effective),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -443,7 +504,7 @@ async function verifyChecksum(filePath: string, expectedHash: string): Promise<v
|
||||
console.log("[cloakbrowser] Checksum verified: SHA-256 OK");
|
||||
}
|
||||
|
||||
async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
async function downloadFile(url: string, dest: string, headers?: Record<string, string>): Promise<void> {
|
||||
console.log(`[cloakbrowser] Downloading from ${url}`);
|
||||
|
||||
const controller = new AbortController();
|
||||
@@ -456,6 +517,7 @@ async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
redirect: "follow",
|
||||
...(headers ? { headers } : {}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -519,6 +581,168 @@ async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pro binary download
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function ensureProBinary(licenseKey: string): Promise<string> {
|
||||
const effective = getEffectiveVersion(true);
|
||||
const effectivePath = getBinaryPath(effective, true);
|
||||
|
||||
if (fs.existsSync(effectivePath) && isExecutable(effectivePath)) {
|
||||
showWelcome();
|
||||
maybeTriggerProUpdateCheck(licenseKey);
|
||||
return effectivePath;
|
||||
}
|
||||
|
||||
const version = await getProLatestVersion();
|
||||
if (!version) {
|
||||
throw new Error("Could not determine latest Pro version from server");
|
||||
}
|
||||
|
||||
const versionPath = getBinaryPath(version, true);
|
||||
if (fs.existsSync(versionPath) && isExecutable(versionPath)) {
|
||||
showWelcome();
|
||||
return versionPath;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[cloakbrowser] Downloading Pro Chromium ${version} for ${getPlatformTag()}...`
|
||||
);
|
||||
await downloadProBinary(version, licenseKey);
|
||||
|
||||
const downloadedPath = getBinaryPath(version, true);
|
||||
if (!fs.existsSync(downloadedPath)) {
|
||||
throw new Error(
|
||||
`Pro download completed but binary not found at: ${downloadedPath}`
|
||||
);
|
||||
}
|
||||
|
||||
// Write Pro version marker
|
||||
try {
|
||||
const cacheDir = getCacheDir();
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const marker = path.join(cacheDir, `latest_pro_version_${getPlatformTag()}`);
|
||||
fs.writeFileSync(marker, version);
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
|
||||
showWelcome();
|
||||
return downloadedPath;
|
||||
}
|
||||
|
||||
/** @internal Exported for testing only. */
|
||||
export async function downloadProBinary(version: string, licenseKey: string): Promise<void> {
|
||||
// Request the explicit version so the served archive matches the signed
|
||||
// manifest verified in verifyProDownload.
|
||||
const downloadUrl = `${DOWNLOAD_BASE_URL}/api/download/${version}`;
|
||||
const binaryDir = getBinaryDir(version, true);
|
||||
const binaryPath = getBinaryPath(version, true);
|
||||
const platformTag = getPlatformTag();
|
||||
|
||||
fs.mkdirSync(path.dirname(binaryDir), { recursive: true });
|
||||
|
||||
const tmpPath = path.join(
|
||||
path.dirname(binaryDir),
|
||||
`_download_${Date.now()}${getArchiveExt()}`
|
||||
);
|
||||
|
||||
try {
|
||||
await downloadFile(downloadUrl, tmpPath, {
|
||||
Authorization: `Bearer ${licenseKey}`,
|
||||
"X-Platform": platformTag,
|
||||
});
|
||||
|
||||
// Pro binaries come from cloakbrowser.dev — the same origin as free
|
||||
// downloads — so the M1 attack the Ed25519 signature defends against
|
||||
// applies equally. Verify with the same non-bypassable signature check;
|
||||
// CLOAKBROWSER_SKIP_CHECKSUM does NOT bypass it (parity with the official
|
||||
// free path).
|
||||
await verifyProDownload(tmpPath, version);
|
||||
|
||||
await extractArchive(tmpPath, binaryDir, binaryPath);
|
||||
} finally {
|
||||
if (fs.existsSync(tmpPath)) {
|
||||
fs.unlinkSync(tmpPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Pro archive with the same non-bypassable Ed25519 signature check as
|
||||
* official free downloads. Pro binaries are served from cloakbrowser.dev (same
|
||||
* origin as the free tier), so a tampered same-origin SHA256SUMS could
|
||||
* otherwise certify a tampered binary (M1, #308). Fetch the Pro SHA256SUMS +
|
||||
* detached SHA256SUMS.sig, verify the signature against the pinned keys FIRST,
|
||||
* bind the manifest to the requested version, then verify the archive's
|
||||
* SHA-256.
|
||||
*
|
||||
* An invalid signature, checksum, or version mismatch throws
|
||||
* BinaryVerificationError (a tampering signal the router surfaces verbatim);
|
||||
* CLOAKBROWSER_SKIP_CHECKSUM cannot bypass it. A failed manifest FETCH is
|
||||
* transient — nothing was validated — and throws a plain Error. A valid-license
|
||||
* user is never silently downgraded to the free binary.
|
||||
* @internal Exported for testing only.
|
||||
*/
|
||||
export async function verifyProDownload(filePath: string, version: string): Promise<void> {
|
||||
const base = `${DOWNLOAD_BASE_URL}/releases/pro/chromium-v${version}`;
|
||||
let manifestBytes: Uint8Array;
|
||||
let sigBytes: Uint8Array;
|
||||
try {
|
||||
const manifestResp = await fetch(`${base}/SHA256SUMS`, {
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!manifestResp.ok) throw new Error(`HTTP ${manifestResp.status} for SHA256SUMS`);
|
||||
const sigResp = await fetch(`${base}/SHA256SUMS.sig`, {
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!sigResp.ok) throw new Error(`HTTP ${sigResp.status} for SHA256SUMS.sig`);
|
||||
manifestBytes = new Uint8Array(await manifestResp.arrayBuffer());
|
||||
sigBytes = new Uint8Array(await sigResp.arrayBuffer());
|
||||
} catch (e) {
|
||||
// Fetch failure is transient, not tampering — throw a plain Error (the
|
||||
// router reports it as "unavailable, retry") rather than a
|
||||
// BinaryVerificationError (which it surfaces as a tampering signal).
|
||||
throw new Error(`Could not fetch the signed SHA256SUMS for Pro ${version} (${e})`);
|
||||
}
|
||||
|
||||
// verifySignature / verifyChecksum throw a plain Error; convert to
|
||||
// BinaryVerificationError so the Pro router treats them as tampering signals
|
||||
// (re-throw) rather than transient failures (fall back to free).
|
||||
try {
|
||||
verifySignature(manifestBytes, sigBytes);
|
||||
} catch (e) {
|
||||
throw new BinaryVerificationError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
const manifestText = new TextDecoder().decode(manifestBytes);
|
||||
|
||||
// Version binding: same forced-downgrade defense as the official path.
|
||||
const declared = parseManifestVersion(manifestText);
|
||||
if (declared !== version) {
|
||||
throw new BinaryVerificationError(
|
||||
`Version mismatch in signed Pro SHA256SUMS: requested ${version}, ` +
|
||||
`manifest declares ${declared ?? "none"}. Refusing (possible downgrade).`
|
||||
);
|
||||
}
|
||||
|
||||
const tarballName = getArchiveName();
|
||||
const expected = parseChecksums(manifestText).get(tarballName);
|
||||
if (!expected) {
|
||||
throw new BinaryVerificationError(
|
||||
`Signature-verified Pro SHA256SUMS has no entry for ${tarballName} — ` +
|
||||
`cannot confirm binary integrity.`
|
||||
);
|
||||
}
|
||||
try {
|
||||
await verifyChecksum(filePath, expected);
|
||||
} catch (e) {
|
||||
throw new BinaryVerificationError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function extractArchive(
|
||||
archivePath: string,
|
||||
destDir: string,
|
||||
@@ -771,3 +995,36 @@ function maybeTriggerUpdateCheck(): void {
|
||||
if (!shouldCheckForUpdate()) return;
|
||||
checkAndDownloadUpdate().catch(() => { });
|
||||
}
|
||||
|
||||
function maybeTriggerProUpdateCheck(licenseKey: string): void {
|
||||
const checkFile = path.join(getCacheDir(), ".last_pro_update_check");
|
||||
try {
|
||||
if (fs.existsSync(checkFile)) {
|
||||
const lastCheck = parseFloat(fs.readFileSync(checkFile, "utf-8").trim());
|
||||
if (Date.now() - lastCheck * 1000 < UPDATE_CHECK_INTERVAL_MS) return;
|
||||
}
|
||||
} catch {
|
||||
// unreadable — proceed
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(checkFile), { recursive: true });
|
||||
fs.writeFileSync(checkFile, String(Date.now() / 1000));
|
||||
|
||||
const latest = await getProLatestVersion();
|
||||
if (!latest) return;
|
||||
|
||||
if (fs.existsSync(getBinaryPath(latest, true))) return;
|
||||
|
||||
console.log(`[cloakbrowser] Newer Pro binary available: ${latest}. Downloading in background...`);
|
||||
await downloadProBinary(latest, licenseKey);
|
||||
|
||||
const marker = path.join(getCacheDir(), `latest_pro_version_${getPlatformTag()}`);
|
||||
fs.writeFileSync(marker, latest);
|
||||
console.log(`[cloakbrowser] Pro background update complete: ${latest} ready. Will use on next launch.`);
|
||||
} catch (err) {
|
||||
// non-fatal
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -24,5 +24,9 @@ export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download
|
||||
// Config
|
||||
export { CHROMIUM_VERSION, getDefaultStealthArgs } from "./config.js";
|
||||
|
||||
// License
|
||||
export { validateLicense } from "./license.js";
|
||||
|
||||
// Types
|
||||
export type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions, BinaryInfo } from "./types.js";
|
||||
export type { LicenseInfo } from "./license.js";
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* License validation and caching for CloakBrowser Pro.
|
||||
* Mirrors Python cloakbrowser/license.py.
|
||||
*
|
||||
* Handles license key resolution, server validation with local caching,
|
||||
* and Pro version checks.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { getCacheDir } from "./config.js";
|
||||
|
||||
const VALIDATE_URL = "https://cloakbrowser.dev/api/license/validate";
|
||||
const PRO_VERSION_URL = "https://cloakbrowser.dev/api/download/version";
|
||||
|
||||
const LICENSE_CACHE_TTL_MS = 86_400_000; // 24 hours
|
||||
const PRO_VERSION_CHECK_INTERVAL_MS = 3_600_000; // 1 hour
|
||||
|
||||
export interface LicenseInfo {
|
||||
valid: boolean;
|
||||
plan: string;
|
||||
expires: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the license key: explicit param > env var > file > undefined.
|
||||
*/
|
||||
export function resolveLicenseKey(licenseKey?: string): string | undefined {
|
||||
const trimmed = licenseKey?.trim();
|
||||
if (trimmed) return trimmed;
|
||||
const envKey = (process.env.CLOAKBROWSER_LICENSE_KEY ?? "").trim();
|
||||
if (envKey) return envKey;
|
||||
try {
|
||||
const keyFile = path.join(getCacheDir(), "license.key");
|
||||
const content = fs.readFileSync(keyFile, "utf-8").trim();
|
||||
if (content) return content;
|
||||
} catch {
|
||||
// File doesn't exist or unreadable
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a license key with the CloakBrowser server.
|
||||
*
|
||||
* Checks a local file cache first (24h TTL). Falls back to stale
|
||||
* cache if the server is unreachable.
|
||||
*
|
||||
* Returns LicenseInfo if validation succeeded, null on total failure.
|
||||
*/
|
||||
export async function validateLicense(licenseKey: string): Promise<LicenseInfo | null> {
|
||||
const cachePath = path.join(getCacheDir(), ".license_cache");
|
||||
const keySha = createHash("sha256").update(licenseKey).digest("hex");
|
||||
|
||||
const cached = readCache(cachePath, keySha);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const resp = await fetch(VALIDATE_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ license_key: licenseKey }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as Record<string, unknown>;
|
||||
|
||||
const info: LicenseInfo = {
|
||||
valid: Boolean(data.valid ?? false),
|
||||
plan: String(data.plan ?? "solo"),
|
||||
expires: data.expires != null ? String(data.expires) : null,
|
||||
};
|
||||
|
||||
if (info.valid) {
|
||||
writeCache(cachePath, keySha, info);
|
||||
}
|
||||
return info;
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`[cloakbrowser] License validation request failed: ${e instanceof Error ? e.message : e}`
|
||||
);
|
||||
|
||||
// Fall back to stale cache
|
||||
const stale = readCache(cachePath, keySha, true);
|
||||
if (stale) {
|
||||
console.warn("[cloakbrowser] Using cached license validation (server unreachable)");
|
||||
return stale;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest Pro binary version from the server.
|
||||
* Rate-limited to 1 call per hour via a marker file.
|
||||
*/
|
||||
export async function getProLatestVersion(): Promise<string | null> {
|
||||
const marker = path.join(getCacheDir(), ".last_pro_version_check");
|
||||
|
||||
try {
|
||||
if (fs.existsSync(marker)) {
|
||||
const stats = fs.statSync(marker);
|
||||
const age = Date.now() - stats.mtimeMs;
|
||||
if (age < PRO_VERSION_CHECK_INTERVAL_MS) {
|
||||
const content = fs.readFileSync(marker, "utf-8").trim();
|
||||
return content || null;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Marker unreadable — proceed with fetch
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(PRO_VERSION_URL, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as Record<string, unknown>;
|
||||
const version = data.version != null ? String(data.version) : null;
|
||||
if (!version) return null;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(marker), { recursive: true });
|
||||
fs.writeFileSync(marker, version);
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
|
||||
return version;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CacheData {
|
||||
key_sha256: string;
|
||||
valid: boolean;
|
||||
plan: string;
|
||||
expires: string | null;
|
||||
validated_at: number;
|
||||
}
|
||||
|
||||
function readCache(
|
||||
cachePath: string,
|
||||
keySha: string,
|
||||
ignoreTtl = false,
|
||||
): LicenseInfo | null {
|
||||
try {
|
||||
if (!fs.existsSync(cachePath)) return null;
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(cachePath, "utf-8")) as CacheData;
|
||||
|
||||
if (data.key_sha256 !== keySha) return null;
|
||||
|
||||
if (!ignoreTtl) {
|
||||
const validatedAt = data.validated_at ?? 0;
|
||||
// A non-numeric validated_at (corrupted cache) is treated as absent rather
|
||||
// than coercing to NaN and silently trusting the entry.
|
||||
if (!Number.isFinite(validatedAt) || Date.now() - validatedAt * 1000 > LICENSE_CACHE_TTL_MS) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.expires) {
|
||||
try {
|
||||
if (new Date(data.expires).getTime() < Date.now()) {
|
||||
return { valid: false, plan: String(data.plan ?? "solo"), expires: data.expires };
|
||||
}
|
||||
} catch {
|
||||
// unparseable date — skip check
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: Boolean(data.valid ?? false),
|
||||
plan: String(data.plan ?? "solo"),
|
||||
expires: data.expires ?? null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(cachePath: string, keySha: string, info: LicenseInfo): void {
|
||||
try {
|
||||
const dir = path.dirname(cachePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const tmpPath = cachePath + ".tmp";
|
||||
fs.writeFileSync(
|
||||
tmpPath,
|
||||
JSON.stringify({
|
||||
key_sha256: keySha,
|
||||
valid: info.valid,
|
||||
plan: info.plan,
|
||||
expires: info.expires,
|
||||
validated_at: Date.now() / 1000,
|
||||
}),
|
||||
);
|
||||
fs.renameSync(tmpPath, cachePath);
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,7 @@ export function buildContextOptions(
|
||||
export async function buildLaunchOptions(
|
||||
options: LaunchOptions = {}
|
||||
): Promise<PlaywrightLaunchOptions> {
|
||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary(options.licenseKey));
|
||||
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy);
|
||||
let resolvedArgs = await resolveWebrtcArgs(options);
|
||||
@@ -272,7 +272,7 @@ export async function launchPersistentContext(
|
||||
options = resolveTimezone(options);
|
||||
const { chromium } = await import("playwright-core");
|
||||
|
||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary(options.licenseKey));
|
||||
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy);
|
||||
let resolvedArgs = await resolveWebrtcArgs(options);
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ function resolveDefaultViewport(options: LaunchOptions): { width: number; height
|
||||
|
||||
/** Resolve binary path, geoip, webrtc, and build final Chrome args. */
|
||||
async function resolveArgs(options: LaunchOptions): Promise<{ binaryPath: string; args: string[] }> {
|
||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary(options.licenseKey));
|
||||
const { exitIp, ...resolved } = (await maybeResolveGeoip(options)) ?? {};
|
||||
let resolvedArgs = (await resolveWebrtcArgs(options)) ?? options.args;
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface LaunchOptions {
|
||||
locale?: string;
|
||||
/** Auto-detect timezone/locale from proxy IP (requires: npm install mmdb-lib). */
|
||||
geoip?: boolean;
|
||||
/** Pro license key. Also reads from CLOAKBROWSER_LICENSE_KEY env var. */
|
||||
licenseKey?: string;
|
||||
/** Raw options passed directly to playwright/puppeteer launch(). */
|
||||
launchOptions?: Record<string, unknown>;
|
||||
/** Enable human-like mouse, keyboard, and scroll behavior. */
|
||||
@@ -66,7 +68,10 @@ export interface LaunchPersistentContextOptions extends LaunchContextOptions {
|
||||
|
||||
export interface BinaryInfo {
|
||||
version: string;
|
||||
/** The wrapper's bundled baseline Chromium version (CHROMIUM_VERSION). */
|
||||
bundledVersion: string;
|
||||
platform: string;
|
||||
tier: "pro" | "free";
|
||||
binaryPath: string;
|
||||
installed: boolean;
|
||||
cacheDir: string;
|
||||
|
||||
+39
-1
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { binaryInfo } from "../src/download.js";
|
||||
import { DEFAULT_VIEWPORT, getChromiumVersion } from "../src/config.js";
|
||||
import { DEFAULT_VIEWPORT, getBinaryPath, getChromiumVersion, getPlatformTag } from "../src/config.js";
|
||||
import * as config from "../src/config.js";
|
||||
|
||||
describe("binaryInfo", () => {
|
||||
@@ -11,6 +13,7 @@ describe("binaryInfo", () => {
|
||||
const info = binaryInfo();
|
||||
|
||||
expect(info.version).toBe(getChromiumVersion());
|
||||
expect(info.bundledVersion).toBeTruthy();
|
||||
expect(info.platform).toMatch(/^(linux|darwin|windows)-(x64|arm64)$/);
|
||||
expect(info.binaryPath).toBeTruthy();
|
||||
expect(typeof info.installed).toBe("boolean");
|
||||
@@ -20,6 +23,41 @@ describe("binaryInfo", () => {
|
||||
else delete process.env.CLOAKBROWSER_CACHE_DIR;
|
||||
}
|
||||
});
|
||||
|
||||
it("reports tier from the installed binary, not a cached license", () => {
|
||||
// A valid, fresh license is cached but NO Pro binary is on disk → free.
|
||||
const orig = process.env.CLOAKBROWSER_CACHE_DIR;
|
||||
const dir = `/tmp/cloakbrowser-test-${Date.now()}-tier`;
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
process.env.CLOAKBROWSER_CACHE_DIR = dir;
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, ".license_cache"),
|
||||
JSON.stringify({
|
||||
key_sha256: "abc",
|
||||
valid: true,
|
||||
plan: "solo",
|
||||
expires: null,
|
||||
validated_at: Date.now() / 1000,
|
||||
})
|
||||
);
|
||||
expect(binaryInfo().tier).toBe("free");
|
||||
|
||||
// Now drop a Pro binary on disk → pro.
|
||||
fs.writeFileSync(path.join(dir, `latest_pro_version_${getPlatformTag()}`), "147.0.5555.1");
|
||||
const bp = getBinaryPath("147.0.5555.1", true);
|
||||
fs.mkdirSync(path.dirname(bp), { recursive: true });
|
||||
fs.writeFileSync(bp, "fake");
|
||||
fs.chmodSync(bp, 0o755);
|
||||
const info = binaryInfo();
|
||||
expect(info.tier).toBe("pro");
|
||||
expect(info.version).toBe("147.0.5555.1");
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
if (orig) process.env.CLOAKBROWSER_CACHE_DIR = orig;
|
||||
else delete process.env.CLOAKBROWSER_CACHE_DIR;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("composable Playwright launch helpers", () => {
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import {
|
||||
resolveLicenseKey,
|
||||
validateLicense,
|
||||
getProLatestVersion,
|
||||
} from "../src/license.js";
|
||||
|
||||
import * as config from "../src/config.js";
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = path.join("/tmp", `cloakbrowser-test-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
vi.spyOn(config, "getCacheDir").mockReturnValue(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
try {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// ── resolveLicenseKey ─────────────────────────────────
|
||||
|
||||
describe("resolveLicenseKey", () => {
|
||||
it("explicit param wins over env", () => {
|
||||
process.env.CLOAKBROWSER_LICENSE_KEY = "env-key";
|
||||
expect(resolveLicenseKey("explicit")).toBe("explicit");
|
||||
delete process.env.CLOAKBROWSER_LICENSE_KEY;
|
||||
});
|
||||
|
||||
it("env var fallback", () => {
|
||||
process.env.CLOAKBROWSER_LICENSE_KEY = "env-key";
|
||||
expect(resolveLicenseKey()).toBe("env-key");
|
||||
delete process.env.CLOAKBROWSER_LICENSE_KEY;
|
||||
});
|
||||
|
||||
it("returns undefined when absent", () => {
|
||||
delete process.env.CLOAKBROWSER_LICENSE_KEY;
|
||||
expect(resolveLicenseKey()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("file fallback when no param or env", () => {
|
||||
delete process.env.CLOAKBROWSER_LICENSE_KEY;
|
||||
const keyFile = path.join(tmpDir, "license.key");
|
||||
fs.writeFileSync(keyFile, "file-key-123\n");
|
||||
expect(resolveLicenseKey()).toBe("file-key-123");
|
||||
});
|
||||
|
||||
it("env takes precedence over file", () => {
|
||||
process.env.CLOAKBROWSER_LICENSE_KEY = "env-key";
|
||||
const keyFile = path.join(tmpDir, "license.key");
|
||||
fs.writeFileSync(keyFile, "file-key");
|
||||
expect(resolveLicenseKey()).toBe("env-key");
|
||||
delete process.env.CLOAKBROWSER_LICENSE_KEY;
|
||||
});
|
||||
|
||||
it("returns undefined when file missing", () => {
|
||||
delete process.env.CLOAKBROWSER_LICENSE_KEY;
|
||||
expect(resolveLicenseKey()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── validateLicense ───────────────────────────────────
|
||||
|
||||
describe("validateLicense", () => {
|
||||
const keySha = crypto.createHash("sha256").update("test-key").digest("hex");
|
||||
|
||||
it("fresh cache skips server call", async () => {
|
||||
const cachePath = path.join(tmpDir, ".license_cache");
|
||||
fs.writeFileSync(
|
||||
cachePath,
|
||||
JSON.stringify({
|
||||
key_sha256: keySha,
|
||||
valid: true,
|
||||
plan: "team",
|
||||
expires: "2026-12-01",
|
||||
validated_at: Date.now() / 1000,
|
||||
})
|
||||
);
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
const result = await validateLicense("test-key");
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.valid).toBe(true);
|
||||
expect(result!.plan).toBe("team");
|
||||
});
|
||||
|
||||
it("stale cache triggers server call", async () => {
|
||||
const cachePath = path.join(tmpDir, ".license_cache");
|
||||
fs.writeFileSync(
|
||||
cachePath,
|
||||
JSON.stringify({
|
||||
key_sha256: keySha,
|
||||
valid: true,
|
||||
plan: "solo",
|
||||
expires: null,
|
||||
validated_at: Date.now() / 1000 - 90000, // 25 hours ago
|
||||
})
|
||||
);
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ valid: true, plan: "solo", expires: null }),
|
||||
} as Response);
|
||||
|
||||
const result = await validateLicense("test-key");
|
||||
expect(globalThis.fetch).toHaveBeenCalledOnce();
|
||||
expect(result!.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("server success returns LicenseInfo", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ valid: true, plan: "business", expires: "2026-07-13" }),
|
||||
} as Response);
|
||||
|
||||
const result = await validateLicense("pro-key");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.valid).toBe(true);
|
||||
expect(result!.plan).toBe("business");
|
||||
expect(result!.expires).toBe("2026-07-13");
|
||||
});
|
||||
|
||||
it("server rejection returns invalid", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ valid: false, plan: "solo", expires: null }),
|
||||
} as Response);
|
||||
|
||||
const result = await validateLicense("bad-key");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("server unreachable uses stale cache", async () => {
|
||||
const cachePath = path.join(tmpDir, ".license_cache");
|
||||
fs.writeFileSync(
|
||||
cachePath,
|
||||
JSON.stringify({
|
||||
key_sha256: keySha,
|
||||
valid: true,
|
||||
plan: "solo",
|
||||
expires: "2026-12-01",
|
||||
validated_at: Date.now() / 1000 - 90000,
|
||||
})
|
||||
);
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
|
||||
|
||||
const result = await validateLicense("test-key");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("server unreachable no cache returns null", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
|
||||
|
||||
const result = await validateLicense("test-key");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("cache stores hash not raw key", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ valid: true, plan: "solo", expires: null }),
|
||||
} as Response);
|
||||
|
||||
await validateLicense("secret-key-123");
|
||||
|
||||
const cachePath = path.join(tmpDir, ".license_cache");
|
||||
const content = fs.readFileSync(cachePath, "utf-8");
|
||||
expect(content).not.toContain("secret-key-123");
|
||||
const expectedSha = crypto
|
||||
.createHash("sha256")
|
||||
.update("secret-key-123")
|
||||
.digest("hex");
|
||||
expect(content).toContain(expectedSha);
|
||||
});
|
||||
|
||||
it("wrong key cache ignored", async () => {
|
||||
const cachePath = path.join(tmpDir, ".license_cache");
|
||||
fs.writeFileSync(
|
||||
cachePath,
|
||||
JSON.stringify({
|
||||
key_sha256: "other-hash",
|
||||
valid: true,
|
||||
plan: "solo",
|
||||
expires: null,
|
||||
validated_at: Date.now() / 1000,
|
||||
})
|
||||
);
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ valid: true, plan: "solo", expires: null }),
|
||||
} as Response);
|
||||
|
||||
await validateLicense("different-key");
|
||||
expect(globalThis.fetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("expired license rejected from cache", async () => {
|
||||
const cachePath = path.join(tmpDir, ".license_cache");
|
||||
const keySha = crypto.createHash("sha256").update("test-key").digest("hex");
|
||||
fs.writeFileSync(
|
||||
cachePath,
|
||||
JSON.stringify({
|
||||
key_sha256: keySha,
|
||||
valid: true,
|
||||
plan: "solo",
|
||||
expires: "2020-01-01T00:00:00+00:00",
|
||||
validated_at: Date.now() / 1000,
|
||||
})
|
||||
);
|
||||
|
||||
const result = await validateLicense("test-key");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("does not cache invalid responses", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ valid: false, plan: "solo", expires: null }),
|
||||
} as Response);
|
||||
|
||||
await validateLicense("bad-key");
|
||||
|
||||
const cachePath = path.join(tmpDir, ".license_cache");
|
||||
expect(fs.existsSync(cachePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("corrupted validated_at is treated as absent cache, not trusted", async () => {
|
||||
const keySha = crypto.createHash("sha256").update("test-key").digest("hex");
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, ".license_cache"),
|
||||
JSON.stringify({
|
||||
key_sha256: keySha,
|
||||
valid: true,
|
||||
plan: "solo",
|
||||
expires: null,
|
||||
validated_at: "not-a-number",
|
||||
})
|
||||
);
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ valid: true, plan: "solo", expires: null }),
|
||||
} as Response);
|
||||
|
||||
const result = await validateLicense("test-key");
|
||||
expect(globalThis.fetch).toHaveBeenCalledOnce(); // corrupted cache ignored → server hit
|
||||
expect(result!.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getProLatestVersion ───────────────────────────────
|
||||
|
||||
describe("getProLatestVersion", () => {
|
||||
it("fetches version from server", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ version: "147.0.1234.5" }),
|
||||
} as Response);
|
||||
|
||||
const version = await getProLatestVersion();
|
||||
expect(version).toBe("147.0.1234.5");
|
||||
});
|
||||
|
||||
it("rate limited by marker file", async () => {
|
||||
const marker = path.join(tmpDir, ".last_pro_version_check");
|
||||
fs.writeFileSync(marker, "147.0.1234.5");
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
const version = await getProLatestVersion();
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(version).toBe("147.0.1234.5");
|
||||
});
|
||||
|
||||
it("network error returns null", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network"));
|
||||
const version = await getProLatestVersion();
|
||||
expect(version).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Config pro parameter ──────────────────────────────
|
||||
|
||||
describe("config pro parameter", () => {
|
||||
it("getBinaryDir adds -pro suffix", () => {
|
||||
const normal = config.getBinaryDir("147.0.0.0");
|
||||
const pro = config.getBinaryDir("147.0.0.0", true);
|
||||
expect(normal).toMatch(/chromium-147\.0\.0\.0$/);
|
||||
expect(pro).toMatch(/chromium-147\.0\.0\.0-pro$/);
|
||||
});
|
||||
|
||||
it("getBinaryDir default has no suffix", () => {
|
||||
const normal = config.getBinaryDir("147.0.0.0");
|
||||
expect(normal).not.toMatch(/-pro$/);
|
||||
});
|
||||
});
|
||||
+103
-1
@@ -26,13 +26,16 @@ vi.mock("../src/config.js", async (importActual) => {
|
||||
});
|
||||
|
||||
import {
|
||||
BinaryVerificationError,
|
||||
downloadProBinary,
|
||||
fetchSignedManifest,
|
||||
parseChecksums,
|
||||
parseManifestVersion,
|
||||
verifyDownloadChecksum,
|
||||
verifyProDownload,
|
||||
verifySignature,
|
||||
} from "../src/download.js";
|
||||
import { getArchiveName, getChromiumVersion } from "../src/config.js";
|
||||
import { DOWNLOAD_BASE_URL, getArchiveName, getChromiumVersion } from "../src/config.js";
|
||||
|
||||
/** Produce SHA256SUMS.sig content (base64 text bytes) for a manifest. */
|
||||
function sign(manifest: Uint8Array): Uint8Array {
|
||||
@@ -173,6 +176,105 @@ describe("verifyDownloadChecksum (official path, fail-closed)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadProBinary (version-pinned URL)", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("requests the explicit version, not /latest", async () => {
|
||||
let capturedUrl = "";
|
||||
// First fetch is the binary download; capture its URL then abort the flow
|
||||
// before verify/extract by returning a non-ok response.
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
||||
capturedUrl = typeof input === "string" ? input : (input as URL).toString();
|
||||
return { ok: false, status: 500, statusText: "stop" } as Response;
|
||||
});
|
||||
|
||||
await downloadProBinary("147.0.1.0", "cb_key").catch(() => {});
|
||||
|
||||
expect(capturedUrl).toBe(`${DOWNLOAD_BASE_URL}/api/download/147.0.1.0`);
|
||||
expect(capturedUrl.endsWith("/latest")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyProDownload (Pro path, fail-closed parity)", () => {
|
||||
const PRO_VERSION = "147.0.1.0";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.CLOAKBROWSER_SKIP_CHECKSUM;
|
||||
});
|
||||
|
||||
function tmpFile(bytes: Buffer): string {
|
||||
const p = path.join(os.tmpdir(), `cloak-pro-${process.pid}-${bytes.length}-${bytes[0]}`);
|
||||
fs.writeFileSync(p, bytes);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Mock fetch: serve `manifestBytes` for SHA256SUMS, its signature for *.sig. */
|
||||
function mockManifest(manifestBytes: Uint8Array, sigBytes = sign(manifestBytes)) {
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
||||
const url = typeof input === "string" ? input : (input as URL).toString();
|
||||
const out = url.endsWith(".sig") ? sigBytes : manifestBytes;
|
||||
return { ok: true, arrayBuffer: async () => out.buffer } as Response;
|
||||
});
|
||||
}
|
||||
|
||||
const body = (lines: string, version = PRO_VERSION) =>
|
||||
enc(`version=${version}\n${lines}`);
|
||||
|
||||
it("passes when signature is valid and hash matches", async () => {
|
||||
const data = Buffer.from("the real pro binary");
|
||||
const file = tmpFile(data);
|
||||
const hash = createHash("sha256").update(data).digest("hex");
|
||||
mockManifest(body(`${hash} ${getArchiveName()}\n`));
|
||||
await expect(verifyProDownload(file, PRO_VERSION)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("CLOAKBROWSER_SKIP_CHECKSUM does NOT bypass Pro verification", async () => {
|
||||
const file = tmpFile(Buffer.from("a malicious pro binary"));
|
||||
const goodHash = createHash("sha256").update(Buffer.from("the real pro binary")).digest("hex");
|
||||
process.env.CLOAKBROWSER_SKIP_CHECKSUM = "true";
|
||||
mockManifest(body(`${goodHash} ${getArchiveName()}\n`));
|
||||
const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e);
|
||||
// The error TYPE is the contract the ensureBinary router branches on:
|
||||
// BinaryVerificationError => re-throw (never downgrade to free).
|
||||
expect(err).toBeInstanceOf(BinaryVerificationError);
|
||||
expect(err.message).toMatch(/Checksum verification failed/);
|
||||
});
|
||||
|
||||
it("treats a failed manifest fetch as transient, not tampering", async () => {
|
||||
// A failed manifest FETCH must be a plain Error (router falls back to free),
|
||||
// NOT a BinaryVerificationError (which the router re-throws as a hard fail).
|
||||
const file = tmpFile(Buffer.from("x"));
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: false, status: 404 } as Response);
|
||||
const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).not.toBeInstanceOf(BinaryVerificationError);
|
||||
});
|
||||
|
||||
it("fails on a signed manifest for the wrong version (downgrade)", async () => {
|
||||
const data = Buffer.from("the real pro binary");
|
||||
const file = tmpFile(data);
|
||||
const hash = createHash("sha256").update(data).digest("hex");
|
||||
mockManifest(body(`${hash} ${getArchiveName()}\n`, "1.0.0.0"));
|
||||
const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(BinaryVerificationError);
|
||||
expect(err.message).toMatch(/Version mismatch/);
|
||||
});
|
||||
|
||||
it("rejects a manifest tampered after signing", async () => {
|
||||
const data = Buffer.from("the real pro binary");
|
||||
const file = tmpFile(data);
|
||||
const hash = createHash("sha256").update(data).digest("hex");
|
||||
const good = body(`${hash} ${getArchiveName()}\n`);
|
||||
const sig = sign(good);
|
||||
const tampered = enc(new TextDecoder().decode(good).replace(getArchiveName(), "evil.tar.gz"));
|
||||
mockManifest(tampered, sig);
|
||||
const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(BinaryVerificationError);
|
||||
expect(err.message).toMatch(/signature verification failed/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("version binding", () => {
|
||||
it("reads the version= line", () => {
|
||||
expect(
|
||||
|
||||
Reference in New Issue
Block a user