Files
wehub-resource-sync 0af812490d
CI / python (push) Failing after 0s
CI / javascript (push) Failing after 1s
CI / dotnet (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 12:12:36 +08:00

302 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)