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:
commit
2fa8f2435f
121 changed files with 17802 additions and 0 deletions
60
apps/wvkey/README.md
Normal file
60
apps/wvkey/README.md
Normal 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`.
|
||||
6
apps/wvkey/requirements.txt
Normal file
6
apps/wvkey/requirements.txt
Normal 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
207
apps/wvkey/wvkey.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue