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.
This commit is contained in:
CloakHQ
2026-06-23 03:13:22 +02:00
parent 8570deaa19
commit 61365cea48
9 changed files with 781 additions and 43 deletions
+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
+25 -19
View File
@@ -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-<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
@@ -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)).
---
+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)
+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:
+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;
}
/**
+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
+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