Compare commits

..
Author SHA1 Message Date
CloakHQ 7e9388e981 release: v0.4.2 — macOS Pro license falls back to free binary 2026-06-23 10:46:48 +02:00
CloakHQ 343a3e8e28 fix(download): fall back to free binary on macOS when Pro 404s
A Pro license on macOS currently has no binary to download. Rather than
hard-failing a paying customer, fall back to the free binary with a clear
notice. Scoped to the 404 case only: transient and checksum-verification
failures still hard-fail (no silent downgrade), and once the macOS Pro
build ships the 404 disappears and Pro is served automatically.

JS side adds DownloadHttpError to carry the HTTP status through fetch's
generic error path so the 404 can be distinguished from transient failures.
2026-06-23 10:41:04 +02:00
CloakHQ 6acd9fe277 release: v0.4.1 — humanize headed-scroll fix, Widevine Docker auto-fetch 2026-06-23 03:43:06 +02:00
CloakHQ 61365cea48 feat(docker): opt-in Widevine CDM auto-fetch for persistent contexts
Add bin/fetch-widevine.py — a stdlib-only fetcher that pulls the Widevine CDM from Google's component server (arch-aware, sha256-verified, atomic, cached). The Docker entrypoint runs it when CLOAKBROWSER_FETCH_WIDEVINE is set (off by default), exporting CLOAKBROWSER_WIDEVINE_CDM so persistent profiles get a working CDM without a local Chrome to copy from. Fail-soft; skips when a CDM is already set or CLOAKBROWSER_WIDEVINE=0. Bare-metal Linux users can run the script directly. README: document the flag, drop the outdated storage-quota note.
2026-06-23 03:13:22 +02:00
CloakHQ 8570deaa19 chore: promote Pro tier in first-launch banner, drop header badge 2026-06-23 03:09:36 +02:00
CloakHQ 9c3ed2dcba fix(humanize): fall back to window dims when viewport is null
Headed launches default to no_viewport, so page.viewport_size is None and
human scroll raised "Viewport size not available". Fall back to live
window.innerWidth/innerHeight. Covers Playwright (py sync/async, JS) and
Puppeteer paths.
2026-06-23 01:34:07 +02:00
CloakHQ 29679a73bf docs(readme): refresh test-results stamp to Jun 2026 / Chromium 148 2026-06-22 04:05:36 +02:00
CloakHQ db9eb4bbf0 chore: prepare 0.4.0 — version bump, Pro tier changelog + README + license v1.1 2026-06-22 03:08:27 +02:00
CloakHQ 10f492e95b feat: add Pro tier license validation and download routing 2026-06-21 04:08:45 +02:00
38 changed files with 3023 additions and 133 deletions
+2 -2
View File
@@ -10,7 +10,7 @@ jobs:
python:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
@@ -22,7 +22,7 @@ jobs:
javascript:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
+5 -5
View File
@@ -24,7 +24,7 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
@@ -42,7 +42,7 @@ jobs:
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
@@ -62,7 +62,7 @@ jobs:
permissions:
id-token: write # OIDC trusted publishing — no PYPI_TOKEN needed
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
@@ -80,7 +80,7 @@ jobs:
permissions:
id-token: write # OIDC trusted publishing + provenance — no NPM_TOKEN needed
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24 # npm 11.11.0 native — no upgrade needed (Node 22.22.2 has broken npm)
@@ -100,7 +100,7 @@ jobs:
attestations: write
packages: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Extract version
run: |
VERSION=$(python -c 'import re; print(re.search(r"__version__\s*=\s*[\"'\'']([^\"'\'']+)", open("cloakbrowser/_version.py").read()).group(1))')
+8 -2
View File
@@ -1,6 +1,6 @@
# CloakBrowser Binary License
**Version 1.0February 2026**
**Version 1.1June 2026**
Copyright (c) 2026 CloakHQ. All rights reserved.
@@ -14,7 +14,13 @@ The Binary is built on Chromium, which is open-source software by The Chromium A
## Grant of Use
You are granted a non-exclusive, non-transferable, royalty-free license to use the Binary for personal or commercial purposes. No fees are required.
You are granted a non-exclusive, non-transferable, royalty-free license to use the Binary for personal or commercial purposes, subject to the Version-Specific Terms below.
## Version-Specific Terms
Starting with Chromium 148, downloading the **latest major** Binary version requires an active CloakBrowser Pro subscription. Previous **major** versions (v146 and earlier) remain available at no cost under this license. Each time a new **major** Chromium version is released, the **prior major version** becomes available for free download. Minor and patch updates to the latest major version are part of the Pro subscription.
Pro subscription details and pricing: https://cloakbrowser.dev
## Restrictions
+17 -1
View File
@@ -6,11 +6,27 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
---
## [Unreleased]
## [0.4.2] — 2026-06-23
- **[wrapper]** macOS Pro licenses now fall back to the free binary (macOS Pro build coming) instead of failing to launch. Transient and signature-verification failures still hard-fail. Python and JS.
## [0.4.1] — 2026-06-23
- **[wrapper]** Humanize: fix "Viewport size not available" crash on headed launches. Headed mode defaults to `no_viewport` (since 0.4.0), so `page.viewport_size` is `None` — human scroll now falls back to the live `window.innerWidth`/`window.innerHeight`. Covers Playwright (Python sync/async, JS) and Puppeteer.
- **[wrapper]** **[docker]** Widevine: opt-in CDM auto-fetch for persistent contexts. Set `CLOAKBROWSER_FETCH_WIDEVINE` and the Docker entrypoint pulls the Widevine CDM from Google's component server (arch-aware, SHA-256 verified, atomic, cached), so DRM playback works without a local Chrome to copy from. Off by default; bare-metal Linux users can run `bin/fetch-widevine.py` directly.
- **[meta]** First-launch banner now promotes the Pro tier (header badge dropped); refreshed README test-results stamp to Jun 2026 / Chromium 148.
## [0.4.0] — 2026-06-22
- **[wrapper]** **CloakBrowser Pro**: all launch functions now accept a `license_key` parameter (`licenseKey` in JS); a key can also be supplied via the `CLOAKBROWSER_LICENSE_KEY` environment variable or a `~/.cloakbrowser/license.key` file. With a valid key the latest binary is downloaded from cloakbrowser.dev; without one, the free binary continues to download from GitHub Releases exactly as before. License validation is cached locally for 24h, and the Pro binary is authenticated with the same pinned Ed25519 signature as the free binary. A valid key whose Pro download or signature check fails surfaces a clear error rather than silently downgrading to the free binary. Adds `validate_license`/`LicenseInfo` exports and a `tier` field on `binary_info()`. Details: https://cloakbrowser.dev
- **[wrapper]** **Security**: downloaded binaries are now verified against a pinned Ed25519 signature on the published `SHA256SUMS` (a detached `SHA256SUMS.sig`), so a compromised download mirror can no longer certify a tampered binary — the previous same-origin checksum proved integrity but not authenticity (#308). The signed manifest also binds the release version, rejecting a forced downgrade to an older signed build. Verification is mandatory on the official download path; silent auto-update is preserved for everyone because only a constant public key is pinned, not per-version hashes. Older installed wrappers are unaffected.
- **[wrapper]** Headed launches no longer apply a fixed emulated viewport on top of the real browser window — the page now tracks the actual window so window-geometry stays self-consistent. Headless keeps a deterministic viewport (unchanged). Applies across `launch`, `launch_context`, `launch_persistent_context` (+ async) and the JS Playwright/Puppeteer wrappers. Passing an explicit `viewport=`/`no_viewport` (Python) or `viewport`/`defaultViewport` (JS) still works exactly as before.
- **[wrapper]** **Breaking**: removed the optional `patchright` backend. The `backend` parameter and `CLOAKBROWSER_BACKEND` environment variable no longer exist, and the `cloakbrowser[patchright]` extra is gone. Stock Playwright is now the only backend. The stealth binary handles automation-signal suppression at the C++ level — patchright added no measurable benefit on top of it (identical reCAPTCHA v3 score to plain Playwright) while breaking proxy auth and `add_init_script` (#27). Callers passing `backend=...` will get a `TypeError`; remove the argument.
- **[binary]** **CloakBrowser Pro — first Pro build**: Chromium `148.0.7778.215.2` for linux-x64, linux-arm64, and windows-x64 — 59 source-level fingerprint patches (up from 58 on 146). macOS builds to follow. Available to Pro subscribers at cloakbrowser.dev; v146 remains free on GitHub Releases.
- **[binary]** Rebased the full patch set across two Chromium major versions — 146 → 147 → 148 — re-applying and adapting every patch to current Chromium internals
- **[binary]** Cross-API fingerprint consistency improvements for Chromium 148 profiles
- **[binary]** WebRTC fingerprint hardening — network signals matched to real Chrome
- **[binary]** Font metric alignment for Windows profiles via the opt-in `--fingerprint-windows-font-metrics` flag — requires Windows fonts installed (see README "Font Setup on Linux")
## [0.3.32] — 2026-06-20
+2 -1
View File
@@ -39,7 +39,8 @@ RUN python -c "from cloakbrowser import ensure_binary; ensure_binary()" \
# CLI shortcuts
COPY bin/cloaktest /usr/local/bin/cloaktest
COPY bin/cloakserve /usr/local/bin/cloakserve
RUN chmod +x /usr/local/bin/cloaktest /usr/local/bin/cloakserve
COPY bin/fetch-widevine.py /usr/local/bin/fetch-widevine.py
RUN chmod +x /usr/local/bin/cloaktest /usr/local/bin/cloakserve /usr/local/bin/fetch-widevine.py
EXPOSE 9222
+92 -39
View File
@@ -14,10 +14,6 @@
<a href="https://hub.docker.com/r/cloakhq/cloakbrowser"><img src="https://img.shields.io/docker/pulls/cloakhq/cloakbrowser?label=docker&logo=docker&logoColor=white" alt="Docker Pulls"></a>
</p>
<p align="center">
<a href="https://ko-fi.com/cloakhq"><img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="Support on Ko-fi"></a>
</p>
<br>
<h3 align="center">Stealth Chromium that passes every bot detection test.</h3>
@@ -49,11 +45,13 @@ Same API, same code — just swap the import. <strong>3 lines of code, 30 second
- **Free and open source** — no subscriptions, no usage limits
**Try it now** — no install needed:
```bash
docker run --rm cloakhq/cloakbrowser cloaktest
```
**Python:**
```python
from cloakbrowser import launch
@@ -64,6 +62,7 @@ browser.close()
```
**JavaScript (Playwright):**
```javascript
import { launch } from 'cloakbrowser';
@@ -100,11 +99,13 @@ See [Troubleshooting](#troubleshooting) for site-specific issues (FingerprintJS,
## Install
**Python:**
```bash
pip install cloakbrowser
```
**JavaScript / Node.js:**
```bash
# With Playwright
npm install cloakbrowser playwright-core
@@ -116,6 +117,7 @@ npm install cloakbrowser puppeteer-core
On first run, the stealth Chromium binary is automatically downloaded (~200MB, cached locally).
**Optional:** Auto-detect timezone/locale from proxy IP:
```bash
pip install cloakbrowser[geoip]
```
@@ -136,22 +138,11 @@ page.goto("https://example.com")
> ⭐ **Star** to show support — **[Watch releases](https://github.com/CloakHQ/CloakBrowser/subscription)** to get notified when new builds drop.
## Browser Profile Manager
Self-hosted alternative to Multilogin, GoLogin, and AdsPower. Create browser profiles with unique fingerprints, proxies, and persistent sessions. Launch and interact with them in your browser via noVNC.
```bash
docker run -p 8080:8080 -v cloakprofiles:/data cloakhq/cloakbrowser-manager
```
Open [http://localhost:8080](http://localhost:8080). Create a profile. Click **Launch**. Done.
**[CloakBrowser Manager](https://github.com/CloakHQ/CloakBrowser-Manager)** — free, open source (MIT)
---
## Latest: v0.3.32 (Chromium 146.0.7680.177.5)
## Latest: v0.4.2 — CloakBrowser Pro (Chromium 148.0.7778.215.2)
- **CloakBrowser Pro** — the latest binary (Chromium 148.0.7778.215.2, 59 source-level patches) is now available to Pro subscribers; v146 stays free forever. Set a `license_key` (`licenseKey` in JS) or the `CLOAKBROWSER_LICENSE_KEY` env var and the wrapper fetches the latest build automatically. See [CloakBrowser Pro](#cloakbrowser-pro)
- **58 fingerprint patches** — rendering consistency improvements across Linux and Windows, corrected GPU/display/graphics parameters to match stock Chrome 146 profiles
- **Windows native GPU passthrough** — real hardware values pass through directly instead of being spoofed, matching real browser behavior
- **HTTP proxy inline credentials** — new network-layer support for proxies with inline authentication
@@ -180,9 +171,31 @@ See the full [CHANGELOG.md](CHANGELOG.md) for details.
CloakBrowser doesn't solve CAPTCHAs — it prevents them from appearing. No CAPTCHA-solving services, no proxy rotation built in — bring your own proxies, use the Playwright API you already know.
## CloakBrowser Pro
The wrapper (Python + JS) is MIT, free forever. The binary uses a delayed
free-release model:
- **Free (v146)** — the previous binary, on [GitHub Releases](https://github.com/CloakHQ/cloakbrowser/releases). Goes stale within weeks as detection evolves.
- **Pro (latest, Chromium 148.0.7778.215.2)** — the newest patches and Chromium upgrades first, so the [results below](#test-results) stay green as anti-bot systems change. Linux + Windows (macOS coming).
Anti-bot detection updates constantly, and an older binary degrades fast.
Pro keeps you on the build that's actively maintained against it.
Use Pro if CloakBrowser is part of production scraping, QA, monitoring, or
automation where stale browser fingerprints cost you time or blocked runs.
Activate with your license key (env var, `license_key=` param, or `~/.cloakbrowser/license.key`):
```bash
export CLOAKBROWSER_LICENSE_KEY=cb_xxxxxxxx
```
Pro plans → **[cloakbrowser.dev](https://cloakbrowser.dev)**
## Test Results
All tests verified against live detection services. Last tested: Apr 2026 (Chromium 146).
All tests verified against live detection services. Last tested: Jun 2026 (Chromium 148).
| Detection Service | Stock Playwright | CloakBrowser | Notes |
|---|---|---|---|
@@ -269,6 +282,9 @@ browser = launch()
# Headed mode (see the browser window)
browser = launch(headless=False)
# Pro — use the latest binary (or set CLOAKBROWSER_LICENSE_KEY env var)
browser = launch(license_key="cb_xxxxxxxx")
# With proxy (HTTP or SOCKS5)
browser = launch(proxy="http://user:pass@proxy:8080")
browser = launch(proxy="socks5://user:pass@proxy:1080")
@@ -379,6 +395,7 @@ asyncio.run(main())
Same as `launch_context()`, but with a persistent user profile. Cookies, localStorage, and cache persist across sessions.
Use this when you need to:
- **Stay logged in** across runs (cookies/sessions survive restarts)
- **Bypass incognito detection** (some sites flag empty, ephemeral profiles)
- **Load Chrome extensions** (extensions only work from a real user data dir)
@@ -409,26 +426,31 @@ Supports all the same options as `launch_context()`: `proxy`, `user_agent`, `vie
Async version: `launch_persistent_context_async()`.
**Storage quota and detection tradeoff:** By default, the binary normalizes storage quota to pass FingerprintJS, which blocks persistent contexts that report non-incognito quota values. This means detection services that penalize incognito mode (like BrowserScan's `notPrivate` check, -10 points) will still flag it. If your target site penalizes incognito but doesn't use FingerprintJS, set a higher quota to appear as a regular profile:
**Storage quota and incognito detection:** the binary normalizes storage quota by default (this also hides the real disk size). Detectors that infer private/incognito mode from quota — e.g. BrowserScan's incognito check (10%) — read the default as incognito. Raise it to present as a regular profile:
```python
ctx = launch_persistent_context("./my-profile", args=["--fingerprint-storage-quota=5000"])
```
| Quota setting | FingerprintJS | BrowserScan `notPrivate` |
|---|---|---|
| Default (auto, ~500MB) | PASS | -10 (flagged as incognito) |
| `--fingerprint-storage-quota=5000` | May trigger detection | PASS (appears non-incognito) |
### Widevine / DRM
The binary is built with Widevine support, but the Widevine CDM is a proprietary Google component we can't redistribute. Sideload it once by copying a `WidevineCdm/` directory from a real Chrome install next to the binary (full steps in [#96](https://github.com/CloakHQ/CloakBrowser/issues/96)):
The binary is built with Widevine support, but the Widevine CDM is a proprietary Google component we can't redistribute. Get it one of two ways (full background in [#96](https://github.com/CloakHQ/CloakBrowser/issues/96)):
**Fetch it** — no Chrome install needed; pulls the CDM from Google's component server (Linux x86-64 only; SHA-256 + CRX3-signature verified). It lands at `~/.cloakbrowser/WidevineCdm`, which the wrapper auto-detects — no env var needed:
```bash
python3 bin/fetch-widevine.py
```
**Or copy it** from an existing Chrome install, next to the binary:
```bash
cp -r /opt/google/chrome/WidevineCdm ~/.cloakbrowser/chromium-<version>/WidevineCdm
```
With the CDM in place, `launch_persistent_context()` enables Widevine **on the first launch** — the wrapper auto-writes the CDM hint file into the profile, so you don't need the manual two-launch workaround. This lets you play DRM-protected video (e.g. Netflix, Spotify Web) and makes a persistent profile present as a regular Chrome install to detection services that probe for DRM/EME support as a real-browser signal.
(In Docker, just pass `-e CLOAKBROWSER_FETCH_WIDEVINE=1` — the entrypoint runs the fetch automatically; see the Docker note below.)
With the CDM in place, `launch_persistent_context()` enables Widevine **on the first launch** — the wrapper auto-writes the CDM hint file into the profile, so you don't need the manual two-launch workaround. This lets you play DRM-protected video (e.g. Netflix, Spotify Web).
```python
from cloakbrowser import launch_persistent_context
@@ -439,6 +461,7 @@ ctx = launch_persistent_context("./my-profile", headless=False)
- **Linux only.** Chromium's hint-file mechanism is Linux/ChromeOS-specific. On Windows the CDM can't initialise (DRM host verification) and macOS uses a different layout, so seeding is a no-op there.
- **Auto by presence.** No flag needed — a sideloaded CDM is the opt-in. Point at a CDM in a non-default location with `CLOAKBROWSER_WIDEVINE_CDM=/path/to/WidevineCdm`, or disable seeding entirely with `CLOAKBROWSER_WIDEVINE=0`.
- **Docker — auto-fetch (opt-in).** No Chrome to copy from inside the image, so the official image can fetch the CDM for you. Run with `-e CLOAKBROWSER_FETCH_WIDEVINE=1` and it pulls the CDM from Google's component server (the same source Chrome uses) on first launch, caches it at `~/.cloakbrowser/WidevineCdm` in the mounted volume, where the wrapper auto-detects it — for free or Pro binaries, and for `docker exec`'d scripts alike. **Off by default** — no network call unless you opt in — and best-effort, so a failed fetch never blocks launch. The download is signature- and checksum-verified before install. Bare-metal Linux users can run the same fetcher directly: `python3 bin/fetch-widevine.py` (pip-only installs can grab that one self-contained file from the repo).
### CLI
@@ -479,6 +502,9 @@ import { launch, launchContext, launchPersistentContext } from 'cloakbrowser';
// Basic
const browser = await launch();
// Pro — use the latest binary (or set CLOAKBROWSER_LICENSE_KEY env var)
const browser = await launch({ licenseKey: 'cb_xxxxxxxx' });
// With options
const browser = await launch({
headless: false,
@@ -625,6 +651,7 @@ Access the original un-patched Playwright page at `page._original` if you need r
| `CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS` | `5` | Max seconds for GeoIP resolution before continuing without it |
| `CLOAKBROWSER_WIDEVINE_CDM` | — | Path to a sideloaded `WidevineCdm` directory (overrides auto-detection next to the binary). See [Widevine / DRM](#widevine--drm) |
| `CLOAKBROWSER_WIDEVINE` | `1` | Set to `0` to disable automatic Widevine hint-file seeding for persistent contexts |
| `CLOAKBROWSER_FETCH_WIDEVINE` | `0` | Docker only: set to `1` to auto-fetch the Widevine CDM on container start (Linux x86-64 only). See [Widevine / DRM](#widevine--drm) |
## Fingerprint Management
@@ -641,9 +668,11 @@ The binary is **stealthy by default** — no flags needed. It auto-generates a r
The binary detects its platform at compile time — a macOS binary reports as macOS with Apple GPU, a Linux binary reports as Linux with NVIDIA GPU. The **wrapper** overrides this on Linux by passing `--fingerprint-platform=windows`, so sessions appear as Windows desktops (more common fingerprint, harder to cluster). Use `--fingerprint-platform` for cross-platform spoofing when running the binary directly.
> **Tip: Use a fixed seed when revisiting the same site.** A random seed makes every session look like a different device — which can be suspicious when hitting the same site repeatedly from the same IP. For reCAPTCHA v3 Enterprise and similar scoring systems, a fixed seed produces a consistent fingerprint across sessions, making you look like a returning visitor:
>
> ```python
> browser = launch(args=["--fingerprint=12345"])
> ```
>
> ```javascript
> const browser = await launch({ args: ['--fingerprint=12345'] });
> ```
@@ -682,6 +711,7 @@ Supported by the binary but **not set by default** — pass via `args` to custom
| `--fingerprint-storage-quota` | Override storage quota in MB — affects `storage.estimate()`, `storageBuckets`, and legacy webkit APIs. Auto-normalized when `--fingerprint` is set |
| `--fingerprint-taskbar-height` | Override taskbar height (binary defaults: Win=48, Mac=95, Linux=0) |
| `--fingerprint-fonts-dir` | Path to directory containing target-platform fonts (see [Font Setup on Linux](#font-setup-on-linux)) |
| `--fingerprint-windows-font-metrics` | **Chromium 148+ binary only** (no-op on earlier builds). Align font metrics with the Windows platform when spoofing Windows on Linux — used in the [FingerprintJS config](#detected-by-fingerprintjs). Requires Windows fonts installed (see [Font Setup on Linux](#font-setup-on-linux)); no effect without them |
| `--fingerprint-webrtc-ip` | WebRTC ICE candidate IP replacement. Use `auto` to resolve from proxy exit IP (makes an HTTP call through the proxy), or pass an explicit IP. Auto-injected when `geoip=True` |
| `--fingerprint-noise=false` | Disable noise injection (canvas, WebGL, audio, client rects) while keeping the deterministic fingerprint seed active |
| `--enable-blink-features=FakeShadowRoot` | Access closed shadow DOM elements |
@@ -736,6 +766,7 @@ browser = launch(args=[
## Examples
**Python** — see [`examples/`](examples/):
- [`basic.py`](examples/basic.py) — Launch and load a page
- [`persistent_context.py`](examples/persistent_context.py) — Persistent profile with cookie/localStorage persistence
- [`recaptcha_score.py`](examples/recaptcha_score.py) — Check your reCAPTCHA v3 score
@@ -743,6 +774,7 @@ browser = launch(args=[
- [`fingerprint_scan_test.py`](examples/fingerprint_scan_test.py) — Test against fingerprint-scan.com and CreepJS
**JavaScript** — see [`js/examples/`](js/examples/):
- [`basic-playwright.ts`](js/examples/basic-playwright.ts) — Playwright launch and load
- [`basic-puppeteer.ts`](js/examples/basic-puppeteer.ts) — Puppeteer launch and load
- [`stealth-test.ts`](js/examples/stealth-test.ts) — Run against 6 detection sites
@@ -808,6 +840,8 @@ The wrapper auto-downloads the correct binary for your platform.
Pre-built image on Docker Hub — no install, no setup.
> **Pro:** the image ships with the free binary. Set `CLOAKBROWSER_LICENSE_KEY` (e.g. `-e CLOAKBROWSER_LICENSE_KEY=cb_xxx`, or in Compose) and the latest binary downloads at runtime.
### Quick test
```bash
@@ -965,6 +999,8 @@ ctx.close()
Run again with the same volume — cookies, localStorage, and cache are restored automatically.
To enable Widevine DRM (Netflix, Spotify Web, etc.) in a persistent profile, add `-e CLOAKBROWSER_FETCH_WIDEVINE=1` to auto-fetch the CDM on first launch (see [Widevine / DRM](#widevine--drm)); it caches in the mounted volume.
**Resource usage:** ~190MB RAM idle, ~280MB with 3 tabs. ~30MB per additional tab.
### Extend with your own image
@@ -1063,12 +1099,12 @@ FingerprintJS (`demo.fingerprint.com/playground`) checks multiple signals. Each
| Detection | Cause | Fix |
|-----------|-------|-----|
| **`nodriver` / bad bot** | IP reputation or missing flags | Residential proxy + config below |
| **`nodriver` / bad bot** | Persistent profile without a Widevine CDM, or poor proxy IP reputation | Residential proxy; for **persistent** contexts add a Widevine CDM (Docker: `-e CLOAKBROWSER_FETCH_WIDEVINE=1`, otherwise sideload — see [Widevine / DRM](#widevine--drm)). Regular `launch()` doesn't need it. |
| **Browser tampering** | Noise injection detected by ML | `--fingerprint-noise=false` |
| **Browser tampering** (fonts) | Font metrics don't match the spoofed Windows platform | `--fingerprint-windows-font-metrics` (Chromium 148+ binary; requires [Windows fonts installed](#font-setup-on-linux)) |
| **Virtual machine** | Screen dimensions don't match viewport | `--fingerprint-screen-width/height` matching viewport |
| **Incognito** | Storage quota normalized to ~500MB | Expected tradeoff — see below |
Config that passes FPJS (verified on v0.3.30, Linux + Windows):
Config that passes FPJS on the latest binary (Linux, residential proxy):
```python
browser = launch(
@@ -1077,8 +1113,7 @@ browser = launch(
geoip=True,
args=[
"--fingerprint-noise=false", # prevents tampering detection
"--fingerprint-screen-width=1920", # match your viewport
"--fingerprint-screen-height=1080",
"--fingerprint-windows-font-metrics", # align font metrics — 148+ binary, needs Windows fonts
],
)
```
@@ -1090,17 +1125,14 @@ const browser = await launch({
geoip: true,
args: [
'--fingerprint-noise=false',
'--fingerprint-screen-width=1920',
'--fingerprint-screen-height=1080',
'--fingerprint-windows-font-metrics', // align font metrics — 148+ binary, needs Windows fonts
],
});
```
For persistent contexts (`launch_persistent_context` / `launchPersistentContext`), also add `--fingerprint-storage-quota=500` to the args.
Requires a **Chromium 148+ binary** and **Windows fonts** installed (see [Font Setup on Linux](#font-setup-on-linux)); run with a **residential proxy** and `geoip=True`.
**Storage quota tradeoff:** The binary normalizes storage quota to ~500MB to pass FPJS, but this makes the session look like incognito to other detection services (e.g. BrowserScan's `notPrivate` check, -10 points). Setting `--fingerprint-storage-quota=5000` passes incognito checks but may trigger FPJS. With quota alone you can't satisfy both — choose based on what your target site checks. See the [storage quota tradeoff table](#launch_persistent_context) for details.
**Resolving the tradeoff (Linux):** Sideloading the Widevine CDM lets a persistent context pass FPJS at a higher quota, so you can satisfy both at once. See [Widevine / DRM](#widevine--drm).
**Persistent contexts** (`launch_persistent_context` / `launchPersistentContext`) need one extra piece beyond the `launch()` config above — a working **Widevine CDM** (Docker: `-e CLOAKBROWSER_FETCH_WIDEVINE=1`; otherwise sideload — see [Widevine / DRM](#widevine--drm)). Storage-quota tuning is unrelated to FingerprintJS here; it only affects detectors that infer incognito from quota, such as BrowserScan (see [storage quota](#launch_persistent_context)).
---
@@ -1151,6 +1183,7 @@ For stateless/ephemeral use cases, `launch(args=["--disable-http2"])` forces HTT
### Something not working? Make sure you're on the latest version
Older versions may use outdated stealth args or download an older binary:
```bash
pip install -U cloakbrowser # Python
npm install cloakbrowser@latest # JavaScript
@@ -1162,6 +1195,7 @@ docker pull cloakhq/cloakbrowser:latest # Docker
### Binary download fails / timeout
Set a custom download URL or use a local binary:
```bash
export CLOAKBROWSER_BINARY_PATH=/path/to/your/chrome
```
@@ -1171,11 +1205,13 @@ export CLOAKBROWSER_BINARY_PATH=/path/to/your/chrome
### New update broke something? Roll back to the previous version
Install a specific wrapper version to downgrade both the wrapper and the binary it downloads:
```bash
pip install cloakbrowser==0.3.21 # Python
npm install cloakbrowser@0.3.21 # JavaScript
docker pull cloakhq/cloakbrowser:0.3.21 # Docker
```
Each wrapper version pins its own binary version, so downgrading the wrapper automatically gets you the matching binary on next launch.
---
@@ -1183,6 +1219,7 @@ Each wrapper version pins its own binary version, so downgrading the wrapper aut
### macOS: "App is damaged" or Gatekeeper blocks launch
The binary is ad-hoc signed. macOS quarantines downloaded files. Run once to clear it:
```bash
xattr -cr ~/.cloakbrowser/chromium-*/Chromium.app
```
@@ -1192,6 +1229,7 @@ xattr -cr ~/.cloakbrowser/chromium-*/Chromium.app
### "playwright install" vs CloakBrowser binary
You do NOT need `playwright install chromium`. CloakBrowser downloads its own binary. You only need Playwright's system deps:
```bash
playwright install-deps chromium
```
@@ -1240,15 +1278,18 @@ await new Promise(r => setTimeout(r, 3000));
```
Other tips for maximizing reCAPTCHA scores:
- **Use Playwright, not Puppeteer** — Puppeteer sends more CDP protocol traffic that reCAPTCHA detects ([details](#puppeteer))
- **Use residential proxies** — datacenter IPs are flagged by IP reputation, not browser fingerprint
- **Spend 15+ seconds on the page** before triggering reCAPTCHA — short visits score lower
- **Space out requests** — back-to-back `grecaptcha.execute()` calls from the same session get penalized. Wait 30+ seconds between pages with reCAPTCHA
- **Use a fixed fingerprint seed** for consistent device identity across sessions (see [Fingerprint Management](#fingerprint-management))
- **Use `page.type()` instead of `page.fill()`** for form filling — `fill()` sets values directly without keyboard events, which reCAPTCHA's behavioral analysis flags. `type()` with a delay simulates real keystrokes:
```python
page.type("#email", "user@example.com", delay=50)
```
- **Minimize `page.evaluate()` calls** before the reCAPTCHA check fires — each one sends CDP traffic
## FAQ
@@ -1256,6 +1297,15 @@ Other tips for maximizing reCAPTCHA scores:
**Q: Is this legal?**
A: CloakBrowser is a browser built on open-source Chromium. We do not condone illegal use. Automating systems without authorization, credential stuffing, and account creation abuse are expressly prohibited. See [BINARY-LICENSE.md](https://github.com/CloakHQ/CloakBrowser/blob/main/BINARY-LICENSE.md) for full terms.
**Q: Is CloakBrowser free?**
A: The wrapper (Python + JS) is MIT and free forever. The binary uses a delayed free-release model: the previous Chromium major version (currently v146) is free on GitHub Releases with unlimited sessions; the latest major version is for [Pro subscribers](https://cloakbrowser.dev). Each new major release rolls the prior major version down to free.
**Q: Do I need a license key for the free version?**
A: No. The free binary downloads automatically with no key. A license key only unlocks the latest (Pro) binary.
**Q: What happens if I cancel Pro?**
A: Your subscription stays active until the end of the current billing period — cancelling doesn't cut you off immediately. After it ends, the wrapper stops pulling new Pro versions and falls back to the free binary on its next license check (cached ~24h). You just stop getting new versions.
**Q: How is this different from Camoufox?**
A: Camoufox patches Firefox. We patch Chromium. Chromium means native Playwright support, larger ecosystem, and TLS fingerprints that match real Chrome. Camoufox returned in early 2026 but is in unstable beta — CloakBrowser is production-ready.
@@ -1284,7 +1334,7 @@ A: Yes. Pass `proxy="http://user:pass@host:port"` or `proxy="socks5://user:pass@
- 📦 **PyPI** — [pypi.org/project/cloakbrowser](https://pypi.org/project/cloakbrowser/)
- 📦 **npm** — [npmjs.com/package/cloakbrowser](https://www.npmjs.com/package/cloakbrowser)
- ☕ **Support** — [ko-fi.com/cloakhq](https://ko-fi.com/cloakhq)
- 📧 **Contact** — cloakhq@pm.me
- 📧 **Contact** — <cloakhq@pm.me>
## Security
@@ -1308,7 +1358,10 @@ cosign verify \
## License
- **Wrapper code** (this repository) — MIT. See [LICENSE](https://github.com/CloakHQ/CloakBrowser/blob/main/LICENSE).
- **CloakBrowser binary** (compiled Chromium) — free to use, no redistribution. See [BINARY-LICENSE.md](https://github.com/CloakHQ/CloakBrowser/blob/main/BINARY-LICENSE.md).
- **CloakBrowser binary** (compiled Chromium):
- **v146 and earlier** — free for personal and commercial use, no redistribution (OEM/SaaS license required to serve third parties).
- **v148+ (latest)** — requires an active [CloakBrowser Pro](https://cloakbrowser.dev) subscription to download.
- See [BINARY-LICENSE.md](https://github.com/CloakHQ/CloakBrowser/blob/main/BINARY-LICENSE.md) for full terms.
## Contributing
+25
View File
@@ -10,4 +10,29 @@ rm -f /tmp/.X99-lock /tmp/.X11-unix/X99
# Start Xvfb for headed mode (Turnstile, CAPTCHAs), then run user command
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
sleep 1
# Opt-in: fetch the Widevine CDM so persistent contexts present as a real
# Chrome (a DRM/EME probe is used by some bot detectors). Off by default — only
# runs when CLOAKBROWSER_FETCH_WIDEVINE is set, and never if the user already
# pointed at a CDM or disabled seeding. The CDM is fetched per-container from
# Google's component server (the same source Chrome uses), cached in the
# ~/.cloakbrowser volume, and is best-effort: a failure must never block launch.
_fetch_widevine="${CLOAKBROWSER_FETCH_WIDEVINE:-}"
case "${_fetch_widevine,,}" in
1|true|yes|on)
# printf (not echo) so a value like `-n` isn't swallowed as a flag.
if [ -z "${CLOAKBROWSER_WIDEVINE_CDM:-}" ] && \
! printf '%s' "${CLOAKBROWSER_WIDEVINE:-}" | grep -qiE '^(0|false|off|no)$'; then
# Fetch to the default location (the version-independent cache root,
# ~/.cloakbrowser/WidevineCdm). The wrapper's auto-detection
# (cloakbrowser/widevine.py, js/src/widevine.ts) falls back to this path
# after the per-binary dir, so the CDM is discoverable by ANY process (CMD
# or `docker exec`) and ANY binary (free or Pro, any version) with no env
# var. Best-effort: a failure must never block launch.
python /usr/local/bin/fetch-widevine.py --quiet \
|| echo "[cloakbrowser] Widevine fetch failed; continuing without it" >&2
fi
;;
esac
exec "$@"
+301
View File
@@ -0,0 +1,301 @@
#!/usr/bin/env python3
"""Fetch the Widevine CDM from Google's component-update server (Linux).
The CloakBrowser binary is built with Widevine support, but the CDM itself is a
proprietary Google component we don't redistribute. This pulls it at runtime from
the same component server Chrome uses, then drops it where the wrapper's
``CLOAKBROWSER_WIDEVINE_CDM`` resolution (cloakbrowser/widevine.py) expects it:
<out>/manifest.json
<out>/_platform_specific/linux_<arch>/libwidevinecdm.so
No curl/jq/unzip needed. Linux x86-64 only (Google doesn't publish the CDM for
linux arm64). The Docker entrypoint runs this when CLOAKBROWSER_FETCH_WIDEVINE is
set; bare-metal Linux users can run it directly.
Integrity: the download is checked against the server-provided SHA-256 (over TLS).
When `cryptography` is importable (it is in any pip/Docker install of cloakbrowser),
the CRX3 publisher signature is additionally verified and bound to the expected
Widevine app id — same trust root Chrome's component updater uses. Standalone runs
without `cryptography` fall back to TLS + SHA-256.
"""
import argparse
import hashlib
import io
import json
import os
import platform
import shutil
import struct
import sys
import tempfile
import urllib.request
import zipfile
# Widevine CDM component id in Chromium's component updater.
APP_ID = "oimompecagnajdejgnnjijobebaeigek"
UPDATE_URL = "https://update.googleapis.com/service/update2/json"
# Deliberately-low installed version so the server always reports an update.
INSTALLED_VERSION = "1.4.9.1088"
XSSI_PREFIX = ")]}'"
def _arch():
"""Map the host machine to the Widevine platform suffix (x86-64 only).
Google's component server publishes the Linux Widevine CDM for x86-64 only —
arm64/aarch64 return no update (verified: the server either reports noupdate
or hands back the x86-64 binary), so reject them with a clear message rather
than letting the request reach the misleading "no update available" path.
"""
m = platform.machine().lower()
if m in ("x86_64", "amd64", "x64"):
return "x64"
if m in ("aarch64", "arm64", "arm"):
raise SystemExit("the Widevine CDM is not published for linux arm64 (x86-64 only)")
raise SystemExit(f"unsupported architecture for Widevine: {platform.machine()!r}")
def _read_varint(b, i):
shift = result = 0
while True:
if i >= len(b):
raise ValueError("truncated varint")
byte = b[i]; i += 1
result |= (byte & 0x7F) << shift
if not byte & 0x80:
return result, i
shift += 7
if shift > 63:
raise ValueError("varint too long")
def _parse_pb(b):
"""Minimal protobuf reader → {field_num: [length-delimited bytes, ...]}."""
out, i, n = {}, 0, len(b)
while i < n:
tag, i = _read_varint(b, i)
field, wire = tag >> 3, tag & 7
if wire == 2:
ln, i = _read_varint(b, i)
out.setdefault(field, []).append(b[i:i + ln]); i += ln
elif wire == 0:
_, i = _read_varint(b, i)
elif wire == 1:
i += 8
elif wire == 5:
i += 4
else:
raise ValueError(f"unsupported protobuf wire type {wire}")
return out
def _crx_appid(pubkey_der):
"""CRX app id = first 16 bytes of SHA-256(pubkey), each nibble mapped ap."""
digest = hashlib.sha256(pubkey_der).digest()[:16]
return "".join(chr(0x61 + (byte >> 4)) + chr(0x61 + (byte & 0xF)) for byte in digest), digest
def _verify_crx3(crx_bytes):
"""Verify the CRX3 RSA publisher signature and bind it to APP_ID.
Returns True if verified, False if `cryptography` is unavailable (caller then
relies on TLS + the server SHA-256). Raises SystemExit on a real failure.
We verify the RSASSA-PKCS1-v1_5 / SHA-256 proof (CRX3 field 2), which is what
Google signs the Widevine component with; ECDSA proofs (field 3) are not
relied on. The app id is derived from the signing key — the same trust root
Chrome verifies — so a non-Widevine publisher key can't satisfy the check.
"""
try:
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.exceptions import InvalidSignature
except ImportError:
return False
if len(crx_bytes) < 12:
raise SystemExit("not a CRX3 file (too short)")
if crx_bytes[:4] != b"Cr24":
raise SystemExit("not a CRX3 file (bad magic)")
version = struct.unpack("<I", crx_bytes[4:8])[0]
if version != 3:
raise SystemExit(f"unexpected CRX version {version}")
header_len = struct.unpack("<I", crx_bytes[8:12])[0]
header = crx_bytes[12:12 + header_len]
archive = crx_bytes[12 + header_len:]
fields = _parse_pb(header)
signed_header = fields.get(10000, [b""])[0]
# Signed payload: "CRX3 SignedData\x00" + uint32LE(len) + signed_header + archive
payload = b"CRX3 SignedData\x00" + struct.pack("<I", len(signed_header)) + signed_header + archive
declared_id = _parse_pb(signed_header).get(1, [b""])[0] # SignedData.crx_id
for proof in fields.get(2, []): # sha256_with_rsa proofs
p = _parse_pb(proof)
pub_der, sig = p.get(1, [None])[0], p.get(2, [None])[0]
if not pub_der or not sig:
continue
appid, digest16 = _crx_appid(pub_der)
if appid != APP_ID:
continue # not the Widevine publisher key — ignore
if declared_id and declared_id != digest16:
raise SystemExit("CRX signed-header crx_id does not match the signing key")
try:
serialization.load_der_public_key(pub_der).verify(
sig, payload, padding.PKCS1v15(), hashes.SHA256())
except InvalidSignature:
raise SystemExit("CRX3 publisher signature is INVALID")
return True
raise SystemExit("no CRX3 RSA proof from the expected Widevine publisher key")
def _post_json(url, payload):
data = json.dumps(payload).encode()
req = urllib.request.Request(
url, data=data,
headers={"User-Agent": "Mozilla/5.0", "Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read().decode("utf-8", "replace")
if body.startswith(XSSI_PREFIX):
body = body[len(XSSI_PREFIX):]
return json.loads(body)
def _resolve_crx(arch):
"""Query the component server; return (version, crx_url, sha256_hex)."""
payload = {"request": {
"@os": "", "@updater": "",
"acceptformat": "crx3,download,puff,run,xz,zucc",
"apps": [{"appid": APP_ID, "installsource": "ondemand",
"updatecheck": {}, "version": INSTALLED_VERSION}],
"dedup": "cr", "ismachine": False, "arch": arch,
"os": {"arch": arch, "platform": "linux"},
"protocol": "4.0", "updaterversion": "142.0.7444.175",
}}
resp = _post_json(UPDATE_URL, payload)
uc = resp["response"]["apps"][0]["updatecheck"]
status = uc.get("status")
if status and status != "ok":
raise SystemExit(f"component server returned status={status!r} (no update available)")
version = uc.get("nextversion", "?")
# Find the first operation that carries download URLs + its sha256.
for pipeline in uc.get("pipelines", []):
for op in pipeline.get("operations", []):
urls = [u["url"] for u in op.get("urls", []) if u.get("url", "").startswith("https")]
if urls:
sha = (op.get("out") or {}).get("sha256")
return version, urls[0], sha
raise SystemExit("no CRX download URL in component server response")
def _download(url, sha256_hex):
with urllib.request.urlopen(url, timeout=120) as resp:
blob = resp.read()
# Integrity: server-provided SHA-256 over TLS (always). The CRX3 publisher
# signature is additionally verified in main() when `cryptography` is present.
if sha256_hex:
got = hashlib.sha256(blob).hexdigest()
if got.lower() != sha256_hex.lower():
raise SystemExit(f"sha256 mismatch: expected {sha256_hex}, got {got}")
return blob
def _extract(crx_bytes, arch, out_dir):
"""Extract manifest.json + the .so into out_dir, replacing any prior copy.
Staged in a temp dir then renamed into place — the rename is atomic, but the
rmtree of an existing out_dir that precedes it is not, so this is not safe
against another process writing the same out_dir concurrently.
"""
so_member = f"_platform_specific/linux_{arch}/libwidevinecdm.so"
# zipfile locates the central directory from the end, so a CRX3 (header+zip)
# opens directly without stripping the prefix.
with zipfile.ZipFile(io.BytesIO(crx_bytes)) as zf:
names = set(zf.namelist())
if "manifest.json" not in names or so_member not in names:
raise SystemExit(f"CRX missing expected members (manifest.json / {so_member})")
parent = os.path.dirname(os.path.abspath(out_dir)) or "."
os.makedirs(parent, exist_ok=True)
tmp = tempfile.mkdtemp(prefix=".widevine.tmp.", dir=parent)
try:
zf.extract("manifest.json", tmp)
zf.extract(so_member, tmp)
os.chmod(os.path.join(tmp, so_member), 0o644)
# Swap into place. The rename is atomic; the preceding rmtree is not.
if os.path.exists(out_dir):
shutil.rmtree(out_dir)
os.rename(tmp, out_dir)
except BaseException:
shutil.rmtree(tmp, ignore_errors=True)
raise
def _default_out():
cache = os.environ.get("CLOAKBROWSER_CACHE_DIR") or os.path.join(os.path.expanduser("~"), ".cloakbrowser")
return os.path.join(cache, "WidevineCdm")
def main(argv=None):
ap = argparse.ArgumentParser(description="Fetch the Widevine CDM for CloakBrowser (Linux).")
ap.add_argument("--out", default=_default_out(),
help="WidevineCdm output directory (default: $CLOAKBROWSER_CACHE_DIR/WidevineCdm)")
ap.add_argument("--force", action="store_true", help="re-download even if already present")
ap.add_argument("--quiet", action="store_true", help="only print the final path / errors")
args = ap.parse_args(argv)
def log(msg):
if not args.quiet:
print(f"[fetch-widevine] {msg}", file=sys.stderr)
# Linux only: the hint-file mechanism is Linux/ChromeOS-specific and the .so
# we fetch is a Linux binary. Fail loudly rather than drop a useless .so.
if platform.system() != "Linux":
raise SystemExit(f"Widevine fetch is Linux-only (this host is {platform.system()})")
out = os.path.abspath(args.out)
if os.path.isfile(os.path.join(out, "manifest.json")) and not args.force:
log("already present (cache hit)")
print(out)
return 0
arch = _arch()
log(f"querying component server (linux {arch})…")
version, url, sha = _resolve_crx(arch)
log(f"Widevine CDM {version} → downloading…")
blob = _download(url, sha) # raises on a SHA-256 mismatch when `sha` is present
# Integrity policy: require at least one positive check before installing a
# native .so the browser will load. The CRX3 publisher signature (when
# `cryptography` is available — it is in any pip/Docker install) is the primary
# guarantee; the server-provided SHA-256 over TLS is the fallback. The server
# can legitimately omit `out.sha256`, so don't treat its presence as given —
# if neither check is available, refuse rather than trust TLS alone.
sig_ok = _verify_crx3(blob)
if sig_ok and sha:
log(f"verified {len(blob)} bytes (SHA-256 + CRX3 publisher signature)")
elif sig_ok:
log(f"verified {len(blob)} bytes (CRX3 publisher signature; server sent no SHA-256)")
elif sha:
log(f"verified {len(blob)} bytes (SHA-256 over TLS; cryptography absent, CRX3 sig skipped)")
else:
raise SystemExit(
"refusing to install: server provided no SHA-256 and `cryptography` is "
"unavailable for CRX3 signature verification — cannot confirm CDM integrity"
)
log(f"extracting → {out}")
_extract(blob, arch, out)
log("done")
print(out)
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except SystemExit:
raise
except Exception as e: # noqa: BLE001 — top-level guard; entrypoint treats nonzero as soft-fail
print(f"[fetch-widevine] error: {e}", file=sys.stderr)
sys.exit(1)
+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__",
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.3.32"
__version__ = "0.4.2"
+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():
+300 -11
View File
@@ -45,15 +45,35 @@ 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)
# Auto-update check interval (1 hour)
UPDATE_CHECK_INTERVAL = 3600
# Pro Chromium major shown in the free-tier welcome banner. Bump at each Pro
# major release (there is no local constant to derive it from — the live Pro
# version comes from the network, which we don't call just to print a banner).
PRO_MAJOR = "148"
def _show_welcome() -> None:
"""Show welcome message on first launch. Uses a marker file to show only once."""
def _show_welcome(pro: bool = False) -> None:
"""Show welcome message on first launch. Uses a marker file to show only once.
The Pro-upsell line is shown to free-tier users only; Pro users get a plain
banner (no "running free tier" message, which would be false for them).
"""
marker = get_cache_dir() / ".welcome_shown"
if marker.exists():
return
@@ -61,7 +81,18 @@ def _show_welcome() -> None:
sys.stderr.write(" CloakBrowser — stealth Chromium for automation\n")
sys.stderr.write(" https://github.com/CloakHQ/CloakBrowser\n")
sys.stderr.write("\n")
sys.stderr.write(" Donate? https://ko-fi.com/cloakhq\n")
if pro:
sys.stderr.write(
f" CloakBrowser Pro active (v{PRO_MAJOR}) — latest binary, newest patches.\n"
)
sys.stderr.write(" Pro support → support@cloakbrowser.dev\n")
else:
free_major = CHROMIUM_VERSION.split(".")[0]
sys.stderr.write(
f" Running free tier (v{free_major}). "
f"Pro = latest binary (v{PRO_MAJOR}) + newest anti-bot patches.\n"
)
sys.stderr.write(" Stay ahead of detection → https://cloakbrowser.dev\n")
sys.stderr.write(" Star us if CloakBrowser helps your project!\n")
sys.stderr.write("\n")
try:
@@ -71,11 +102,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 +123,56 @@ 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:
# macOS has no Pro binary yet. Rather than hard-failing a paying
# customer, fall back to the free binary with a clear notice.
# Scoped to the 404 (binary-not-found) case so that (a) transient
# and verification failures still hard-fail — no silent downgrade —
# and (b) the moment the macOS Pro build ships, the 404 disappears
# and Pro is served automatically with no wrapper change.
if (
get_platform_tag().startswith("darwin")
and isinstance(e, httpx.HTTPStatusError)
and e.response.status_code == 404
):
logger.warning(
"macOS Pro binary is not available yet — using the free "
"binary for now. Your license stays valid and you'll get "
"the Pro binary on macOS automatically once the build ships."
)
else:
# 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 +260,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(pro=True)
_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(pro=True)
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(pro=True)
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 +620,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 +778,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 +970,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()
+7
View File
@@ -64,6 +64,13 @@ def human_scroll_into_view(
"""
viewport = page.viewport_size
if not viewport:
# Headed launches default to no_viewport so the page tracks the real OS
# window; page.viewport_size is then None. Fall back to the live window
# dimensions so humanize works headed (the stealth-relevant mode).
viewport = page.evaluate(
"() => ({ width: window.innerWidth, height: window.innerHeight })"
)
if not viewport or not viewport.get("height"):
raise RuntimeError("Viewport size not available")
viewport_height = viewport["height"]
+7
View File
@@ -60,6 +60,13 @@ async def async_human_scroll_into_view(
"""
viewport = page.viewport_size
if not viewport:
# Headed launches default to no_viewport so the page tracks the real OS
# window; page.viewport_size is then None. Fall back to the live window
# dimensions so humanize works headed (the stealth-relevant mode).
viewport = await page.evaluate(
"() => ({ width: window.innerWidth, height: window.innerHeight })"
)
if not viewport or not viewport.get("height"):
raise RuntimeError("Viewport size not available")
viewport_height = viewport["height"]
+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 -10
View File
@@ -43,22 +43,38 @@ def _seeding_disabled() -> bool:
def resolve_widevine_cdm_dir(binary_path: str | os.PathLike) -> Path | None:
"""Locate a sideloaded Widevine CDM directory, or None if absent.
Resolution:
- If CLOAKBROWSER_WIDEVINE_CDM is set, it is used **exclusively** (overrides
auto-detection). An invalid value (no ``manifest.json``) skips seeding.
- Otherwise, ``<dir of the chrome binary>/WidevineCdm`` where a user
naturally drops it, and where it ends up for both downloaded and
CLOAKBROWSER_BINARY_PATH (local build / Docker mount) binaries.
Resolution order:
1. If CLOAKBROWSER_WIDEVINE_CDM is set, it is used **exclusively** (overrides
auto-detection). An invalid value (no ``manifest.json``) skips seeding.
2. ``<dir of the chrome binary>/WidevineCdm`` where a user naturally drops
a manual sideload, per Chromium binary version.
3. ``<cache dir>/WidevineCdm`` (``~/.cloakbrowser/WidevineCdm``) the
version-independent location the Docker auto-fetch and ``fetch-widevine.py``
write to. This fallback lets one fetched CDM serve any binary (free or
Pro, any version) with no env var the CDM ``.so`` is arch-specific but
not version-specific.
A directory counts only if it contains ``manifest.json`` (so we don't seed a
hint pointing at a bogus path). The returned path is absolute and
symlink-resolved (``Path.resolve()``).
"""
custom = os.environ.get("CLOAKBROWSER_WIDEVINE_CDM")
# `is not None` (not truthiness): a present-but-empty env var is "set" and
# used exclusively — it resolves to an invalid path and skips seeding.
cdm_dir = Path(custom) if custom is not None else Path(os.fspath(binary_path)).parent / "WidevineCdm"
return cdm_dir.resolve() if (cdm_dir / "manifest.json").is_file() else None
if custom is not None:
# Set exclusively (overrides auto-detection). An empty/whitespace value is
# invalid — return None rather than let Path("") resolve to "." and match a
# stray manifest.json in the working directory.
if not custom.strip():
return None
cdm_dir = Path(custom)
return cdm_dir.resolve() if (cdm_dir / "manifest.json").is_file() else None
from .config import get_cache_dir # local import avoids any import-cycle risk
for cdm_dir in (Path(os.fspath(binary_path)).parent / "WidevineCdm",
get_cache_dir() / "WidevineCdm"):
if (cdm_dir / "manifest.json").is_file():
return cdm_dir.resolve()
return None
def seed_widevine_hint(user_data_dir: str | os.PathLike, binary_path: str | os.PathLike) -> None:
+26 -7
View File
@@ -231,11 +231,30 @@ const page = await browser.newPage();
| Platform | Chromium | Patches | Status |
|---|---|---|---|
| Linux x86_64 | 145 | 48 | ✅ Latest |
| Linux arm64 (RPi, Graviton) | 145 | 48 | ✅ Latest |
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest |
| macOS x86_64 (Intel) | 145 | 26 | ✅ Latest |
| Windows x86_64 | 145 | 48 | ✅ Latest |
| Linux x86_64 | 146 | 58 | ✅ Latest |
| Linux arm64 (RPi, Graviton) | 146 | 58 | ✅ |
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ |
| macOS x86_64 (Intel) | 145 | 26 | ✅ |
| Windows x86_64 | 146 | 58 | ✅ Latest |
## CloakBrowser Pro
The wrapper (Python + JS) is MIT, free forever. The binary uses a delayed
free-release model:
- **Free (v146)** — free forever on [GitHub Releases](https://github.com/CloakHQ/cloakbrowser/releases). Unlimited sessions. Works today, goes stale as detection evolves.
- **Pro (latest, v148)** — the newest patches and Chromium upgrades first, so the [test results](#test-results) stay green as anti-bot systems change. Linux + Windows (macOS coming).
Anti-bot detection updates constantly — an older binary degrades within weeks.
Pro keeps you on the build that's actively maintained against it.
Activate with your license key (env var, `licenseKey` option, or `~/.cloakbrowser/license.key`):
```bash
export CLOAKBROWSER_LICENSE_KEY=cb_xxxxxxxx
```
Pro plans → **[cloakbrowser.dev](https://cloakbrowser.dev)**
## Requirements
@@ -294,13 +313,13 @@ Other tips for maximizing reCAPTCHA scores:
When auto-update downloads a newer binary, the previous version stays in `~/.cloakbrowser/`. Point `CLOAKBROWSER_BINARY_PATH` to the older cached binary:
```bash
# Linux
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.159.2/chrome
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-146.0.7680.177.4/chrome
# macOS
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.109.2/Chromium.app/Contents/MacOS/Chromium
# Windows
set CLOAKBROWSER_BINARY_PATH=%USERPROFILE%\.cloakbrowser\chromium-145.0.7632.159.7\chrome.exe
set CLOAKBROWSER_BINARY_PATH=%USERPROFILE%\.cloakbrowser\chromium-146.0.7680.177.4\chrome.exe
```
## Links
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "cloakbrowser",
"version": "0.3.32",
"version": "0.4.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cloakbrowser",
"version": "0.3.32",
"version": "0.4.2",
"license": "MIT",
"dependencies": {
"tar": "^7.0.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cloakbrowser",
"version": "0.3.32",
"version": "0.4.2",
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
"type": "module",
"main": "dist/index.js",
+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 {
+316 -11
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,9 +34,42 @@ 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
// Pro Chromium major shown in the welcome banner. Bump at each Pro major release
// (no local constant to derive it from — the live Pro version comes from the
// network, which we don't call just to print a banner). Mirrors download.py.
const PRO_MAJOR = "148";
/**
* 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";
}
}
/**
* A non-2xx HTTP response during a binary download. Carries the status code so
* callers can distinguish a 404 (binary not built for this platform) from
* transient failures.
*/
export class DownloadHttpError extends Error {
status: number;
constructor(status: number, statusText: string) {
super(`Download failed: HTTP ${status} ${statusText}`);
this.name = "DownloadHttpError";
this.status = status;
}
}
// ---------------------------------------------------------------------------
// Public API
@@ -45,7 +79,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 +92,56 @@ 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;
// macOS has no Pro binary yet. Rather than hard-failing a paying
// customer, fall back to the free binary with a clear notice. Scoped to
// the 404 (binary-not-found) case so that (a) transient and verification
// failures still hard-fail — no silent downgrade — and (b) the moment the
// macOS Pro build ships, the 404 disappears and Pro is served
// automatically with no wrapper change.
if (
getPlatformTag().startsWith("darwin") &&
e instanceof DownloadHttpError &&
e.status === 404
) {
console.warn(
"[cloakbrowser] macOS Pro binary is not available yet — using the " +
"free binary for now. Your license stays valid and you'll get the " +
"Pro binary on macOS automatically once the build ships."
);
// fall through to the free-tier download below
} else {
// 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 +193,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),
};
}
@@ -144,15 +242,26 @@ export async function checkForUpdate(): Promise<string | null> {
// Welcome message (shown once per install)
// ---------------------------------------------------------------------------
function showWelcome(): void {
function showWelcome(pro = false): void {
const marker = path.join(getCacheDir(), ".welcome_shown");
if (fs.existsSync(marker)) return;
console.error();
console.error(" CloakBrowser — stealth Chromium for automation");
console.error(" https://github.com/CloakHQ/CloakBrowser");
console.error();
console.error(" Issues? https://github.com/CloakHQ/CloakBrowser/issues");
console.error(" Donate? https://ko-fi.com/cloakhq");
if (pro) {
console.error(
` CloakBrowser Pro active (v${PRO_MAJOR}) — latest binary, newest patches.`,
);
console.error(" Pro support → support@cloakbrowser.dev");
} else {
const freeMajor = CHROMIUM_VERSION.split(".")[0];
console.error(
` Running free tier (v${freeMajor}). ` +
`Pro = latest binary (v${PRO_MAJOR}) + newest anti-bot patches.`,
);
console.error(" Stay ahead of detection → https://cloakbrowser.dev");
}
console.error(" Star us if CloakBrowser helps your project!");
console.error();
try {
@@ -443,7 +552,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,10 +565,11 @@ async function downloadFile(url: string, dest: string): Promise<void> {
const response = await fetch(url, {
signal: controller.signal,
redirect: "follow",
...(headers ? { headers } : {}),
});
if (!response.ok) {
throw new Error(`Download failed: HTTP ${response.status} ${response.statusText}`);
throw new DownloadHttpError(response.status, response.statusText);
}
if (!response.body) {
@@ -519,6 +629,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(true);
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(true);
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(true);
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 +1043,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
}
})();
}
+10 -2
View File
@@ -93,8 +93,16 @@ export async function humanScrollIntoView(
cursorY: number,
cfg: HumanConfig,
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
const viewport = page.viewport();
if (!viewport) throw new Error('Viewport size not available');
// Headed launches default to null defaultViewport so the page tracks the real
// OS window; page.viewport() is then null. Fall back to the live window
// dimensions so humanize works headed (the stealth-relevant mode).
let viewport = page.viewport();
if (!viewport) {
viewport = await page.evaluate(
() => ({ width: window.innerWidth, height: window.innerHeight }),
);
}
if (!viewport || !viewport.height) throw new Error('Viewport size not available');
let box = await getBox();
if (!box) throw new Error('Element not found while scrolling into view');
+10 -2
View File
@@ -53,8 +53,16 @@ export async function humanScrollIntoView(
cursorY: number,
cfg: HumanConfig,
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number; didScroll: boolean }> {
const viewport = page.viewportSize();
if (!viewport) throw new Error('Viewport size not available');
// Headed launches default to no_viewport so the page tracks the real OS
// window; page.viewportSize() is then null. Fall back to the live window
// dimensions so humanize works headed (the stealth-relevant mode).
let viewport = page.viewportSize();
if (!viewport) {
viewport = await page.evaluate(
() => ({ width: window.innerWidth, height: window.innerHeight }),
);
}
if (!viewport || !viewport.height) throw new Error('Viewport size not available');
let box = await getBox();
if (!box) throw new Error('Element not found while scrolling into view');
+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;
+24 -9
View File
@@ -17,6 +17,8 @@
import fs from "node:fs";
import path from "node:path";
import { getCacheDir } from "./config.js";
const HINT_FILENAME = "latest-component-updated-widevine-cdm";
/** True if `file` exists and is a regular file (mirrors Python's Path.is_file()). */
@@ -45,11 +47,14 @@ function seedingDisabled(): boolean {
/**
* Locate a sideloaded Widevine CDM directory, or null if absent.
*
* Resolution:
* - If CLOAKBROWSER_WIDEVINE_CDM is set, it is used exclusively (overrides
* auto-detection). An invalid value (no `manifest.json`) skips seeding.
* - Otherwise, `<dir of the chrome binary>/WidevineCdm` where a user naturally
* drops it, and where it lives for both downloaded and CLOAKBROWSER_BINARY_PATH binaries.
* Resolution order:
* 1. If CLOAKBROWSER_WIDEVINE_CDM is set, it is used exclusively (overrides
* auto-detection). An invalid value (no `manifest.json`) skips seeding.
* 2. `<dir of the chrome binary>/WidevineCdm` a manual sideload, per version.
* 3. `<cache dir>/WidevineCdm` (`~/.cloakbrowser/WidevineCdm`) the
* version-independent location the Docker auto-fetch and fetch-widevine.py
* write to. This fallback lets one fetched CDM serve any binary (free or
* Pro, any version) with no env var the CDM `.so` is arch- not version-specific.
*
* A directory counts only if it contains `manifest.json`. The returned path is
* absolute and symlink-resolved (mirrors Python's Path.resolve()).
@@ -57,10 +62,20 @@ function seedingDisabled(): boolean {
*/
export function resolveWidevineCdmDir(binaryPath: string): string | null {
const custom = process.env.CLOAKBROWSER_WIDEVINE_CDM;
// `!== undefined` (not truthiness): a present-but-empty env var is "set" and
// used exclusively — it resolves to an invalid path and skips seeding.
const cdmDir = custom !== undefined ? custom : path.join(path.dirname(binaryPath), "WidevineCdm");
return isFile(path.join(cdmDir, "manifest.json")) ? realPath(cdmDir) : null;
if (custom !== undefined) {
// Set exclusively (overrides auto-detection). An empty/whitespace value is
// invalid — return null rather than let path.join("", ...) match a stray
// manifest.json in the working directory.
if (custom.trim() === "") return null;
return isFile(path.join(custom, "manifest.json")) ? realPath(custom) : null;
}
for (const cdmDir of [
path.join(path.dirname(binaryPath), "WidevineCdm"),
path.join(getCacheDir(), "WidevineCdm"),
]) {
if (isFile(path.join(cdmDir, "manifest.json"))) return realPath(cdmDir);
}
return null;
}
/**
+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(
+26 -1
View File
@@ -35,6 +35,8 @@ beforeEach(() => {
setPlatform("linux"); // seeding is Linux-only; default to Linux in tests
delete process.env.CLOAKBROWSER_WIDEVINE;
delete process.env.CLOAKBROWSER_WIDEVINE_CDM;
// Isolate the cache-root fallback from any real ~/.cloakbrowser on the host.
process.env.CLOAKBROWSER_CACHE_DIR = tmpDir("cloak-cache-");
});
afterEach(() => {
@@ -42,6 +44,7 @@ afterEach(() => {
Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
delete process.env.CLOAKBROWSER_WIDEVINE;
delete process.env.CLOAKBROWSER_WIDEVINE_CDM;
delete process.env.CLOAKBROWSER_CACHE_DIR;
for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
});
@@ -66,6 +69,25 @@ describe("resolveWidevineCdmDir", () => {
expect(resolveWidevineCdmDir(binary)).toBe(fs.realpathSync(cdm));
});
it("falls back to <cache dir>/WidevineCdm when none next to the binary (Pro case)", () => {
const cache = tmpDir("cloak-cacheroot-");
process.env.CLOAKBROWSER_CACHE_DIR = cache;
const cdm = makeCdm(path.join(cache, "WidevineCdm"));
// Pro binary in its own dir with no adjacent CDM.
const proBin = path.join(tmpDir("cloak-pro-"), "chromium-148.0-pro");
fs.mkdirSync(proBin, { recursive: true });
expect(resolveWidevineCdmDir(path.join(proBin, "chrome"))).toBe(fs.realpathSync(cdm));
});
it("binary-dir CDM wins over the cache-root fallback", () => {
const cache = tmpDir("cloak-cacheroot-");
process.env.CLOAKBROWSER_CACHE_DIR = cache;
makeCdm(path.join(cache, "WidevineCdm")); // cache-root CDM present...
const binary = fakeBinary();
const nextTo = makeCdm(path.join(path.dirname(binary), "WidevineCdm")); // ...sideload wins
expect(resolveWidevineCdmDir(binary)).toBe(fs.realpathSync(nextTo));
});
it("env var is exclusive — invalid env skips, no fallback to binary dir", () => {
const binary = fakeBinary();
makeCdm(path.join(path.dirname(binary), "WidevineCdm")); // valid CDM next to binary
@@ -75,7 +97,10 @@ describe("resolveWidevineCdmDir", () => {
expect(resolveWidevineCdmDir(binary)).toBeNull();
});
it("empty env var is exclusive — no fallback to binary dir", () => {
it("empty env var resolves to null (exclusive, never scans the working dir)", () => {
// The empty check returns null before any path.join/isFile, so a stray
// ./manifest.json can't be matched. (The CWD-ignore case is proven in the
// Python suite; vitest workers don't allow process.chdir to simulate it here.)
const binary = fakeBinary();
makeCdm(path.join(path.dirname(binary), "WidevineCdm")); // valid CDM next to binary
process.env.CLOAKBROWSER_WIDEVINE_CDM = ""; // set but empty
+309
View File
@@ -0,0 +1,309 @@
"""Unit tests for bin/fetch-widevine.py — CRX3/protobuf parsing, app-id pinning,
signature verification, the integrity-install policy, and zip extraction.
All offline: the network (`_resolve_crx`/`_download`) is mocked, and a minimal
CRX3 is synthesized in-process with a throwaway RSA key (with ``APP_ID``
monkeypatched to that key's derived id) so the real verify path is exercised
without Google's signing key.
"""
import hashlib
import importlib.util
import io
import os
import struct
import zipfile
from pathlib import Path
import pytest
# bin/fetch-widevine.py isn't importable by name (hyphen + bin/ not a package).
_FW_PATH = Path(__file__).resolve().parent.parent / "bin" / "fetch-widevine.py"
_spec = importlib.util.spec_from_file_location("fetch_widevine", _FW_PATH)
fw = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(fw)
crypto = pytest.importorskip("cryptography")
from cryptography.hazmat.primitives import hashes, serialization # noqa: E402
from cryptography.hazmat.primitives.asymmetric import padding, rsa # noqa: E402
# ---------------------------------------------------------------------------
# protobuf primitives
# ---------------------------------------------------------------------------
def _encode_varint(n):
out = bytearray()
while True:
b = n & 0x7F
n >>= 7
out.append(b | (0x80 if n else 0))
if not n:
return bytes(out)
def _encode_ld(field, data):
"""Encode one length-delimited (wire type 2) protobuf field."""
return _encode_varint((field << 3) | 2) + _encode_varint(len(data)) + data
class TestReadVarint:
def test_single_byte(self):
assert fw._read_varint(b"\x00", 0) == (0, 1)
assert fw._read_varint(b"\x7f", 0) == (127, 1)
def test_multi_byte(self):
# 300 = 0b100101100 -> 0xAC 0x02
assert fw._read_varint(b"\xac\x02", 0) == (300, 2)
def test_resumes_at_offset(self):
buf = b"\xff" + _encode_varint(16384)
val, i = fw._read_varint(buf, 1)
assert val == 16384 and i == len(buf)
def test_truncated_raises(self):
with pytest.raises(ValueError, match="truncated"):
fw._read_varint(b"\x80\x80", 0) # continuation bit set, runs off end
def test_too_long_raises(self):
with pytest.raises(ValueError, match="too long"):
fw._read_varint(b"\x80" * 12 + b"\x01", 0)
class TestParsePb:
def test_length_delimited_collected_repeated(self):
blob = _encode_ld(2, b"aa") + _encode_ld(2, b"bb") + _encode_ld(1, b"c")
out = fw._parse_pb(blob)
assert out[2] == [b"aa", b"bb"]
assert out[1] == [b"c"]
def test_skips_varint_and_fixed_fields(self):
# field 3 varint, field 4 fixed64, field 5 fixed32, then field 1 LD
blob = (
_encode_varint((3 << 3) | 0) + _encode_varint(99)
+ _encode_varint((4 << 3) | 1) + b"\x00" * 8
+ _encode_varint((5 << 3) | 5) + b"\x00" * 4
+ _encode_ld(1, b"x")
)
out = fw._parse_pb(blob)
assert out[1] == [b"x"]
assert 3 not in out # varint values aren't retained
class TestArch:
@pytest.mark.parametrize("machine", ["x86_64", "amd64", "AMD64", "x64"])
def test_x86_64_supported(self, machine, monkeypatch):
monkeypatch.setattr(fw.platform, "machine", lambda: machine)
assert fw._arch() == "x64"
@pytest.mark.parametrize("machine", ["aarch64", "arm64", "arm"])
def test_arm_rejected_clearly(self, machine, monkeypatch):
# Google publishes the Linux CDM for x86-64 only; arm must fail loudly.
monkeypatch.setattr(fw.platform, "machine", lambda: machine)
with pytest.raises(SystemExit, match="not published for linux arm64"):
fw._arch()
def test_unsupported_raises(self, monkeypatch):
monkeypatch.setattr(fw.platform, "machine", lambda: "mips")
with pytest.raises(SystemExit, match="unsupported architecture"):
fw._arch()
class TestAppId:
def test_constant_is_the_real_widevine_id(self):
# Trust anchor: a typo here would silently accept the wrong publisher.
# This exact id is what the live component server signs against (verified
# end-to-end against update.googleapis.com).
assert fw.APP_ID == "oimompecagnajdejgnnjijobebaeigek"
class TestCrxAppId:
def test_matches_independent_computation(self):
pub = b"some-der-bytes"
digest = hashlib.sha256(pub).digest()[:16]
expected = "".join(
chr(0x61 + (b >> 4)) + chr(0x61 + (b & 0xF)) for b in digest
)
appid, digest16 = fw._crx_appid(pub)
assert appid == expected
assert digest16 == digest
assert len(appid) == 32 # 16 bytes -> 32 chars, alphabet a..p
assert all("a" <= c <= "p" for c in appid)
# ---------------------------------------------------------------------------
# CRX3 signature verification
# ---------------------------------------------------------------------------
def _build_crx3(privkey, archive=b"PK\x03\x04zip", *, crx_id=None, tamper=False):
"""Synthesize a minimal, validly-signed CRX3 for the given private key."""
pub_der = privkey.public_key().public_bytes(
serialization.Encoding.DER,
serialization.PublicFormat.SubjectPublicKeyInfo,
)
if crx_id is None:
crx_id = hashlib.sha256(pub_der).digest()[:16]
signed_header = _encode_ld(1, crx_id) # SignedData.crx_id
payload = (
b"CRX3 SignedData\x00"
+ struct.pack("<I", len(signed_header))
+ signed_header
+ archive
)
sig = privkey.sign(payload, padding.PKCS1v15(), hashes.SHA256())
if tamper:
archive = archive + b"X" # invalidate the signature
proof = _encode_ld(1, pub_der) + _encode_ld(2, sig)
header = _encode_ld(2, proof) + _encode_ld(10000, signed_header)
return b"Cr24" + struct.pack("<I", 3) + struct.pack("<I", len(header)) + header + archive
@pytest.fixture(scope="module")
def rsa_key():
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
class TestVerifyCrx3:
def _pin(self, monkeypatch, privkey):
pub_der = privkey.public_key().public_bytes(
serialization.Encoding.DER,
serialization.PublicFormat.SubjectPublicKeyInfo,
)
monkeypatch.setattr(fw, "APP_ID", fw._crx_appid(pub_der)[0])
def test_valid_signature_accepts(self, rsa_key, monkeypatch):
self._pin(monkeypatch, rsa_key)
assert fw._verify_crx3(_build_crx3(rsa_key)) is True
def test_tampered_archive_rejected(self, rsa_key, monkeypatch):
self._pin(monkeypatch, rsa_key)
with pytest.raises(SystemExit, match="INVALID"):
fw._verify_crx3(_build_crx3(rsa_key, tamper=True))
def test_wrong_publisher_key_rejected(self, rsa_key, monkeypatch):
# APP_ID pinned to a DIFFERENT key than the one that signed.
other = rsa.generate_private_key(public_exponent=65537, key_size=2048)
self._pin(monkeypatch, other)
with pytest.raises(SystemExit, match="expected Widevine publisher key"):
fw._verify_crx3(_build_crx3(rsa_key))
def test_crx_id_mismatch_rejected(self, rsa_key, monkeypatch):
self._pin(monkeypatch, rsa_key)
with pytest.raises(SystemExit, match="crx_id"):
fw._verify_crx3(_build_crx3(rsa_key, crx_id=b"\x00" * 16))
def test_bad_magic_rejected(self, rsa_key, monkeypatch):
self._pin(monkeypatch, rsa_key)
with pytest.raises(SystemExit, match="bad magic"):
fw._verify_crx3(b"NOPE" + _build_crx3(rsa_key)[4:])
def test_too_short_rejected(self):
# < 12 bytes: clean SystemExit, not a raw struct.error.
with pytest.raises(SystemExit, match="too short"):
fw._verify_crx3(b"Cr24")
def test_returns_false_without_cryptography(self, monkeypatch):
# Force the inner `from cryptography...` import to fail.
import builtins
real_import = builtins.__import__
def fake_import(name, *a, **k):
if name.startswith("cryptography"):
raise ImportError("blocked for test")
return real_import(name, *a, **k)
monkeypatch.setattr(builtins, "__import__", fake_import)
assert fw._verify_crx3(b"Cr24anything") is False
# ---------------------------------------------------------------------------
# Integrity-install policy (main): refuse only when NOTHING is verifiable
# ---------------------------------------------------------------------------
class TestIntegrityPolicy:
@pytest.mark.parametrize("sig_ok,sha,should_install", [
(True, "deadbeef", True), # Docker normal
(True, None, True), # server omitted sha; sig still verified
(False, "deadbeef", True), # no crypto, but sha present
(False, None, False), # no crypto AND no sha -> REFUSE
])
def test_branches(self, sig_ok, sha, should_install, monkeypatch, tmp_path):
monkeypatch.setattr(fw.platform, "system", lambda: "Linux")
monkeypatch.setattr(fw, "_arch", lambda: "x64")
monkeypatch.setattr(fw, "_resolve_crx", lambda arch: ("9.9.9", "https://x/crx", sha))
monkeypatch.setattr(fw, "_download", lambda url, s: b"BLOB")
monkeypatch.setattr(fw, "_verify_crx3", lambda blob: sig_ok)
extracted = {}
monkeypatch.setattr(fw, "_extract", lambda blob, arch, out: extracted.setdefault("out", out))
out = tmp_path / "WidevineCdm"
if should_install:
assert fw.main(["--out", str(out), "--quiet"]) == 0
assert extracted["out"] == os.path.abspath(str(out))
else:
with pytest.raises(SystemExit, match="refusing to install"):
fw.main(["--out", str(out), "--quiet"])
assert "out" not in extracted # never reached extraction
def test_cache_hit_skips_download(self, monkeypatch, tmp_path):
monkeypatch.setattr(fw.platform, "system", lambda: "Linux")
out = tmp_path / "WidevineCdm"
out.mkdir()
(out / "manifest.json").write_text("{}")
called = {"n": 0}
monkeypatch.setattr(fw, "_resolve_crx", lambda arch: called.__setitem__("n", called["n"] + 1))
assert fw.main(["--out", str(out), "--quiet"]) == 0
assert called["n"] == 0 # short-circuited before any network
def test_non_linux_refuses(self, monkeypatch, tmp_path):
monkeypatch.setattr(fw.platform, "system", lambda: "Darwin")
with pytest.raises(SystemExit, match="Linux-only"):
fw.main(["--out", str(tmp_path / "WidevineCdm"), "--quiet"])
# ---------------------------------------------------------------------------
# zip extraction — only the two expected members land; missing members fail
# ---------------------------------------------------------------------------
def _crx_with_zip(members):
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
for name, data in members.items():
zf.writestr(name, data)
# _extract reads the zip from the end, so a raw CRX prefix isn't required.
return buf.getvalue()
class TestExtract:
def test_extracts_only_expected_members(self, tmp_path):
so = "_platform_specific/linux_x64/libwidevinecdm.so"
crx = _crx_with_zip({
"manifest.json": b"{}",
so: b"\x7fELF-fake",
"evil/../../escape.txt": b"x", # extra member must be ignored
})
out = tmp_path / "WidevineCdm"
fw._extract(crx, "x64", str(out))
assert (out / "manifest.json").is_file()
assert (out / so).is_file()
assert not (tmp_path / "escape.txt").exists()
assert not (out / "evil").exists()
def test_missing_member_raises(self, tmp_path):
crx = _crx_with_zip({"manifest.json": b"{}"}) # no .so
with pytest.raises(SystemExit, match="missing expected members"):
fw._extract(crx, "x64", str(tmp_path / "WidevineCdm"))
def test_atomic_replace_of_existing_dir(self, tmp_path):
out = tmp_path / "WidevineCdm"
out.mkdir()
(out / "stale").write_text("old")
so = "_platform_specific/linux_x64/libwidevinecdm.so"
crx = _crx_with_zip({"manifest.json": b"{}", so: b"new"})
fw._extract(crx, "x64", str(out))
assert (out / "manifest.json").is_file()
assert not (out / "stale").exists() # old contents fully replaced
+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."""
+43 -3
View File
@@ -11,11 +11,13 @@ _HINT = "WidevineCdm/latest-component-updated-widevine-cdm"
@pytest.fixture(autouse=True)
def _force_linux(monkeypatch):
def _force_linux(monkeypatch, tmp_path):
"""Run as if on Linux unless a test overrides it (seeding is Linux-only)."""
monkeypatch.setattr(widevine.platform, "system", lambda: "Linux")
monkeypatch.delenv("CLOAKBROWSER_WIDEVINE", raising=False)
monkeypatch.delenv("CLOAKBROWSER_WIDEVINE_CDM", raising=False)
# Isolate the cache-root fallback from any real ~/.cloakbrowser on the host.
monkeypatch.setenv("CLOAKBROWSER_CACHE_DIR", str(tmp_path / "_isolated_cache"))
def _make_cdm(dirpath):
@@ -113,12 +115,50 @@ def test_env_var_is_exclusive(tmp_path, monkeypatch):
assert resolve_widevine_cdm_dir(binary) is None
def test_resolve_falls_back_to_cache_root(tmp_path, monkeypatch):
"""No CDM next to the binary -> auto-detect falls back to <cache>/WidevineCdm.
Simulates the Pro case: a Pro binary sits in its own chromium-<ver>-pro dir
with no adjacent CDM, while the Docker auto-fetch left one at the cache root.
"""
cache = tmp_path / "cache"
monkeypatch.setenv("CLOAKBROWSER_CACHE_DIR", str(cache))
cdm = _make_cdm(cache / "WidevineCdm")
pro_binary = tmp_path / "chromium-148.0-pro" / "chrome"
pro_binary.parent.mkdir(parents=True) # binary dir exists, but has no CDM
assert resolve_widevine_cdm_dir(pro_binary) == cdm.resolve()
def test_resolve_binary_dir_wins_over_cache_root(tmp_path, monkeypatch):
"""A manual sideload next to the binary takes precedence over the cache root."""
cache = tmp_path / "cache"
monkeypatch.setenv("CLOAKBROWSER_CACHE_DIR", str(cache))
_make_cdm(cache / "WidevineCdm") # cache-root CDM present...
binary = _binary(tmp_path)
next_to = _make_cdm(binary.parent / "WidevineCdm") # ...but sideload wins
assert resolve_widevine_cdm_dir(binary) == next_to.resolve()
def test_seeds_hint_from_cache_root_fallback(tmp_path, monkeypatch):
"""End-to-end: a cache-root CDM seeds the hint for a binary with none adjacent."""
cache = tmp_path / "cache"
monkeypatch.setenv("CLOAKBROWSER_CACHE_DIR", str(cache))
cdm = _make_cdm(cache / "WidevineCdm")
profile = tmp_path / "profile"
seed_widevine_hint(profile, _binary(tmp_path)) # binary has no adjacent CDM
assert json.loads((profile / _HINT).read_text())["Path"] == str(cdm.resolve())
def test_empty_env_var_is_exclusive(tmp_path, monkeypatch):
"""An empty (but set) CLOAKBROWSER_WIDEVINE_CDM is exclusive — no binary-dir fallback."""
"""An empty (but set) CLOAKBROWSER_WIDEVINE_CDM resolves to None — and must NOT
pick up a stray manifest.json in the working directory (``Path("")`` -> ``.``)."""
binary = _binary(tmp_path)
_make_cdm(binary.parent / "WidevineCdm") # valid CDM next to binary
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", "")
monkeypatch.chdir(tmp_path) # so a stray ./manifest.json can't match
cwd = tmp_path / "cwd"
cwd.mkdir()
(cwd / "manifest.json").write_text("{}") # stray manifest in CWD must be ignored
monkeypatch.chdir(cwd)
assert resolve_widevine_cdm_dir(binary) is None