Initial commit: Null DRM Official

Capture, decrypt, and restream toolkit with compiled-in app modules
(RTE, TG4, BBC), on-device MITM proxy, streamd control plane, and www.
BBC module.yaml is published (clear streams); other module values stay local.
This commit is contained in:
404errordeveloper 2026-10-06 00:25:35 +02:00
commit 2fa8f2435f
121 changed files with 17802 additions and 0 deletions

60
apps/wvkey/README.md Normal file
View file

@ -0,0 +1,60 @@
# wvkey — Widevine CDM helper
The only Python in the project. Go (`apps/pkg/wvkey`) shells out to it to turn a
PSSH plus a license endpoint into `KID:KEY` lines via pywidevine.
```text
apps/wvkey/
wvkey.py
requirements.txt
```
Keep it thin: it does the CDM exchange and nothing else.
## Setup
```bash
# from the repo root
python -m venv .venv
.venv/bin/pip install -r apps/wvkey/requirements.txt # Windows: .venv\Scripts\pip
```
Put your Widevine device file somewhere gitignored, e.g. `data/device.wvd`. The Go
side finds `.venv` automatically; override with `--python`.
Every unique value is a flag. There are no baked-in device paths, account URLs or
user agents.
## Run
Two license shapes:
```bash
# JSON challenge wrapper with an Authorization header
.venv/bin/python apps/wvkey/wvkey.py \
--mode modulardrm \
--wvd data/device.wvd \
--pssh '<base64>' \
--auth 'Bearer ...' \
--pid '<release id>' \
--license-url 'https://...' \
--quiet
# raw binary challenge (octet-stream)
.venv/bin/python apps/wvkey/wvkey.py \
--mode raw \
--wvd data/device.wvd \
--pssh '<base64>' \
--license-url 'https://...' \
--quiet
```
Options: `--user-agent`, `--all` (print every CONTENT key — needed for multi-KID
streams).
Which mode an app needs is declared by its module (`KeyMode()`), never sniffed from
the license URL at runtime. See [docs/capture.md](../../docs/capture.md) for how to
tell them apart from a capture.
Go callers use `wvkey.Fetch` / `FetchRaw` / `FetchRawAll` and always pass `Script`,
`WVD` and `LicenseURL`.

View file

@ -0,0 +1,6 @@
# Dependencies for wvkey.py (the only Python in the project).
# Install into a venv at the repo root:
# python -m venv .venv
# .venv/bin/pip install -r apps/wvkey/requirements.txt
pywidevine==1.9.0
requests==2.34.2

207
apps/wvkey/wvkey.py Normal file
View file

