mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
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:
+2
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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)).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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 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("<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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user