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

262
docs/capture.md Normal file
View file

@ -0,0 +1,262 @@
# Using the capture binary
`drm capture` runs an HTTPS MITM **on the phone**, drives the app, and records the
DRM exchange. It is both the production capture path and the tool you use to
discover everything a new module needs.
## Device setup (once)
```bash
adb devices # phone listed and authorised
./bin/drm proxy build # cross-compile the on-device MITM (linux/arm64)
./bin/drm proxy install-ca --reinject
```
The phone must be rooted (Magisk). `install-ca` pushes the MITM CA and bind-mounts
it over the system trust store, including into already-running zygote namespaces —
a user-store CA is not enough, modern apps ignore it.
Verify the mount:
```bash
adb shell 'ls /system/etc/security/cacerts/ | grep 6c3578b4'
adb shell 'su -c "ls /apex/com.android.conscrypt/cacerts/6c3578b4.0"'
```
If an app still reports `tls: unknown certificate`, re-run the reinject and
force-stop the app so it inherits the mount:
```bash
./bin/drm proxy reinject-ca
adb shell am force-stop <package>
```
Keep the screen on for the whole session — UI automation cannot tap a dark screen:
```bash
adb shell svc power stayon true
adb shell settings put system screen_off_timeout 1800000
```
Restore the timeout afterwards (default is usually 60000).
## Three modes
### Passive — no module needed
```bash
./bin/drm capture --wait 300
```
Starts the MITM and nothing else. You open any app and play something; it records
whatever license, PSSH and manifest traffic appears and writes
`outputs/capture/<stamp>/`. This is the first thing to run against an unknown app.
### Launch only
```bash
./bin/drm capture --app myapp --wvd data/device.wvd
```
Launches the app, then waits while you navigate by hand. Useful while you are still
working out the UI sequence.
### Full automation
```bash
./bin/drm capture --app myapp --channel main --auto-play --wvd data/device.wvd
```
Runs the module's `Launch` and `AutoPlay`, then waits for the required fields.
## Flags
| Flag | Default | Purpose |
|---|---|---|
| `--app` | _(empty)_ | module to drive; empty means passive |
| `--channel` | — | channel id, required by `--auto-play` |
| `--auto-play` | false | run the module's navigation |
| `--wait` | 180 | seconds to wait for the capture to complete |
| `--wvd` | — | Widevine device file; required to fetch keys |
| `--python` | `.venv` | interpreter for `wvkey.py` |
| `--key-mode` | from the module | force `modulardrm` or `raw` |
| `--user-agent` | — | UA for the license request |
| `--serial` | sole device | target a specific phone |
| `--close` | true | force-stop the app when done |
| `--<app>.<field>` | — | override any of that module's values |
## What a run looks like
```text
[*] Stopping any old appproxy...
[*] Pushing appproxy...
[+] appproxy pid=7604; capture -> /data/local/tmp/appproxy_cap.json
[*] Setting HTTP proxy -> 127.0.0.1:8080 (was "null")
[*] Re-injecting system CA (Magisk)...
[+] System CA present in conscrypt
[*] Launching rte (air.RTE.OSMF.Minimal)…
[*] Auto-play RTE 2…
[*] tap_ui "Live"/"Live tab, 2 out of 5" @ 324,2253
[*] tap chip @ 356,297
[*] tap Play @ 540,720
[+] air.RTE.OSMF.Minimal is PLAYING
[*] Waiting for license + PSSH + manifest…
[+] Capture:
pid: RKRPCEib8z9V
mpd: https://dai.google.com/linear/dash/pa/event/.../stream/...
license: https://widevine.entitlement.eu.theplatform.com/wv/web/ModularDrm?...
pssh: AAAASnBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAACoiIGRmMTYzMzgyMWRk…
[+] key: 5490781a77572107ec251237e94e3b53:cee5fc45b70040ffea2b2576fb5abda5
[+] session: outputs/rte/20261005-220252/session.json
[*] Closing air.RTE.OSMF.Minimal…
[*] HTTP proxy cleared
```
### Output
```text
outputs/<app>/<stamp>/
session.json everything, machine-readable
mpd.txt manifest URL
key.txt KID:KEY (one per line for multi-key streams)
pssh.txt Widevine init data
auth.txt Authorization value, when the app uses one
pid.txt provider id, when the app uses one
outputs/<app>/latest/ a copy of the most recent run
```
Live log while a capture runs: `.cache/appproxy-<serial>.log`.
---
## Discovering a new app
### 1. Record everything
```bash
./bin/drm proxy discover --install-ca --reinject
# play the app on the phone; Ctrl+C when done
```
Writes `outputs/discover/<stamp>/`:
| File | Contents |
|---|---|
| `appproxy_traffic.jsonl` | every HTTP(S) request/response, one JSON object per line |
| `appproxy_cap.json` | the structured fields the proxy recognised |
| `appproxy.log` | tagged lines: `[MPD]`, `[LIC]`, `[PSSH]`, `[MAN]` |
### 2. Mine it
```bash
# the android package id
adb shell dumpsys window | grep mCurrentFocus
# license endpoint
grep -iE 'license|licence|widevine|/lic/' outputs/discover/*/appproxy.log
# manifest candidates
grep -oE 'https?://[^"]+\.(m3u8|mpd)' outputs/discover/*/appproxy_traffic.jsonl \
| sort -u
# the hosts involved, by frequency
grep -oE '"host":"[^"]+"' outputs/discover/*/appproxy_traffic.jsonl \
| sort | uniq -c | sort -rn | head -20
# config blobs that often carry ids and tokens
grep -oE 'https?://[^"]+(config|playback)[^"]*\.json' \
outputs/discover/*/appproxy_traffic.jsonl | sort -u
```
What to pull out, and where it goes in `module.yaml`:
| Found | Key |
|---|---|
| package id | `package` |
| license endpoint | `license_url` |
| CDN host serving the manifest | `score.hosts` |
| analytics / EPG hosts polluting the results | `score.deny` |
| account ids, policy keys, video ids | provider-specific keys |
### 3. Work out the UI
Dump the tree at each step and read off stable selectors:
```bash
adb shell uiautomator dump /sdcard/ui.xml
adb shell cat /sdcard/ui.xml > ui.xml
```
Extract the useful attributes:
```bash
grep -oE 'resource-id="[^"]*"' ui.xml | sort -u
grep -oE 'content-desc="[^"]*"' ui.xml | sort -u
```
Prefer `resource-id` over `content-desc` or `text` — ids survive translation and
copy changes. Check a candidate marker really exists before you rely on it; a
launch marker that never matches costs the full timeout on every capture and is
easy to miss because the pipeline recovers anyway.
Test taps by hand before writing Go:
```bash
adb shell input tap 540 732
adb shell input swipe 540 1700 540 700 350
```
### 4. Determine the key mode
| The app sends | Use |
|---|---|
| a JSON wrapper around the challenge, with an Authorization header | `modulardrm` |
| a raw binary challenge, `Content-Type: application/octet-stream` | `raw` |
Check the license request body in `appproxy_traffic.jsonl`. Then set `KeyMode()`
in the module — never sniff it from the URL at runtime.
### 5. Decide which fields are required
Look at what `appproxy_cap.json` actually contained after a successful play:
- DASH + JSON license → usually `auth`, `pid`, `pssh`, `mpd`
- HLS + binary license → usually `license_url`, `mpd` (the PSSH comes from the
playlist afterwards)
Pass exactly those to `a.Hints(...)`. Asking for a field the app never emits makes
every capture time out.
### 6. Verify
Where an app has a public catalog, capture the same channel both ways and compare —
independent paths agreeing on the PSSH and key set is strong evidence both are
right:
```bash
./bin/drm catalog --app myapp --channel main --keys --wvd data/device.wvd --json > a.json
./bin/drm capture --app myapp --channel main --auto-play --wvd data/device.wvd
diff <(jq -r '.keys[]' a.json | sort) <(sort outputs/myapp/latest/key.txt)
```
Also sanity-check that the KID you captured matches whatever you recorded in
`module.yaml` for that channel.
## Troubleshooting
| Symptom | Likely cause |
|---|---|
| `tls: unknown certificate` in the app | CA not mounted into conscrypt; reinject, then force-stop the app |
| `timed out waiting for capture ([auth pid pssh mpd])` | playback never started, or `Hints` asks for a field this app does not send |
| `launch: ui node not found within Ns` | launch marker does not match — dump the tree and pick a resource id |
| `launch: exit status 1` | monkey raced a force-stop; usually transient, retry |
| `auto-play failed: ... not found` | UI changed, or the phone is on a different screen than expected |
| manifest field holds an EPG or config URL | add that host to `score.deny` |
| `CA inject skipped/failed` | transient after repeated runs; re-run `proxy reinject-ca` |
| Screen asleep mid-run | `adb shell svc power stayon true` |
Clear the phone's proxy if a run is interrupted:
```bash
./bin/drm proxy clear-proxy
```