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
+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