mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-10-18 23:07:13 -05:00
feat: persist the fingerprint seed per persistent profile
launch_persistent_context() now stores its seed in <user_data_dir>/.cloakbrowser-seed on first launch and reuses it, so a profile keeps one identity across sessions. Same file format in Python, JS and .NET. An explicit --fingerprint in args wins and leaves the file alone; stealth_args=False skips it; non-persistent launches stay random. Concurrent first launches publish without overwriting (link), so they agree on one seed. Filesystems without hard links (FAT/exFAT, some network mounts) fall back to an exclusive create, and an empty file is re-read briefly as in-progress. A corrupt file is logged and regenerated.
This commit is contained in:
16 files changed
+860
-5
No files matched your search
+8
-1
@@ -418,8 +418,15 @@ export const IGNORE_DEFAULT_ARGS = ["--enable-automation", "--enable-unsafe-swif
|
||||
// innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks).
|
||||
export const DEFAULT_VIEWPORT = { width: 1920, height: 947 };
|
||||
|
||||
export const SEED_MIN = 10000;
|
||||
export const SEED_MAX = 99999;
|
||||
|
||||
export function randomSeed(): number {
|
||||
return Math.floor(Math.random() * (SEED_MAX - SEED_MIN + 1)) + SEED_MIN;
|
||||
}
|
||||
|
||||
export function getDefaultStealthArgs(): string[] {
|
||||
const seed = Math.floor(Math.random() * 90000) + 10000; // 10000-99999
|
||||
const seed = randomSeed();
|
||||
const isMac = process.platform === "darwin";
|
||||
|
||||
const base = [
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
resolveLicenseKey,
|
||||
} from "./license.js";
|
||||
import { seedWidevineHint } from "./widevine.js";
|
||||
import { persistentSeedArgs } from "./profile-seed.js";
|
||||
|
||||
/** @internal Accept both timezone and timezoneId — either works, no warning. Exported for testing. */
|
||||
export function resolveTimezone<T extends { timezone?: string; timezoneId?: string }>(options: T): T {
|
||||
@@ -355,6 +356,7 @@ export async function launchPersistentContext(
|
||||
);
|
||||
let resolvedArgs = await resolveWebrtcArgs(options);
|
||||
resolvedArgs = appendWebrtcExitIp(resolvedArgs, exitIp);
|
||||
resolvedArgs = persistentSeedArgs(options.userDataDir, options.stealthArgs, resolvedArgs);
|
||||
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
|
||||
maybeWarnWindowsFonts(args);
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Fingerprint seed persistence for persistent profiles.
|
||||
* Mirrors Python cloakbrowser/config.py::persistent_seed_args.
|
||||
*
|
||||
* The first launch of a profile stores its seed in `<userDataDir>/.cloakbrowser-seed`
|
||||
* (decimal + newline, same file in all wrappers); later launches reuse it, so the
|
||||
* profile keeps one fingerprint identity across sessions.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { SEED_MAX, SEED_MIN, randomSeed } from "./config.js";
|
||||
|
||||
export const PROFILE_SEED_FILE = ".cloakbrowser-seed";
|
||||
|
||||
/** Stored seed, or null if the file is missing or corrupt (corrupt is removed). */
|
||||
function readProfileSeed(file: string): number | null {
|
||||
let data: Buffer;
|
||||
try {
|
||||
data = fs.readFileSync(file);
|
||||
// Empty = a concurrent launch's exclusive create (no-hard-link fallback)
|
||||
// hasn't written yet; give it ~1s before calling the file corrupt.
|
||||
for (let i = 0; i < 20 && data.length === 0; i++) {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50); // sync sleep
|
||||
data = fs.readFileSync(file);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.code === "ENOENT") return null;
|
||||
throw e;
|
||||
}
|
||||
const text = data.toString("latin1").trim();
|
||||
if (/^[0-9]+$/.test(text) && Number(text) >= SEED_MIN && Number(text) <= SEED_MAX) {
|
||||
return Number(text);
|
||||
}
|
||||
// A corrupt file gets a fresh seed (= new identity), so say so loudly.
|
||||
console.warn(
|
||||
`[cloakbrowser] Corrupt fingerprint seed file ${file} (${JSON.stringify(text.slice(0, 32))}); generating a new seed`,
|
||||
);
|
||||
try {
|
||||
fs.unlinkSync(file);
|
||||
} catch (e: any) {
|
||||
if (e?.code !== "ENOENT") throw e; // ENOENT: a concurrent launch already removed it
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Create `file` exclusively (EEXIST if present), write `content`, fsync. */
|
||||
function writeSynced(file: string, content: string): void {
|
||||
const fd = fs.openSync(file, "wx");
|
||||
try {
|
||||
fs.writeSync(fd, content);
|
||||
fs.fsyncSync(fd);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend the profile's stored `--fingerprint=<seed>` to `args`, creating it on
|
||||
* first launch. It is a user arg, so it overrides the random stealth default via
|
||||
* buildArgs dedup. An explicit `--fingerprint` (including `=off`) or
|
||||
* `stealthArgs: false` leaves the file alone.
|
||||
*/
|
||||
export function persistentSeedArgs(
|
||||
userDataDir: string | undefined,
|
||||
stealthArgs: boolean | undefined,
|
||||
args: string[] | undefined,
|
||||
): string[] | undefined {
|
||||
if (stealthArgs === false || !userDataDir) return args;
|
||||
if ((args ?? []).some((a) => a.split("=")[0] === "--fingerprint")) return args;
|
||||
|
||||
const file = path.join(userDataDir, PROFILE_SEED_FILE);
|
||||
let seed = readProfileSeed(file);
|
||||
if (seed === null) {
|
||||
fs.mkdirSync(userDataDir, { recursive: true });
|
||||
const tmp = path.join(userDataDir, `${PROFILE_SEED_FILE}.${randomUUID()}.tmp`);
|
||||
const content = `${randomSeed()}\n`;
|
||||
try {
|
||||
writeSynced(tmp, content);
|
||||
// Publish without replacing: concurrent first launches all end up with
|
||||
// whichever seed was linked first.
|
||||
try {
|
||||
fs.linkSync(tmp, file);
|
||||
} catch (e: any) {
|
||||
if (e?.code !== "EEXIST") {
|
||||
// No hard links (FAT/exFAT, some network mounts): exclusive create
|
||||
// still never overwrites. ponytail: a concurrent reader can catch the
|
||||
// file empty here and regenerate; link() avoids that window.
|
||||
try {
|
||||
writeSynced(file, content);
|
||||
} catch (e2: any) {
|
||||
if (e2?.code !== "EEXIST") throw e2;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
fs.unlinkSync(tmp);
|
||||
} catch (e) {
|
||||
console.warn(`[cloakbrowser] Failed to remove temp seed file ${tmp}:`, e);
|
||||
}
|
||||
}
|
||||
seed = readProfileSeed(file);
|
||||
if (seed === null) throw new Error(`Could not create fingerprint seed file ${file}`);
|
||||
}
|
||||
return [`--fingerprint=${seed}`, ...(args ?? [])];
|
||||
}
|
||||
+6
-1
@@ -25,6 +25,7 @@ import {
|
||||
resolveLicenseKey,
|
||||
} from "./license.js";
|
||||
import { seedWidevineHint } from "./widevine.js";
|
||||
import { persistentSeedArgs } from "./profile-seed.js";
|
||||
|
||||
export { CloakBrowserLicenseError } from "./license.js";
|
||||
|
||||
@@ -60,7 +61,9 @@ 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[] }> {
|
||||
async function resolveArgs(
|
||||
options: LaunchOptions & { userDataDir?: string },
|
||||
): Promise<{ binaryPath: string; args: string[] }> {
|
||||
const binaryPath =
|
||||
process.env.CLOAKBROWSER_BINARY_PATH ||
|
||||
(await ensureBinary(
|
||||
@@ -72,6 +75,8 @@ async function resolveArgs(options: LaunchOptions): Promise<{ binaryPath: string
|
||||
let resolvedArgs = (await resolveWebrtcArgs(options)) ?? options.args;
|
||||
|
||||
resolvedArgs = appendWebrtcExitIp(resolvedArgs, exitIp);
|
||||
// Only launchPersistentContext passes userDataDir; plain launch() stays random.
|
||||
resolvedArgs = persistentSeedArgs(options.userDataDir, options.stealthArgs, resolvedArgs);
|
||||
const args = buildArgs({ ...options, ...resolved, args: resolvedArgs });
|
||||
maybeWarnWindowsFonts(args);
|
||||
return { binaryPath, args };
|
||||
|
||||
@@ -461,6 +461,8 @@ describe("launchPersistentContext (unit)", () => {
|
||||
};
|
||||
|
||||
vi.doMock("playwright-core", () => ({ chromium: mockChromium }));
|
||||
// Fixed fake profile paths: keep the seed file off disk (covered in profile-seed.test.ts).
|
||||
vi.doMock("../src/profile-seed.js", () => ({ persistentSeedArgs: (_d: any, _s: any, a: any) => a }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { PROFILE_SEED_FILE, persistentSeedArgs } from "../src/profile-seed.js";
|
||||
|
||||
// macOS adds ._ AppleDouble files on FAT volumes, where these tests also run.
|
||||
const listing = (d: string) => fs.readdirSync(d).filter((n) => !n.startsWith("._"));
|
||||
const seedOf = (args: string[]) => args.filter((a) => a.startsWith("--fingerprint="));
|
||||
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cb-seed-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("persistentSeedArgs", () => {
|
||||
it("writes the seed on first call and reuses it", () => {
|
||||
const profile = path.join(dir, "missing", "profile"); // created on demand
|
||||
const first = persistentSeedArgs(profile, undefined, ["--x"])!;
|
||||
const stored = fs.readFileSync(path.join(profile, PROFILE_SEED_FILE), "utf8");
|
||||
expect(stored).toMatch(/^\d{5}\n$/);
|
||||
expect(first).toEqual([`--fingerprint=${stored.trim()}`, "--x"]);
|
||||
expect(persistentSeedArgs(profile, true, ["--x"])).toEqual(first);
|
||||
});
|
||||
|
||||
it("reads a Python-written seed file", () => {
|
||||
fs.writeFileSync(path.join(dir, PROFILE_SEED_FILE), "12345\n"); // exact Python output
|
||||
expect(persistentSeedArgs(dir, true, undefined)).toEqual(["--fingerprint=12345"]);
|
||||
});
|
||||
|
||||
it.each(["--fingerprint=4242", "--fingerprint=off"])("explicit %s wins, file untouched", (flag) => {
|
||||
expect(persistentSeedArgs(dir, true, [flag])).toEqual([flag]);
|
||||
expect(fs.existsSync(path.join(dir, PROFILE_SEED_FILE))).toBe(false);
|
||||
fs.writeFileSync(path.join(dir, PROFILE_SEED_FILE), "12345\n");
|
||||
expect(persistentSeedArgs(dir, true, [flag])).toEqual([flag]);
|
||||
expect(fs.readFileSync(path.join(dir, PROFILE_SEED_FILE), "utf8")).toBe("12345\n");
|
||||
});
|
||||
|
||||
it("stealthArgs false writes nothing", () => {
|
||||
expect(persistentSeedArgs(dir, false, ["--x"])).toEqual(["--x"]);
|
||||
expect(fs.readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("empty userDataDir is ignored", () => {
|
||||
expect(persistentSeedArgs("", true, ["--x"])).toEqual(["--x"]);
|
||||
});
|
||||
|
||||
it.each(["", "abc\n", "5\n", "123456\n", "-12345\n"])("corrupt %j logs and regenerates", (content) => {
|
||||
const file = path.join(dir, PROFILE_SEED_FILE);
|
||||
fs.writeFileSync(file, content);
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const args = persistentSeedArgs(dir, true, undefined)!;
|
||||
expect(warn.mock.calls[0][0]).toContain(file);
|
||||
const stored = fs.readFileSync(file, "utf8");
|
||||
expect(stored).toMatch(/^\d{5}\n$/);
|
||||
expect(args).toEqual([`--fingerprint=${stored.trim()}`]);
|
||||
});
|
||||
|
||||
it("falls back to exclusive create when hard links are unsupported", () => {
|
||||
// FAT/exFAT and some network mounts reject link() with ENOTSUP/EPERM.
|
||||
const link = vi.spyOn(fs, "linkSync").mockImplementation(() => {
|
||||
throw Object.assign(new Error("operation not supported"), { code: "ENOTSUP" });
|
||||
});
|
||||
const first = persistentSeedArgs(dir, true, undefined)!;
|
||||
expect(persistentSeedArgs(dir, true, undefined)).toEqual(first);
|
||||
const stored = fs.readFileSync(path.join(dir, PROFILE_SEED_FILE), "utf8");
|
||||
expect(first).toEqual([`--fingerprint=${stored.trim()}`]);
|
||||
expect(listing(dir)).toEqual([PROFILE_SEED_FILE]);
|
||||
expect(link).toHaveBeenCalledTimes(1); // the mock really intercepted the publish
|
||||
});
|
||||
|
||||
it("concurrent first launches in separate processes agree", async () => {
|
||||
// Runs the built dist/ (npm run build first, as CI does).
|
||||
const moduleUrl = new URL("../dist/profile-seed.js", import.meta.url).href;
|
||||
const script =
|
||||
`const { persistentSeedArgs } = await import(${JSON.stringify(moduleUrl)});` +
|
||||
`console.log(persistentSeedArgs(${JSON.stringify(dir)}, true, [])[0]);`;
|
||||
const outs = await Promise.all(
|
||||
Array.from({ length: 8 }, () =>
|
||||
promisify(execFile)(process.execPath, ["--input-type=module", "-e", script]),
|
||||
),
|
||||
);
|
||||
const seeds = new Set(outs.map((o) => o.stdout.trim()));
|
||||
expect(seeds.size).toBe(1);
|
||||
expect(listing(dir)).toEqual([PROFILE_SEED_FILE]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("launchers use the stored seed", () => {
|
||||
const origEnv = process.env.CLOAKBROWSER_BINARY_PATH;
|
||||
beforeEach(() => {
|
||||
process.env.CLOAKBROWSER_BINARY_PATH = "/fake/chrome";
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
if (origEnv) process.env.CLOAKBROWSER_BINARY_PATH = origEnv;
|
||||
else delete process.env.CLOAKBROWSER_BINARY_PATH;
|
||||
});
|
||||
|
||||
it("playwright launchPersistentContext reuses the seed, launch() stays random", async () => {
|
||||
const chromium = {
|
||||
launchPersistentContext: vi.fn().mockResolvedValue({ close: vi.fn(), pages: () => [] }),
|
||||
launch: vi.fn().mockResolvedValue({ close: vi.fn(), on: vi.fn(), contexts: () => [] }),
|
||||
};
|
||||
vi.doMock("playwright-core", () => ({ chromium }));
|
||||
const { launchPersistentContext, launch } = await import("../src/playwright.js");
|
||||
|
||||
await launchPersistentContext({ userDataDir: dir });
|
||||
await launchPersistentContext({ userDataDir: dir });
|
||||
const [a, b] = chromium.launchPersistentContext.mock.calls.map((c: any) => seedOf(c[1].args));
|
||||
const stored = fs.readFileSync(path.join(dir, PROFILE_SEED_FILE), "utf8").trim();
|
||||
expect(a).toEqual([`--fingerprint=${stored}`]);
|
||||
expect(b).toEqual(a);
|
||||
|
||||
for (let i = 0; i < 5; i++) await launch();
|
||||
const plain = new Set(chromium.launch.mock.calls.map((c: any) => seedOf(c[0].args)[0]));
|
||||
expect(plain.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("puppeteer launchPersistentContext uses the stored seed", async () => {
|
||||
const launch = vi.fn().mockResolvedValue({ pages: vi.fn().mockResolvedValue([]), close: vi.fn(), on: vi.fn() });
|
||||
vi.doMock("puppeteer-core", () => ({ default: { launch } }));
|
||||
fs.writeFileSync(path.join(dir, PROFILE_SEED_FILE), "12345\n");
|
||||
const { launchPersistentContext } = await import("../src/puppeteer.js");
|
||||
|
||||
await launchPersistentContext({ userDataDir: dir });
|
||||
expect(seedOf(launch.mock.calls[0][0].args)).toEqual(["--fingerprint=12345"]);
|
||||
});
|
||||
|
||||
it("puppeteer launch() shares resolveArgs but stays random and writes nothing", async () => {
|
||||
const launch = vi.fn().mockResolvedValue({ pages: vi.fn().mockResolvedValue([]), close: vi.fn(), on: vi.fn() });
|
||||
vi.doMock("puppeteer-core", () => ({ default: { launch } }));
|
||||
const cwd = process.cwd();
|
||||
process.chdir(dir); // a stray relative seed file would land here
|
||||
try {
|
||||
const pptr = await import("../src/puppeteer.js");
|
||||
for (let i = 0; i < 5; i++) await pptr.launch();
|
||||
} finally {
|
||||
process.chdir(cwd);
|
||||
}
|
||||
const seeds = new Set(launch.mock.calls.map((c: any) => seedOf(c[0].args)[0]));
|
||||
expect(seeds.size).toBeGreaterThan(1);
|
||||
expect(listing(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("playwright launchContext stays random and writes nothing", async () => {
|
||||
const context = { close: vi.fn(), pages: () => [], on: vi.fn() };
|
||||
const chromium = {
|
||||
launch: vi.fn().mockResolvedValue({ close: vi.fn(), on: vi.fn(), contexts: () => [], newContext: vi.fn().mockResolvedValue(context) }),
|
||||
};
|
||||
vi.doMock("playwright-core", () => ({ chromium }));
|
||||
const cwd = process.cwd();
|
||||
process.chdir(dir);
|
||||
try {
|
||||
const { launchContext } = await import("../src/playwright.js");
|
||||
for (let i = 0; i < 5; i++) await launchContext();
|
||||
} finally {
|
||||
process.chdir(cwd);
|
||||
}
|
||||
const seeds = new Set(chromium.launch.mock.calls.map((c: any) => seedOf(c[0].args)[0]));
|
||||
expect(seeds.size).toBeGreaterThan(1);
|
||||
expect(listing(dir)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,11 @@ vi.mock("../src/download.js", () => ({
|
||||
ensureBinary: vi.fn().mockResolvedValue("/fake/chrome"),
|
||||
}));
|
||||
|
||||
// "./my-profile" is a fake path: keep the seed file off disk (covered in profile-seed.test.ts).
|
||||
vi.mock("../src/profile-seed.js", () => ({
|
||||
persistentSeedArgs: (_d: any, _s: any, a: any) => a,
|
||||
}));
|
||||
|
||||
vi.mock("../src/geoip.js", () => ({
|
||||
resolveProxyGeo: vi.fn().mockResolvedValue({ timezone: null, locale: null }),
|
||||
maybeResolveGeoip: vi.fn().mockResolvedValue({}),
|
||||
|
||||
@@ -8,6 +8,9 @@ vi.mock("../src/widevine.js", () => ({
|
||||
seedWidevineHint: vi.fn(),
|
||||
resolveWidevineCdmDir: vi.fn(),
|
||||
}));
|
||||
vi.mock("../src/profile-seed.js", () => ({
|
||||
persistentSeedArgs: (_d: any, _s: any, a: any) => a,
|
||||
}));
|
||||
vi.mock("../src/download.js", () => ({
|
||||
ensureBinary: vi.fn().mockResolvedValue("/fake/chrome"),
|
||||
}));
|
||||
|
||||
Reference in new issue
Block a user