@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""Thin Widevine CDM helper for the Go capture tooling.
Prints a single CONTENT key as KID:KEY on stdout (no banners) when --quiet.
Exit code 0 on success.
Example:
.venv/Scripts/python.exe wvkey.py \\
--wvd path/to/device.wvd \\
--pssh 'AAAA…' \\
--license-url 'https://…' \\
--auth 'Basic …' \\
--pid 'xxxx' \\
--quiet
"""
from __future__ import annotations
import argparse
import base64
import json
import sys
from pathlib import Path
import requests
from pywidevine.cdm import Cdm
from pywidevine.device import Device
from pywidevine.pssh import PSSH
def fetch_content_keys_modulardrm(
*,
wvd: Path,
pssh_b64: str,
auth: str,
release_pid: str,
license_url: str,
user_agent: str,
) -> list[str]:
"""RTE / thePlatform ModularDrm (JSON challenge wrapper)."""
device = Device.load(str(wvd))
cdm = Cdm.from_device(device)
session_id = cdm.open()
try:
challenge = cdm.get_license_challenge(session_id, PSSH(pssh_b64))
challenge_b64 = base64.b64encode(challenge).decode("utf-8")
payload = {
"getWidevineLicense": {
"releasePid": release_pid,
"widevineChallenge": challenge_b64,
}
}
headers = {
"Accept-Encoding": "gzip",
"Authorization": auth,
"Content-Type": "application/json; charset=utf-8",
}
if user_agent:
headers["User-Agent"] = user_agent
response = requests.post(license_url, json=payload, headers=headers, timeout=30)
data = response.json()
if data.get("isException"):
raise RuntimeError(data.get("description") or "license server exception")
license_b64 = (
data.get("getWidevineLicenseResponse", {}).get("license")
or data.get("license")
)
if not license_b64:
raise RuntimeError("no license in response: " + json.dumps(data)[:1500])
cdm.parse_license(session_id, base64.b64decode(license_b64))
keys = [
f"{key.kid.hex}:{key.key.hex()}"
for key in cdm.get_keys(session_id)
if key.type == "CONTENT"
]
finally:
cdm.close(session_id)
if not keys:
raise RuntimeError("no CONTENT keys")
return keys
def fetch_content_keys_raw(
*,
wvd: Path,
pssh_b64: str,
license_url: str,
user_agent: str,
) -> list[str]:
"""Brightcove / generic Widevine: POST raw challenge, parse raw license body."""
device = Device.load(str(wvd))
cdm = Cdm.from_device(device)
session_id = cdm.open()
try:
challenge = cdm.get_license_challenge(session_id, PSSH(pssh_b64))
headers = {
"Content-Type": "application/octet-stream",
"Accept": "*/*",
}
if user_agent:
headers["User-Agent"] = user_agent
response = requests.post(
license_url, data=challenge, headers=headers, timeout=30
)
if response.status_code >= 400:
raise RuntimeError(
f"license HTTP {response.status_code}: {response.text[:500]!r}"
)
body = response.content
if not body:
raise RuntimeError("empty license response body")
# Some servers wrap base64 text; try raw first, then b64.
try:
cdm.parse_license(session_id, body)
except Exception:
try:
cdm.parse_license(session_id, base64.b64decode(body))
except Exception as exc:
raise RuntimeError(
f"parse_license failed ({len(body)} bytes): {exc}"
) from exc
keys = [
f"{key.kid.hex}:{key.key.hex()}"
for key in cdm.get_keys(session_id)
if key.type == "CONTENT"
]
finally:
cdm.close(session_id)
if not keys:
raise RuntimeError("no CONTENT keys")
return keys
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="Widevine CDM helper → KID:KEY")
p.add_argument("--wvd", type=Path, required=True, help="Path to .wvd device file")
p.add_argument("--pssh", required=True, help="Base64 PSSH box")
p.add_argument(
"--mode",
choices=("modulardrm", "raw"),
default="modulardrm",
help="modulardrm=RTE JSON; raw=Brightcove/octet-stream challenge",
)
p.add_argument("--auth", default="", help="Authorization header (modulardrm)")
p.add_argument("--pid", default="", help="releasePid (modulardrm)")
p.add_argument(
"--license-url",
required=True,
help="Widevine license server URL",
)
p.add_argument(
"--user-agent",
default="",
help="Optional User-Agent for the license request",
)
p.add_argument(
"--quiet",
action="store_true",
help="Print only KID:KEY lines (for Go tooling)",
)
p.add_argument(
"--all",
action="store_true",
help="Print every CONTENT key (default: first only)",
)
args = p.parse_args(argv)
if not args.wvd.is_file():
print(f"[!] missing WVD: {args.wvd}", file=sys.stderr)
return 2
try:
if args.mode == "raw":
keys = fetch_content_keys_raw(
wvd=args.wvd,
pssh_b64=args.pssh,
license_url=args.license_url,
user_agent=args.user_agent,
)
else:
if not args.auth or not args.pid:
print("[!] --auth and --pid required for modulardrm mode", file=sys.stderr)
return 2
keys = fetch_content_keys_modulardrm(
wvd=args.wvd,
pssh_b64=args.pssh,
auth=args.auth,
release_pid=args.pid,
license_url=args.license_url,
user_agent=args.user_agent,
)
except Exception as exc:
print(f"[!] {exc}", file=sys.stderr)
return 1
out = keys if args.all else keys[:1]
if not args.quiet:
print("[+] Keys:", file=sys.stderr)
for pair in out:
print(f"--key {pair}", file=sys.stderr)
for pair in out:
print(pair)
return 0
if __name__ == "__main__":
raise SystemExit(main())