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:
@@ -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)
|
||||
Reference in New Issue
Block a user