From 61365cea484d950820714931c57d614d4bb01cf7 Mon Sep 17 00:00:00 2001 From: CloakHQ Date: Mon, 22 Jun 2026 07:41:41 +0200 Subject: [PATCH] feat(docker): opt-in Widevine CDM auto-fetch for persistent contexts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Dockerfile | 3 +- README.md | 44 ++--- bin/docker-entrypoint.sh | 25 +++ bin/fetch-widevine.py | 301 ++++++++++++++++++++++++++++++++++ cloakbrowser/widevine.py | 36 ++-- js/src/widevine.ts | 33 +++- js/tests/widevine.test.ts | 27 ++- tests/test_fetch_widevine.py | 309 +++++++++++++++++++++++++++++++++++ tests/test_widevine.py | 46 +++++- 9 files changed, 781 insertions(+), 43 deletions(-) create mode 100644 bin/fetch-widevine.py create mode 100644 tests/test_fetch_widevine.py diff --git a/Dockerfile b/Dockerfile index c1951f3..59dc8e4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 7993f58..96e0f2a 100644 --- a/README.md +++ b/README.md @@ -426,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-/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 @@ -456,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 @@ -645,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 @@ -704,7 +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` | 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-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 | @@ -992,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 @@ -1090,13 +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` (requires Windows fonts installed) | +| **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( @@ -1105,7 +1113,7 @@ browser = launch( geoip=True, args=[ "--fingerprint-noise=false", # prevents tampering detection - "--fingerprint-windows-font-metrics", # align font metrics (requires Windows fonts) + "--fingerprint-windows-font-metrics", # align font metrics — 148+ binary, needs Windows fonts ], ) ``` @@ -1117,16 +1125,14 @@ const browser = await launch({ geoip: true, args: [ '--fingerprint-noise=false', - '--fingerprint-windows-font-metrics', // align font metrics (requires Windows fonts) + '--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)). --- diff --git a/bin/docker-entrypoint.sh b/bin/docker-entrypoint.sh index fad74be..473fd59 100644 --- a/bin/docker-entrypoint.sh +++ b/bin/docker-entrypoint.sh @@ -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 "$@" diff --git a/bin/fetch-widevine.py b/bin/fetch-widevine.py new file mode 100644 index 0000000..9352352 --- /dev/null +++ b/bin/fetch-widevine.py @@ -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: + + /manifest.json + /_platform_specific/linux_/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 a–p.""" + 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(" 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, ``/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. ``/WidevineCdm`` — where a user naturally drops + a manual sideload, per Chromium binary version. + 3. ``/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: diff --git a/js/src/widevine.ts b/js/src/widevine.ts index d3955c1..2b54ce2 100644 --- a/js/src/widevine.ts +++ b/js/src/widevine.ts @@ -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, `/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. `/WidevineCdm` — a manual sideload, per version. + * 3. `/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; } /** diff --git a/js/tests/widevine.test.ts b/js/tests/widevine.test.ts index deb733a..5d31778 100644 --- a/js/tests/widevine.test.ts +++ b/js/tests/widevine.test.ts @@ -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 /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 diff --git a/tests/test_fetch_widevine.py b/tests/test_fetch_widevine.py new file mode 100644 index 0000000..b97c35e --- /dev/null +++ b/tests/test_fetch_widevine.py @@ -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(" 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 diff --git a/tests/test_widevine.py b/tests/test_widevine.py index 5699a7f..dd67e41 100644 --- a/tests/test_widevine.py +++ b/tests/test_widevine.py @@ -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 /WidevineCdm. + + Simulates the Pro case: a Pro binary sits in its own chromium--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