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

View file

@ -0,0 +1,463 @@
// Package phonecap runs one phone MITM capture: proxy → launch → autoplay →
// wait → wvkey → session → optional force-stop. Used by capture.exe and agent.
package phonecap
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"drmdecryption/adb"
"drmdecryption/app"
"drmdecryption/capture"
"drmdecryption/proxy"
"drmdecryption/repo"
"drmdecryption/session"
"drmdecryption/wvkey"
)
// DefaultCAHash is the subject hash of the bundled MITM CA, used when an app
// module does not state its own.
const DefaultCAHash = "6c3578b4"
// Options for a single capture job on one device.
type Options struct {
App app.App
Channel string
Client *adb.Client // serial-scoped when multi-device
Root string
Python string
WVD string // path to .wvd device file (required for key fetch)
UserAgent string // optional license-request User-Agent
Wait time.Duration
NoAutoPlay bool
// KeyMode: "auto" (default), "modulardrm", or "raw".
KeyMode string
// CloseApp force-stops the package when the job ends (success or failure).
CloseApp bool
// ClearProxy clears global http_proxy after the job.
ClearProxy bool
}
// Result is the captured session plus on-disk path.
type Result struct {
Session session.Session
Path string
Key string
MPD string
}
// RunPassive starts the MITM only — no app launch / auto-play. Waits for
// whatever DRM/manifest traffic the phone generates, prints it, and writes
// outputs/capture/<stamp>/ when anything useful appears.
func RunPassive(opt Options) (res Result, err error) {
if opt.Client == nil {
opt.Client = adb.New()
}
if opt.Root == "" {
opt.Root = repo.Root()
}
if opt.Wait <= 0 {
opt.Wait = 180 * time.Second
}
c := opt.Client
root := opt.Root
if opt.ClearProxy {
defer ClearProxyLater(c)
}
if err := c.EnsureDevice(); err != nil {
return res, err
}
proxyBin, caHash := resolveProxy(nil, root)
if err := proxy.PushAndStart(c, proxyBin); err != nil {
return res, err
}
if err := proxy.EnsureHTTPProxy(c, ""); err != nil {
return res, err
}
proxy.ReinjectCA(c, caHash)
fmt.Println("[*] Passive capture — open any app and start playback on the phone")
fmt.Println(" (no auto-play; Ctrl+C will not save — wait for traffic or raise --wait)")
hints := capture.DefaultPassiveHints()
_ = os.MkdirAll(filepath.Join(root, ".cache"), 0o755)
serialTag := c.Serial
if serialTag == "" {
serialTag = "default"
}
localLog := filepath.Join(root, ".cache", "appproxy-"+sanitize(serialTag)+".log")
localCap := filepath.Join(root, ".cache", "appproxy-"+sanitize(serialTag)+"_cap.json")
stop := make(chan struct{})
go capture.MirrorLogLoop(c, hints.RemoteLog, localLog, stop)
defer close(stop)
fmt.Println("[*] Waiting for any license / PSSH / manifest…")
fmt.Println(" watch:", localLog)
capData, err := capture.Wait(c, hints, localLog, localCap, opt.Wait)
if err != nil {
return res, err
}
licenseURL := capData.Get("license_url")
if strings.TrimSpace(capData.Get("pssh")) == "" && strings.TrimSpace(capData.Get("mpd")) != "" {
if pssh, err := extractHLSPSSH(capData.Get("mpd")); err == nil && pssh != "" {
capData["pssh"] = pssh
}
}
fmt.Println("[+] Capture:")
for _, k := range []string{"pid", "mpd", "license_url", "auth", "pssh"} {
v := capData.Get(k)
if v == "" {
continue
}
label := k
if k == "license_url" {
label = "license"
}
fmt.Println(" ", label+":", trim(v, 110))
}
key := ""
if capData.Get("pssh") != "" && licenseURL != "" {
k, kerr := fetchKey(opt, root, capData, licenseURL)
if kerr != nil {
fmt.Fprintf(os.Stderr, "[!] key fetch skipped: %v\n", kerr)
} else {
key = k
fmt.Println("[+] key:", strings.ReplaceAll(key, "\n", " | "))
}
} else {
fmt.Println("[*] Not enough fields for wvkey (need pssh + license_url) — raw capture saved")
}
sess := session.Session{
Channel: opt.Channel,
PSSH: capData.Get("pssh"),
Auth: capData.Get("auth"),
PID: capData.Get("pid"),
Key: key,
MPD: capData.Get("mpd"),
LicenseURL: licenseURL,
}
path, err := session.Write(root, "capture", sess)
if err != nil {
return res, err
}
fmt.Println("[+] session:", path)
res.Session = sess
res.Path = path
res.Key = key
res.MPD = sess.MPD
return res, nil
}
// Run executes the full phone capture pipeline for a registered app module.
func Run(opt Options) (res Result, err error) {
if opt.App == nil {
return res, fmt.Errorf("app plugin required (pass --app, or omit --app for passive capture)")
}
if opt.Client == nil {
opt.Client = adb.New()
}
if opt.Root == "" {
opt.Root = repo.Root()
}
if opt.Wait <= 0 {
opt.Wait = 180 * time.Second
}
c := opt.Client
a := opt.App
root := opt.Root
// Defers run LIFO: close app first, then clear proxy.
if opt.ClearProxy {
defer ClearProxyLater(c)
}
if opt.CloseApp {
defer func() {
fmt.Printf("[*] Closing %s…\n", a.Package())
c.ForceStop(a.Package())
}()
}
if err := c.EnsureDevice(); err != nil {
return res, err
}
proxyBin, caHash := resolveProxy(a, root)
hints := a.CaptureHints()
useTProxy := false
if tm, ok := a.(app.TransparentMITM); ok && tm.UseTransparentMITM() {
useTProxy = true
}
if useTProxy {
// Transparent REDIRECT — keeps phone VPN (BBC UK geo) working.
proxy.ClearHTTPProxy(c)
remoteDir := "/data/local/tmp/capture/" + a.Package()
if err := proxy.StartTransparent(c, proxyBin, a.Package(), "8080", remoteDir, false); err != nil {
return res, err
}
defer proxy.StopTransparent(c)
hints.RemoteCaps = []string{remoteDir + "/cap.json"}
hints.RemoteLog = remoteDir + "/appproxy.log"
fmt.Println("[*] Transparent MITM (VPN OK) — package", a.Package())
} else {
if err := proxy.PushAndStart(c, proxyBin, hints.Score.ProxyArgs()...); err != nil {
return res, err
}
if err := proxy.EnsureHTTPProxy(c, ""); err != nil {
return res, err
}
proxy.ReinjectCA(c, caHash)
}
if err := a.Launch(c); err != nil {
fmt.Fprintf(os.Stderr, "[!] launch: %v\n", err)
}
if !opt.NoAutoPlay {
if err := a.AutoPlay(c, opt.Channel); err != nil {
fmt.Fprintf(os.Stderr, "[!] auto-play failed: %v\n", err)
fmt.Println("[*] Open the channel on the phone manually and start playback…")
}
} else {
fmt.Println("[*] Open the channel on the phone and wait for playback…")
}
_ = os.MkdirAll(filepath.Join(root, ".cache"), 0o755)
serialTag := c.Serial
if serialTag == "" {
serialTag = "default"
}
localLog := filepath.Join(root, ".cache", "appproxy-"+sanitize(serialTag)+".log")
localCap := filepath.Join(root, ".cache", "appproxy-"+sanitize(serialTag)+"_cap.json")
stop := make(chan struct{})
go capture.MirrorLogLoop(c, hints.RemoteLog, localLog, stop)
defer close(stop)
need := hints.Require
if len(need) == 0 {
need = []string{"manifest"}
}
fmt.Printf("[*] Waiting for %s…\n", strings.Join(need, " + "))
fmt.Println(" watch:", localLog)
capData, err := capture.Wait(c, hints, localLog, localCap, opt.Wait)
if err != nil {
return res, err
}
licenseURL := first(capData.Get("license_url"), a.LicenseURL())
keyMode := strings.ToLower(strings.TrimSpace(opt.KeyMode))
if keyMode == "" || keyMode == "auto" {
keyMode = strings.ToLower(strings.TrimSpace(a.KeyMode()))
}
// Brightcove/HLS: proxy often captures license + master URL but not PSSH.
// Skip for clear/MPD-only apps (KeyMode none).
if keyMode != "none" && keyMode != "clear" {
if strings.TrimSpace(capData.Get("pssh")) == "" && strings.TrimSpace(capData.Get("mpd")) != "" {
if pssh, err := extractHLSPSSH(capData.Get("mpd")); err == nil && pssh != "" {
capData["pssh"] = pssh
} else if err != nil {
fmt.Fprintf(os.Stderr, "[!] HLS PSSH extract: %v\n", err)
}
}
}
mpdURL := strings.TrimSpace(capData.Get("mpd"))
// Console: always surface the MPD we will use (BBC clear streams only need this).
fmt.Println()
fmt.Println("========== MPD ==========")
if mpdURL != "" {
fmt.Println(mpdURL)
} else {
fmt.Println("(none)")
}
fmt.Println("=========================")
fmt.Println()
fmt.Println("[+] Capture:")
for _, k := range []string{"pid", "mpd", "license_url", "auth", "pssh"} {
v := capData.Get(k)
if k == "license_url" {
v = first(v, licenseURL)
}
if v == "" {
continue
}
label := k
if k == "license_url" {
label = "license"
}
fmt.Println(" ", label+":", trim(v, 120))
}
key := ""
if keyMode == "none" || keyMode == "clear" {
fmt.Println("[*] Key mode none — skipping wvkey (clear / MPD-only capture)")
} else {
var kerr error
key, kerr = fetchKey(opt, root, capData, licenseURL)
if kerr != nil {
return res, kerr
}
fmt.Println("[+] key:", strings.ReplaceAll(key, "\n", " | "))
}
sess := session.Session{
Channel: opt.Channel,
PSSH: capData.Get("pssh"),
Auth: capData.Get("auth"),
PID: capData.Get("pid"),
Key: key,
MPD: mpdURL,
LicenseURL: licenseURL,
}
path, err := session.Write(root, a.Name(), sess)
if err != nil {
return res, err
}
fmt.Println("[+] session:", path)
if mpdURL != "" {
fmt.Println("[+] mpd:", mpdURL)
}
res.Session = sess
res.Path = path
res.Key = key
res.MPD = sess.MPD
return res, nil
}
// ClearProxyLater clears http_proxy (exported for agent defer helpers).
func ClearProxyLater(c *adb.Client) {
proxy.ClearHTTPProxy(c)
}
func resolveProxy(a app.App, root string) (bin, caHash string) {
bin = filepath.Join(root, "bin", "proxy-android-arm64")
caHash = DefaultCAHash
if a != nil {
if p := a.ProxyBin(); p != "" {
bin = p
}
if h := a.CAHash(); h != "" {
caHash = h
}
}
if _, err := os.Stat(bin); err != nil {
for _, p := range []string{
filepath.Join(root, "bin", "proxy-android-arm64"),
filepath.Join(root, "apps", "proxy", "proxy-android-arm64"),
} {
if st, e := os.Stat(p); e == nil && !st.IsDir() {
bin = p
break
}
}
}
return bin, caHash
}
func fetchKey(opt Options, root string, cap capture.Data, licenseURL string) (string, error) {
if strings.TrimSpace(opt.WVD) == "" {
return "", fmt.Errorf("wvkey requires --wvd (path to .wvd device file)")
}
py := opt.Python
if py == "" {
py = filepath.Join(root, ".venv", "Scripts", "python.exe")
if _, err := os.Stat(py); err != nil {
py = filepath.Join(root, ".venv", "bin", "python")
}
}
wopt := wvkey.Options{
Python: py,
Script: filepath.Join(root, "apps", "wvkey", "wvkey.py"),
WVD: opt.WVD,
PSSH: cap.Get("pssh"),
Auth: cap.Get("auth"),
PID: cap.Get("pid"),
LicenseURL: licenseURL,
UserAgent: opt.UserAgent,
}
mode := strings.ToLower(strings.TrimSpace(opt.KeyMode))
if mode == "" || mode == "auto" {
mode = "modulardrm"
if opt.App != nil {
if m := strings.ToLower(strings.TrimSpace(opt.App.KeyMode())); m != "" {
mode = m
}
}
}
switch mode {
case "raw":
// Brightcove returns multiple CONTENT keys; NRE needs all of them.
keys, err := wvkey.FetchRawAll(wopt)
if err != nil {
return "", err
}
return strings.Join(keys, "\n"), nil
default:
return wvkey.Fetch(wopt)
}
}
func first(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
var (
reWVKeyURI = regexp.MustCompile(`(?is)KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed".*?URI="data:text/plain;base64,([A-Za-z0-9+/=]+)"`)
reWVKeyURI2 = regexp.MustCompile(`(?is)URI="data:text/plain;base64,([A-Za-z0-9+/=]+)".*?KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"`)
)
func extractHLSPSSH(masterURL string) (string, error) {
resp, err := http.Get(masterURL)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
return "", err
}
s := string(body)
if m := reWVKeyURI.FindStringSubmatch(s); len(m) == 2 {
return m[1], nil
}
if m := reWVKeyURI2.FindStringSubmatch(s); len(m) == 2 {
return m[1], nil
}
return "", fmt.Errorf("no Widevine PSSH in HLS master")
}
func trim(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
func sanitize(s string) string {
s = strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
return r
default:
return '_'
}
}, s)
return s
}