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.
463 lines
12 KiB
Go
463 lines
12 KiB
Go
// 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
|
|
}
|